From b08922de80a164c1ce982594bbecb4d83b83d8fc Mon Sep 17 00:00:00 2001 From: PangMo5 Date: Fri, 11 Sep 2026 14:49:28 +0900 Subject: [PATCH] fix: preserve fullscreen layouts through window restoration - Retain complete layout templates and bind replacement windows to distinct slots across app relaunches, partial restoration, and window removal. - Preserve display ownership before sleep when the screen list becomes empty, cancel stale topology notifications, and retain reconciled wake layouts. - Let explicit layout edits replace pending restoration while preserving Auto-balance ratios during unchanged window discovery. - Document macOS window reopening and independent profile layouts in all five supported languages; preserve automatic profile-switching policy. Tests: 767 macOS tests passed; Release build succeeded; 35 generated docs verified. Tart validation covered native TextEdit reopening, inactive and staggered windows, virtual display reconnects, and restoration after a real restart with manual Tatami launch. Physical clamshell and unattended login launch remain outside the VM validation boundary. --- Localization/Docs.json | 63 ++++- .../Sources/Dependencies/DisplayClient.swift | 4 +- .../Dependencies/LayoutStoreClient.swift | 38 +++ .../WorkspaceActivationFeature+Activate.swift | 72 ++--- .../WorkspaceActivationFeature+Drag.swift | 4 + .../WorkspaceActivationFeature+Sync.swift | 213 +++++++-------- .../WorkspaceActivationFeature.swift | 29 +- TatamiKit/Sources/Tiling/BSPTree.swift | 15 +- .../Sources/FullscreenZoomRestoreTests.swift | 53 ++-- .../Sources/WorkspaceActivationTests.swift | 254 +++++++++++++++++- docs/CONFIGURATION.md | 27 +- docs/TROUBLESHOOTING.md | 8 + docs/ja/CONFIGURATION.md | 12 +- docs/ja/TROUBLESHOOTING.md | 5 + docs/ko/CONFIGURATION.md | 12 +- docs/ko/TROUBLESHOOTING.md | 5 + docs/zh-Hans/CONFIGURATION.md | 12 +- docs/zh-Hans/TROUBLESHOOTING.md | 5 + docs/zh-Hant/CONFIGURATION.md | 12 +- docs/zh-Hant/TROUBLESHOOTING.md | 5 + 20 files changed, 659 insertions(+), 189 deletions(-) diff --git a/Localization/Docs.json b/Localization/Docs.json index 63d9870..d702a07 100644 --- a/Localization/Docs.json +++ b/Localization/Docs.json @@ -1693,13 +1693,6 @@ "zh-Hans": "新分割中放置新窗口的一侧:{0}(上、左)或 {1}(下、右)。", "zh-Hant": "新分割中放置新視窗的一側:{0}(上、左)或 {1}(下、右)。" }, - "175957a0258662a6": { - "en": "Workspaces always remember their layout. Split axes and ratios are persisted to disk and restored on the next launch. System sleep preserves the live tree; if macOS recreates a window surface on wake, Tatami reconnects it to the saved layout without writing the temporary wake-up state back to disk.", - "ko": "작업 공간은 항상 배치를 기억해요. 분할 방향과 비율을 디스크에 저장하고 재실행하면 복원해요. 잠자기 중에는 현재 트리를 유지해요. 깨어날 때 macOS가 창을 다시 만들면 저장한 배치에 연결하되 임시 상태를 디스크에 덮어쓰지 않아요.", - "ja": "ワークスペースは常に配置を覚えます。分割方向と比率を保存して再起動時に復元し、スリープ中はツリーを保ちます。復帰時に macOS が画面を作り直しても、保存済み配置へ再接続し、一時状態を上書きしません。", - "zh-Hans": "工作区始终记住布局。分割轴和比例保存到磁盘,重启时恢复;睡眠保留活动树。唤醒时若 macOS 重建窗口表面,Tatami 将其重新连接到保存的布局,不把临时唤醒状态写回磁盘。", - "zh-Hant": "工作空間始終記住排列。分割軸與比例儲存到磁碟,重新啟動時還原;睡眠保留作用中樹。喚醒時若 macOS 重建視窗表面,Tatami 將其重新連接到儲存的排列,不把暫時喚醒狀態寫回磁碟。" - }, "ed3fe2e278b34b23": { "en": "Move the cursor to the window Tatami focuses. Directional focus, app/window switching, workspace changes, close refocus, Swap, toggling a split's orientation, and fullscreen zoom in either direction carry it to the new position. Always on Top and Leave As Is targets use their live window frame. When the switch came from activating an app outside Tatami, such as from the Dock or Spotlight, the cursor goes to the window the system actually raised rather than the workspace's most recently used one. The pointer is left alone whenever the pointer itself caused the change: clicking a window, releasing a drag, a drag that swaps or splits, and a drag that snaps back. Grow/shrink and Balance stay out too, so holding a key down does not re-center on every repeat.", "ko": "Tatami가 포커스한 창으로 커서를 옮겨요. 방향 포커스, 앱·창 전환, 작업 공간 변경, 창 닫기 뒤 포커스, 교체, 분할 방향 변경, 확대·복귀에서 새 위치로 따라가요. 항상 위·그대로 두기 창은 실제 창 영역을 사용해요. Dock이나 Spotlight에서 앱을 켰다면 최근 창이 아니라 시스템이 실제로 올린 창을 따라가요. 클릭이나 드래그 놓기, 드래그 교체·삽입·원위치 복귀처럼 포인터가 시작한 변경에서는 포인터를 그대로 둬요. 늘리기·줄이기·균등 배치에서도 움직이지 않아 키를 누르고 있을 때마다 중앙으로 돌아가지 않아요.", @@ -4941,5 +4934,61 @@ "ja": "執筆、開発、レビューなど、作業ごとにワークスペースを作れます。BSP タイル表示が新しいウインドウの場所を自動で整えます。戻ったときは、使っていたアプリと配置で作業を再開できます。", "zh-Hans": "为写作、编程或审阅分别建立工作区。打开新窗口时,BSP 平铺会自动为它安排位置。回到工作区,就能用原来的应用和布局继续工作。", "zh-Hant": "為寫作、開發或審閱分別建立工作空間。開啟新視窗時,BSP 並排會自動為它安排位置。回到工作空間,就能用原來的 App 和配置繼續工作。" + }, + "0ba8c07a09d42333": { + "en": "Window and layout restoration", + "ko": "창과 레이아웃 복원", + "ja": "ウインドウとレイアウトの復元", + "zh-Hans": "窗口与布局恢复", + "zh-Hant": "視窗與配置回復" + }, + "89ad33fcf59277d4": { + "en": "Tatami remembers split directions, proportions, and fullscreen zoom separately for each workspace. When windows close or arrive one at a time, the remaining windows fill the available space while Tatami keeps the missing layout slots. Reopened windows can return to those slots. An explicit layout change, such as resizing, rearranging, or toggling fullscreen zoom, replaces that saved layout with the current windows.", + "ko": "Tatami는 작업 공간마다 분할 방향, 비율, 전체 화면 상태를 기억해요. 창이 닫히거나 하나씩 열리는 동안에는 현재 창으로 화면을 채우고, 아직 없는 창의 자리를 보관해요. 다시 열린 창은 보관된 자리로 돌아갈 수 있어요. 크기 조절, 재배치, 전체 화면 전환처럼 직접 레이아웃을 바꾸면 현재 창을 기준으로 새로 기억해요.", + "ja": "Tatami はワークスペースごとに分割方向、比率、全画面表示の状態を記憶します。ウインドウが閉じられたり、順番に開いたりするときは、現在のウインドウで画面を使いながら、まだないウインドウの位置を保持します。再び開いたウインドウはその位置に戻れます。サイズ変更、配置変更、全画面表示の切り替えなどを行うと、現在のウインドウをもとに配置を記憶し直します。", + "zh-Hans": "Tatami 分别记住每个工作区的分割方向、比例和全屏状态。窗口关闭或陆续打开时,现有窗口会填满可用空间,同时保留缺失窗口的位置。重新打开的窗口可以回到这些位置。手动调整大小、重新排列或切换全屏等布局操作,会按当前窗口重新保存布局。", + "zh-Hant": "Tatami 會分別記住每個工作空間的分割方向、比例和全螢幕狀態。視窗關閉或陸續開啟時,現有視窗會填滿可用空間,同時保留缺少視窗的位置。重新開啟的視窗可以回到這些位置。手動調整大小、重新排列或切換全螢幕等配置操作,會依目前的視窗重新儲存配置。" + }, + "fa5068084c0ec65f": { + "en": "macOS and each app decide which windows reopen. Tatami applies the remembered layout to windows they provide; it does not reopen documents itself. These macOS settings control different situations:", + "ko": "어떤 창을 다시 열지는 macOS와 각 앱이 결정해요. Tatami는 열린 창에 기억한 레이아웃을 적용하며, 문서를 직접 다시 열지는 않아요. 다음 macOS 설정은 각각 다른 상황에 적용돼요.", + "ja": "どのウインドウを開き直すかは、macOS と各アプリが決めます。Tatami は開いたウインドウに記憶した配置を適用し、書類そのものは開き直しません。次の macOS 設定は、それぞれ異なる場面で使われます。", + "zh-Hans": "由 macOS 和各应用决定重新打开哪些窗口。Tatami 将记住的布局应用到这些窗口,不会自行重新打开文稿。以下 macOS 设置分别控制不同情况:", + "zh-Hant": "由 macOS 和各個 App 決定重新開啟哪些視窗。Tatami 會將記住的配置套用至這些視窗,不會自行重新開啟文件。以下 macOS 設定分別控制不同情況:" + }, + "40bbf5c85ad5717e": { + "en": "**Close windows when quitting an application**, in **System Settings → Desktop & Dock**: turn this off to let supporting apps reopen their windows when you launch them again. When it is on, an app can open a fresh window instead.", + "ko": "**시스템 설정 → 데스크탑 및 Dock**의 **응용 프로그램을 종료하면 윈도우 닫기**: 끄면 이 기능을 지원하는 앱이 다음 실행 때 창을 다시 열 수 있어요. 켜면 앱이 새 창을 열 수 있어요.", + "ja": "**システム設定 → デスクトップと Dock**の**アプリケーションを終了するときにウインドウを閉じる**:オフにすると、対応するアプリは次の起動時にウインドウを開き直せます。オンの場合は、新しいウインドウが開くことがあります。", + "zh-Hans": "**系统设置 → 桌面与程序坞**中的**退出应用程序时关闭窗口**:关闭此选项后,支持此功能的应用可以在下次启动时重新打开窗口。开启时,应用可能改为打开新窗口。", + "zh-Hant": "**系統設定 → 桌面與 Dock**中的**結束應用程式時關閉視窗**:關閉此選項後,支援此功能的 App 可以在下次啟動時重新開啟視窗。開啟時,App 可能改為開啟新視窗。" + }, + "96130b6df329dae0": { + "en": "**Reopen windows when logging back in**, in the logout, restart, or shutdown dialog: select this to ask macOS to reopen apps and windows at the next login. Enable Tatami’s **Launch at Login**, or open Tatami after logging in, to apply its layouts.", + "ko": "로그아웃·재시동·시스템 종료 대화상자의 **다시 로그인하면 윈도우 다시 열기**: 선택하면 macOS가 다음 로그인 때 앱과 창을 다시 열어요. Tatami의 **로그인 시 실행**을 켜거나 로그인 후 Tatami를 열면 저장한 레이아웃을 적용할 수 있어요.", + "ja": "ログアウト、再起動、システム終了のダイアログにある**再ログイン時にウインドウを再度開く**:選択すると、次のログイン時に macOS がアプリとウインドウを開き直します。Tatami の**ログイン時に起動**を有効にするか、ログイン後に Tatami を開くと、配置を適用できます。", + "zh-Hans": "注销、重新启动或关机对话框中的**再次登录时重新打开窗口**:选中后,macOS 会在下次登录时重新打开应用和窗口。启用 Tatami 的**登录时启动**,或在登录后打开 Tatami,即可应用其布局。", + "zh-Hant": "登出、重新啟動或關機對話框中的**再次登入時重新打開視窗**:勾選後,macOS 會在下次登入時重新開啟 App 和視窗。啟用 Tatami 的**登入時啟動**,或在登入後開啟 Tatami,即可套用配置。" + }, + "f0c7808f93512a84": { + "en": "Tatami matches layout slots by app and window order, not by document name. If an app changes the order in which it recreates windows, a different document can occupy a saved slot. A fresh window can also reuse an old slot. A workspace’s **Auto Open** setting launches its apps separately from macOS document restoration.", + "ko": "Tatami는 문서 이름 대신 앱과 창 순서로 자리를 연결해요. 앱이 창을 다시 만드는 순서를 바꾸면 다른 문서가 같은 자리에 놓일 수 있어요. 새 창도 이전 자리를 사용할 수 있어요. 작업 공간의 **자동 열기**는 macOS의 문서 복원과 별개로 앱을 실행하는 설정이에요.", + "ja": "Tatami は書類名ではなく、アプリとウインドウの順序で配置を対応付けます。アプリがウインドウを作り直す順序を変えると、別の書類が保存された位置に入ることがあります。新しいウインドウも以前の位置を使えます。ワークスペースの**自動で開く**は、macOS の書類復元とは別にアプリを起動する設定です。", + "zh-Hans": "Tatami 按应用和窗口顺序匹配布局位置,而不是按文稿名称。如果应用改变重新创建窗口的顺序,其他文稿可能占据原来保存的位置。新窗口也可以使用旧位置。工作区的**自动打开**设置用于启动应用,与 macOS 的文稿恢复相互独立。", + "zh-Hant": "Tatami 依 App 和視窗順序比對配置位置,而非文件名稱。如果 App 改變重新建立視窗的順序,其他文件可能佔用原先儲存的位置。新視窗也可以使用舊位置。工作空間的**自動開啟**設定用於啟動 App,與 macOS 的文件回復各自獨立。" + }, + "41f312c812673929": { + "en": "Fullscreen zoom changes after connecting a display", + "ko": "디스플레이 연결 후 전체 화면 상태가 달라져요", + "ja": "ディスプレイの接続後に全画面表示が変わる", + "zh-Hans": "连接显示器后全屏状态发生变化", + "zh-Hant": "連接顯示器後全螢幕狀態改變" + }, + "b1249606cefe557f": { + "en": "Check which profile and workspace are active. Display rules can automatically switch profiles when the connected monitors change. Each workspace has its own saved layout and fullscreen zoom, even when another profile contains the same apps. Configure the layout in each profile you use, or adjust the profile’s display rule if the switch was unintended.", + "ko": "현재 프로필과 작업 공간을 확인해 보세요. 연결된 모니터가 바뀌면 디스플레이 규칙에 따라 프로필이 자동으로 전환될 수 있어요. 다른 프로필에 같은 앱이 있어도 레이아웃과 전체 화면 상태는 작업 공간마다 따로 기억해요. 사용하는 각 프로필에서 배치를 설정하거나, 원치 않는 전환이라면 프로필의 디스플레이 규칙을 조정하세요.", + "ja": "現在のプロファイルとワークスペースを確認してください。接続中のモニターが変わると、ディスプレイルールによってプロファイルが自動で切り替わることがあります。別のプロファイルに同じアプリがあっても、配置と全画面表示の状態はワークスペースごとに記憶されます。使う各プロファイルで配置を設定するか、意図しない切り替えならプロファイルのディスプレイルールを調整してください。", + "zh-Hans": "请检查当前启用的配置方案和工作区。连接的显示器发生变化时,显示器规则可能会自动切换配置方案。即使另一个配置方案包含相同应用,每个工作区的布局和全屏状态仍分别保存。请在使用的各配置方案中设置布局,或调整显示器规则以避免意外切换。", + "zh-Hant": "請檢查目前使用的設定組合和工作空間。連接的顯示器改變時,顯示器規則可能會自動切換設定組合。即使另一個設定組合包含相同 App,每個工作空間的配置和全螢幕狀態仍會分別儲存。請在使用的各設定組合中設定配置,或調整顯示器規則以避免意外切換。" } } diff --git a/TatamiKit/Sources/Dependencies/DisplayClient.swift b/TatamiKit/Sources/Dependencies/DisplayClient.swift index d2a1ab5..e58425a 100644 --- a/TatamiKit/Sources/Dependencies/DisplayClient.swift +++ b/TatamiKit/Sources/Dependencies/DisplayClient.swift @@ -116,12 +116,14 @@ extension DisplayClient: DependencyKey { let configuration = currentScreenConfiguration() // Drop only truly identical pokes. Same displays with different // geometry are meaningful and must reach the reducer for a reflow. + observer.pending?.cancel() + observer.pending = nil guard configuration != observer.last else { return } // Genuine change: trailing-debounce so a reconnect's burst of distinct // intermediate configs collapses to the final settled one before we // forward it (only ~1 reducer pass per real reconfigure). - observer.pending?.cancel() let work = DispatchWorkItem { + let configuration = currentScreenConfiguration() guard configuration != observer.last else { return } observer.last = configuration continuation.yield(configuration.map(\.name)) diff --git a/TatamiKit/Sources/Dependencies/LayoutStoreClient.swift b/TatamiKit/Sources/Dependencies/LayoutStoreClient.swift index 03bf01f..9ff0a72 100644 --- a/TatamiKit/Sources/Dependencies/LayoutStoreClient.swift +++ b/TatamiKit/Sources/Dependencies/LayoutStoreClient.swift @@ -44,6 +44,20 @@ public struct LayoutSnapshot: Codable, Hashable, Sendable { self.fullscreenZoomedSlots = fullscreenZoomedSlots } + init( + liveTree: BSPNode, + fullscreenZoomed: Set, + unresolvedSlots: Set = [], + ) { + let slots = slotAssignment(liveTree.windows) + self.init( + tree: liveTree.mapWindows { slots[$0]! }, + fullscreenZoomedSlots: Set(fullscreenZoomed.compactMap { slots[$0] }) + .union(unresolvedSlots) + .sorted { ($0.bundleId, $0.occurrence) < ($1.bundleId, $1.occurrence) }, + ) + } + // MARK: Public public static let currentVersion = 2 @@ -84,6 +98,30 @@ public struct LayoutSnapshot: Codable, Hashable, Sendable { return LayoutSnapshot(tree: slotTree, fullscreenZoomedSlots: zoomSlots) } + /// Exact surviving identities retain their slots. Assign each new window to + /// at most one vacant occurrence of the same app, in deterministic ID order. + /// Retired bindings remain available so a temporarily hidden window can + /// reclaim its original slot on a later discovery. + func restorationBindings( + keys: [WindowKey], + retaining previous: [SlotID: WindowKey], + ) -> [SlotID: WindowKey] { + let live = Set(keys) + var bindings = previous + let assigned = Set(previous.values.filter { live.contains($0) }) + var available = keys.filter { !assigned.contains($0) } + .sorted { ($0.windowID, $0.pid) < ($1.windowID, $1.pid) } + for slot in tree.windows.sorted(by: { + ($0.bundleId, $0.occurrence) < ($1.bundleId, $1.occurrence) + }) { + if let key = bindings[slot], live.contains(key) { continue } + guard let index = available.firstIndex(where: { $0.bundleId == slot.bundleId }) else { continue } + let key = available.remove(at: index) + bindings[slot] = key + } + return bindings + } + } // MARK: - MigratingSnapshot diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift index bf22bf2..864b70c 100644 --- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift +++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Activate.swift @@ -291,18 +291,13 @@ extension WorkspaceActivationFeature { // MARK: Internal - /// Map persisted fullscreen-zoom slots back onto live windows via the same - /// windowID-rank assignment `hydrate` uses (`slotToKey` over `keys`), so a - /// specific same-app window resolves to the exact slot it was zoomed in — not - /// just "some window of that app". Slots whose window isn't in the laid-out - /// tree are dropped, so the layout degrades gracefully when an app has fewer - /// windows than at save time. + /// Resolve zoom through the same retained bindings as the live tree. + /// The caller retains unresolved slots until their windows arrive. static func resolveFullscreenZoom( slots: [SlotID], - keys: [WindowKey], + keyForSlot: [SlotID: WindowKey], among windows: [WindowKey], ) -> Set { - let keyForSlot = slotToKey(keys) let present = Set(windows) return Set(slots.compactMap { keyForSlot[$0] }.filter { present.contains($0) }) } @@ -2354,7 +2349,15 @@ extension WorkspaceActivationFeature { )) let sessionTree = state.tilingTrees[workspace.id] let pendingRestoration = state.pendingLayoutRestorations[workspace.id] - let autoOpeningBundleIDs = Set(workspace.apps.filter { $0.autoOpen && $0.layout == .tiled }.map(\.bundleIdentifier)) + let restorationBindings = state.layoutRestorationBindings[workspace.id] + ?? sessionTree.map { slotToKey($0.windows) } ?? [:] + let residentSnapshot = sessionTree.map { + LayoutSnapshot( + liveTree: $0, + fullscreenZoomed: state.fullscreenZoomed[workspace.id] ?? [], + unresolvedSlots: state.unresolvedFullscreenZoomSlots[workspace.id] ?? [], + ) + } let sharedTiledBundleIds = Set( state.config.sharedApps.filter { $0.layout == .tiled }.map(\.bundleIdentifier) ) @@ -2613,8 +2616,10 @@ extension WorkspaceActivationFeature { let persistedSnapshot: LayoutSnapshot? = if let pendingRestoration { pendingRestoration + } else if let residentSnapshot { + residentSnapshot } else { - sessionTree == nil ? await store.load(workspaceId) : nil + await store.load(workspaceId) } guard !Task.isCancelled else { return } // Cache-first discovery: a warm `WindowKeyCache` entry costs zero @@ -2652,12 +2657,13 @@ extension WorkspaceActivationFeature { ) } mark("discover") - let (tree, frames, restoredZoom, unresolvedZoomSlots) = await MainActor.run { + let (tree, frames, restoredZoom, unresolvedZoomSlots, bindings) = await MainActor.run { () -> ( BSPNode?, [WindowKey: CGRect], Set, - Set + Set, + [SlotID: WindowKey] ) in let workArea = displays.workArea(targetDisplay).insetBy( dx: CGFloat(settings.layout.gapOuter), @@ -2665,8 +2671,13 @@ extension WorkspaceActivationFeature { ) var base = sessionTree var persistedZoomSlots = [SlotID]() + let bindings = persistedSnapshot?.restorationBindings( + keys: keys, + retaining: restorationBindings, + ) ?? [:] + let liveBindings = bindings.filter { Set(keys).contains($0.value) } if let snapshot = persistedSnapshot { - base = BSPNode.hydrate(template: snapshot.tree, keys: keys) + base = BSPNode.hydrate(template: snapshot.tree, keyForSlot: liveBindings) persistedZoomSlots = snapshot.fullscreenZoomedSlots } let restoredLayout = base != nil @@ -2695,13 +2706,14 @@ extension WorkspaceActivationFeature { ) } let resolvedZoom: Set = { - if !zoomed.isEmpty { return zoomed } - guard let tree else { return [] } - return Self.resolveFullscreenZoom( - slots: persistedZoomSlots, - keys: keys, - among: tree.windows, - ) + if persistedSnapshot != nil { + return Self.resolveFullscreenZoom( + slots: persistedZoomSlots, + keyForSlot: liveBindings, + among: tree?.windows ?? [], + ) + } + return zoomed.intersection(keys) }() let frames = Self.computeFrames( tree: tree, @@ -2709,28 +2721,28 @@ extension WorkspaceActivationFeature { targetDisplay: targetDisplay, fullscreenZoomed: resolvedZoom, ) - let assignments = slotAssignment(keys) - let resolvedSlots = Set(resolvedZoom.compactMap { assignments[$0] }) + let resolvedSlots = Set(liveBindings.keys) let unresolvedZoomSlots = Set(persistedZoomSlots).subtracting(resolvedSlots) - return (tree, frames, resolvedZoom, unresolvedZoomSlots) + return (tree, frames, resolvedZoom, unresolvedZoomSlots, bindings) } mark("layout") // Cancellation can arrive while the main actor computes a large tree. // Do not publish or persist a superseded workspace's layout snapshot. guard !Task.isCancelled else { return } await send(.tilingTreeUpdated(workspaceId: workspaceId, tree: tree)) - // Wait for the first window of apps this activation opens, not - // for every old occurrence (an app may reopen fewer windows). - let missingLayoutApps = Set(persistedSnapshot?.tree.windows.map(\.bundleId) ?? []) - .intersection(autoOpeningBundleIDs) - .subtracting(keys.map(\.bundleId)) - let stillRestoring = missingLayoutApps.isEmpty ? nil : persistedSnapshot + // macOS and the app decide how many windows reopen and when. + // Keep every missing occurrence until it arrives or a user edits + // the layout; the first window is not restoration completion. + let liveSlots = Set(bindings.filter { Set(keys).contains($0.value) }.keys) + let missingSlots = Set(persistedSnapshot?.tree.windows ?? []).subtracting(liveSlots) + let stillRestoring = missingSlots.isEmpty ? nil : persistedSnapshot await send(.persistedLayoutRestorationUpdated( workspaceId: workspaceId, snapshot: stillRestoring, + bindings: bindings, )) - if persistedSnapshot != nil, zoomed.isEmpty { + if persistedSnapshot != nil { await send(.persistedFullscreenZoomRestored( workspaceId: workspaceId, keys: restoredZoom, diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift index c73ce8b..0cddef8 100644 --- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift +++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Drag.swift @@ -113,6 +113,8 @@ extension WorkspaceActivationFeature { guard newTree != tree else { return .none } state.pendingLayoutRestorations[workspaceId] = nil + state.layoutRestorationBindings[workspaceId] = nil + state.unresolvedFullscreenZoomSlots[workspaceId] = nil state.tilingTrees[workspaceId] = newTree return .merge( @@ -214,6 +216,8 @@ extension WorkspaceActivationFeature { state.tilingTrees[workspaceId] = newTree let zoomed = state.fullscreenZoomed[workspaceId] ?? [] state.pendingLayoutRestorations[workspaceId] = nil + state.layoutRestorationBindings[workspaceId] = nil + state.unresolvedFullscreenZoomSlots[workspaceId] = nil return .merge( flushPointerDrivenLayout(workspaceId: workspaceId, state: &state), diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift index 321c6bd..12887ea 100644 --- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift +++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature+Sync.swift @@ -181,6 +181,23 @@ extension WorkspaceActivationFeature { return tree } + /// Keep the desired layout independently of live membership. This never + /// opens windows; slots apply only when the app or macOS supplies them. + /// Explicit layout commands end restoration. + func rememberLayoutForRestoration( + workspaceId: Workspace.ID, + tree: BSPNode, + state: inout State, + ) { + guard state.pendingLayoutRestorations[workspaceId] == nil else { return } + state.pendingLayoutRestorations[workspaceId] = LayoutSnapshot( + liveTree: tree, + fullscreenZoomed: state.fullscreenZoomed[workspaceId] ?? [], + unresolvedSlots: state.unresolvedFullscreenZoomSlots[workspaceId] ?? [], + ) + state.layoutRestorationBindings[workspaceId] = slotToKey(tree.windows) + } + /// Enter the reducer-owned single-flight/dirty reconciliation path. /// No timer or cooperative scheduling guess is involved. func requestWindowSync(_ bundleId: String) -> Effect { @@ -479,13 +496,12 @@ extension WorkspaceActivationFeature { windowSnapshot.invalidateWindowIDs(invalidatedWindowIDs) var pruned: BSPNode? = tree for key in gone { pruned = pruned?.removing(key) } - state.pendingLayoutRestorations[workspaceId] = nil + rememberLayoutForRestoration(workspaceId: workspaceId, tree: tree, state: &state) let balanced = axis == .none ? pruned : pruned?.balanced(axis: axis) state.tilingTrees[workspaceId] = balanced let newWindows = Set(balanced?.windows ?? []) state.removeFromWindowMRU(Set(gone), workspaceId: workspaceId) state.removeFromPresentationMonitoring(Set(gone)) - let zoomed = state.fullscreenZoomed[workspaceId] ?? [] prunedAny = true if let display = state.displayShowing(workspaceId) { layoutRootsByDisplay[display] = state.activeWorkspacesByDisplay[display] ?? workspaceId @@ -533,15 +549,11 @@ extension WorkspaceActivationFeature { ) } - effects.append( - persist( - balanced, - fullscreenZoomed: zoomed, - unresolvedFullscreenZoomSlots: - state.unresolvedFullscreenZoomSlots[workspaceId] ?? [], - for: workspace, - ) - ) + if let snapshot = state.pendingLayoutRestorations[workspaceId] { + effects.append(.run { [store = layoutStore] _ in + await store.save(workspaceId, snapshot) + }) + } postLayoutFocusEffects.append(postLayoutFocusEffect) // Pruning only runs when windows actually left the screen. effects.append(handleEmptied(workspaceId: workspaceId, state: state)) @@ -654,11 +666,8 @@ extension WorkspaceActivationFeature { return .concatenate(topologyReplay, .send(.activeSpaceChanged)) } - /// Complete one bundle from the wake reconciliation batch. If every - /// pre-suspend WindowKey disappeared, WindowServer recycled the whole - /// workspace and there is no live identity left from which to preserve the - /// prior tree. Reset only that genuinely lost layout using the same - /// Auto-balance contract as the explicit Balance command. + /// Finish recovery without treating recycled process/window IDs as a + /// request to balance. Activation and sync restore their logical slots. func completeSystemLayoutRecovery( bundleId: String, state: inout State, @@ -670,53 +679,24 @@ extension WorkspaceActivationFeature { let suspendedLayouts = state.suspendedLayoutWindows state.isRecoveringSystemLayout = false state.suspendedLayoutWindows = [:] - - let settings = state.config.settings var effects = [Effect]() - for (workspaceId, suspendedWindows) in suspendedLayouts { + for workspaceId in suspendedLayouts.keys { guard state.visibleWorkspaceIDs.contains(workspaceId), - let workspace = state.config.activeProfile?.workspaces[id: workspaceId], - let current = state.tilingTrees[workspaceId], - !current.windows.isEmpty + let workspace = state.config.activeProfile?.workspaces[id: workspaceId] else { continue } - - let lostEveryIdentity = Set(current.windows).isDisjoint(with: suspendedWindows) - let recovered: BSPNode - if lostEveryIdentity { - state.pendingLayoutRestorations[workspaceId] = nil - let (_, workArea) = tilingContext(for: workspaceId, state: state) - recovered = current.balancedForCommand( - autoBalance: settings.layout.autoBalance, - in: workArea, - gap: CGFloat(settings.layout.gapInner), - splitAxis: settings.layout.splitType.bspSplitAxis(), - ) - state.tilingTrees[workspaceId] = recovered - state.insertionPoint[workspaceId] = recovered.windows.first - debugLog.log( - "Suspend", - "reset lost layout ws=\(workspace.name) mode=\(settings.layout.autoBalance.rawValue)", - ) + if let snapshot = state.pendingLayoutRestorations[workspaceId] { + effects.append(.run { [store = layoutStore] _ in + await store.save(workspaceId, snapshot) + }) } else { - recovered = current + effects.append(persist( + state.tilingTrees[workspaceId], + fullscreenZoomed: state.fullscreenZoomed[workspaceId] ?? [], + unresolvedFullscreenZoomSlots: state.unresolvedFullscreenZoomSlots[workspaceId] ?? [], + for: workspace, + )) } - let zoomed = state.fullscreenZoomed[workspaceId] ?? [] - let save = persist( - recovered, - fullscreenZoomed: zoomed, - unresolvedFullscreenZoomSlots: - state.unresolvedFullscreenZoomSlots[workspaceId] ?? [], - for: workspace, - ) - effects.append( - lostEveryIdentity - ? .merge( - flushLayout(workspaceId: workspaceId, state: &state), - save, - ) - : save - ) } return .merge(effects) } @@ -1030,29 +1010,60 @@ extension WorkspaceActivationFeature { } } - // Restore late-arriving slots into the saved shape. A closed window is - // new user intent and ends the startup restore. + if let zoom = state.fullscreenZoomed[workspaceId] { + state.fullscreenZoomed[workspaceId] = Set(zoom.map { replacements[$0] ?? $0 }) + } + if let bindings = state.layoutRestorationBindings[workspaceId] { + state.layoutRestorationBindings[workspaceId] = bindings.mapValues { replacements[$0] ?? $0 } + } + let hadPendingRestoration = state.pendingLayoutRestorations[workspaceId] != nil + + // Membership is availability, not a request to forget the saved layout. + // App shutdown delivers one destroy per window; capture before the first + // removal and retain the template through staggered recreation. let currentKeys = tree?.windows ?? [] + let membershipChanged = Set(replacementBaseline?.windows ?? []) != Set(currentKeys) let removedDuringSync = Set(replacementBaseline?.windows ?? []) .subtracting(currentKeys) - if !removedDuringSync.isEmpty { - state.pendingLayoutRestorations[workspaceId] = nil - } else if - let restoration = state.pendingLayoutRestorations[workspaceId], - let restored = BSPNode.hydrate(template: restoration.tree, keys: currentKeys) - { - tree = Self.mergeTree( - existing: restored, - target: currentKeys, - focused: { focused }, - insertionPoint: insertionPointKey, - workArea: workArea, - settings: settings, + if !removedDuringSync.isEmpty, let replacementBaseline { + rememberLayoutForRestoration( + workspaceId: workspaceId, + tree: replacementBaseline, + state: &state, + ) + } + if let restoration = state.pendingLayoutRestorations[workspaceId] { + let bindings = restoration.restorationBindings( + keys: currentKeys, + retaining: state.layoutRestorationBindings[workspaceId] ?? [:], + ) + let liveBindings = bindings.filter { Set(currentKeys).contains($0.value) } + // Rehydrate only for changed membership. A no-op discovery must retain + // the current ratios, including Auto-balance applied to a partial tree. + if membershipChanged { + let restored = BSPNode.hydrate(template: restoration.tree, keyForSlot: liveBindings) + tree = Self.mergeTree( + existing: restored, + target: currentKeys, + focused: { focused }, + insertionPoint: insertionPointKey, + workArea: workArea, + settings: settings, + ) + } + let zoomSlots = Set(restoration.fullscreenZoomedSlots) + let zoomKeys = Self.resolveFullscreenZoom( + slots: restoration.fullscreenZoomedSlots, + keyForSlot: liveBindings, + among: tree?.windows ?? [], ) - let expectedApps = Set(workspace.apps.filter { $0.autoOpen && $0.layout == .tiled }.map(\.bundleIdentifier)) - .intersection(restoration.tree.windows.map(\.bundleId)) - if expectedApps.isSubset(of: Set(currentKeys.map(\.bundleId))) { + state.fullscreenZoomed[workspaceId] = zoomKeys.isEmpty ? nil : zoomKeys + let unresolved = zoomSlots.subtracting(liveBindings.keys) + state.unresolvedFullscreenZoomSlots[workspaceId] = unresolved.isEmpty ? nil : unresolved + state.layoutRestorationBindings[workspaceId] = bindings + if Set(restoration.tree.windows).isSubset(of: Set(liveBindings.keys)) { state.pendingLayoutRestorations[workspaceId] = nil + state.layoutRestorationBindings[workspaceId] = nil } } @@ -1060,8 +1071,6 @@ extension WorkspaceActivationFeature { // must preserve user-resized ratios, and a WindowServer identity swap is // still the same logical slot. Compare against the replacement-normalized // baseline so neither reconciliation path silently re-equalizes the tree. - let membershipChanged = Set(replacementBaseline?.windows ?? []) - != Set(tree?.windows ?? []) let axis = settings.layout.autoBalance let balanced = axis == .none || !membershipChanged ? tree @@ -1106,32 +1115,8 @@ extension WorkspaceActivationFeature { ) } - // A native-tab switch (Ghostty, Terminal) retires the active tab's - // CGWindowID and surfaces a new one for the same app — so a fullscreen-zoom - // recorded on the retired id would fall back to a half tile. Migrate the - // zoom to the replacement (same app, newly in the tree). - // - // Only *migrate* — never drop a zoom key just because its window isn't in - // the tree this pass. A window transiently absent (monitor unplug/replug - // churn empties the tree, then the window returns with the same id) would - // otherwise lose its zoom permanently. A key whose window is genuinely gone - // is harmless: `computeFrames` ignores a zoom key not in the tree, so the - // workspace un-zooms correctly on close and the stale key just lingers. - if var zoom = state.fullscreenZoomed[workspaceId], !zoom.isEmpty { - var changed = false - for stale in zoom where balanced?.pathTo(window: stale) == nil { - guard - let replacement = replacements[stale] - ?? addedKeys.first(where: { $0.bundleId == stale.bundleId }) - else { continue } - zoom.remove(stale) - zoom.insert(replacement) - changed = true - } - if changed { state.fullscreenZoomed[workspaceId] = zoom.isEmpty ? nil : zoom } - } - if + state.pendingLayoutRestorations[workspaceId] == nil, let balanced, var unresolved = state.unresolvedFullscreenZoomSlots[workspaceId], !unresolved.isEmpty @@ -1297,15 +1282,21 @@ extension WorkspaceActivationFeature { postLayoutFocusEffect, ) } - let persistence = state.isRecoveringSystemLayout || state.pendingLayoutRestorations[workspaceId] != nil - ? Effect.none - : persist( - final, - fullscreenZoomed: zoomed, - unresolvedFullscreenZoomSlots: - state.unresolvedFullscreenZoomSlots[workspaceId] ?? [], - for: workspace, - ) + let persistence: Effect = + if state.isRecoveringSystemLayout { + .none + } else if let snapshot = state.pendingLayoutRestorations[workspaceId] { + hadPendingRestoration + ? .none + : .run { [store = layoutStore] _ in await store.save(workspaceId, snapshot) } + } else { + persist( + final, + fullscreenZoomed: zoomed, + unresolvedFullscreenZoomSlots: state.unresolvedFullscreenZoomSlots[workspaceId] ?? [], + for: workspace, + ) + } return .merge( layoutThenFocus, observeEffect, diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift index ccc0656..bb0df0b 100644 --- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift +++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift @@ -147,6 +147,7 @@ public struct WorkspaceActivationFeature { case screenLock case sessionInactive case systemSleep + case displaysUnavailable } @Shared(.tatamiConfig) public var config @@ -323,9 +324,12 @@ public struct WorkspaceActivationFeature { /// startup discovery. The first later sync that sees the matching live /// occurrences promotes them into `fullscreenZoomed`. public var unresolvedFullscreenZoomSlots = [Workspace.ID: Set]() - /// Keep the saved shape while auto-opened windows are still arriving. + /// Keep the saved shape while windows are absent or still arriving. /// A partial discovery must not replace a complete persisted layout. public var pendingLayoutRestorations = [Workspace.ID: LayoutSnapshot]() + /// Live bindings retained across partial restoration; a surviving second + /// window must not become occurrence zero when the first disappears. + public var layoutRestorationBindings = [Workspace.ID: [SlotID: WindowKey]]() /// Active composition per display — a host workspace plus borrowed /// blocks. Absent → that display shows its host alone (default behavior). @@ -1300,7 +1304,11 @@ public struct WorkspaceActivationFeature { /// GUI layout-preview edit for the *active* workspace: apply a structural /// op to its live tree, re-tile on screen, and persist per memory setting. case layoutEdited(workspaceId: Workspace.ID, op: LayoutEditOp) - case persistedLayoutRestorationUpdated(workspaceId: Workspace.ID, snapshot: LayoutSnapshot?) + case persistedLayoutRestorationUpdated( + workspaceId: Workspace.ID, + snapshot: LayoutSnapshot?, + bindings: [SlotID: WindowKey] = [:], + ) /// A GUI edit changed an *inactive* workspace's saved layout — drop its /// resident in-memory tree/zoom so the next activation rebuilds from the /// edited snapshot rather than the stale session state. @@ -1537,6 +1545,15 @@ public struct WorkspaceActivationFeature { .cancellable(id: CancelID.windowEvents, cancelInFlight: true) case .displaysReconfigured(let names): + // A headless transition can precede willSleep. Preserve the last + // desktop before any topology reconciliation can erase its ownership. + if names.isEmpty { + state.pendingDisplayTopologyReconcile = true + return beginLayoutSuspension(.displaysUnavailable, state: &state) + } + if state.layoutSuspensionReasons.contains(.displaysUnavailable) { + return endLayoutSuspension(.displaysUnavailable, state: &state) + } // Behind the lock/sleep shield the window server reports whatever it // likes: displays drop out and come back on their own, often more than // once, and AX answers empty. Reconciling there tears down the @@ -4284,6 +4301,8 @@ public struct WorkspaceActivationFeature { let newTree = tree.applying(op) guard newTree != tree else { return .none } state.pendingLayoutRestorations[workspaceId] = nil + state.layoutRestorationBindings[workspaceId] = nil + state.unresolvedFullscreenZoomSlots[workspaceId] = nil state.tilingTrees[workspaceId] = newTree let zoomed = state.fullscreenZoomed[workspaceId] ?? [] return .merge( @@ -4310,11 +4329,13 @@ public struct WorkspaceActivationFeature { state.fullscreenZoomed[workspaceId] = nil state.unresolvedFullscreenZoomSlots[workspaceId] = nil state.pendingLayoutRestorations[workspaceId] = nil + state.layoutRestorationBindings[workspaceId] = nil state.insertionPoint[workspaceId] = nil return .none - case .persistedLayoutRestorationUpdated(let workspaceId, let snapshot): + case .persistedLayoutRestorationUpdated(let workspaceId, let snapshot, let bindings): state.pendingLayoutRestorations[workspaceId] = snapshot + state.layoutRestorationBindings[workspaceId] = snapshot == nil ? nil : bindings return .none case .persistedFullscreenZoomRestored( @@ -5942,6 +5963,8 @@ public struct WorkspaceActivationFeature { // An explicit edit owns the current layout, including during startup. state.pendingLayoutRestorations[workspaceId] = nil + state.layoutRestorationBindings[workspaceId] = nil + state.unresolvedFullscreenZoomSlots[workspaceId] = nil let settings = state.config.settings // The block's geometry: a composition sub-rect when composed, else the diff --git a/TatamiKit/Sources/Tiling/BSPTree.swift b/TatamiKit/Sources/Tiling/BSPTree.swift index 11bc625..6991023 100644 --- a/TatamiKit/Sources/Tiling/BSPTree.swift +++ b/TatamiKit/Sources/Tiling/BSPTree.swift @@ -239,7 +239,15 @@ extension BSPNode where WindowID == WindowKey { template: BSPNode, keys: [WindowKey], ) -> BSPNode? { - let keyForSlot = slotToKey(keys) + hydrate(template: template, keyForSlot: slotToKey(keys)) + } + + /// Hydrate with retained slot bindings so surviving windows do not change + /// occurrence when another window closes during restoration. + public static func hydrate( + template: BSPNode, + keyForSlot: [SlotID: WindowKey], + ) -> BSPNode? { func build(_ node: BSPNode) -> BSPNode? { switch node { case .leaf(let slotLeaf): @@ -669,8 +677,9 @@ extension BSPNode { /// Grow or shrink the focused tile at its parent split, following the /// layout's orientation rather than assuming a left/right arrangement. public func resizing(window: WindowID, delta: CGFloat) -> BSPNode { - guard let path = pathTo(window: window), !path.isEmpty, - case .branch(let parent) = subtree(at: Array(path.dropLast())) + guard + let path = pathTo(window: window), !path.isEmpty, + case .branch(let parent) = subtree(at: Array(path.dropLast())) else { return self } return resizing(window: window, axis: parent.split, delta: delta) } diff --git a/TatamiTests/Sources/FullscreenZoomRestoreTests.swift b/TatamiTests/Sources/FullscreenZoomRestoreTests.swift index eb7d556..10a0409 100644 --- a/TatamiTests/Sources/FullscreenZoomRestoreTests.swift +++ b/TatamiTests/Sources/FullscreenZoomRestoreTests.swift @@ -13,75 +13,92 @@ import Testing /// their exact windows rather than collapsing onto the first. @Suite("FullscreenZoomRestore") struct FullscreenZoomRestoreTests { - private func key(_ pid: pid_t, _ wid: CGWindowID, _ bundle: String) -> WindowKey { - WindowKey(pid: pid, windowID: wid, bundleId: bundle) - } - private func slot(_ bundle: String, _ occurrence: Int) -> SlotID { - SlotID(bundleId: bundle, occurrence: occurrence) - } + // MARK: Internal @Test - func differentAppsBothRestore() { + func `different apps both restore`() { let a = key(1, 10, "com.a") let b = key(2, 20, "com.b") let resolved = WorkspaceActivationFeature.resolveFullscreenZoom( - slots: [slot("com.a", 0), slot("com.b", 0)], keys: [a, b], among: [a, b] + slots: [slot("com.a", 0), slot("com.b", 0)], + keyForSlot: slotToKey([a, b]), + among: [a, b], ) #expect(resolved == [a, b]) } @Test - func sameAppWindowsEachRestoreToADistinctWindow() { + func `same app windows each restore to A distinct window`() { let a1 = key(1, 10, "com.a") let a2 = key(1, 11, "com.a") let resolved = WorkspaceActivationFeature.resolveFullscreenZoom( - slots: [slot("com.a", 0), slot("com.a", 1)], keys: [a1, a2], among: [a1, a2] + slots: [slot("com.a", 0), slot("com.a", 1)], + keyForSlot: slotToKey([a1, a2]), + among: [a1, a2], ) #expect(resolved == [a1, a2]) #expect(resolved.count == 2) } @Test - func occurrenceTargetsTheExactWindowNotJustAnyOfThatApp() { + func `occurrence targets the exact window not just any of that app`() { let a1 = key(1, 10, "com.a") let a2 = key(1, 11, "com.a") // Occurrence 1 = the higher-windowID window (a2), not "some com.a window". let resolved = WorkspaceActivationFeature.resolveFullscreenZoom( - slots: [slot("com.a", 1)], keys: [a1, a2], among: [a1, a2] + slots: [slot("com.a", 1)], + keyForSlot: slotToKey([a1, a2]), + among: [a1, a2], ) #expect(resolved == [a2]) } @Test - func mixedSameAndDifferentApps() { + func `mixed same and different apps`() { let a1 = key(1, 10, "com.a") let a2 = key(1, 11, "com.a") let b = key(2, 20, "com.b") let resolved = WorkspaceActivationFeature.resolveFullscreenZoom( slots: [slot("com.a", 0), slot("com.b", 0), slot("com.a", 1)], - keys: [a1, a2, b], among: [a1, a2, b] + keyForSlot: slotToKey([a1, a2, b]), + among: [a1, a2, b], ) #expect(resolved == [a1, a2, b]) } @Test - func degradesWhenFewerLiveWindowsThanSaved() { + func `degrades when fewer live windows than saved`() { let a1 = key(1, 10, "com.a") // Saved two "com.a" zooms but only one window is live now — occurrence 1 // has no live match and is dropped. let resolved = WorkspaceActivationFeature.resolveFullscreenZoom( - slots: [slot("com.a", 0), slot("com.a", 1)], keys: [a1], among: [a1] + slots: [slot("com.a", 0), slot("com.a", 1)], + keyForSlot: slotToKey([a1]), + among: [a1], ) #expect(resolved == [a1]) } @Test - func dropsSlotsWithNoLiveMatch() { + func `drops slots with no live match`() { let a = key(1, 10, "com.a") let resolved = WorkspaceActivationFeature.resolveFullscreenZoom( - slots: [slot("com.a", 0), slot("com.missing", 0)], keys: [a], among: [a] + slots: [slot("com.a", 0), slot("com.missing", 0)], + keyForSlot: slotToKey([a]), + among: [a], ) #expect(resolved == [a]) } + + // MARK: Private + + private func key(_ pid: pid_t, _ wid: CGWindowID, _ bundle: String) -> WindowKey { + WindowKey(pid: pid, windowID: wid, bundleId: bundle) + } + + private func slot(_ bundle: String, _ occurrence: Int) -> SlotID { + SlotID(bundleId: bundle, occurrence: occurrence) + } + } diff --git a/TatamiTests/Sources/WorkspaceActivationTests.swift b/TatamiTests/Sources/WorkspaceActivationTests.swift index e13559f..edd6020 100644 --- a/TatamiTests/Sources/WorkspaceActivationTests.swift +++ b/TatamiTests/Sources/WorkspaceActivationTests.swift @@ -17,6 +17,249 @@ struct WorkspaceActivationFeatureTests { // MARK: Internal + @Test + func `a pending absent window does not undo auto balance on a no op sync`() async { + let first = WindowKey(pid: 1, windowID: 101, bundleId: "app.first") + let second = WindowKey(pid: 2, windowID: 202, bundleId: "app.second") + let missing = WindowKey(pid: 3, windowID: 303, bundleId: "app.missing") + let workspace = Workspace(name: "Work", apps: [first, second, missing].map { + AppAssignment(bundleIdentifier: $0.bundleId, name: $0.bundleId) + }) + let original = BSPNode.branch(BSPBranch( + split: .horizontal, + ratio: 0.3, + left: .leaf(first), + right: .branch(BSPBranch(split: .vertical, ratio: 0.2, left: .leaf(second), right: .leaf(missing))), + )) + let balanced = original.removing(missing)?.balanced(axis: .both) + let state = Self.makeState(workspaces: [workspace]) { + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.focusedDisplay = Self.display + $0.tilingTrees[workspace.id] = balanced + $0.pendingLayoutRestorations[workspace.id] = LayoutSnapshot(liveTree: original, fullscreenZoomed: []) + $0.layoutRestorationBindings[workspace.id] = slotToKey(original.windows) + $0.$config.withLock { $0.settings.layout.autoBalance = .both } + } + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } + store.exhaustivity = .off + await store.send(.syncAppWindowsResolved( + bundleId: first.bundleId, + resizableKeys: [first], + onScreenFrames: [first.windowID: .zero, second.windowID: .zero], + )) + await store.finish() + #expect(store.state.tilingTrees[workspace.id] == balanced) + #expect(store.state.pendingLayoutRestorations[workspace.id] != nil) + } + + @Test(arguments: [false, true]) + func `a surviving window keeps its slot through another windows removal and return`(prune: Bool) async { + let first = WindowKey(pid: 41, windowID: 101, bundleId: "app.editor") + let survivor = WindowKey(pid: 41, windowID: 102, bundleId: first.bundleId) + let replacement = WindowKey(pid: 41, windowID: 201, bundleId: first.bundleId) + let workspace = Workspace(name: "Editor", apps: [ + AppAssignment(bundleIdentifier: first.bundleId, name: "Editor") + ]) + let original = BSPNode.branch(BSPBranch( + split: .horizontal, + ratio: 0.3, + left: .leaf(first), + right: .leaf(survivor), + )) + let saved = LayoutSnapshot(liveTree: original, fullscreenZoomed: [first]) + let writes = LockIsolated<[LayoutSnapshot]>([]) + let state = Self.makeState(workspaces: [workspace]) { + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.focusedDisplay = Self.display + $0.tilingTrees[workspace.id] = original + $0.fullscreenZoomed[workspace.id] = [first] + $0.$config.withLock { $0.settings.layout.autoBalance = .none } + } + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.windowSnapshot.onScreenWindowIDs = { [survivor.windowID] } + $0.layoutStore.save = { _, snapshot in writes.withValue { $0.append(snapshot) } } + } + store.exhaustivity = .off + if prune { + await store.send(.windowServerWindowEvent(.terminated(first.windowID))) + } else { + await store.send(.syncAppWindowsResolved( + bundleId: first.bundleId, + resizableKeys: [survivor], + onScreenFrames: [survivor.windowID: .zero], + )) + } + await store.finish() + #expect(store.state.pendingLayoutRestorations[workspace.id] == saved) + #expect(writes.value.last == saved) + #expect(store.state.fullscreenZoomed[workspace.id]?.contains(survivor) != true) + + await store.send(.syncAppWindowsResolved( + bundleId: first.bundleId, + resizableKeys: [survivor, replacement], + onScreenFrames: [survivor.windowID: .zero, replacement.windowID: .zero], + )) + await store.finish() + #expect(store.state.tilingTrees[workspace.id] == original.mapWindows { $0 == first ? replacement : $0 }) + #expect(store.state.fullscreenZoomed[workspace.id] == [replacement]) + #expect(store.state.pendingLayoutRestorations[workspace.id] == nil) + #expect(store.state.layoutRestorationBindings[workspace.id] == nil) + } + + @Test(arguments: [false, true]) + func `activation preserves resident layout and zoom when window identities change`(newProcess: Bool) async { + let old = [ + WindowKey(pid: 41, windowID: 101, bundleId: "app.editor"), + WindowKey(pid: 41, windowID: 102, bundleId: "app.editor"), + ] + let live = [ + WindowKey(pid: newProcess ? 42 : 41, windowID: 201, bundleId: "app.editor"), + WindowKey(pid: newProcess ? 42 : 41, windowID: 202, bundleId: "app.editor"), + ] + let workspace = Workspace(name: "Editor", apps: [ + AppAssignment(bundleIdentifier: "app.editor", name: "Editor") + ]) + let resident = BSPNode.branch(BSPBranch( + split: .horizontal, + ratio: 0.3, + left: .leaf(old[1]), + right: .leaf(old[0]), + )) + let expected = BSPNode.branch(BSPBranch( + split: .horizontal, + ratio: 0.3, + left: .leaf(live[1]), + right: .leaf(live[0]), + )) + let state = Self.makeState(workspaces: [workspace]) { + $0.focusedDisplay = Self.display + $0.tilingTrees[workspace.id] = resident + $0.fullscreenZoomed[workspace.id] = Set(old) + $0.$config.withLock { $0.settings.layout.autoBalance = .none } + } + let writes = LockIsolated<[LayoutSnapshot]>([]) + let applications = LockIsolated<[FrameApplication]>([]) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.floatingOverlay.retainOnly = { _ in } + $0.floatingOverlay.setFloating = { _ in } + $0.windowSnapshot.cachedKeys = { _, _ in live } + $0.windowTiler.apply = { request in applications.withValue { $0.append(request) } } + $0.layoutStore.save = { _, snapshot in writes.withValue { $0.append(snapshot) } } + } + store.exhaustivity = .off + + await store.send(.activate(workspaceId: workspace.id, setFocus: false)) + await store.receive { if case .activationCompleted = $0 { return true } + return false + } + await store.finish() + + #expect(store.state.tilingTrees[workspace.id] == expected) + #expect(store.state.fullscreenZoomed[workspace.id] == Set(live)) + #expect(writes.value.last?.fullscreenZoomedSlots.count == 2) + #expect(applications.value.last?.windowFrames[live[0]] == applications.value.last?.windowFrames[live[1]]) + } + + @Test(arguments: [false, true]) + func `saved layout survives staggered windows from one app`(autoOpen: Bool) async throws { + let first = WindowKey(pid: 42, windowID: 101, bundleId: "app.editor") + let late = WindowKey(pid: 42, windowID: 202, bundleId: "app.editor") + let workspace = Workspace(name: "Editor", apps: [ + AppAssignment(bundleIdentifier: first.bundleId, name: "Editor", autoOpen: autoOpen) + ]) + let expected = BSPNode.branch(BSPBranch( + split: .horizontal, + ratio: 0.3, + left: .leaf(late), + right: .leaf(first), + )) + let slots = slotAssignment(expected.windows) + let saved = LayoutSnapshot( + tree: expected.mapWindows { slots[$0]! }, + fullscreenZoomedSlots: [try #require(slots[late])], + ) + let writes = LockIsolated<[LayoutSnapshot]>([]) + let state = Self.makeState(workspaces: [workspace]) { + $0.focusedDisplay = Self.display + $0.$config.withLock { $0.settings.layout.autoBalance = .none } + } + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.floatingOverlay.retainOnly = { _ in } + $0.floatingOverlay.setFloating = { _ in } + $0.windowSnapshot.cachedKeys = { _, _ in [first] } + $0.layoutStore.load = { _ in saved } + $0.layoutStore.save = { _, snapshot in writes.withValue { $0.append(snapshot) } } + } + store.exhaustivity = .off + await store.send(.activate(workspaceId: workspace.id, setFocus: false)) + await store.receive { if case .activationCompleted = $0 { return true } + return false + } + await store.finish() + + #expect(store.state.pendingLayoutRestorations[workspace.id] == saved) + #expect(writes.value.isEmpty) + await store.send(.syncAppWindowsResolved( + bundleId: first.bundleId, + resizableKeys: [first, late], + onScreenFrames: [first.windowID: .zero, late.windowID: .zero], + )) + await store.finish() + #expect(store.state.tilingTrees[workspace.id] == expected) + #expect(store.state.fullscreenZoomed[workspace.id] == [late]) + #expect(store.state.pendingLayoutRestorations[workspace.id] == nil) + #expect(writes.value.last == saved) + } + + @Test(arguments: [false, true]) + func `an empty display list before sleep preserves the last desktop`(displayReturnsFirst: Bool) async { + let window = WindowKey(pid: 42, windowID: 101, bundleId: "app.editor") + let workspace = Workspace(name: "Editor", apps: [ + AppAssignment(bundleIdentifier: window.bundleId, name: "Editor") + ]) + let state = Self.makeState(workspaces: [workspace]) { + $0.connectedDisplays = [Self.display] + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.tilingTrees[workspace.id] = .leaf(window) + $0.fullscreenZoomed[workspace.id] = [window] + } + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.floatingOverlay.setFloating = { _ in } + $0.sls.isActiveSpaceFullscreen = { false } + $0.windowSnapshot.cachedKeys = { _, _ in [window] } + $0.windowSnapshot.onScreenWindowIDs = { [window.windowID] } + } + store.exhaustivity = .off + + await store.send(.displaysReconfigured([])) + await store.send(.systemWillSuspend) + await store.finish() + + #expect(store.state.activeWorkspacesByDisplay[Self.display] == workspace.id) + #expect(store.state.connectedDisplays == [Self.display]) + #expect(store.state.suspendedLayoutWindows[workspace.id] == [window]) + #expect(store.state.fullscreenZoomed[workspace.id] == [window]) + + if displayReturnsFirst { + await store.send(.displaysReconfigured([Self.display])) + #expect(store.state.isLayoutSuspended) + await store.send(.systemDidWake) + } else { + await store.send(.systemDidWake) + #expect(store.state.isLayoutSuspended) + await store.send(.displaysReconfigured([Self.display])) + } + await store.finish() + #expect(!store.state.isLayoutSuspended) + #expect(!store.state.pendingDisplayTopologyReconcile) + #expect(store.state.activeWorkspacesByDisplay[Self.display] == workspace.id) + #expect(store.state.tilingTrees[workspace.id]?.windows == [window]) + #expect(store.state.fullscreenZoomed[workspace.id] == [window]) + } + @Test(arguments: [false, true]) func `keyboard switcher quick taps return to the last focused window`(byWindow: Bool) async { let a = WindowKey(pid: 1, windowID: 10, bundleId: "app.a") @@ -7837,7 +8080,7 @@ struct WorkspaceActivationFeatureTests { } @Test(arguments: AutoBalanceMode.allCases) - func `resume resets a fully recycled layout using auto balance`( + func `resume preserves the reconciled shape after every identity changes`( mode: AutoBalanceMode ) async { let old = [ @@ -7874,12 +8117,7 @@ struct WorkspaceActivationFeatureTests { ) ) let workArea = CGRect(x: 0, y: 0, width: 1_000, height: 800) - let expected = current.balancedForCommand( - autoBalance: mode, - in: workArea, - gap: 0, - splitAxis: nil, - ) + let expected = current let state = Self.makeState(workspaces: [workspace]) { $0.$config.withLock { $0.settings.layout.autoBalance = mode @@ -7915,7 +8153,7 @@ struct WorkspaceActivationFeatureTests { #expect(!store.state.isRecoveringSystemLayout) #expect(store.state.suspendedLayoutWindows.isEmpty) #expect(store.state.pendingSystemLayoutBundleIds.isEmpty) - #expect(applications.value.last?.windowFrames.keys.count == windows.count) + #expect(applications.value.isEmpty) let slots = slotAssignment(expected.windows) #expect(saved.value.last?.tree == expected.mapWindows { slots[$0]! }) } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e391dac..712ffd9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -153,10 +153,29 @@ actions show feedback. | `splitType` | string | `"auto"` | Default split axis when a new window splits a tile: `auto` (aspect-based), `horizontal`, `vertical`. | | `windowPlacement` | string | `"second"` | Which child of the new split holds the inserted window: `first` (top/left) or `second` (bottom/right). | -Workspaces always remember their layout. Split axes and ratios are persisted -to disk and restored on the next launch. System sleep preserves the live tree; -if macOS recreates a window surface on wake, Tatami reconnects it to the saved -layout without writing the temporary wake-up state back to disk. +### Window and layout restoration + +Tatami remembers split directions, proportions, and fullscreen zoom separately for each +workspace. When windows close or arrive one at a time, the remaining windows fill the +available space while Tatami keeps the missing layout slots. Reopened windows can return +to those slots. An explicit layout change, such as resizing, rearranging, or toggling +fullscreen zoom, replaces that saved layout with the current windows. + +macOS and each app decide which windows reopen. Tatami applies the remembered layout to +windows they provide; it does not reopen documents itself. These macOS settings control +different situations: + +- **Close windows when quitting an application**, in **System Settings → Desktop & + Dock**: turn this off to let supporting apps reopen their windows when you launch them + again. When it is on, an app can open a fresh window instead. +- **Reopen windows when logging back in**, in the logout, restart, or shutdown dialog: + select this to ask macOS to reopen apps and windows at the next login. Enable Tatami’s + **Launch at Login**, or open Tatami after logging in, to apply its layouts. + +Tatami matches layout slots by app and window order, not by document name. If an app +changes the order in which it recreates windows, a different document can occupy a saved +slot. A fresh window can also reuse an old slot. A workspace’s **Auto Open** setting +launches its apps separately from macOS document restoration. ## `[settings.focus]` diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3e157a9..e21acea 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -59,3 +59,11 @@ If the control still disappears, enable **Settings → General → Debug Logging reproduce one workspace switch, and inspect `~/.config/tatami/tatami.log` for an `OverlayAware evaluate ... preserve=` entry. A `preserve=false` result means the current control did not satisfy the conditions above. + +## Fullscreen zoom changes after connecting a display + +Check which profile and workspace are active. Display rules can automatically switch +profiles when the connected monitors change. Each workspace has its own saved layout and +fullscreen zoom, even when another profile contains the same apps. Configure the layout +in each profile you use, or adjust the profile’s display rule if the switch was +unintended. diff --git a/docs/ja/CONFIGURATION.md b/docs/ja/CONFIGURATION.md index d6b0d34..7617ffd 100644 --- a/docs/ja/CONFIGURATION.md +++ b/docs/ja/CONFIGURATION.md @@ -138,7 +138,17 @@ overlayAwareApps = ["notion.id"] |`splitType`|string|`"auto"`|新しいウインドウで分割するときの既定方向です。`auto`(縦横比で判断)、`horizontal`、`vertical` を使えます。| |`windowPlacement`|string|`"second"`|新しい分割のどちらへ入れるかを選びます。`first` は上・左、`second` は下・右です。| -ワークスペースは常に配置を覚えます。分割方向と比率を保存して再起動時に復元し、スリープ中はツリーを保ちます。復帰時に macOS が画面を作り直しても、保存済み配置へ再接続し、一時状態を上書きしません。 + +### ウインドウとレイアウトの復元 + +Tatami はワークスペースごとに分割方向、比率、全画面表示の状態を記憶します。ウインドウが閉じられたり、順番に開いたりするときは、現在のウインドウで画面を使いながら、まだないウインドウの位置を保持します。再び開いたウインドウはその位置に戻れます。サイズ変更、配置変更、全画面表示の切り替えなどを行うと、現在のウインドウをもとに配置を記憶し直します。 + +どのウインドウを開き直すかは、macOS と各アプリが決めます。Tatami は開いたウインドウに記憶した配置を適用し、書類そのものは開き直しません。次の macOS 設定は、それぞれ異なる場面で使われます。 + +- **システム設定 → デスクトップと Dock**の**アプリケーションを終了するときにウインドウを閉じる**:オフにすると、対応するアプリは次の起動時にウインドウを開き直せます。オンの場合は、新しいウインドウが開くことがあります。 +- ログアウト、再起動、システム終了のダイアログにある**再ログイン時にウインドウを再度開く**:選択すると、次のログイン時に macOS がアプリとウインドウを開き直します。Tatami の**ログイン時に起動**を有効にするか、ログイン後に Tatami を開くと、配置を適用できます。 + +Tatami は書類名ではなく、アプリとウインドウの順序で配置を対応付けます。アプリがウインドウを作り直す順序を変えると、別の書類が保存された位置に入ることがあります。新しいウインドウも以前の位置を使えます。ワークスペースの**自動で開く**は、macOS の書類復元とは別にアプリを起動する設定です。 ## `[settings.focus]` diff --git a/docs/ja/TROUBLESHOOTING.md b/docs/ja/TROUBLESHOOTING.md index 3f7a7c8..0453c9a 100644 --- a/docs/ja/TROUBLESHOOTING.md +++ b/docs/ja/TROUBLESHOOTING.md @@ -40,3 +40,8 @@ overlayAwareApps = [ ``` 消える場合は**設定 → 一般**でデバッグログを有効にし、一度作業を切り替えて `~/.config/tatami/tatami.log` の `OverlayAware evaluate ... preserve=` を確認します。`preserve=false` は条件を満たさなかったことを示します。 + + +## ディスプレイの接続後に全画面表示が変わる + +現在のプロファイルとワークスペースを確認してください。接続中のモニターが変わると、ディスプレイルールによってプロファイルが自動で切り替わることがあります。別のプロファイルに同じアプリがあっても、配置と全画面表示の状態はワークスペースごとに記憶されます。使う各プロファイルで配置を設定するか、意図しない切り替えならプロファイルのディスプレイルールを調整してください。 diff --git a/docs/ko/CONFIGURATION.md b/docs/ko/CONFIGURATION.md index 0d16b08..6c0215b 100644 --- a/docs/ko/CONFIGURATION.md +++ b/docs/ko/CONFIGURATION.md @@ -138,7 +138,17 @@ overlayAwareApps = ["notion.id"] |`splitType`|string|`"auto"`|새 창이 들어올 때 기본으로 나누는 방향이에요. `auto`(화면 비율 기준), `horizontal`, `vertical`를 사용할 수 있어요.| |`windowPlacement`|string|`"second"`|새 창을 새 분할의 어느 쪽에 놓을지 정해요. `first`은 위·왼쪽, `second`은 아래·오른쪽이에요.| -작업 공간은 항상 배치를 기억해요. 분할 방향과 비율을 디스크에 저장하고 재실행하면 복원해요. 잠자기 중에는 현재 트리를 유지해요. 깨어날 때 macOS가 창을 다시 만들면 저장한 배치에 연결하되 임시 상태를 디스크에 덮어쓰지 않아요. + +### 창과 레이아웃 복원 + +Tatami는 작업 공간마다 분할 방향, 비율, 전체 화면 상태를 기억해요. 창이 닫히거나 하나씩 열리는 동안에는 현재 창으로 화면을 채우고, 아직 없는 창의 자리를 보관해요. 다시 열린 창은 보관된 자리로 돌아갈 수 있어요. 크기 조절, 재배치, 전체 화면 전환처럼 직접 레이아웃을 바꾸면 현재 창을 기준으로 새로 기억해요. + +어떤 창을 다시 열지는 macOS와 각 앱이 결정해요. Tatami는 열린 창에 기억한 레이아웃을 적용하며, 문서를 직접 다시 열지는 않아요. 다음 macOS 설정은 각각 다른 상황에 적용돼요. + +- **시스템 설정 → 데스크탑 및 Dock**의 **응용 프로그램을 종료하면 윈도우 닫기**: 끄면 이 기능을 지원하는 앱이 다음 실행 때 창을 다시 열 수 있어요. 켜면 앱이 새 창을 열 수 있어요. +- 로그아웃·재시동·시스템 종료 대화상자의 **다시 로그인하면 윈도우 다시 열기**: 선택하면 macOS가 다음 로그인 때 앱과 창을 다시 열어요. Tatami의 **로그인 시 실행**을 켜거나 로그인 후 Tatami를 열면 저장한 레이아웃을 적용할 수 있어요. + +Tatami는 문서 이름 대신 앱과 창 순서로 자리를 연결해요. 앱이 창을 다시 만드는 순서를 바꾸면 다른 문서가 같은 자리에 놓일 수 있어요. 새 창도 이전 자리를 사용할 수 있어요. 작업 공간의 **자동 열기**는 macOS의 문서 복원과 별개로 앱을 실행하는 설정이에요. ## `[settings.focus]` diff --git a/docs/ko/TROUBLESHOOTING.md b/docs/ko/TROUBLESHOOTING.md index 81e921e..8674368 100644 --- a/docs/ko/TROUBLESHOOTING.md +++ b/docs/ko/TROUBLESHOOTING.md @@ -40,3 +40,8 @@ overlayAwareApps = [ ``` 계속 사라진다면 **설정 → 일반**에서 디버그 로그를 켜고 작업 공간을 한 번 전환해보세요. `~/.config/tatami/tatami.log`의 `OverlayAware evaluate ... preserve=` 항목을 확인하세요. `preserve=false`는 컨트롤이 위 조건을 충족하지 못했다는 뜻이에요. + + +## 디스플레이 연결 후 전체 화면 상태가 달라져요 + +현재 프로필과 작업 공간을 확인해 보세요. 연결된 모니터가 바뀌면 디스플레이 규칙에 따라 프로필이 자동으로 전환될 수 있어요. 다른 프로필에 같은 앱이 있어도 레이아웃과 전체 화면 상태는 작업 공간마다 따로 기억해요. 사용하는 각 프로필에서 배치를 설정하거나, 원치 않는 전환이라면 프로필의 디스플레이 규칙을 조정하세요. diff --git a/docs/zh-Hans/CONFIGURATION.md b/docs/zh-Hans/CONFIGURATION.md index 05e9a14..0999e1e 100644 --- a/docs/zh-Hans/CONFIGURATION.md +++ b/docs/zh-Hans/CONFIGURATION.md @@ -138,7 +138,17 @@ overlayAwareApps = ["notion.id"] |`splitType`|string|`"auto"`|新窗口分割区域时的默认轴:`auto`(按宽高比)、`horizontal`、`vertical`。| |`windowPlacement`|string|`"second"`|新分割中放置新窗口的一侧:`first`(上、左)或 `second`(下、右)。| -工作区始终记住布局。分割轴和比例保存到磁盘,重启时恢复;睡眠保留活动树。唤醒时若 macOS 重建窗口表面,Tatami 将其重新连接到保存的布局,不把临时唤醒状态写回磁盘。 + +### 窗口与布局恢复 + +Tatami 分别记住每个工作区的分割方向、比例和全屏状态。窗口关闭或陆续打开时,现有窗口会填满可用空间,同时保留缺失窗口的位置。重新打开的窗口可以回到这些位置。手动调整大小、重新排列或切换全屏等布局操作,会按当前窗口重新保存布局。 + +由 macOS 和各应用决定重新打开哪些窗口。Tatami 将记住的布局应用到这些窗口,不会自行重新打开文稿。以下 macOS 设置分别控制不同情况: + +- **系统设置 → 桌面与程序坞**中的**退出应用程序时关闭窗口**:关闭此选项后,支持此功能的应用可以在下次启动时重新打开窗口。开启时,应用可能改为打开新窗口。 +- 注销、重新启动或关机对话框中的**再次登录时重新打开窗口**:选中后,macOS 会在下次登录时重新打开应用和窗口。启用 Tatami 的**登录时启动**,或在登录后打开 Tatami,即可应用其布局。 + +Tatami 按应用和窗口顺序匹配布局位置,而不是按文稿名称。如果应用改变重新创建窗口的顺序,其他文稿可能占据原来保存的位置。新窗口也可以使用旧位置。工作区的**自动打开**设置用于启动应用,与 macOS 的文稿恢复相互独立。 ## `[settings.focus]` diff --git a/docs/zh-Hans/TROUBLESHOOTING.md b/docs/zh-Hans/TROUBLESHOOTING.md index 2cb0423..bea4132 100644 --- a/docs/zh-Hans/TROUBLESHOOTING.md +++ b/docs/zh-Hans/TROUBLESHOOTING.md @@ -40,3 +40,8 @@ overlayAwareApps = [ ``` 若仍然消失,在**设置 → 通用**启用调试日志,复现一次工作区切换,检查 `~/.config/tatami/tatami.log` 的 `OverlayAware evaluate ... preserve=` 条目。`preserve=false` 表示当前控件不符合上述条件。 + + +## 连接显示器后全屏状态发生变化 + +请检查当前启用的配置方案和工作区。连接的显示器发生变化时,显示器规则可能会自动切换配置方案。即使另一个配置方案包含相同应用,每个工作区的布局和全屏状态仍分别保存。请在使用的各配置方案中设置布局,或调整显示器规则以避免意外切换。 diff --git a/docs/zh-Hant/CONFIGURATION.md b/docs/zh-Hant/CONFIGURATION.md index a705b3e..abd7b79 100644 --- a/docs/zh-Hant/CONFIGURATION.md +++ b/docs/zh-Hant/CONFIGURATION.md @@ -138,7 +138,17 @@ overlayAwareApps = ["notion.id"] |`splitType`|string|`"auto"`|新視窗分割區域時的預設軸:`auto`(依寬高比)、`horizontal`、`vertical`。| |`windowPlacement`|string|`"second"`|新分割中放置新視窗的一側:`first`(上、左)或 `second`(下、右)。| -工作空間始終記住排列。分割軸與比例儲存到磁碟,重新啟動時還原;睡眠保留作用中樹。喚醒時若 macOS 重建視窗表面,Tatami 將其重新連接到儲存的排列,不把暫時喚醒狀態寫回磁碟。 + +### 視窗與配置回復 + +Tatami 會分別記住每個工作空間的分割方向、比例和全螢幕狀態。視窗關閉或陸續開啟時,現有視窗會填滿可用空間,同時保留缺少視窗的位置。重新開啟的視窗可以回到這些位置。手動調整大小、重新排列或切換全螢幕等配置操作,會依目前的視窗重新儲存配置。 + +由 macOS 和各個 App 決定重新開啟哪些視窗。Tatami 會將記住的配置套用至這些視窗,不會自行重新開啟文件。以下 macOS 設定分別控制不同情況: + +- **系統設定 → 桌面與 Dock**中的**結束應用程式時關閉視窗**:關閉此選項後,支援此功能的 App 可以在下次啟動時重新開啟視窗。開啟時,App 可能改為開啟新視窗。 +- 登出、重新啟動或關機對話框中的**再次登入時重新打開視窗**:勾選後,macOS 會在下次登入時重新開啟 App 和視窗。啟用 Tatami 的**登入時啟動**,或在登入後開啟 Tatami,即可套用配置。 + +Tatami 依 App 和視窗順序比對配置位置,而非文件名稱。如果 App 改變重新建立視窗的順序,其他文件可能佔用原先儲存的位置。新視窗也可以使用舊位置。工作空間的**自動開啟**設定用於啟動 App,與 macOS 的文件回復各自獨立。 ## `[settings.focus]` diff --git a/docs/zh-Hant/TROUBLESHOOTING.md b/docs/zh-Hant/TROUBLESHOOTING.md index a3d6d94..99eea62 100644 --- a/docs/zh-Hant/TROUBLESHOOTING.md +++ b/docs/zh-Hant/TROUBLESHOOTING.md @@ -40,3 +40,8 @@ overlayAwareApps = [ ``` 若仍然消失,在**設定 → 一般**啟用偵錯記錄,重現一次工作空間切換,檢查 `~/.config/tatami/tatami.log` 的 `OverlayAware evaluate ... preserve=` 項目。`preserve=false` 表示目前控制項不符合上述條件。 + + +## 連接顯示器後全螢幕狀態改變 + +請檢查目前使用的設定組合和工作空間。連接的顯示器改變時,顯示器規則可能會自動切換設定組合。即使另一個設定組合包含相同 App,每個工作空間的配置和全螢幕狀態仍會分別儲存。請在使用的各設定組合中設定配置,或調整顯示器規則以避免意外切換。