oficina / sheets Public

Add Mac Home and document windows with native spreadsheet editing

Paul Campbell pushed by paul@keymail.dev 42a62f86b2270c65bbc12c56e6b78a36a7e4dbbe parent 1c2f0f2
34 files changed, +1507 −49
  • docs/superpowers/specs/2026-09-20-mac-document-windows.md +69 −0
  • native/apple/Package.swift +4 −3
  • native/apple/Sources/SheetsApp/SheetsAccountWindow.swift +26 −0
  • native/apple/Sources/SheetsApp/SheetsApp.swift +16 −3
  • native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift +23 −0
  • native/apple/Sources/SheetsApp/SheetsWindowCommands.swift +32 −0
  • native/apple/Sources/SheetsApp/SheetsWindows.swift +47 −0
  • native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings +70 −0
  • native/apple/Sources/SheetsKit/SheetAutoSize.swift +18 −0
  • native/apple/Sources/SheetsKit/SheetCell.swift +47 −5
  • native/apple/Sources/SheetsKit/SheetEditorContext.swift +9 −0
  • native/apple/Sources/SheetsKit/SheetFiles.swift +3 −1
  • native/apple/Sources/SheetsKit/SheetGrid.swift +79 −10
  • native/apple/Sources/SheetsKit/SheetModel.swift +8 −6
  • native/apple/Sources/SheetsKit/SheetPanels.swift +2 −1
  • native/apple/Sources/SheetsKit/SheetTable.swift +1 −1
  • native/apple/Sources/SheetsKit/SheetThumbnail.swift +4 −2
  • native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift +24 −0
  • native/apple/Sources/SheetsKit/SheetsEditorCommands.swift +61 −0
  • native/apple/Sources/SheetsKit/SheetsMacStrings.swift +5 −0
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +91 −17
  • native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift +71 −0
  • native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift +27 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-20-mac-document-windows.md b/docs/superpowers/specs/2026-09-20-mac-document-windows.md
new file mode 100644
index 0000000..4de6ef9
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-20-mac-document-windows.md
@@ -0,0 +1,69 @@
+# Mac document windows — 2026-09-20
+
+Paul requests a separate Home launcher and document windows for Docs and Sheets.
+This extends the native Apple specs; editors remain native and server-backed.
+
+Home is a singleton with a Recent sidebar and searchable thumbnail grid. Templates
+are deferred until template creation exists. Each open document has an independent
+model, live stream, selection and save queue. Window identity includes the account
+and document; opening an existing document focuses it. Document/session state remains in memory; credentials retain the existing
+Keychain handling and are never encoded in scene restoration values. Missing restored sessions return
+the user to Home. File > Recent and Window > Home remain available with no editor.
+Recent entries are held in memory for this iteration, scoped to the account session.
+
+Mac document windows have no app navigation sidebar. Existing editing and file
+actions move into menu-bar commands targeted at the focused scene. Standard native
+text responder actions remain available in text fields. Docs adds a document-only
+inspector and zoom starting at 100%; zoom changes display scale, never text wrapping.
+No server page-settings API exists: inspector exposes existing personal layout
+settings and document information, without pretending to persist new paper settings.
+
+Sheets edits in the selected cell, preserving drafts on failed save and respecting
+existing write serialization. Header buttons select whole rows/columns. Double-click
+on column boundaries autosizes using displayed text/font measurements and existing
+size endpoint; menu alternative provided. Remove only the grid's outer focus effect,
+retain cell selection and keyboard focus. Enter/Tab commit; Escape discards.
+
+Docs rendering investigation compares web CSS/font and page geometry against TextKit.
+Use identical bundled fonts, logical width, sizes, paragraph spacing and line height.
+Native optimistic edits must not be overwritten by stale projection generations or
+IME composition. Avoid unnecessary complete attributed storage replacement and
+selection resets; preserve model render acknowledgement. Exact cross-engine layout
+parity requires measured fixture verification, not a compilation claim.
+
+Validate Mac builds, existing native tests and focused regression cases; exercise
+window/menu targeting and editing where UI automation is available. Build iOS to
+check shared code. No merge, deployment or app publication is part of this change.
+
+Review: GPT-6 Astra (high), 2026-09-20. Required invariants incorporated:
+window keys combine canonical origin, account identity and document identity;
+clients are immutable per window. Sign out is unavailable until that account's
+windows close; switching the launcher account leaves those windows intact.
+Close/quit guards cover in-flight, queued, failed and composing edits. Window
+registry lifecycle ends only after the actual guarded close. Edit commands obey
+the first responder, not merely the focused scene. Render reconciliation compares
+actual optimistic text storage, updates CRDT metadata and acknowledges visually
+equal generations. Zoom keeps logical layout independent of magnified viewport.
+
+## Verification and remaining coverage
+
+Implementation reviewed by GPT-6 Astra (high), including a final account-isolation
+and localization review. Both native Mac test suites and iOS simulator builds pass.
+A synthetic AppKit interaction fixture exercises Sheets typing successive characters,
+failed-save draft retention, Escape, and whole-row/column selection. A live SwiftUI
+scene probe verified File actions and Window > Home after closing Home.
+
+Docs regression tests cover minimal text-storage reconciliation, metadata-only
+updates and fixed logical page width. Deliberately restoring full storage replacement
+and viewport-dependent width made the corresponding tests fail. A synthetic WebKit
+fixture matches native heading, bold and body line breaks and body line spacing.
+
+This verifies the measured typography cases, not universal browser/TextKit parity.
+Complex lists, inline code, mixed styles, margin collapsing and multi-page pagination
+need a larger shared layout fixture suite before exact parity can be claimed. The
+existing native line-based page breaking and web block-based pagination differ.
+That remaining work is triggered by the requested exact cross-platform typesetting
+acceptance check; it is not considered complete by this branch.
+
+Builds are local ad-hoc Mac applications, not signed distribution or TestFlight
+releases. No server deployment, merge or publication was performed.
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
index adb01bd..1b1d410 100644
--- a/native/apple/Package.swift
+++ b/native/apple/Package.swift
@@ -5,10 +5,11 @@ import Foundation
let sharedNative: Package.Dependency = ProcessInfo.processInfo.environment["OFICINA_NATIVE_PACKAGE_PATH"].map { .package(name: "native", path: $0) }
?? .package(url: "https://amadan.net/rastrillo/native", revision: "229525909e1d6ac3b6a353d1c4d022798b0e9a24")
-let package = Package(name: "SheetsNative", platforms: [.macOS(.v26), .iOS(.v26)],
+let package = Package(name: "SheetsNative", defaultLocalization: "en", platforms: [.macOS(.v26), .iOS(.v26)],
products: [.library(name:"SheetsKit",targets:["SheetsKit"]), .executable(name:"SheetsMac",targets:["SheetsMac"]), .executable(name:"SheetsReview",targets:["SheetsReview"])],
dependencies:[sharedNative],
- targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native")]),
+ targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native")], resources: [.process("Resources")]),
.executableTarget(name:"SheetsMac",dependencies:[.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "SheetsKit",.product(name:"RastrilloApple",package:"native")],path:"Sources/SheetsApp"),
.executableTarget(name:"SheetsReview",dependencies:["SheetsKit",.product(name:"RastrilloApple",package:"native")]),
- .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit"])])
+ .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit"]),
+ .testTarget(name:"SheetsWindowTests",dependencies:["SheetsMac",.product(name:"RastrilloApple",package:"native")])])
diff --git a/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift b/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift
index ce074cb..4bbf1c9 100644
--- a/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift
+++ b/native/apple/Sources/SheetsApp/SheetsAccountWindow.swift
@@ -6,15 +6,41 @@ import SwiftUI
/// one window must never discard another window's unconfirmed edits.
struct SheetsAccountWindow: View {
@StateObject private var connection = NativeConnection(defaultServer: "https://sheets.oficina.works")
+ #if os(macOS)
+ @Bindable var windows: SheetsWindows
+ @Environment(\.openWindow) private var openWindow
+ #endif
var body: some View {
Group {
if let client = connection.client {
+ #if os(macOS)
+ if let account = connection.activeAccountID {
+ SheetsWorkspace(client: client, accounts: connection.showAccounts, signOut: {
+ guard connection.activeAccountID == account, windows.canSignOut(account) else { return }
+ windows.entries.removeAll { $0.account == account }
+ connection.signOut()
+ }, canSignOut: windows.canSignOut(account), createRequested: $windows.createRequested, openDocument: { document, title in
+ guard connection.activeAccountID == account else { return }
+ let id = windows.reserve(account: account, document: document, title: title, client: client)
+ openWindow(id: "document", value: id)
+ }, updateRecents: { documents in
+ guard connection.activeAccountID == account else { return }
+ windows.updateRecents(documents, account: account, client: client)
+ })
+ .id(account)
+ }
+ #else
SheetsWorkspace(client: client, accounts: connection.showAccounts, signOut: connection.signOut)
.id(connection.activeAccountID)
+ #endif
} else {
NativeAccountsView("Oficina Sheets", systemImage: "tablecells", connection: connection)
}
}.onOpenURL(perform: connection.handleOpenURL)
+ #if os(macOS)
+ .focusedSceneValue(\.sheetsWindowAccount, connection.activeAccountID)
+ .onChange(of: connection.activeAccountID, initial: true) { _, value in windows.activeAccount = value }
+ #endif
.tint(.green)
#if os(macOS)
.frame(minWidth: 760, minHeight: 600)
diff --git a/native/apple/Sources/SheetsApp/SheetsApp.swift b/native/apple/Sources/SheetsApp/SheetsApp.swift
index 5a3f64f..0f425da 100644
--- a/native/apple/Sources/SheetsApp/SheetsApp.swift
+++ b/native/apple/Sources/SheetsApp/SheetsApp.swift
@@ -10,14 +10,27 @@ import SwiftUI
@State private var updater = NativeUpdater()
#endif
#if os(macOS)
+ @State private var windows = SheetsWindows()
@NSApplicationDelegateAdaptor(SheetsApplicationDelegate.self) private var appDelegate
#endif
var body: some Scene {
- WindowGroup { SheetsAccountWindow() }
#if os(macOS)
- #if canImport(RastrilloMacUpdates)
- .commands { NativeUpdateCommands(updater: updater) }
+ Window(sheetsMacString("Home"), id: "home") { SheetsAccountWindow(windows: windows) }
+ .defaultSize(width: 1000, height: 760)
+ .commands {
+ SheetsWindowCommands(windows: windows)
+ SheetsEditorCommands()
+ #if canImport(RastrilloMacUpdates)
+ NativeUpdateCommands(updater: updater)
#endif
+ }
+ WindowGroup("Sheets", id: "document", for: UUID.self) { $id in
+ SheetsDocumentWindow(windows: windows, id: id)
+ }
+ .defaultSize(width: 1120, height: 820)
+ .restorationBehavior(.disabled)
+ #else
+ WindowGroup { SheetsAccountWindow() }
#endif
}
}
diff --git a/native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift b/native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift
new file mode 100644
index 0000000..125b531
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsDocumentWindow.swift
@@ -0,0 +1,23 @@
+#if os(macOS)
+import SheetsKit
+import SwiftUI
+
+struct SheetsDocumentWindow: View {
+ let windows: SheetsWindows
+ let id: UUID?
+ @Environment(\.openWindow) private var openWindow
+ var body: some View {
+ if let entry = windows.entries.first(where: { $0.id == id }) {
+ SheetsWorkspace(client: entry.client, signOut: {}, documentID: entry.document)
+ .id(entry.id)
+ .focusedSceneValue(\.sheetsWindowAccount, entry.account)
+ .onAppear { windows.setOpen(entry.id, true) }
+ .onDisappear { windows.setOpen(entry.id, false) }
+ } else {
+ ContentUnavailableView { Label(sheetsMacString("Open a document from Home"), systemImage: "house") } actions: {
+ Button(sheetsMacString("Home")) { openWindow(id: "home") }
+ }
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift b/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift
new file mode 100644
index 0000000..77b325f
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsWindowCommands.swift
@@ -0,0 +1,32 @@
+#if os(macOS)
+import SwiftUI
+import SheetsKit
+
+struct SheetsWindowCommands: Commands {
+ let windows: SheetsWindows
+ @FocusedValue(\.sheetsWindowAccount) private var account
+ @Environment(\.openWindow) private var openWindow
+ var body: some Commands {
+ CommandGroup(replacing: .newItem) {
+ Button(sheetsMacString("New spreadsheet")) {
+ windows.createRequested = true
+ openWindow(id: "home")
+ }.keyboardShortcut("n")
+ Menu(sheetsMacString("Recent")) {
+ ForEach(windows.entries.filter { $0.account == (account ?? windows.activeAccount) }.prefix(20)) { entry in
+ Button(entry.title) {
+ windows.setOpen(entry.id, true)
+ openWindow(id: "document", value: entry.id)
+ }
+ }
+ if !windows.entries.contains(where: { $0.account == (account ?? windows.activeAccount) }) {
+ Text(sheetsMacString("No recent documents"))
+ }
+ }
+ Divider()
+ SheetsDocumentMenu()
+ }
+
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsApp/SheetsWindows.swift b/native/apple/Sources/SheetsApp/SheetsWindows.swift
new file mode 100644
index 0000000..d8e502b
--- /dev/null
+++ b/native/apple/Sources/SheetsApp/SheetsWindows.swift
@@ -0,0 +1,47 @@
+#if os(macOS)
+import Foundation
+import Observation
+import RastrilloApple
+import SwiftUI
+
+/// Scene values are opaque IDs, never credentials. Reserve before opening a scene.
+@MainActor @Observable final class SheetsWindows {
+ struct Entry: Identifiable {
+ let id: UUID
+ let account: String
+ let document: UInt64
+ let client: NativeHTTP
+ var title: String
+ var isOpen = false
+ }
+ var entries: [Entry] = []
+ var activeAccount: String?
+ var createRequested = false
+ func reserve(account: String, document: UInt64, title: String, client: NativeHTTP) -> UUID {
+ if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) {
+ var entry = entries.remove(at: index)
+ entry.title = title
+ entry.isOpen = true
+ entries.insert(entry, at: 0)
+ return entry.id
+ }
+ let entry = Entry(id: UUID(), account: account, document: document, client: client, title: title, isOpen: true)
+ entries.insert(entry, at: 0)
+ return entry.id
+ }
+ func updateRecents(_ documents: [(UInt64, String)], account: String, client: NativeHTTP) {
+ for (document, title) in documents {
+ if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) {
+ entries[index].title = title
+ } else {
+ entries.append(Entry(id: UUID(), account: account, document: document, client: client, title: title))
+ }
+ }
+ }
+ func setOpen(_ id: UUID, _ value: Bool) {
+ if let index = entries.firstIndex(where: { $0.id == id }) { entries[index].isOpen = value }
+ }
+ func canSignOut(_ account: String?) -> Bool { !entries.contains { $0.account == account && $0.isOpen } }
+}
+extension FocusedValues { @Entry var sheetsWindowAccount: String? }
+#endif
diff --git a/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
new file mode 100644
index 0000000..1542309
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ar.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "الحسابات";
+"Actual size" = "الحجم الفعلي";
+"Alignment" = "المحاذاة";
+"Block quote" = "كتلة اقتباس";
+"Bold" = "عريض";
+"Bullet list" = "قائمة نقطية";
+"Cell contents" = "محتوى الخلية";
+"Clear formatting" = "مسح التنسيق";
+"Close this account’s document windows before signing out." = "أغلق نوافذ مستندات هذا الحساب قبل تسجيل الخروج.";
+"Create a document to get started." = "ستظهر المستندات التي تنشئها هنا، بدءًا بالأحدث.";
+"Docs" = "Docs";
+"Document" = "المستند";
+"Document inspector" = "خصائص المستند";
+"Double-click to fit column to contents" = "انقر مرتين لملاءمة العمود للمحتوى";
+"Edit %@" = "تحرير %@";
+"Export" = "تصدير";
+"Fit column to contents" = "ملاءمة العمود للمحتوى";
+"Font" = "الخط";
+"Format" = "تنسيق";
+"Heading %d" = "العنوان %d";
+"Home" = "الرئيسية";
+"Indent" = "زيادة المسافة البادئة";
+"Inline code" = "شيفرة";
+"Inspector" = "خصائص المستند";
+"Italic" = "مائل";
+"Markdown…" = "Markdown";
+"New document" = "مستند جديد";
+"No documents" = "لا توجد مستندات بعد";
+"No recent documents" = "لا توجد مستندات بعد";
+"Normal" = "نص أساسي";
+"Numbered list" = "قائمة مرقّمة";
+"Open a document from Home" = "افتح مستندًا من الرئيسية";
+"Outdent" = "تقليل المسافة البادئة";
+"PDF…" = "PDF";
+"Pages" = "صفحات";
+"Paragraph style" = "نمط الفقرة";
+"Print…" = "طباعة";
+"Recent" = "الأخيرة";
+"Recent documents" = "المستندات الأخيرة";
+"Recent spreadsheets" = "جداول البيانات الأخيرة";
+"Retry" = "إعادة المحاولة";
+"Save" = "حفظ";
+"Select column %@" = "تحديد العمود %@";
+"Select row %d" = "تحديد الصف %d";
+"Shared" = "تمت مشاركتها معك";
+"Share…" = "مشاركة";
+"Sign out" = "تسجيل الخروج";
+"Size" = "الحجم";
+"Zoom" = "التكبير";
+"New spreadsheet" = "جدول بيانات جديد";
+"Rename…" = "إعادة تسمية…";
+"Export workbook…" = "تصدير المصنف…";
+"Version history…" = "سجل الإصدارات…";
+"Undo" = "تراجع";
+"Redo" = "إعادة";
+"Cut" = "قص";
+"Copy" = "نسخ";
+"Paste" = "لصق";
+"Select all" = "تحديد الكل";
+"Clear contents" = "مسح المحتويات";
+"Fill down" = "تعبئة لأسفل";
+"Fill right" = "تعبئة لليمين";
+"Row and column sizes…" = "أحجام الصفوف والأعمدة…";
+"Insert" = "إدراج";
+"Sheet" = "ورقة";
+"Sort and filter…" = "فرز وتصفية…";
+"Comments…" = "تعليقات…";
+"Refresh" = "تحديث";
+"Import workbook" = "استيراد مصنف";
+"Search spreadsheets" = "البحث في جداول البيانات";
diff --git a/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
new file mode 100644
index 0000000..c89ba83
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/bn.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "অ্যাকাউন্ট";
+"Actual size" = "প্রকৃত আকার";
+"Alignment" = "প্রান্তিককরণ";
+"Block quote" = "উদ্ধৃতির ব্লক";
+"Bold" = "গাঢ়";
+"Bullet list" = "বুলেটযুক্ত তালিকা";
+"Cell contents" = "ঘরের বিষয়বস্তু";
+"Clear formatting" = "বিন্যাস মুছুন";
+"Close this account’s document windows before signing out." = "সাইন আউট করার আগে এই অ্যাকাউন্টের নথির উইন্ডোগুলি বন্ধ করুন।";
+"Create a document to get started." = "আপনার তৈরি নথি এখানে দেখা যাবে, নতুনগুলো আগে।";
+"Docs" = "Docs";
+"Document" = "নথি";
+"Document inspector" = "নথি পরিদর্শক";
+"Double-click to fit column to contents" = "বিষয়বস্তু অনুযায়ী কলাম মাপতে দুবার ক্লিক করুন";
+"Edit %@" = "%@ সম্পাদনা করুন";
+"Export" = "রপ্তানি";
+"Fit column to contents" = "বিষয়বস্তু অনুযায়ী কলাম মাপুন";
+"Font" = "ফন্ট";
+"Format" = "বিন্যাস";
+"Heading %d" = "শিরোনাম %d";
+"Home" = "হোম";
+"Indent" = "ইন্ডেন্ট বাড়ান";
+"Inline code" = "কোড";
+"Inspector" = "নথি পরিদর্শক";
+"Italic" = "তির্যক";
+"Markdown…" = "Markdown";
+"New document" = "নতুন নথি";
+"No documents" = "এখনও কোনো নথি নেই";
+"No recent documents" = "এখনও কোনো নথি নেই";
+"Normal" = "মূল পাঠ্য";
+"Numbered list" = "সংখ্যাযুক্ত তালিকা";
+"Open a document from Home" = "হোম থেকে একটি নথি খুলুন";
+"Outdent" = "ইন্ডেন্ট কমান";
+"PDF…" = "PDF";
+"Pages" = "পৃষ্ঠাভিত্তিক";
+"Paragraph style" = "অনুচ্ছেদের শৈলী";
+"Print…" = "প্রিন্ট করুন";
+"Recent" = "সাম্প্রতিক";
+"Recent documents" = "সাম্প্রতিক নথি";
+"Recent spreadsheets" = "সাম্প্রতিক স্প্রেডশিট";
+"Retry" = "আবার চেষ্টা করুন";
+"Save" = "সংরক্ষণ করুন";
+"Select column %@" = "কলাম %@ নির্বাচন করুন";
+"Select row %d" = "সারি %d নির্বাচন করুন";
+"Shared" = "আপনার সঙ্গে শেয়ার করা";
+"Share…" = "শেয়ার করুন";
+"Sign out" = "সাইন আউট করুন";
+"Size" = "আকার";
+"Zoom" = "জুম";
+"New spreadsheet" = "নতুন স্প্রেডশিট";
+"Rename…" = "নাম বদলান…";
+"Export workbook…" = "ওয়ার্কবুক রপ্তানি করুন…";
+"Version history…" = "সংস্করণের ইতিহাস…";
+"Undo" = "পূর্বাবস্থায় ফেরান";
+"Redo" = "আবার করুন";
+"Cut" = "কাটুন";
+"Copy" = "কপি করুন";
+"Paste" = "পেস্ট করুন";
+"Select all" = "সব নির্বাচন করুন";
+"Clear contents" = "বিষয়বস্তু মুছুন";
+"Fill down" = "নিচে পূরণ করুন";
+"Fill right" = "ডানে পূরণ করুন";
+"Row and column sizes…" = "সারি ও কলামের মাপ…";
+"Insert" = "ঢোকান";
+"Sheet" = "শিট";
+"Sort and filter…" = "সাজান ও ফিল্টার করুন…";
+"Comments…" = "মন্তব্য…";
+"Refresh" = "রিফ্রেশ করুন";
+"Import workbook" = "ওয়ার্কবুক আমদানি করুন";
+"Search spreadsheets" = "স্প্রেডশিট খুঁজুন";
diff --git a/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
new file mode 100644
index 0000000..fa760a7
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "Accounts";
+"Actual size" = "Actual size";
+"Alignment" = "Alignment";
+"Block quote" = "Block quote";
+"Bold" = "Bold";
+"Bullet list" = "Bulleted list";
+"Cell contents" = "Cell contents";
+"Clear formatting" = "Clear formatting";
+"Close this account’s document windows before signing out." = "Close this account’s document windows before signing out.";
+"Create a document to get started." = "Documents you create will show up here, newest first.";
+"Docs" = "Docs";
+"Document" = "Document";
+"Document inspector" = "Document inspector";
+"Double-click to fit column to contents" = "Double-click to fit column to contents";
+"Edit %@" = "Edit %@";
+"Export" = "Export";
+"Fit column to contents" = "Fit column to contents";
+"Font" = "Font";
+"Format" = "Format";
+"Heading %d" = "Heading %d";
+"Home" = "Home";
+"Indent" = "Increase indent";
+"Inline code" = "Code";
+"Inspector" = "Document inspector";
+"Italic" = "Italic";
+"Markdown…" = "Markdown";
+"New document" = "New document";
+"No documents" = "No documents yet";
+"No recent documents" = "No documents yet";
+"Normal" = "Body text";
+"Numbered list" = "Numbered list";
+"Open a document from Home" = "Open a document from Home";
+"Outdent" = "Decrease indent";
+"PDF…" = "PDF";
+"Pages" = "Paged";
+"Paragraph style" = "Paragraph style";
+"Print…" = "Print";
+"Recent" = "Recent";
+"Recent documents" = "Recent documents";
+"Recent spreadsheets" = "Recent spreadsheets";
+"Retry" = "Try again";
+"Save" = "Save";
+"Select column %@" = "Select column %@";
+"Select row %d" = "Select row %d";
+"Shared" = "Shared with you";
+"Share…" = "Share";
+"Sign out" = "Sign out";
+"Size" = "Size";
+"Zoom" = "Zoom";
+"New spreadsheet" = "New spreadsheet";
+"Rename…" = "Rename…";
+"Export workbook…" = "Export workbook…";
+"Version history…" = "Version history…";
+"Undo" = "Undo";
+"Redo" = "Redo";
+"Cut" = "Cut";
+"Copy" = "Copy";
+"Paste" = "Paste";
+"Select all" = "Select all";
+"Clear contents" = "Clear contents";
+"Fill down" = "Fill down";
+"Fill right" = "Fill right";
+"Row and column sizes…" = "Row and column sizes…";
+"Insert" = "Insert";
+"Sheet" = "Sheet";
+"Sort and filter…" = "Sort and filter…";
+"Comments…" = "Comments…";
+"Refresh" = "Refresh";
+"Import workbook" = "Import workbook";
+"Search spreadsheets" = "Search spreadsheets";
diff --git a/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
new file mode 100644
index 0000000..da781bb
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/es.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "Cuentas";
+"Actual size" = "Tamaño real";
+"Alignment" = "Alineación";
+"Block quote" = "Cita en bloque";
+"Bold" = "Negrita";
+"Bullet list" = "Lista con viñetas";
+"Cell contents" = "Contenido de la celda";
+"Clear formatting" = "Borrar formato";
+"Close this account’s document windows before signing out." = "Cierra las ventanas de documentos de esta cuenta antes de cerrar sesión.";
+"Create a document to get started." = "Los documentos que crees aparecerán aquí, empezando por los más recientes.";
+"Docs" = "Docs";
+"Document" = "Documento";
+"Document inspector" = "Inspector del documento";
+"Double-click to fit column to contents" = "Haz doble clic para ajustar la columna al contenido";
+"Edit %@" = "Editar %@";
+"Export" = "Exportar";
+"Fit column to contents" = "Ajustar columna al contenido";
+"Font" = "Fuente";
+"Format" = "Formato";
+"Heading %d" = "Título %d";
+"Home" = "Inicio";
+"Indent" = "Aumentar sangría";
+"Inline code" = "Código";
+"Inspector" = "Inspector del documento";
+"Italic" = "Cursiva";
+"Markdown…" = "Markdown";
+"New document" = "Nuevo documento";
+"No documents" = "Aún no hay documentos";
+"No recent documents" = "Aún no hay documentos";
+"Normal" = "Texto del cuerpo";
+"Numbered list" = "Lista numerada";
+"Open a document from Home" = "Abre un documento desde Inicio";
+"Outdent" = "Reducir sangría";
+"PDF…" = "PDF";
+"Pages" = "Por páginas";
+"Paragraph style" = "Estilo de párrafo";
+"Print…" = "Imprimir";
+"Recent" = "Recientes";
+"Recent documents" = "Documentos recientes";
+"Recent spreadsheets" = "Hojas de cálculo recientes";
+"Retry" = "Reintentar";
+"Save" = "Guardar";
+"Select column %@" = "Seleccionar columna %@";
+"Select row %d" = "Seleccionar fila %d";
+"Shared" = "Compartidos contigo";
+"Share…" = "Compartir";
+"Sign out" = "Cerrar sesión";
+"Size" = "Tamaño";
+"Zoom" = "Zoom";
+"New spreadsheet" = "Nueva hoja de cálculo";
+"Rename…" = "Renombrar…";
+"Export workbook…" = "Exportar libro…";
+"Version history…" = "Historial de versiones…";
+"Undo" = "Deshacer";
+"Redo" = "Rehacer";
+"Cut" = "Cortar";
+"Copy" = "Copiar";
+"Paste" = "Pegar";
+"Select all" = "Seleccionar todo";
+"Clear contents" = "Borrar contenido";
+"Fill down" = "Rellenar hacia abajo";
+"Fill right" = "Rellenar hacia la derecha";
+"Row and column sizes…" = "Tamaños de filas y columnas…";
+"Insert" = "Insertar";
+"Sheet" = "Hoja";
+"Sort and filter…" = "Ordenar y filtrar…";
+"Comments…" = "Comentarios…";
+"Refresh" = "Actualizar";
+"Import workbook" = "Importar libro";
+"Search spreadsheets" = "Buscar hojas de cálculo";
diff --git a/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
new file mode 100644
index 0000000..cb1dfa5
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ga.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "Cuntais";
+"Actual size" = "Fíormhéid";
+"Alignment" = "Ailíniú";
+"Block quote" = "Athfhriotal i mbloc";
+"Bold" = "Trom";
+"Bullet list" = "Liosta le hurchair";
+"Cell contents" = "Ábhar na cille";
+"Clear formatting" = "Glan an fhormáidiú";
+"Close this account’s document windows before signing out." = "Dún fuinneoga cáipéisí an chuntais seo sula síníonn tú amach.";
+"Create a document to get started." = "Beidh na doiciméid a chruthaíonn tú anseo, na cinn is nua ar dtús.";
+"Docs" = "Docs";
+"Document" = "Cáipéis";
+"Document inspector" = "Cigire cáipéise";
+"Double-click to fit column to contents" = "Déchliceáil chun an colún a oiriúnú don ábhar";
+"Edit %@" = "Cuir %@ in eagar";
+"Export" = "Easpórtáil";
+"Fit column to contents" = "Oiriúnaigh an colún don ábhar";
+"Font" = "Cló";
+"Format" = "Formáid";
+"Heading %d" = "Ceannteideal %d";
+"Home" = "Baile";
+"Indent" = "Méadaigh an eangú";
+"Inline code" = "Cód";
+"Inspector" = "Cigire cáipéise";
+"Italic" = "Iodálach";
+"Markdown…" = "Markdown";
+"New document" = "Doiciméad nua";
+"No documents" = "Níl aon doiciméid ann fós";
+"No recent documents" = "Níl aon doiciméid ann fós";
+"Normal" = "Téacs coirp";
+"Numbered list" = "Liosta uimhrithe";
+"Open a document from Home" = "Oscail cáipéis ón mBaile";
+"Outdent" = "Laghdaigh an eangú";
+"PDF…" = "PDF";
+"Pages" = "Leathanaigh";
+"Paragraph style" = "Stíl ailt";
+"Print…" = "Priontáil";
+"Recent" = "Le déanaí";
+"Recent documents" = "Cáipéisí le déanaí";
+"Recent spreadsheets" = "Scarbhileoga le déanaí";
+"Retry" = "Bain triail eile as";
+"Save" = "Sábháil";
+"Select column %@" = "Roghnaigh colún %@";
+"Select row %d" = "Roghnaigh ró %d";
+"Shared" = "Comhroinnte leat";
+"Share…" = "Comhroinn";
+"Sign out" = "Sínigh amach";
+"Size" = "Méid";
+"Zoom" = "Súmáil";
+"New spreadsheet" = "Scarbhileog nua";
+"Rename…" = "Athainmnigh…";
+"Export workbook…" = "Easpórtáil leabhar oibre…";
+"Version history…" = "Stair na leaganacha…";
+"Undo" = "Cealaigh";
+"Redo" = "Athdhéan";
+"Cut" = "Gearr";
+"Copy" = "Cóipeáil";
+"Paste" = "Greamaigh";
+"Select all" = "Roghnaigh uile";
+"Clear contents" = "Glan an t-ábhar";
+"Fill down" = "Líon síos";
+"Fill right" = "Líon ar dheis";
+"Row and column sizes…" = "Méideanna rónna agus colún…";
+"Insert" = "Ionsáigh";
+"Sheet" = "Bileog";
+"Sort and filter…" = "Sórtáil agus scag…";
+"Comments…" = "Nótaí tráchta…";
+"Refresh" = "Athnuaigh";
+"Import workbook" = "Iompórtáil leabhar oibre";
+"Search spreadsheets" = "Cuardaigh scarbhileoga";
diff --git a/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
new file mode 100644
index 0000000..6299d2e
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/hi.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "खाते";
+"Actual size" = "वास्तविक आकार";
+"Alignment" = "संरेखण";
+"Block quote" = "ब्लॉक उद्धरण";
+"Bold" = "बोल्ड";
+"Bullet list" = "बुलेट वाली सूची";
+"Cell contents" = "सेल की सामग्री";
+"Clear formatting" = "फ़ॉर्मैटिंग हटाएँ";
+"Close this account’s document windows before signing out." = "साइन आउट करने से पहले इस खाते की दस्तावेज़ विंडो बंद करें।";
+"Create a document to get started." = "आपके बनाए दस्तावेज़ यहाँ दिखेंगे, सबसे नए पहले।";
+"Docs" = "Docs";
+"Document" = "दस्तावेज़";
+"Document inspector" = "दस्तावेज़ निरीक्षक";
+"Double-click to fit column to contents" = "सामग्री के अनुसार आकार बदलने के लिए डबल-क्लिक करें";
+"Edit %@" = "%@ संपादित करें";
+"Export" = "निर्यात";
+"Fit column to contents" = "सामग्री के अनुसार स्तंभ का आकार बदलें";
+"Font" = "फ़ॉन्ट";
+"Format" = "फ़ॉर्मैट";
+"Heading %d" = "शीर्षक %d";
+"Home" = "होम";
+"Indent" = "इंडेंट बढ़ाएँ";
+"Inline code" = "कोड";
+"Inspector" = "दस्तावेज़ निरीक्षक";
+"Italic" = "इटैलिक";
+"Markdown…" = "Markdown";
+"New document" = "नया दस्तावेज़";
+"No documents" = "अभी कोई दस्तावेज़ नहीं";
+"No recent documents" = "अभी कोई दस्तावेज़ नहीं";
+"Normal" = "मुख्य पाठ";
+"Numbered list" = "क्रमांकित सूची";
+"Open a document from Home" = "होम से दस्तावेज़ खोलें";
+"Outdent" = "इंडेंट घटाएँ";
+"PDF…" = "PDF";
+"Pages" = "पृष्ठों में";
+"Paragraph style" = "अनुच्छेद शैली";
+"Print…" = "प्रिंट करें";
+"Recent" = "हाल के";
+"Recent documents" = "हाल के दस्तावेज़";
+"Recent spreadsheets" = "हाल की स्प्रेडशीट";
+"Retry" = "फिर कोशिश करें";
+"Save" = "सहेजें";
+"Select column %@" = "स्तंभ %@ चुनें";
+"Select row %d" = "पंक्ति %d चुनें";
+"Shared" = "आपके साथ शेयर किए गए";
+"Share…" = "शेयर करें";
+"Sign out" = "साइन आउट करें";
+"Size" = "आकार";
+"Zoom" = "ज़ूम";
+"New spreadsheet" = "नई स्प्रेडशीट";
+"Rename…" = "नाम बदलें…";
+"Export workbook…" = "कार्यपुस्तिका निर्यात करें…";
+"Version history…" = "संस्करण इतिहास…";
+"Undo" = "पूर्ववत करें";
+"Redo" = "फिर करें";
+"Cut" = "काटें";
+"Copy" = "कॉपी करें";
+"Paste" = "चिपकाएँ";
+"Select all" = "सभी चुनें";
+"Clear contents" = "सामग्री मिटाएँ";
+"Fill down" = "नीचे भरें";
+"Fill right" = "दाईं ओर भरें";
+"Row and column sizes…" = "पंक्ति और स्तंभ के आकार…";
+"Insert" = "सम्मिलित करें";
+"Sheet" = "शीट";
+"Sort and filter…" = "क्रमबद्ध और फ़िल्टर करें…";
+"Comments…" = "टिप्पणियाँ…";
+"Refresh" = "रीफ़्रेश करें";
+"Import workbook" = "कार्यपुस्तिका आयात करें";
+"Search spreadsheets" = "स्प्रेडशीट खोजें";
diff --git a/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
new file mode 100644
index 0000000..787ec84
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ja.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "アカウント";
+"Actual size" = "実際のサイズ";
+"Alignment" = "配置";
+"Block quote" = "引用ブロック";
+"Bold" = "太字";
+"Bullet list" = "箇条書き";
+"Cell contents" = "セルの内容";
+"Clear formatting" = "書式をクリア";
+"Close this account’s document windows before signing out." = "サインアウトする前に、このアカウントの書類ウインドウを閉じてください。";
+"Create a document to get started." = "作成したドキュメントが新しい順にここに表示されます。";
+"Docs" = "Docs";
+"Document" = "書類";
+"Document inspector" = "書類インスペクタ";
+"Double-click to fit column to contents" = "ダブルクリックで列幅を内容に合わせる";
+"Edit %@" = "%@ を編集";
+"Export" = "書き出す";
+"Fit column to contents" = "列幅を内容に合わせる";
+"Font" = "フォント";
+"Format" = "書式";
+"Heading %d" = "見出し %d";
+"Home" = "ホーム";
+"Indent" = "インデントを増やす";
+"Inline code" = "コード";
+"Inspector" = "書類インスペクタ";
+"Italic" = "斜体";
+"Markdown…" = "Markdown";
+"New document" = "新規ドキュメント";
+"No documents" = "ドキュメントはまだありません";
+"No recent documents" = "ドキュメントはまだありません";
+"Normal" = "本文";
+"Numbered list" = "番号付きリスト";
+"Open a document from Home" = "ホームから書類を開く";
+"Outdent" = "インデントを減らす";
+"PDF…" = "PDF";
+"Pages" = "ページ区切り";
+"Paragraph style" = "段落スタイル";
+"Print…" = "プリント";
+"Recent" = "最近使った項目";
+"Recent documents" = "最近の書類";
+"Recent spreadsheets" = "最近のスプレッドシート";
+"Retry" = "再試行";
+"Save" = "保存";
+"Select column %@" = "列 %@ を選択";
+"Select row %d" = "行 %d を選択";
+"Shared" = "あなたと共有";
+"Share…" = "共有";
+"Sign out" = "サインアウト";
+"Size" = "サイズ";
+"Zoom" = "ズーム";
+"New spreadsheet" = "新規スプレッドシート";
+"Rename…" = "名前を変更…";
+"Export workbook…" = "ブックを書き出す…";
+"Version history…" = "バージョン履歴…";
+"Undo" = "取り消す";
+"Redo" = "やり直す";
+"Cut" = "カット";
+"Copy" = "コピー";
+"Paste" = "ペースト";
+"Select all" = "すべてを選択";
+"Clear contents" = "内容を消去";
+"Fill down" = "下方向へコピー";
+"Fill right" = "右方向へコピー";
+"Row and column sizes…" = "行と列のサイズ…";
+"Insert" = "挿入";
+"Sheet" = "シート";
+"Sort and filter…" = "並べ替えとフィルター…";
+"Comments…" = "コメント…";
+"Refresh" = "更新";
+"Import workbook" = "ブックを読み込む";
+"Search spreadsheets" = "スプレッドシートを検索";
diff --git a/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
new file mode 100644
index 0000000..7e008d6
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/pt.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "Contas";
+"Actual size" = "Tamanho real";
+"Alignment" = "Alinhamento";
+"Block quote" = "Citação em bloco";
+"Bold" = "Negrito";
+"Bullet list" = "Lista com marcas";
+"Cell contents" = "Conteúdo da célula";
+"Clear formatting" = "Limpar formatação";
+"Close this account’s document windows before signing out." = "Feche as janelas de documentos desta conta antes de terminar a sessão.";
+"Create a document to get started." = "Os documentos que criar aparecem aqui, começando pelos mais recentes.";
+"Docs" = "Docs";
+"Document" = "Documento";
+"Document inspector" = "Inspetor do documento";
+"Double-click to fit column to contents" = "Clique duas vezes para ajustar a coluna ao conteúdo";
+"Edit %@" = "Editar %@";
+"Export" = "Exportar";
+"Fit column to contents" = "Ajustar coluna ao conteúdo";
+"Font" = "Tipo de letra";
+"Format" = "Formato";
+"Heading %d" = "Título %d";
+"Home" = "Início";
+"Indent" = "Aumentar avanço";
+"Inline code" = "Código";
+"Inspector" = "Inspetor do documento";
+"Italic" = "Itálico";
+"Markdown…" = "Markdown";
+"New document" = "Novo documento";
+"No documents" = "Ainda não há documentos";
+"No recent documents" = "Ainda não há documentos";
+"Normal" = "Corpo do texto";
+"Numbered list" = "Lista numerada";
+"Open a document from Home" = "Abra um documento no Início";
+"Outdent" = "Diminuir avanço";
+"PDF…" = "PDF";
+"Pages" = "Paginado";
+"Paragraph style" = "Estilo de parágrafo";
+"Print…" = "Imprimir";
+"Recent" = "Recentes";
+"Recent documents" = "Documentos recentes";
+"Recent spreadsheets" = "Folhas de cálculo recentes";
+"Retry" = "Tentar novamente";
+"Save" = "Guardar";
+"Select column %@" = "Selecionar coluna %@";
+"Select row %d" = "Selecionar linha %d";
+"Shared" = "Partilhados consigo";
+"Share…" = "Partilhar";
+"Sign out" = "Terminar sessão";
+"Size" = "Tamanho";
+"Zoom" = "Zoom";
+"New spreadsheet" = "Nova planilha";
+"Rename…" = "Renomear…";
+"Export workbook…" = "Exportar pasta de trabalho…";
+"Version history…" = "Histórico de versões…";
+"Undo" = "Desfazer";
+"Redo" = "Refazer";
+"Cut" = "Recortar";
+"Copy" = "Copiar";
+"Paste" = "Colar";
+"Select all" = "Selecionar tudo";
+"Clear contents" = "Limpar conteúdo";
+"Fill down" = "Preencher para baixo";
+"Fill right" = "Preencher à direita";
+"Row and column sizes…" = "Tamanhos de linhas e colunas…";
+"Insert" = "Inserir";
+"Sheet" = "Planilha";
+"Sort and filter…" = "Ordenar e filtrar…";
+"Comments…" = "Comentários…";
+"Refresh" = "Atualizar";
+"Import workbook" = "Importar pasta de trabalho";
+"Search spreadsheets" = "Pesquisar planilhas";
diff --git a/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
new file mode 100644
index 0000000..510d270
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/ru.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "Учётные записи";
+"Actual size" = "Фактический размер";
+"Alignment" = "Выравнивание";
+"Block quote" = "Блок цитаты";
+"Bold" = "Полужирный";
+"Bullet list" = "Маркированный список";
+"Cell contents" = "Содержимое ячейки";
+"Clear formatting" = "Очистить форматирование";
+"Close this account’s document windows before signing out." = "Перед выходом закройте окна документов этой учётной записи.";
+"Create a document to get started." = "Созданные вами документы появятся здесь, начиная с самых новых.";
+"Docs" = "Docs";
+"Document" = "Документ";
+"Document inspector" = "Инспектор документа";
+"Double-click to fit column to contents" = "Дважды щёлкните для подгонки ширины столбца";
+"Edit %@" = "Изменить %@";
+"Export" = "Экспорт";
+"Fit column to contents" = "Подогнать ширину столбца";
+"Font" = "Шрифт";
+"Format" = "Формат";
+"Heading %d" = "Заголовок %d";
+"Home" = "Главная";
+"Indent" = "Увеличить отступ";
+"Inline code" = "Код";
+"Inspector" = "Инспектор документа";
+"Italic" = "Курсив";
+"Markdown…" = "Markdown";
+"New document" = "Новый документ";
+"No documents" = "Документов пока нет";
+"No recent documents" = "Документов пока нет";
+"Normal" = "Основной текст";
+"Numbered list" = "Нумерованный список";
+"Open a document from Home" = "Откройте документ на главной";
+"Outdent" = "Уменьшить отступ";
+"PDF…" = "PDF";
+"Pages" = "Постраничный";
+"Paragraph style" = "Стиль абзаца";
+"Print…" = "Печать";
+"Recent" = "Недавние";
+"Recent documents" = "Недавние документы";
+"Recent spreadsheets" = "Недавние таблицы";
+"Retry" = "Повторить попытку";
+"Save" = "Сохранить";
+"Select column %@" = "Выбрать столбец %@";
+"Select row %d" = "Выбрать строку %d";
+"Shared" = "Доступные вам";
+"Share…" = "Поделиться";
+"Sign out" = "Выйти";
+"Size" = "Размер";
+"Zoom" = "Масштаб";
+"New spreadsheet" = "Новая таблица";
+"Rename…" = "Переименовать…";
+"Export workbook…" = "Экспорт книги…";
+"Version history…" = "История версий…";
+"Undo" = "Отменить";
+"Redo" = "Повторить";
+"Cut" = "Вырезать";
+"Copy" = "Копировать";
+"Paste" = "Вставить";
+"Select all" = "Выбрать всё";
+"Clear contents" = "Очистить содержимое";
+"Fill down" = "Заполнить вниз";
+"Fill right" = "Заполнить вправо";
+"Row and column sizes…" = "Размеры строк и столбцов…";
+"Insert" = "Вставка";
+"Sheet" = "Лист";
+"Sort and filter…" = "Сортировка и фильтр…";
+"Comments…" = "Комментарии…";
+"Refresh" = "Обновить";
+"Import workbook" = "Импорт книги";
+"Search spreadsheets" = "Поиск таблиц";
diff --git a/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
new file mode 100644
index 0000000..7a0f646
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/vi.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "Tài khoản";
+"Actual size" = "Kích thước thực";
+"Alignment" = "Căn chỉnh";
+"Block quote" = "Khối trích dẫn";
+"Bold" = "Đậm";
+"Bullet list" = "Danh sách dấu đầu dòng";
+"Cell contents" = "Nội dung ô";
+"Clear formatting" = "Xóa định dạng";
+"Close this account’s document windows before signing out." = "Đóng các cửa sổ tài liệu của tài khoản này trước khi đăng xuất.";
+"Create a document to get started." = "Tài liệu bạn tạo sẽ xuất hiện ở đây, mới nhất trước.";
+"Docs" = "Docs";
+"Document" = "Tài liệu";
+"Document inspector" = "Thuộc tính tài liệu";
+"Double-click to fit column to contents" = "Nhấp đúp để khớp cột với nội dung";
+"Edit %@" = "Sửa %@";
+"Export" = "Xuất";
+"Fit column to contents" = "Khớp cột với nội dung";
+"Font" = "Phông chữ";
+"Format" = "Định dạng";
+"Heading %d" = "Tiêu đề %d";
+"Home" = "Trang chủ";
+"Indent" = "Tăng thụt lề";
+"Inline code" = "Mã";
+"Inspector" = "Thuộc tính tài liệu";
+"Italic" = "Nghiêng";
+"Markdown…" = "Markdown";
+"New document" = "Tài liệu mới";
+"No documents" = "Chưa có tài liệu";
+"No recent documents" = "Chưa có tài liệu";
+"Normal" = "Văn bản nội dung";
+"Numbered list" = "Danh sách đánh số";
+"Open a document from Home" = "Mở tài liệu từ Trang chủ";
+"Outdent" = "Giảm thụt lề";
+"PDF…" = "PDF";
+"Pages" = "Theo trang";
+"Paragraph style" = "Kiểu đoạn văn";
+"Print…" = "In";
+"Recent" = "Gần đây";
+"Recent documents" = "Tài liệu gần đây";
+"Recent spreadsheets" = "Bảng tính gần đây";
+"Retry" = "Thử lại";
+"Save" = "Lưu";
+"Select column %@" = "Chọn cột %@";
+"Select row %d" = "Chọn hàng %d";
+"Shared" = "Được chia sẻ với bạn";
+"Share…" = "Chia sẻ";
+"Sign out" = "Đăng xuất";
+"Size" = "Kích cỡ";
+"Zoom" = "Thu phóng";
+"New spreadsheet" = "Bảng tính mới";
+"Rename…" = "Đổi tên…";
+"Export workbook…" = "Xuất sổ làm việc…";
+"Version history…" = "Lịch sử phiên bản…";
+"Undo" = "Hoàn tác";
+"Redo" = "Làm lại";
+"Cut" = "Cắt";
+"Copy" = "Sao chép";
+"Paste" = "Dán";
+"Select all" = "Chọn tất cả";
+"Clear contents" = "Xóa nội dung";
+"Fill down" = "Điền xuống";
+"Fill right" = "Điền sang phải";
+"Row and column sizes…" = "Kích thước hàng và cột…";
+"Insert" = "Chèn";
+"Sheet" = "Trang tính";
+"Sort and filter…" = "Sắp xếp và lọc…";
+"Comments…" = "Bình luận…";
+"Refresh" = "Làm mới";
+"Import workbook" = "Nhập sổ làm việc";
+"Search spreadsheets" = "Tìm bảng tính";
diff --git a/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
new file mode 100644
index 0000000..4af29a1
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/yue.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "帳戶";
+"Actual size" = "實際大小";
+"Alignment" = "對齊";
+"Block quote" = "引文區塊";
+"Bold" = "粗體";
+"Bullet list" = "項目符號清單";
+"Cell contents" = "儲存格內容";
+"Clear formatting" = "清除格式";
+"Close this account’s document windows before signing out." = "登出前請先關閉此帳戶嘅文件視窗。";
+"Create a document to get started." = "你建立嘅文件會喺呢度顯示,最新嘅排最前。";
+"Docs" = "Docs";
+"Document" = "文件";
+"Document inspector" = "文件檢查器";
+"Double-click to fit column to contents" = "按兩下以按內容調整欄寬";
+"Edit %@" = "編輯 %@";
+"Export" = "輸出";
+"Fit column to contents" = "按內容調整欄寬";
+"Font" = "字體";
+"Format" = "格式";
+"Heading %d" = "標題 %d";
+"Home" = "主頁";
+"Indent" = "增加縮排";
+"Inline code" = "程式碼";
+"Inspector" = "文件檢查器";
+"Italic" = "斜體";
+"Markdown…" = "Markdown";
+"New document" = "新增文件";
+"No documents" = "仲未有文件";
+"No recent documents" = "仲未有文件";
+"Normal" = "內文";
+"Numbered list" = "編號清單";
+"Open a document from Home" = "由主頁開啟文件";
+"Outdent" = "減少縮排";
+"PDF…" = "PDF";
+"Pages" = "分頁";
+"Paragraph style" = "段落樣式";
+"Print…" = "列印";
+"Recent" = "最近項目";
+"Recent documents" = "最近文件";
+"Recent spreadsheets" = "最近試算表";
+"Retry" = "再試一次";
+"Save" = "儲存";
+"Select column %@" = "選取 %@ 欄";
+"Select row %d" = "選取第 %d 行";
+"Shared" = "同你分享嘅文件";
+"Share…" = "分享";
+"Sign out" = "登出";
+"Size" = "大小";
+"Zoom" = "縮放";
+"New spreadsheet" = "新增試算表";
+"Rename…" = "重新命名…";
+"Export workbook…" = "匯出活頁簿…";
+"Version history…" = "版本記錄…";
+"Undo" = "還原";
+"Redo" = "重做";
+"Cut" = "剪下";
+"Copy" = "複製";
+"Paste" = "貼上";
+"Select all" = "全選";
+"Clear contents" = "清除內容";
+"Fill down" = "向下填滿";
+"Fill right" = "向右填滿";
+"Row and column sizes…" = "列與欄大小…";
+"Insert" = "插入";
+"Sheet" = "工作表";
+"Sort and filter…" = "排序及篩選…";
+"Comments…" = "留言…";
+"Refresh" = "重新整理";
+"Import workbook" = "匯入活頁簿";
+"Search spreadsheets" = "搜尋試算表";
diff --git a/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
new file mode 100644
index 0000000..c2abb6d
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/Resources/zh-Hans.lproj/Mac.strings
@@ -0,0 +1,70 @@
+"Accounts" = "账户";
+"Actual size" = "实际大小";
+"Alignment" = "对齐";
+"Block quote" = "块引用";
+"Bold" = "粗体";
+"Bullet list" = "项目符号列表";
+"Cell contents" = "单元格内容";
+"Clear formatting" = "清除格式";
+"Close this account’s document windows before signing out." = "退出登录前请关闭此账户的文档窗口。";
+"Create a document to get started." = "您创建的文档会显示在这里,最新的排在最前面。";
+"Docs" = "Docs";
+"Document" = "文档";
+"Document inspector" = "文档检查器";
+"Double-click to fit column to contents" = "双击以根据内容调整列宽";
+"Edit %@" = "编辑 %@";
+"Export" = "导出";
+"Fit column to contents" = "根据内容调整列宽";
+"Font" = "字体";
+"Format" = "格式";
+"Heading %d" = "标题 %d";
+"Home" = "主页";
+"Indent" = "增加缩进";
+"Inline code" = "代码";
+"Inspector" = "文档检查器";
+"Italic" = "斜体";
+"Markdown…" = "Markdown";
+"New document" = "新建文档";
+"No documents" = "还没有文档";
+"No recent documents" = "还没有文档";
+"Normal" = "正文";
+"Numbered list" = "编号列表";
+"Open a document from Home" = "从主页打开文档";
+"Outdent" = "减少缩进";
+"PDF…" = "PDF";
+"Pages" = "分页";
+"Paragraph style" = "段落样式";
+"Print…" = "打印";
+"Recent" = "最近项目";
+"Recent documents" = "最近文档";
+"Recent spreadsheets" = "最近电子表格";
+"Retry" = "重试";
+"Save" = "保存";
+"Select column %@" = "选择 %@ 列";
+"Select row %d" = "选择第 %d 行";
+"Shared" = "与您共享";
+"Share…" = "共享";
+"Sign out" = "退出登录";
+"Size" = "大小";
+"Zoom" = "缩放";
+"New spreadsheet" = "新建电子表格";
+"Rename…" = "重命名…";
+"Export workbook…" = "导出工作簿…";
+"Version history…" = "版本历史…";
+"Undo" = "撤销";
+"Redo" = "重做";
+"Cut" = "剪切";
+"Copy" = "复制";
+"Paste" = "粘贴";
+"Select all" = "全选";
+"Clear contents" = "清除内容";
+"Fill down" = "向下填充";
+"Fill right" = "向右填充";
+"Row and column sizes…" = "行和列大小…";
+"Insert" = "插入";
+"Sheet" = "工作表";
+"Sort and filter…" = "排序和筛选…";
+"Comments…" = "评论…";
+"Refresh" = "刷新";
+"Import workbook" = "导入工作簿";
+"Search spreadsheets" = "搜索电子表格";
diff --git a/native/apple/Sources/SheetsKit/SheetAutoSize.swift b/native/apple/Sources/SheetsKit/SheetAutoSize.swift
new file mode 100644
index 0000000..fd9d26f
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetAutoSize.swift
@@ -0,0 +1,18 @@
+#if os(macOS)
+import AppKit
+
+extension SheetModel {
+ /// Fits the loaded grid (the same visible rows used by the editor).
+ func fitColumn(_ column: Int) async {
+ guard (0..<cols).contains(column), mayWrite, !busy, !hasUnconfirmedChanges else { return }
+ var width: CGFloat = 40
+ for (point, cell) in cells where point.c == column {
+ let style = styles[cell.f ?? ""]
+ var font = NSFont.systemFont(ofSize: 13, weight: style?.b == true ? .bold : .regular)
+ if style?.i == true { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
+ width = max(width, (cell.display as NSString).size(withAttributes: [.font: font]).width + 14)
+ }
+ await resize(axis: "col", size: min(1000, Int(ceil(width))), index: column)
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetCell.swift b/native/apple/Sources/SheetsKit/SheetCell.swift
index d3b499e..0c9dcfc 100644
--- a/native/apple/Sources/SheetsKit/SheetCell.swift
+++ b/native/apple/Sources/SheetsKit/SheetCell.swift
@@ -3,7 +3,12 @@ import SwiftUI
struct SheetCell: View {
@Bindable var model: SheetModel
let point: Coordinate
+ @Binding var editing: Coordinate?
+ var select: () -> Void
var beginEditing: () -> Void = {}
+ var endEditing: () -> Void
+ @State private var textSelection: TextSelection?
+ @FocusState private var fieldFocused: Bool
@Environment(\.colorScheme) private var scheme
private func width(_ col: Int) -> Double { model.sizes.cols?[String(col)] ?? 110 }
private func height(_ row: Int) -> Double { model.sizes.rows?[String(row)] ?? 32 }
@@ -13,10 +18,47 @@ struct SheetCell: View {
let alignment: Alignment =
style?.a == "center"
? .center : style?.a == "right" || (style?.a == nil && cell?.n == true) ? .trailing : .leading
- return Button {
- model.select(point)
- } label: {
- Text(cell?.display ?? "").font(.body.weight(style?.b == true ? .bold : .regular))
+ return Group {
+ #if os(macOS)
+ if editing == point {
+ TextField(sheetsMacString("Cell contents"), text: $model.draft, selection: $textSelection)
+ .textFieldStyle(.plain)
+ .font(.system(size: 13))
+ .padding(.horizontal, 5)
+ .frame(width: width(point.c), height: height(point.r))
+ .background(fill(style))
+ .overlay { Rectangle().strokeBorder(Color.accentColor, lineWidth: 2) }
+ .focused($fieldFocused)
+ .disabled(!model.mayWrite || model.busy)
+ .task {
+ fieldFocused = true
+ await Task.yield()
+ textSelection = TextSelection(insertionPoint: model.draft.endIndex)
+ }
+ .onSubmit { commit(right: false) }
+ .onKeyPress(.tab) { commit(right: true); return .handled }
+ .onKeyPress(.escape) {
+ guard !model.busy else { return .handled }
+ model.discardAll(); endEditing(); return .handled
+ }
+ .accessibilityLabel(String(format: sheetsMacString("Edit %@"), point.ref))
+ } else { display(cell, style: style, alignment: alignment) }
+ #else
+ display(cell, style: style, alignment: alignment)
+ #endif
+ }
+ }
+ private func commit(right: Bool) {
+ guard !model.busy, model.selection.start == point else { return }
+ Task {
+ await model.commit(moveRight: right)
+ if !model.hasUnconfirmedChanges { endEditing() }
+ else { fieldFocused = true }
+ }
+ }
+ private func display(_ cell: Cell?, style: CellStyle?, alignment: Alignment) -> some View {
+ Button(action: select) {
+ Text(cell?.display ?? "").font(.system(size: 13, weight: style?.b == true ? .bold : .regular))
.italic(style?.i == true).lineLimit(1).padding(.horizontal, 5)
.frame(width: width(point.c), height: height(point.r), alignment: alignment)
.foregroundStyle(ink(style)).background(fill(style))
@@ -27,7 +69,7 @@ struct SheetCell: View {
lineWidth: model.selection.contains(point) ? 2 : 0.5)
}
}.buttonStyle(.plain)
- .simultaneousGesture(TapGesture(count: 2).onEnded { if model.mayWrite { beginEditing() } })
+ .simultaneousGesture(TapGesture(count: 2).onEnded { if model.mayWrite, !model.busy, !model.hasUnconfirmedChanges { model.select(point); beginEditing() } })
.accessibilityLabel("\(point.ref), \(cell?.display ?? "Empty")")
.accessibilityValue(style?.lc == true ? "Imported colours have low contrast" : "")
.accessibilityAddTraits(model.selection.contains(point) ? .isSelected : [])
diff --git a/native/apple/Sources/SheetsKit/SheetEditorContext.swift b/native/apple/Sources/SheetsKit/SheetEditorContext.swift
new file mode 100644
index 0000000..331a2d0
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetEditorContext.swift
@@ -0,0 +1,9 @@
+#if os(macOS)
+import SwiftUI
+
+struct SheetEditorContext {
+ let model: SheetModel
+ let panel: Binding<String?>
+}
+extension FocusedValues { @Entry var sheetEditor: SheetEditorContext? }
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetFiles.swift b/native/apple/Sources/SheetsKit/SheetFiles.swift
index da8ef7b..c8245bc 100644
--- a/native/apple/Sources/SheetsKit/SheetFiles.swift
+++ b/native/apple/Sources/SheetsKit/SheetFiles.swift
@@ -34,6 +34,7 @@ struct ImportProgress: Decodable {
struct SheetFiles: View {
@Bindable var model: SheetModel
let importing: Bool
+ var openImported: ((UInt64, String) -> Void)? = nil
@State private var picker = false
@State private var exporter = false
@State private var workbook: WorkbookExport?
@@ -147,7 +148,8 @@ struct SheetFiles: View {
if let id = UInt64(state.location.split(separator: "/").last ?? ""),
let sheet = model.sheets.first(where: { $0.ID == id })
{
- await model.open(sheet)
+ if let openImported { openImported(sheet.ID, sheet.Title) }
+ else { await model.open(sheet) }
}
return
}
diff --git a/native/apple/Sources/SheetsKit/SheetGrid.swift b/native/apple/Sources/SheetsKit/SheetGrid.swift
index 74f3f04..c9c63d6 100644
--- a/native/apple/Sources/SheetsKit/SheetGrid.swift
+++ b/native/apple/Sources/SheetsKit/SheetGrid.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct SheetGrid: View {
@Bindable var model: SheetModel
+ @State private var editing: Coordinate?
+ @FocusState private var focused: Bool
var beginEditing: () -> Void = {}
var body: some View {
ScrollView([.horizontal, .vertical]) {
@@ -9,10 +11,15 @@ struct SheetGrid: View {
Section {
ForEach(0..<model.rows, id: \.self) { row in
HStack(spacing: 0) {
- Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary)
+ Button { selectRow(row) } label: {
+ Text(String(row + 1)).frame(width: 48, height: height(row)).background(.quaternary)
+ }.buttonStyle(.plain).accessibilityLabel(String(format: sheetsMacString("Select row %d"), row + 1))
ForEach(0..<model.cols, id: \.self) { col in
SheetCell(
- model: model, point: Coordinate(r: row, c: col), beginEditing: beginEditing)
+ model: model, point: Coordinate(r: row, c: col), editing: $editing,
+ select: { select(Coordinate(r: row, c: col)) },
+ beginEditing: { edit(Coordinate(r: row, c: col)) },
+ endEditing: { editing = nil; focused = true })
}
}
}
@@ -20,38 +27,100 @@ struct SheetGrid: View {
HStack(spacing: 0) {
Text("").frame(width: 48, height: 32)
ForEach(0..<model.cols, id: \.self) { col in
- Text(Coordinate(r: 0, c: col).ref.dropLast()).frame(width: width(col), height: 32)
+ HStack(spacing: 0) {
+ Button { selectColumn(col) } label: {
+ Text(Coordinate(r: 0, c: col).ref.dropLast())
+ .frame(width: max(0, width(col) - 8), height: 32)
+ .contentShape(.rect)
+ }.buttonStyle(.plain)
+ .accessibilityLabel(String(format: sheetsMacString("Select column %@"), String(Coordinate(r: 0, c: col).ref.dropLast())))
+ #if os(macOS)
+ Rectangle().fill(.secondary.opacity(0.15)).frame(width: 8, height: 32)
+ .contentShape(.rect)
+ .onTapGesture(count: 2) { Task { await model.fitColumn(col) } }
+ .help(sheetsMacString("Double-click to fit column to contents"))
+ .accessibilityHidden(true)
+ #else
+ Color.clear.frame(width: 8, height: 32)
+ #endif
+ }
+ #if os(macOS)
+ .contextMenu { Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(col) } } }
+ #endif
}
}.background(.regularMaterial)
}
}
- }.accessibilityLabel("Spreadsheet grid")
+ }.defaultScrollAnchor(.topLeading)
+ .accessibilityLabel("Spreadsheet grid")
.focusable()
+ .focusEffectDisabled()
+ .focused($focused)
+ .onChange(of: model.selection.start) { _, _ in editing = nil }
+ .onChange(of: focused) { _, value in model.gridFocused = value }
+ .onDisappear { model.gridFocused = false }
.onKeyPress(.leftArrow) { move(0, -1) }
.onKeyPress(.rightArrow) { move(0, 1) }
.onKeyPress(.upArrow) { move(-1, 0) }
.onKeyPress(.downArrow) { move(1, 0) }
.onKeyPress(.delete) {
- guard model.mayWrite else { return .ignored }
+ guard editing == nil, model.mayWrite else { return .ignored }
Task { await model.clear() }
return .handled
}
.onKeyPress(.return) {
- guard model.mayWrite else { return .ignored }
- beginEditing()
+ guard editing == nil, model.mayWrite else { return .ignored }
+ edit(model.selection.start)
return .handled
}
- .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols)) {
+ .onKeyPress(characters: .alphanumerics.union(.punctuationCharacters).union(.symbols).union(.whitespaces)) {
press in
- guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges,
+ guard editing == nil, model.mayWrite, !model.busy, !model.hasUnconfirmedChanges,
press.modifiers.intersection([.command, .control, .option]).isEmpty
else { return .ignored }
model.draft = press.characters
- beginEditing()
+ edit(model.selection.start)
return .handled
}
}
+ private func edit(_ point: Coordinate) {
+ guard model.mayWrite, !model.busy, model.failedWrites.isEmpty else { return }
+ #if os(macOS)
+ editing = point
+ #else
+ beginEditing()
+ #endif
+ }
+ private func select(_ point: Coordinate) {
+ guard !model.busy else { return }
+ if model.hasUnconfirmedChanges {
+ Task {
+ await model.saveAll()
+ guard !model.hasUnconfirmedChanges, !model.busy else { return }
+ editing = nil; model.select(point); focused = true
+ }
+ } else {
+ if editing != point { editing = nil }
+ model.select(point)
+ focused = true
+ }
+ }
+ private func selectRow(_ row: Int) {
+ guard !model.hasUnconfirmedChanges, !model.busy else { return }
+ editing = nil
+ model.rangeText = "A\(row + 1):" + Coordinate(r: row, c: model.cols - 1).ref
+ model.selectRange()
+ focused = true
+ }
+ private func selectColumn(_ col: Int) {
+ guard !model.hasUnconfirmedChanges, !model.busy else { return }
+ editing = nil
+ model.rangeText = Coordinate(r: 0, c: col).ref + ":" + Coordinate(r: model.rows - 1, c: col).ref
+ model.selectRange()
+ focused = true
+ }
private func move(_ r: Int, _ c: Int) -> KeyPress.Result {
+ guard editing == nil else { return .ignored }
let p = model.selection.start
model.select(
Coordinate(r: max(0, min(model.rows - 1, p.r + r)), c: max(0, min(model.cols - 1, p.c + c))))
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift
index 1cf6c09..2ad4dc7 100644
--- a/native/apple/Sources/SheetsKit/SheetModel.swift
+++ b/native/apple/Sources/SheetsKit/SheetModel.swift
@@ -11,6 +11,7 @@ import RastrilloApple
var version: UInt64 = 0
var selection = CellRange(Coordinate(r: 0, c: 0), Coordinate(r: 0, c: 0))
var rangeText = "A1"
+ var gridFocused = false
var draft = ""
var originalDraft = ""
var dirty: Bool { draft != originalDraft }
@@ -177,7 +178,7 @@ import RastrilloApple
await accept(value)
} catch { self.error = error.localizedDescription }
}
- func resize(axis: String, size: Int) async {
+ func resize(axis: String, size: Int, index: Int? = nil) async {
guard mayWrite && !busy && !hasUnconfirmedChanges else { return }
busy = true
defer { busy = false }
@@ -185,7 +186,7 @@ import RastrilloApple
let response: GridResponse = try await client.request(
grid + "/size", method: "POST",
body: JSONSerialization.data(withJSONObject: [
- axis: axis == "col" ? selection.start.c : selection.start.r, "size": size,
+ axis: index ?? (axis == "col" ? selection.start.c : selection.start.r), "size": size,
]))
await accept(response)
} catch { self.error = error.localizedDescription }
@@ -228,10 +229,10 @@ import RastrilloApple
return false
}
}
- func create() async {
+ @discardableResult func create(openCreated: Bool = true) async -> SheetSummary? {
guard !hasUnconfirmedChanges && !busy else {
error = "Save or discard the current edit first."
- return
+ return nil
}
busy = true
defer { busy = false }
@@ -239,8 +240,9 @@ import RastrilloApple
let sheet: SheetSummary = try await form("/sheets", [:])
await loadList()
busy = false
- await open(sheet)
- } catch { self.error = error.localizedDescription }
+ if openCreated { await open(sheet) }
+ return sheet
+ } catch { self.error = error.localizedDescription; return nil }
}
func loadHistory() async {
let sheetID = page?.Sheet.ID
diff --git a/native/apple/Sources/SheetsKit/SheetPanels.swift b/native/apple/Sources/SheetsKit/SheetPanels.swift
index 5d7feb7..5c25b2b 100644
--- a/native/apple/Sources/SheetsKit/SheetPanels.swift
+++ b/native/apple/Sources/SheetsKit/SheetPanels.swift
@@ -3,6 +3,7 @@ import SwiftUI
struct SheetPanels: View {
@Bindable var model: SheetModel
let panel: String
+ var openImported: ((UInt64, String) -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var width = 110
@@ -79,7 +80,7 @@ struct SheetPanels: View {
.font(.caption)
case "Comments": SheetComments(model: model)
case "Share": SheetSharing(model: model)
- case "Import", "Export": SheetFiles(model: model, importing: panel == "Import")
+ case "Import", "Export": SheetFiles(model: model, importing: panel == "Import", openImported: openImported)
default: EmptyView()
}
}.formStyle(.grouped).navigationTitle(panel)
diff --git a/native/apple/Sources/SheetsKit/SheetTable.swift b/native/apple/Sources/SheetsKit/SheetTable.swift
index 3d6d4b1..0af8392 100644
--- a/native/apple/Sources/SheetsKit/SheetTable.swift
+++ b/native/apple/Sources/SheetsKit/SheetTable.swift
@@ -22,7 +22,7 @@ struct SheetTable: View {
HStack(spacing: 0) {
Text(String(row.Number)).frame(width: 48)
ForEach(row.Cells, id: \.C) { cell in
- SheetCell(model: model, point: Coordinate(r: cell.R, c: cell.C))
+ SheetCell(model: model, point: Coordinate(r: cell.R, c: cell.C), editing: .constant(nil), select: { model.select(Coordinate(r: cell.R, c: cell.C)) }, endEditing: {})
}
}
}
diff --git a/native/apple/Sources/SheetsKit/SheetThumbnail.swift b/native/apple/Sources/SheetsKit/SheetThumbnail.swift
index d3328dc..d4c6c9c 100644
--- a/native/apple/Sources/SheetsKit/SheetThumbnail.swift
+++ b/native/apple/Sources/SheetsKit/SheetThumbnail.swift
@@ -16,7 +16,9 @@ struct SheetPreviewCell: Decodable, Hashable, Sendable {
/// Renders shape metadata only: no cell text, web view, or external image fetch.
struct SheetThumbnail: View {
private let preview: SheetPreview?
- init(_ preview: SheetPreview?) { self.preview = preview }
+ var width: CGFloat = 52
+ var height: CGFloat = 68
+ init(_ preview: SheetPreview?, width: CGFloat = 52, height: CGFloat = 68) { self.preview = preview; self.width = width; self.height = height }
var body: some View {
Canvas { context, size in
@@ -41,7 +43,7 @@ struct SheetThumbnail: View {
with: .color(.secondary.opacity(cell.Bold ? 0.8 : 0.4)))
}
}
- .frame(width: 52, height: 68)
+ .frame(width: width, height: height)
.accessibilityHidden(true)
}
}
diff --git a/native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift b/native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift
new file mode 100644
index 0000000..894ca7a
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsDocumentMenu.swift
@@ -0,0 +1,24 @@
+#if os(macOS)
+import SwiftUI
+
+public struct SheetsDocumentMenu: View {
+ @FocusedValue(\.sheetEditor) private var editor
+ public init() {}
+ private var available: Bool { editor?.model.page != nil }
+ private var writable: Bool {
+ guard let model = editor?.model else { return false }
+ return model.mayWrite && !model.busy && !model.hasUnconfirmedChanges
+ }
+ public var body: some View {
+ Group {
+ Button(sheetsMacString("Save")) { if let model = editor?.model { Task { await model.saveAll() } } }
+ .keyboardShortcut("s").disabled(!available)
+ Button(sheetsMacString("Rename…")) { editor?.panel.wrappedValue = "Rename" }.disabled(!writable)
+ Button(sheetsMacString("Export workbook…")) { editor?.panel.wrappedValue = "Export" }.disabled(!available)
+ Button(sheetsMacString("Version history…")) { editor?.panel.wrappedValue = "History" }.disabled(!available)
+ Button(sheetsMacString("Share…")) { editor?.panel.wrappedValue = "Share" }.disabled(editor?.model.page?.MayShare != true)
+
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift b/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift
new file mode 100644
index 0000000..9151495
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsEditorCommands.swift
@@ -0,0 +1,61 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+public struct SheetsEditorCommands: Commands {
+ @FocusedValue(\.sheetEditor) private var editor
+ public init() {}
+ private var available: Bool { editor?.model.page != nil }
+ private var writable: Bool {
+ guard let model = editor?.model else { return false }
+ return model.mayWrite && !model.busy && !model.hasUnconfirmedChanges
+ }
+ private func route(_ selector: Selector, grid: (SheetModel) -> Void) {
+ if NSApp.keyWindow?.firstResponder is NSTextView {
+ NSApp.sendAction(selector, to: nil, from: nil)
+ } else if let model = editor?.model, model.gridFocused { grid(model) }
+ else { NSApp.sendAction(selector, to: nil, from: nil) }
+ }
+ public var body: some Commands {
+
+ CommandGroup(replacing: .undoRedo) {
+ Button(sheetsMacString("Undo")) { route(Selector(("undo:"))) { model in Task { await model.command("/undo") } } }.keyboardShortcut("z")
+ Button(sheetsMacString("Redo")) { NSApp.sendAction(Selector(("redo:")), to: nil, from: nil) }.keyboardShortcut("z", modifiers: [.command, .shift])
+ }
+ CommandGroup(replacing: .pasteboard) {
+ Button(sheetsMacString("Cut")) { route(#selector(NSText.cut(_:))) { model in
+ guard model.mayWrite, !model.busy, !model.hasUnconfirmedChanges else { return }
+ model.copy(); Task { await model.clear() }
+ } }.keyboardShortcut("x")
+ Button(sheetsMacString("Copy")) { route(#selector(NSText.copy(_:))) { $0.copy() } }.keyboardShortcut("c")
+ Button(sheetsMacString("Paste")) { route(#selector(NSText.paste(_:))) { model in Task { await model.paste() } } }.keyboardShortcut("v")
+ Button(sheetsMacString("Select all")) { route(#selector(NSText.selectAll(_:))) { model in
+ model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref
+ model.selectRange()
+ } }.keyboardShortcut("a")
+ }
+ CommandGroup(after: .pasteboard) {
+ Button(sheetsMacString("Clear contents")) { if let model = editor?.model { Task { await model.clear() } } }.disabled(!writable)
+ Button(sheetsMacString("Fill down")) { if let model = editor?.model { Task { await model.fill(down: true) } } }.keyboardShortcut("d").disabled(!writable)
+ Button(sheetsMacString("Fill right")) { if let model = editor?.model { Task { await model.fill(down: false) } } }.disabled(!writable)
+ }
+ CommandMenu(sheetsMacString("Format")) {
+ if let model = editor?.model {
+ SheetFormatMenu(model: model)
+ Button(sheetsMacString("Row and column sizes…")) { editor?.panel.wrappedValue = "Sizes" }.disabled(!writable)
+ Button(sheetsMacString("Fit column to contents")) { Task { await model.fitColumn(model.selection.start.c) } }.disabled(!writable)
+ }
+ }
+ CommandMenu(sheetsMacString("Insert")) {
+ ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in
+ Button(name) { if let model = editor?.model { Task { await model.insertFunction(name) } } }.disabled(!writable)
+ }
+ }
+ CommandMenu(sheetsMacString("Sheet")) {
+ Button(sheetsMacString("Sort and filter…")) { editor?.panel.wrappedValue = "Data" }.disabled(!available)
+ Button(sheetsMacString("Comments…")) { editor?.panel.wrappedValue = "Comments" }.disabled(!available)
+ Button(sheetsMacString("Refresh")) { if let model = editor?.model { Task { await model.refresh() } } }.disabled(!available)
+ }
+ }
+}
+#endif
diff --git a/native/apple/Sources/SheetsKit/SheetsMacStrings.swift b/native/apple/Sources/SheetsKit/SheetsMacStrings.swift
new file mode 100644
index 0000000..1f5be0f
--- /dev/null
+++ b/native/apple/Sources/SheetsKit/SheetsMacStrings.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+public func sheetsMacString(_ key: String) -> String {
+ NSLocalizedString(key, tableName: "Mac", bundle: .module, comment: "")
+}
diff --git a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
index c894bad..41263ee 100644
--- a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
+++ b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift
@@ -15,15 +15,25 @@ import SwiftUI
@FocusState private var formulaFocused: Bool
@Environment(\.scenePhase) private var scenePhase
@AppStorage("sheets.appearance") private var appearance = "auto"
+ private let updateRecents: (([(UInt64, String)]) -> Void)?
+ private let canSignOut: Bool
+ @Binding private var createRequested: Bool
+ private let documentID: UInt64?
+ private let openDocument: ((UInt64, String) -> Void)?
private let signOut: () -> Void
private let accounts: (() -> Void)?
- public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void) {
+ public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: UInt64? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), openDocument: ((UInt64, String) -> Void)? = nil, updateRecents: (([(UInt64, String)]) -> Void)? = nil) {
+ self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self.documentID = documentID; self.openDocument = openDocument
_model = State(initialValue: SheetModel(client: client))
self.signOut = signOut
self.accounts = accounts
}
public var body: some View {
- NavigationSplitView(preferredCompactColumn: $column) {
+ Group {
+ #if os(macOS)
+ if documentID != nil { editor } else { home }
+ #else
+ NavigationSplitView(preferredCompactColumn: $column) {
library
} detail: {
if model.page != nil {
@@ -34,7 +44,19 @@ import SwiftUI
description: Text("Create a spreadsheet or open one from the list."))
}
}
- .task { await model.loadList() }
+ #endif
+ }
+ .onChange(of: model.sheets.map { "\($0.ID)\u{0}\($0.Title)" }) {
+ if query.isEmpty { updateRecents?(model.sheets.map { ($0.ID, $0.Title) }) }
+ }
+ .onChange(of: createRequested, initial: true) { _, requested in
+ if requested && documentID == nil { createRequested = false; Task { await createSheet() } }
+ }
+ .task {
+ if let documentID {
+ await model.open(SheetSummary(ID: documentID, Title: "", TabID: 0, TabName: ""))
+ } else { await model.loadList() }
+ }
.onChange(of: model.page?.Sheet.ID) { _, value in if value != nil { column = .detail } }
.onChange(of: scenePhase) { _, value in
if value != .active { model.clearCopyProvenance() }
@@ -46,7 +68,7 @@ import SwiftUI
#if os(macOS)
.background(SheetCloseGuard(model: model).frame(width: 0, height: 0))
#endif
- .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name) }
+ .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name, openImported: openDocument) }
.confirmationDialog("Keep your cell edit?", isPresented: $askDiscard, titleVisibility: .visible)
{
Button("Save and continue") {
@@ -62,6 +84,13 @@ import SwiftUI
Button("Cancel", role: .cancel) { pendingSheet = nil }
}
}
+ private func createSheet() async {
+ #if os(macOS)
+ if let sheet = await model.create(openCreated: false) { openDocument?(sheet.ID, sheet.Title) }
+ #else
+ await model.create()
+ #endif
+ }
private var filteredSheets: [SheetSummary] {
query.isEmpty ? model.sheets : model.sheets.filter { $0.Title.localizedStandardContains(query) }
}
@@ -100,12 +129,12 @@ import SwiftUI
}
}
.navigationTitle("Sheets")
- .searchable(text: $query, prompt: "Search spreadsheets")
+ .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets"))
.refreshable { await model.loadList() }
.toolbar {
if let accounts {
ToolbarItem(placement: .navigation) {
- Button("Accounts", systemImage: "chevron.backward", action: accounts)
+ Button(sheetsMacString("Accounts"), systemImage: "chevron.backward", action: accounts)
.disabled(model.hasUnconfirmedChanges || model.busy)
}
}
@@ -118,7 +147,7 @@ import SwiftUI
Text("Light").tag("light")
Text("Dark").tag("dark")
}
- Button("Sign out", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive,
+ Button(sheetsMacString("Sign out"), systemImage: "rectangle.portrait.and.arrow.right", role: .destructive,
action: signOut).disabled(model.hasUnconfirmedChanges || model.busy)
} label: {
VStack(spacing: 3) {
@@ -138,7 +167,7 @@ import SwiftUI
}
}
private var newSheet: some View {
- Button("New spreadsheet", systemImage: "plus") { Task { await model.create() } }
+ Button(sheetsMacString("New spreadsheet"), systemImage: "plus") { Task { await createSheet() } }
.disabled(model.hasUnconfirmedChanges || model.busy)
}
private func show(_ name: String) {
@@ -156,18 +185,18 @@ import SwiftUI
!model.mayWrite
)
.keyboardShortcut("z")
- Button("Copy") { model.copy() }.keyboardShortcut("c")
- Button("Cut") {
+ Button(sheetsMacString("Copy")) { model.copy() }.keyboardShortcut("c")
+ Button(sheetsMacString("Cut")) {
model.copy()
Task { await model.clear() }
}.keyboardShortcut("x").disabled(!model.mayWrite)
- Button("Paste") { Task { await model.paste() } }.keyboardShortcut("v").disabled(
+ Button(sheetsMacString("Paste")) { Task { await model.paste() } }.keyboardShortcut("v").disabled(
!model.mayWrite)
- Button("Clear contents") { Task { await model.clear() } }.disabled(!model.mayWrite)
- Button("Fill down") { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled(
+ Button(sheetsMacString("Clear contents")) { Task { await model.clear() } }.disabled(!model.mayWrite)
+ Button(sheetsMacString("Fill down")) { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled(
!model.mayWrite)
- Button("Fill right") { Task { await model.fill(down: false) } }.disabled(!model.mayWrite)
- Button("Select all") {
+ Button(sheetsMacString("Fill right")) { Task { await model.fill(down: false) } }.disabled(!model.mayWrite)
+ Button(sheetsMacString("Select all")) {
model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref
model.selectRange()
}.keyboardShortcut("a")
@@ -183,15 +212,17 @@ import SwiftUI
Button("Sort and filter") { show("Data") }.disabled(
model.hasUnconfirmedChanges || model.busy)
Button("Comments") { show("Comments") }
- Button("Refresh", systemImage: "arrow.clockwise") { Task { await model.refresh() } }
+ Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.refresh() } }
}
private var editor: some View {
VStack(spacing: 0) {
+ #if os(iOS)
ViewThatFits(in: .horizontal) {
HStack { editorCommands }.fixedSize(horizontal: true, vertical: false)
Menu("Sheet actions", systemImage: "ellipsis.circle") { editorCommands }
.frame(maxWidth: .infinity, alignment: .leading)
}.padding(8)
+ #endif
HStack {
TextField("Cell or range", text: $model.rangeText).frame(width: 110).onSubmit {
model.selectRange()
@@ -214,7 +245,7 @@ import SwiftUI
return .handled
}
if model.dirty {
- Button("Save") { Task { await model.commit() } }.disabled(model.busy)
+ Button(sheetsMacString("Save")) { Task { await model.commit() } }.disabled(model.busy)
Button("Discard") { model.discard() }.disabled(model.busy)
}
}.textFieldStyle(.roundedBorder).padding(8)
@@ -254,8 +285,51 @@ import SwiftUI
).font(.caption).padding(4)
}
}.navigationTitle(model.page?.Sheet.Title ?? "Sheets")
+ #if os(macOS)
+ .focusedSceneValue(\.sheetEditor, SheetEditorContext(model: model, panel: Binding(get: { panel?.name }, set: { panel = $0.map { Panel(name: $0) } })))
+ #endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
+ #if os(macOS)
+ private var home: some View {
+ NavigationSplitView {
+ List { Label(sheetsMacString("Recent"), systemImage: "clock").listRowBackground(Color.accentColor.opacity(0.12)) }
+ .navigationSplitViewColumnWidth(min: 160, ideal: 190, max: 240)
+ } detail: {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ Text(sheetsMacString("Recent spreadsheets")).font(.largeTitle.bold())
+ if !model.error.isEmpty { Text(model.error).foregroundStyle(.red) }
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: 260))], spacing: 24) {
+ ForEach(filteredSheets) { sheet in
+ Button { openDocument?(sheet.ID, sheet.Title) } label: {
+ VStack(alignment: .leading, spacing: 10) {
+ SheetThumbnail(sheet.Preview, width: 200, height: 150)
+ .background(.background).clipShape(.rect(cornerRadius: 5))
+ Text(sheet.Title).font(.headline).lineLimit(2)
+ Text(sheet.TabName).font(.caption).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(12)
+ }.buttonStyle(.plain)
+ }
+ }
+ if filteredSheets.isEmpty {
+ ContentUnavailableView("No spreadsheets", systemImage: "tablecells", description: Text("Create a spreadsheet or import a workbook."))
+ }
+ }.padding(28)
+ }
+ .navigationTitle(sheetsMacString("Home"))
+ .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets"))
+ .toolbar {
+ if let accounts { Button(sheetsMacString("Accounts"), action: accounts).disabled(model.busy) }
+ newSheet
+ Button(sheetsMacString("Import workbook")) { show("Import") }
+ Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.loadList() } }
+ Button(sheetsMacString("Sign out"), action: signOut).disabled(!canSignOut || model.busy).help(sheetsMacString("Close this account’s document windows before signing out."))
+ }
+ }
+ }
+ #endif
+
}
diff --git a/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift b/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
new file mode 100644
index 0000000..cbd7123
--- /dev/null
+++ b/native/apple/Tests/SheetsKitTests/SheetNativeInteractionTests.swift
@@ -0,0 +1,71 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+import XCTest
+import RastrilloApple
+@testable import SheetsKit
+
+final class SheetNativeInteractionTests: XCTestCase {
+ @MainActor func testTypingEntersCellAndFailedCommitKeepsDraft() async throws {
+ let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://127.0.0.1:1")!))
+ model.page = SheetPage(Sheet: SheetSummary(ID: 1, Title: "Fixture", TabID: 1, TabName: "Sheet 1"), Rows: 4, Cols: 3, Truncated: false, MayWrite: true, MayComment: true, MayShare: false, FillSwatches: [], InkSwatches: [], NumberFormats: [], Warned: nil)
+ let host = NSHostingView(rootView: SheetGrid(model: model).background(Color.white).preferredColorScheme(.light))
+ let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
+ window.contentView = host
+ NSApp.setActivationPolicy(.regular)
+ window.makeKeyAndOrderFront(nil)
+ host.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+ NSApp.activate()
+ defer { window.orderOut(nil) }
+ try await Task.sleep(for: .milliseconds(300))
+ for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
+ let event = NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 350), modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!
+ window.sendEvent(event)
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ key("h", code: 4, window: window)
+ try await Task.sleep(for: .milliseconds(150))
+ XCTAssertEqual(model.draft, "h")
+ XCTAssertTrue(window.firstResponder is NSTextView, "Typing must transfer focus into a native cell text field")
+ key("e", code: 14, window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(model.draft, "he", "The initial character must not be selected and overwritten")
+ key("\t", code: 48, window: window)
+ try await Task.sleep(for: .milliseconds(500))
+ XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 0))
+ XCTAssertEqual(model.draft, "he")
+ XCTAssertTrue(model.hasUnconfirmedChanges)
+ XCTAssertFalse(model.failedWrites.isEmpty)
+ XCTAssertTrue(window.firstResponder is NSTextView)
+ key("\u{1b}", code: 53, window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertFalse(model.hasUnconfirmedChanges)
+ XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 0))
+ if ProcessInfo.processInfo.environment["OFICINA_CAPTURE_FIXTURES"] == "1",
+ let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) {
+ host.cacheDisplay(in: host.bounds, to: bitmap)
+ try bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: "/tmp/oficina-sheets-grid-fixture.png"))
+ }
+ // Column B header then row 2 header select complete ranges.
+ click(NSPoint(x: 200, y: 375), window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(model.selection.start, Coordinate(r: 0, c: 1))
+ XCTAssertEqual(model.selection.end, Coordinate(r: 3, c: 1))
+ click(NSPoint(x: 24, y: 320), window: window)
+ try await Task.sleep(for: .milliseconds(100))
+ XCTAssertEqual(model.selection.start, Coordinate(r: 1, c: 0))
+ XCTAssertEqual(model.selection.end, Coordinate(r: 1, c: 2))
+ }
+ @MainActor private func click(_ point: NSPoint, window: NSWindow) {
+ for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
+ let event = NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)!
+ window.sendEvent(event)
+ }
+ }
+ @MainActor private func key(_ text: String, code: UInt16, window: NSWindow) {
+ let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: text, charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!
+ window.sendEvent(event)
+ }
+}
+#endif
diff --git a/native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift b/native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift
new file mode 100644
index 0000000..648936e
--- /dev/null
+++ b/native/apple/Tests/SheetsWindowTests/WindowRoutingTests.swift
@@ -0,0 +1,27 @@
+#if os(macOS)
+import Foundation
+import Testing
+import RastrilloApple
+@testable import SheetsMac
+
+@MainActor @Test func windowsReuseDocumentsWithinAccountAndOriginOnly() {
+ let windows = SheetsWindows()
+ let first = NativeHTTP(origin: URL(string: "https://first.invalid")!)
+ let second = NativeHTTP(origin: URL(string: "https://second.invalid")!)
+ let document: UInt64 = 7
+ let id = windows.reserve(account: "a", document: document, title: "One", client: first)
+ #expect(windows.reserve(account: "a", document: document, title: "Renamed", client: first) == id)
+ #expect(windows.entries.count == 1)
+ #expect(!windows.canSignOut("a"))
+ #expect(windows.reserve(account: "b", document: document, title: "Other account", client: first) != id)
+ #expect(windows.reserve(account: "a", document: document, title: "Other server", client: second) != id)
+ #expect(windows.entries.count == 3)
+ windows.setOpen(id, false)
+ #expect(!windows.canSignOut("a"))
+ for entry in windows.entries where entry.account == "a" { windows.setOpen(entry.id, false) }
+ #expect(windows.canSignOut("a"))
+ windows.updateRecents([(document, "Current title")], account: "a", client: first)
+ #expect(windows.entries.first(where: { $0.id == id })?.title == "Current title")
+ #expect(windows.entries.first(where: { $0.id == id })?.isOpen == false)
+}
+#endif