From ff26debf00ff3c73726ec735682b4844a1b64340 Mon Sep 17 00:00:00 2001 From: PangMo5 Date: Thu, 10 Sep 2026 15:08:15 +0900 Subject: [PATCH 1/2] fix: preserve workspace recency in keyboard window switching - Track recent focus per workspace, including shared and non-tiled windows, and preserve it across workspace switches and layout changes. - Freeze held switcher sessions and remember the resolved focus anchor so quick taps return to the last-used window before AX notifications arrive. - Maintain recency through Borrow, window replacement, and app termination. - Send complete modifier lifetimes from DemoLab keyboard taps. - Document switching behavior and the Tatami name in all five languages. Tests: 744 macOS tests and 61 DemoLab tests passed; 66-step VM scenario passed; 35 localized documents verified; host Debug build and launch passed. --- DemoLab/Sources/DemoDriverKit/KeyDriver.swift | 22 +- DemoLab/Sources/demokey/main.swift | 2 +- Localization/Docs.json | 14 + README.md | 3 + .../WorkspaceActivationFeature.swift | 76 ++++- .../Sources/WorkspaceActivationTests.swift | 260 ++++++++++++++++++ docs/CONFIGURATION.md | 7 + docs/ja/CONFIGURATION.md | 2 + docs/ja/README.md | 2 + docs/ko/CONFIGURATION.md | 2 + docs/ko/README.md | 2 + docs/zh-Hans/CONFIGURATION.md | 2 + docs/zh-Hans/README.md | 2 + docs/zh-Hant/CONFIGURATION.md | 2 + docs/zh-Hant/README.md | 2 + 15 files changed, 384 insertions(+), 16 deletions(-) diff --git a/DemoLab/Sources/DemoDriverKit/KeyDriver.swift b/DemoLab/Sources/DemoDriverKit/KeyDriver.swift index f460688..25f493e 100644 --- a/DemoLab/Sources/DemoDriverKit/KeyDriver.swift +++ b/DemoLab/Sources/DemoDriverKit/KeyDriver.swift @@ -118,30 +118,34 @@ public enum KeyDriver { /// than a demo that stops with an error. public static var isTrusted: Bool { AXIsProcessTrusted() } - /// Taps one chord: key down with the modifier flags set, hold, key up with - /// the same flags. Carbon hotkeys (`RegisterEventHotKey`, which is what - /// Magnet uses) match on the flags carried by the key event itself. + /// Taps one complete chord, including real modifier-down/up events. Flags + /// on Tab alone can trigger Carbon while leaving the session modifier state + /// set, so a native-style switcher never sees the release that commits it. public static func press(_ chord: Chord, holdMilliseconds: Int = 24) throws { let source = try eventSource() - // Ledger first, post second: an interrupt landing between the two must err - // towards a stray key-up, never towards a key left down. - HeldKeys.add(code: chord.keyCode, flag: []) + let held = modifierKeys.filter { chord.flags.contains($0.flag) } do { + for entry in held { + // Ledger first: interruption must never leave a posted key held down. + HeldKeys.add(code: entry.code, flag: entry.flag) + try post(keyCode: entry.code, keyDown: true, flags: HeldKeys.flags, source: source) + } + if !held.isEmpty { sleep(milliseconds: modifierSettleMilliseconds) } + HeldKeys.add(code: chord.keyCode, flag: []) try post(keyCode: chord.keyCode, keyDown: true, flags: chord.flags, source: source) sleep(milliseconds: holdMilliseconds) - try post(keyCode: chord.keyCode, keyDown: false, flags: chord.flags, source: source) } catch { releaseHeldKeys(using: source) throw error } - HeldKeys.drop(code: chord.keyCode) + releaseHeldKeys(using: source) } /// Holds `modifiers` down, taps each key in turn, waits, then releases. /// /// The modifiers are pressed as real key events, not merely set as flags on /// the key taps: Tatami's held-modifier app/window switcher polls - /// `CGEventSource.flagsState`, which flags on a key event alone never reach. + /// `CGEventSource.flagsState` and needs the complete modifier lifetime. /// The modifiers are always released — when a tap fails partway, and when the /// operator interrupts the take mid-hold (see `installReleaseGuard`). public static func hold( diff --git a/DemoLab/Sources/demokey/main.swift b/DemoLab/Sources/demokey/main.swift index 3aea7f6..d66bed0 100644 --- a/DemoLab/Sources/demokey/main.swift +++ b/DemoLab/Sources/demokey/main.swift @@ -163,7 +163,7 @@ enum DemoKey { hold Holds modifiers down as real key presses, taps each key in --keys, waits, then releases. This is what Tatami's held-modifier switcher needs: it polls the HID - flags state, which flags on a key event alone never reach. + flags state and needs matching modifier-down/up events to commit on release. --keys LIST comma-separated chords, e.g. "tab,tab" (required) --gap-ms N wait between taps (default 90) --release-after-ms wait before releasing the modifiers (default 180) diff --git a/Localization/Docs.json b/Localization/Docs.json index d41cbe4..8d09c2f 100644 --- a/Localization/Docs.json +++ b/Localization/Docs.json @@ -1,4 +1,18 @@ { + "d08480de0ce2a617": { + "en": "Keyboard window switching remembers recent focus separately for each workspace while Tatami is running. Tap and release the next-window shortcut to return to the last-used app or window. Hold its modifier to browse the list; its order stays fixed until you release or cancel. Switching workspaces or rearranging tiles preserves that workspace's history. Window-switching gestures keep their directional layout order.", + "ko": "키보드 창 전환은 Tatami가 실행 중인 동안 작업 공간마다 최근 포커스 순서를 따로 기억해요. 다음 창 단축키를 짧게 눌렀다 놓으면 마지막으로 사용한 앱이나 창으로 돌아가요. 보조 키를 누른 채 목록을 고를 수 있고, 키를 놓거나 취소할 때까지 목록 순서는 바뀌지 않아요. 다른 작업 공간으로 이동하거나 창 배치를 바꿔도 각 공간의 기록은 유지돼요. 창 전환 제스처는 배치 순서대로 이동해요.", + "ja": "キーボードでのウインドウ切り替えは、Tatami の実行中、ワークスペースごとに最近フォーカスした順序を記憶します。「次のウインドウ」のショートカットを押してすぐ離すと、直前に使ったアプリやウインドウに戻ります。修飾キーを押したまま一覧を選べます。キーを離すかキャンセルするまで一覧の順序は変わりません。ワークスペースの切り替えやタイルの並べ替えでも履歴は維持されます。ウインドウ切り替えのジェスチャは配置順に移動します。", + "zh-Hans": "Tatami 运行期间,键盘窗口切换会为每个工作区分别记住最近的焦点顺序。短按并松开“下一个窗口”快捷键,即可返回上次使用的应用或窗口。按住修饰键可浏览列表,列表顺序保持不变,直到松开或取消。切换工作区或重新排列窗口不会清除该工作区的记录。窗口切换手势仍按布局顺序移动。", + "zh-Hant": "Tatami 執行期間,鍵盤視窗切換會為每個工作空間分別記住最近的焦點順序。短按並放開「下一個視窗」快速鍵,即可回到上次使用的 App 或視窗。按住修飾鍵可瀏覽清單,清單順序會維持不變,直到放開或取消。切換工作空間或重新排列視窗不會清除該空間的紀錄。視窗切換手勢仍依排列順序移動。" + }, + "7d1b20ca5ef5ec14": { + "en": "*Tatami* (畳) are traditional Japanese floor mats, fitted together to cover a room. Tatami brings that idea to your Mac, fitting windows together into a workspace.", + "ko": "*다다미*(畳)는 일본 전통 방의 바닥에 맞춰 까는 매트예요. 다다미를 맞춰 방을 채우듯, Tatami는 창을 배치해 Mac의 작업 공간을 정리해요.", + "ja": "Tatami の名前は、和室の床に敷き詰める「畳」に由来します。畳を組み合わせて部屋を整えるように、Mac のウインドウを並べてワークスペースを整えます。", + "zh-Hans": "*Tatami*(畳)是日本传统房间里拼铺的榻榻米。就像榻榻米铺满房间,Tatami 将 Mac 上的窗口拼排成工作区。", + "zh-Hant": "*Tatami*(畳)是日本傳統房間裡拼鋪的榻榻米。如同榻榻米鋪滿房間,Tatami 將 Mac 上的視窗排列成工作空間。" + }, "75be03c96fc163e7": { "en": "Choose confirmations for each action independently. Every option is enabled by default. Selecting \"Don't ask again\" and confirming disables only that action. Cancelling keeps the preference unchanged. Older global confirmation settings are migrated to these options; explicitly configured options take precedence.", "ko": "동작별로 확인 여부를 고를 수 있어요. 기본으로 모두 켜져 있어요. ‘다시 보지 않기’를 선택하고 실행하면 해당 동작의 확인만 꺼져요. 취소하면 설정을 유지해요. 기존 전체 확인 설정은 각 항목으로 이전하며, 직접 지정한 항목이 우선해요.", diff --git a/README.md b/README.md index 7e047cb..37a3d49 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ A macOS workspace manager with BSP window tiling. +*Tatami* (畳) are traditional Japanese floor mats, fitted together to cover a room. +Tatami brings that idea to your Mac, fitting windows together into a workspace. + Tatami groups your apps into virtual workspaces you switch between with a keystroke or a configurable trackpad gesture, and tiles their windows automatically with a binary space partitioning (BSP) engine. No SIP changes and no shell diff --git a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift index b7dbfce..ccc0656 100644 --- a/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift +++ b/TatamiKit/Sources/Features/WorkspaceActivation/WorkspaceActivationFeature.swift @@ -296,6 +296,11 @@ public struct WorkspaceActivationFeature { /// the focused window closes — fall back through the list to the next /// most-recent window that's still on screen. public var mruWindows = [Workspace.ID: [WindowKey]]() + /// Keyboard switcher recency for each workspace, including Floating, + /// Leave As Is, and Shared Apps. Unlike the tree-only restoration MRU, + /// this history covers every eligible switcher window and survives layout + /// reordering and workspace switches during the current app session. + public var windowSwitcherMRU = [Workspace.ID: [WindowKey]]() /// Last exact focus reported by the merged app/AX event stream. App /// activation and AX observers can report the same focus several times, /// sometimes after the cursor has already moved away. Only a transition @@ -834,6 +839,10 @@ public struct WorkspaceActivationFeature { _ keys: Set, workspaceId: Workspace.ID, ) { + let contexts = [workspaceId, composition(on: displayShowing(workspaceId))?.host].compactMap { $0 } + for context in Set(contexts) { + windowSwitcherMRU[context]?.removeAll(where: keys.contains) + } guard !keys.isEmpty, var mru = mruWindows[workspaceId] else { return } mru.removeAll(where: keys.contains) mruWindows[workspaceId] = mru.isEmpty ? nil : mru @@ -845,6 +854,11 @@ public struct WorkspaceActivationFeature { _ replacements: [WindowKey: WindowKey], workspaceId: Workspace.ID, ) { + for context in Array(windowSwitcherMRU.keys) { + var seen = Set() + windowSwitcherMRU[context] = windowSwitcherMRU[context]?.map { replacements[$0] ?? $0 } + .filter { seen.insert($0).inserted } + } guard !replacements.isEmpty, var mru = mruWindows[workspaceId] else { return } mru = mru.map { replacements[$0] ?? $0 } var seen = Set() @@ -855,6 +869,9 @@ public struct WorkspaceActivationFeature { /// A terminated process invalidates every one of its window ids, including /// floating/unmanaged entries that never appeared in a BSP tree. mutating func removeBundleFromWindowMRU(_ bundleId: String) { + for context in Array(windowSwitcherMRU.keys) { + windowSwitcherMRU[context]?.removeAll { $0.bundleId == bundleId } + } for workspaceId in Array(mruWindows.keys) { guard var mru = mruWindows[workspaceId] else { continue } mru.removeAll { $0.bundleId == bundleId } @@ -863,6 +880,9 @@ public struct WorkspaceActivationFeature { } mutating func removeProcessFromWindowMRU(_ pid: pid_t) { + for context in Array(windowSwitcherMRU.keys) { + windowSwitcherMRU[context]?.removeAll { $0.pid == pid } + } for workspaceId in Array(mruWindows.keys) { guard var mru = mruWindows[workspaceId] else { continue } mru.removeAll { $0.pid == pid } @@ -917,6 +937,18 @@ public struct WorkspaceActivationFeature { return monitored } + /// Borrow's host keeps the combined visible order, while the borrowed + /// workspace also remembers its own order for when it is activated alone. + mutating func recordWindowSwitcherFocus(_ key: WindowKey, workspaceId: Workspace.ID) { + let contexts = [workspaceId, composition(on: displayShowing(workspaceId))?.host].compactMap { $0 } + for context in Set(contexts) { + var recent = windowSwitcherMRU[context] ?? [] + recent.removeAll { $0 == key } + recent.insert(key, at: 0) + windowSwitcherMRU[context] = recent + } + } + /// Record exact focus in the owning workspace. Events permit registered /// unsynced windows; activation reconciliation requires a visible tree leaf. @discardableResult @@ -971,6 +1003,14 @@ public struct WorkspaceActivationFeature { if inTree { insertionPoint[workspaceId] = key } let workspaceApps = config.activeProfile?.workspaces[id: workspaceId]?.apps ?? [] + if + updateFocusedDisplay, + inTree + || workspaceApps.contains(where: { $0.bundleIdentifier == key.bundleId }) + || config.sharedApps.contains(where: { $0.bundleIdentifier == key.bundleId }) + { + recordWindowSwitcherFocus(key, workspaceId: workspaceId) + } guard inTree || (!requireVisibleTreeMembership @@ -3923,6 +3963,7 @@ public struct WorkspaceActivationFeature { direction: direction, holdModifiers: current.holdModifiers, focusedWindow: current.focusedWindow, + sessionOrder: current.windows, interactionDisplay: current.display, onScreenFrames: current.onScreenFrames, state: state, @@ -3980,6 +4021,15 @@ public struct WorkspaceActivationFeature { ) else { return .none } state.windowCycleSession = cycle + // The resolved anchor is actual focus, even if its AX notification is + // still queued. Remember it before committing a quick tap so the next + // invocation can return to it without waiting for observer delivery. + if let key, cycle.windows.contains(where: { cycle.byWindow ? $0 == key : $0.bundleId == key.bundleId }) { + state.recordWindowSwitcherFocus( + key, + workspaceId: state.workspaceOwning(key) ?? cycle.workspaceId, + ) + } var effects: [Effect] = [ .run { [clock, modifierKeys, holdModifiers] send in @@ -4027,6 +4077,7 @@ public struct WorkspaceActivationFeature { direction: direction, holdModifiers: current.holdModifiers, focusedWindow: current.focusedWindow, + sessionOrder: current.windows, interactionDisplay: current.display, onScreenFrames: current.onScreenFrames, state: state, @@ -5130,14 +5181,15 @@ public struct WorkspaceActivationFeature { } } - /// Resolve one logical step without moving focus. Both immediate gesture - /// cycling and held-modifier keyboard sessions share this ordering/MRU path, - /// so their app-level and window-level behavior cannot drift. + /// Resolve one logical step without moving focus. Immediate directional + /// gestures keep layout order; keyboard sessions use workspace recency and + /// freeze that order until the modifier is released. private func windowCycle( from key: WindowKey?, direction: CycleDirection, holdModifiers: HotKeyModifiers, focusedWindow: WindowKey? = nil, + sessionOrder: [WindowKey]? = nil, interactionDisplay: DisplayName? = nil, onScreenFrames: [CGWindowID: CGRect]? = nil, state: State, @@ -5195,8 +5247,8 @@ public struct WorkspaceActivationFeature { else { return nil } // Borrow is one visible task surface, so cycling spans every tiled tree in - // that display's composition. Keep the host first for deterministic HUD - // order; the current key still determines the next/previous wrap point. + // that display's composition. Layout order seeds windows with no focus + // history; keyboard sessions put recently focused candidates ahead of them. let display = interactionDisplay ?? keyDisplay ?? state.displayShowing(workspaceId) let composition = state.composition(on: display) let workspaceIds = composition.map { @@ -5281,6 +5333,18 @@ public struct WorkspaceActivationFeature { } guard !allWindows.isEmpty else { return nil } + if !holdModifiers.isEmpty { + let live = Set(allWindows) + let context = composition?.host ?? workspaceId + let recent = (state.windowSwitcherMRU[context] ?? []) + + workspaceIds.filter { $0 != context }.flatMap { state.windowSwitcherMRU[$0] ?? [] } + let candidates = sessionOrder + ?? ((key.map { [$0] } ?? []) + recent + allWindows) + var seen = Set() + allWindows = candidates.filter { live.contains($0) && seen.insert($0).inserted } + guard !allWindows.isEmpty else { return nil } + } + let byWindow = state.config.settings.switching.cycleSameAppWindows var ordered = allWindows if !byWindow { @@ -5308,7 +5372,7 @@ public struct WorkspaceActivationFeature { // App-level cycle lands on that app's most-recently-focused window rather // than whichever representative happened to appear first in the tree. - if !byWindow { + if !byWindow, holdModifiers.isEmpty { let targetOwner = state.workspaceOwning(target) ?? workspaceId let mru = state.mruWindows[targetOwner] ?? [] let live = Set(allWindows) diff --git a/TatamiTests/Sources/WorkspaceActivationTests.swift b/TatamiTests/Sources/WorkspaceActivationTests.swift index 3c20f72..e13559f 100644 --- a/TatamiTests/Sources/WorkspaceActivationTests.swift +++ b/TatamiTests/Sources/WorkspaceActivationTests.swift @@ -17,6 +17,144 @@ struct WorkspaceActivationFeatureTests { // MARK: Internal + @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") + let b = WindowKey(pid: 2, windowID: 20, bundleId: "app.b") + let c = WindowKey(pid: 3, windowID: 30, bundleId: "app.c") + let workspace = Workspace(name: "Work") + let state = Self.makeState(workspaces: [workspace]) { + $0.$config.withLock { + $0.settings.switching.cycleSameAppWindows = byWindow + $0.settings.focus.mouseFollowsFocus = false + } + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.tilingTrees[workspace.id] = .branch(BSPBranch( + split: .vertical, + ratio: 0.5, + left: .leaf(a), + right: .branch(BSPBranch(split: .horizontal, ratio: 0.5, left: .leaf(b), right: .leaf(c))), + )) + } + let focused = LockIsolated<[WindowKey]>([]) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.modifierKeys.current = { .option } + $0.focusManager.focusWindow = { key in focused.withValue { $0.append(key) } } + } + store.exhaustivity = .off + + for key in [b, c, a] { + await store.send(.windowChanged(.windowFocused(bundleId: key.bundleId, key: key))) + } + expectNoDifference(store.state.windowSwitcherMRU[workspace.id], [a, c, b]) + + // Later taps intentionally precede AX focus notifications. The next + // resolved anchor must be enough to remember the window we just left. + for (anchor, target) in [(a, c), (c, a), (a, c)] { + await store.send(.cycleWindowShortcutResolved(windowKey: anchor, direction: .next, holdModifiers: .option)) + expectNoDifference(store.state.windowCycleSession?.selected, target) + await store.send(.windowCycleModifierReleased) + await store.finish() + } + expectNoDifference(focused.value, [c, a, c]) + } + + @Test + func `held keyboard switcher freezes order while focus history changes`() async { + let a = WindowKey(pid: 1, windowID: 10, bundleId: "app.a") + let b = WindowKey(pid: 2, windowID: 20, bundleId: "app.b") + let c = WindowKey(pid: 3, windowID: 30, bundleId: "app.c") + let workspace = Workspace(name: "Work") + let state = Self.makeState(workspaces: [workspace]) { + $0.$config.withLock { $0.settings.focus.mouseFollowsFocus = false } + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.tilingTrees[workspace.id] = .branch(BSPBranch( + split: .vertical, + ratio: 0.5, + left: .leaf(a), + right: .branch(BSPBranch(split: .horizontal, ratio: 0.5, left: .leaf(b), right: .leaf(c))), + )) + $0.windowSwitcherMRU[workspace.id] = [a, c, b] + } + let focused = LockIsolated<[WindowKey]>([]) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.modifierKeys.current = { .option } + $0.focusManager.focusWindow = { key in focused.withValue { $0.append(key) } } + } + store.exhaustivity = .off + + await store.send(.cycleWindowShortcutResolved(windowKey: a, direction: .next, holdModifiers: .option)) + expectNoDifference(store.state.windowCycleSession?.windows, [a, c, b]) + await store.send(.windowChanged(.windowFocused(bundleId: b.bundleId, key: b))) + expectNoDifference(store.state.windowSwitcherMRU[workspace.id], [b, a, c]) + await store.send(.cycleWindowShortcut(.next, holdModifiers: .option)) + expectNoDifference(store.state.windowCycleSession?.selected, b) + expectNoDifference(store.state.windowCycleSession?.windows, [a, c, b]) + await store.send(.windowCycleHUDInteraction(.move(.previous))) + expectNoDifference(store.state.windowCycleSession?.selected, c) + await store.send(.windowCycleHUDInteraction(.cancel)) + await store.finish() + expectNoDifference(focused.value, []) + expectNoDifference(store.state.windowSwitcherMRU[workspace.id], [b, a, c]) + } + + @Test + func `keyboard switcher remembers each workspace across switching and layout changes`() async { + let a = WindowKey(pid: 1, windowID: 10, bundleId: "app.a") + let b = WindowKey(pid: 2, windowID: 20, bundleId: "app.b") + let shared = WindowKey(pid: 3, windowID: 30, bundleId: "app.shared") + let first = Workspace(name: "First") + let second = Workspace(name: "Second") + var state = Self.makeState(workspaces: [first, second]) { + $0.$config.withLock { + $0.settings.focus.mouseFollowsFocus = false + $0.sharedApps = [SharedApp(bundleIdentifier: shared.bundleId, name: "Shared")] + } + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = first.id + $0.tilingTrees[first.id] = .branch(BSPBranch( + split: .vertical, + ratio: 0.5, + left: .leaf(a), + right: .branch(BSPBranch(split: .horizontal, ratio: 0.5, left: .leaf(b), right: .leaf(shared))), + )) + $0.tilingTrees[second.id] = .branch(BSPBranch( + split: .horizontal, + ratio: 0.5, + left: .leaf(b), + right: .leaf(shared), + )) + } + for key in [b, shared, a] { state.recordFocusedWindow(key) } + state.activeWorkspacesByDisplay[Self.display] = second.id + for key in [shared, b] { state.recordFocusedWindow(key) } + expectNoDifference(state.windowSwitcherMRU[first.id], [a, shared, b]) + expectNoDifference(state.windowSwitcherMRU[second.id], [b, shared]) + state.activeWorkspacesByDisplay[Self.display] = first.id + let reordered = BSPNode.branch(BSPBranch( + split: .horizontal, + ratio: 0.5, + left: .leaf(shared), + right: .branch(BSPBranch(split: .vertical, ratio: 0.5, left: .leaf(b), right: .leaf(a))), + )) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.modifierKeys.current = { .option } + } + store.exhaustivity = .off + await store.send(.tilingTreeUpdated(workspaceId: first.id, tree: reordered)) + await store.send(.cycleWindowShortcutResolved(windowKey: a, direction: .next, holdModifiers: .option)) + expectNoDifference(store.state.windowCycleSession?.windows, [a, shared, b]) + expectNoDifference(store.state.windowCycleSession?.selected, shared) + expectNoDifference(store.state.windowSwitcherMRU[second.id], [b, shared]) + await store.send(.windowCycleHUDInteraction(.cancel)) + await store.finish() + } + @Test func `window cycle leaves shared app when AX reorders its focused window`() async { let tiled = WindowKey(pid: 1, windowID: 10, bundleId: "app.document") @@ -60,6 +198,120 @@ struct WorkspaceActivationFeatureTests { #expect(focused.value == [first, second, tiled]) } + @Test(arguments: [false, true]) + func `keyboard switcher represents each app with its latest exact window`(byWindow: Bool) async { + let a1 = WindowKey(pid: 1, windowID: 10, bundleId: "app.a") + let a2 = WindowKey(pid: 1, windowID: 11, bundleId: "app.a") + let b = WindowKey(pid: 2, windowID: 20, bundleId: "app.b") + let workspace = Workspace(name: "Work") + var state = Self.makeState(workspaces: [workspace]) { + $0.$config.withLock { + $0.settings.switching.cycleSameAppWindows = byWindow + $0.settings.focus.mouseFollowsFocus = false + } + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.tilingTrees[workspace.id] = .branch(BSPBranch( + split: .vertical, + ratio: 0.5, + left: .leaf(a1), + right: .branch(BSPBranch(split: .horizontal, ratio: 0.5, left: .leaf(a2), right: .leaf(b))), + )) + } + for key in [a1, a2, b] { state.recordFocusedWindow(key) } + let focused = LockIsolated(nil) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.modifierKeys.current = { .option } + $0.focusManager.focusWindow = { focused.setValue($0) } + } + store.exhaustivity = .off + await store.send(.cycleWindowShortcutResolved(windowKey: b, direction: .next, holdModifiers: .option)) + expectNoDifference(store.state.windowCycleSession?.windows, byWindow ? [b, a2, a1] : [b, a2]) + expectNoDifference(store.state.windowCycleSession?.selected, a2) + await store.send(.windowCycleModifierReleased) + await store.finish() + expectNoDifference(focused.value, a2) + } + + @Test + func `keyboard switcher retains non tiled recency and filters other displays`() async { + let tiled = WindowKey(pid: 1, windowID: 10, bundleId: "app.tiled") + let floating = WindowKey(pid: 2, windowID: 20, bundleId: "app.shared") + let otherDisplay = WindowKey(pid: 2, windowID: 21, bundleId: "app.shared") + let unmanaged = WindowKey(pid: 3, windowID: 30, bundleId: "app.unmanaged") + let workspace = Workspace(name: "Work", apps: [ + AppAssignment(bundleIdentifier: unmanaged.bundleId, name: "Unmanaged", layout: .unmanaged) + ]) + var state = Self.makeState(workspaces: [workspace]) { + $0.$config.withLock { + $0.sharedApps = [SharedApp(bundleIdentifier: floating.bundleId, name: "Shared", layout: .floating)] + $0.settings.switching.cycleSameAppWindows = true + $0.settings.focus.mouseFollowsFocus = false + } + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = workspace.id + $0.tilingTrees[workspace.id] = .leaf(tiled) + } + for key in [unmanaged, floating, tiled] { state.recordFocusedWindow(key) } + expectNoDifference(state.windowSwitcherMRU[workspace.id], [tiled, floating, unmanaged]) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.modifierKeys.current = { .option } + $0.windowSnapshot.cachedKeys = { bundles, _ in + [otherDisplay, unmanaged, floating].filter { bundles.contains($0.bundleId) } + } + } + store.exhaustivity = .off + await store.send(.cycleWindowShortcutResolved( + windowKey: tiled, + direction: .next, + holdModifiers: .option, + interactionDisplay: Self.display, + onScreenFrames: [ + floating.windowID: CGRect(x: 100, y: 100, width: 300, height: 300), + unmanaged.windowID: CGRect(x: 400, y: 100, width: 300, height: 300), + otherDisplay.windowID: CGRect(x: 10000, y: 100, width: 300, height: 300), + ], + )) + expectNoDifference(store.state.windowCycleSession?.windows, [tiled, floating, unmanaged]) + await store.send(.windowCycleHUDInteraction(.cancel)) + await store.finish() + } + + @Test + func `keyboard switcher interleaves Borrow focus without replacing the borrowed workspace history`() async { + let a = WindowKey(pid: 1, windowID: 10, bundleId: "app.a") + let b = WindowKey(pid: 2, windowID: 20, bundleId: "app.b") + let c = WindowKey(pid: 3, windowID: 30, bundleId: "app.c") + let host = Workspace(name: "Host") + let borrowed = Workspace(name: "Borrowed") + var state = Self.makeState(workspaces: [host, borrowed]) { + $0.$config.withLock { $0.settings.focus.mouseFollowsFocus = false } + $0.focusedDisplay = Self.display + $0.activeWorkspacesByDisplay[Self.display] = host.id + $0.tilingTrees[host.id] = .branch(BSPBranch(split: .vertical, ratio: 0.5, left: .leaf(a), right: .leaf(b))) + $0.tilingTrees[borrowed.id] = .leaf(c) + $0.compositionsByDisplay[Self.display] = Composition( + host: host.id, + borrowed: [BorrowedSlot(workspace: borrowed.id, edge: .right, fraction: 0.35)], + ) + } + for key in [b, c, a] { state.recordFocusedWindow(key) } + expectNoDifference(state.windowSwitcherMRU[host.id], [a, c, b]) + expectNoDifference(state.windowSwitcherMRU[borrowed.id], [c]) + let store = TestStore(initialState: state) { WorkspaceActivationFeature() } withDependencies: { + $0.continuousClock = TestClock() + $0.modifierKeys.current = { .option } + } + store.exhaustivity = .off + await store.send(.cycleWindowShortcutResolved(windowKey: a, direction: .next, holdModifiers: .option)) + expectNoDifference(store.state.windowCycleSession?.windows, [a, c, b]) + expectNoDifference(store.state.windowCycleSession?.selected, c) + await store.send(.windowCycleHUDInteraction(.cancel)) + await store.finish() + } + @Test(arguments: [false, true]) func `foreground work window releases overlay suppression without a launcher gesture`(windowEventOnly: Bool) async { let old = Workspace(name: "Old") @@ -6414,6 +6666,7 @@ struct WorkspaceActivationFeatureTests { ) { $0.insertionPoint[notionWorkspace.id] = pulse $0.mruWindows[notionWorkspace.id] = [pulse, notion] + $0.windowSwitcherMRU[notionWorkspace.id] = [pulse] $0.lastObservedFocusedWindow = pulse } } @@ -6456,6 +6709,7 @@ struct WorkspaceActivationFeatureTests { $0.focusedDisplay = displayB $0.insertionPoint[targetWorkspace.id] = pulse $0.mruWindows[targetWorkspace.id] = [pulse] + $0.windowSwitcherMRU[targetWorkspace.id] = [pulse] $0.lastObservedFocusedWindow = pulse } } @@ -6514,6 +6768,7 @@ struct WorkspaceActivationFeatureTests { ) ) $0.mruWindows[ws.id] = [closed, survivor] + $0.windowSwitcherMRU[ws.id] = [closed, survivor] } let store = TestStore(initialState: state) { WorkspaceActivationFeature() @@ -6523,6 +6778,7 @@ struct WorkspaceActivationFeatureTests { await store.send(.tilingTreeUpdated(workspaceId: ws.id, tree: .leaf(survivor))) #expect(store.state.mruWindows[ws.id] == [survivor]) + expectNoDifference(store.state.windowSwitcherMRU[ws.id], [survivor]) } @Test @@ -6532,6 +6788,7 @@ struct WorkspaceActivationFeatureTests { let ws = Workspace(name: "one") let state = Self.makeState(workspaces: [ws]) { $0.mruWindows[ws.id] = [floating, survivor] + $0.windowSwitcherMRU[ws.id] = [floating, survivor] } let invalidatedBundles = LockIsolated<[String]>([]) let store = TestStore(initialState: state) { @@ -6547,6 +6804,7 @@ struct WorkspaceActivationFeatureTests { await store.send(.appTerminated(bundleId: floating.bundleId)) #expect(store.state.mruWindows[ws.id] == [survivor]) + expectNoDifference(store.state.windowSwitcherMRU[ws.id], [survivor]) #expect(invalidatedBundles.value == [floating.bundleId]) await store.skipReceivedActions() } @@ -7334,6 +7592,7 @@ struct WorkspaceActivationFeatureTests { $0.tilingTrees[workspace.id] = oldTree $0.insertionPoint[workspace.id] = retired $0.mruWindows[workspace.id] = [retired, survivor] + $0.windowSwitcherMRU[workspace.id] = [retired, survivor] } let applications = LockIsolated<[FrameApplication]>([]) let saved = LockIsolated<[LayoutSnapshot]>([]) @@ -7363,6 +7622,7 @@ struct WorkspaceActivationFeatureTests { #expect(store.state.tilingTrees[workspace.id] == expectedTree) #expect(store.state.insertionPoint[workspace.id] == replacement) #expect(store.state.mruWindows[workspace.id] == [replacement, survivor]) + expectNoDifference(store.state.windowSwitcherMRU[workspace.id], [replacement, survivor]) #expect(applications.value.count == 1) let application = try #require(applications.value.last) let survivorFrame = try #require(application.windowFrames[survivor]) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9f8c66d..e391dac 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -185,6 +185,13 @@ layout without writing the temporary wake-up state back to disk. | `borrowDefaultEdge` | string? | _(unset)_ | Where a borrow docks by default: `top`, `bottom`, `left`, `right`. Unset → the borrow combo waits for a direction key (h/j/k/l or arrows). A workspace's `borrowEdge` overrides this. | | `borrowFraction` | double | `0.4` | The borrowed block's share of the screen along the split axis (0.1…0.9). A workspace's `borrowFraction` overrides this. | +Keyboard window switching remembers recent focus separately for each workspace +while Tatami is running. Tap and release the next-window shortcut to return to +the last-used app or window. Hold its modifier to browse the list; its order +stays fixed until you release or cancel. Switching workspaces or rearranging +tiles preserves that workspace's history. Window-switching gestures keep their +directional layout order. + ## `[settings.gestures]` | Key | Type | Default | Description | diff --git a/docs/ja/CONFIGURATION.md b/docs/ja/CONFIGURATION.md index 03c79f2..d6b0d34 100644 --- a/docs/ja/CONFIGURATION.md +++ b/docs/ja/CONFIGURATION.md @@ -169,6 +169,8 @@ overlayAwareApps = ["notion.id"] |`borrowDefaultEdge`|string?|_(未設定)_|既定の借りる位置です。`top`、`bottom`、`left`、`right` を使えます。未設定ならキー操作の後に h/j/k/l または矢印を待ち、ワークスペースの `borrowEdge` を優先します。| |`borrowFraction`|double|`0.4`|分割方向で借りた領域が占める割合(0.1〜0.9)です。ワークスペースの `borrowFraction` が優先されます。| +キーボードでのウインドウ切り替えは、Tatami の実行中、ワークスペースごとに最近フォーカスした順序を記憶します。「次のウインドウ」のショートカットを押してすぐ離すと、直前に使ったアプリやウインドウに戻ります。修飾キーを押したまま一覧を選べます。キーを離すかキャンセルするまで一覧の順序は変わりません。ワークスペースの切り替えやタイルの並べ替えでも履歴は維持されます。ウインドウ切り替えのジェスチャは配置順に移動します。 + ## `[settings.gestures]` diff --git a/docs/ja/README.md b/docs/ja/README.md index ff3625c..0cec70b 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -9,6 +9,8 @@ BSP タイル表示を備えた macOS 用ワークスペースマネージャです。 +Tatami の名前は、和室の床に敷き詰める「畳」に由来します。畳を組み合わせて部屋を整えるように、Mac のウインドウを並べてワークスペースを整えます。 + Tatami はアプリを仮想ワークスペースにまとめ、キーや設定したトラックパッドのジェスチャで切り替えます。BSP エンジンがウインドウを自動で整えるので、SIP の変更やシェルスクリプトは不要です。 diff --git a/docs/ko/CONFIGURATION.md b/docs/ko/CONFIGURATION.md index faf3798..0d16b08 100644 --- a/docs/ko/CONFIGURATION.md +++ b/docs/ko/CONFIGURATION.md @@ -169,6 +169,8 @@ overlayAwareApps = ["notion.id"] |`borrowDefaultEdge`|string?|_(설정 안 함)_|기본 빌려오기 위치예요. `top`, `bottom`, `left`, `right`을 사용할 수 있어요. 설정하지 않으면 키 조합 뒤에 h/j/k/l 또는 화살표를 기다려요. 작업 공간의 `borrowEdge`가 이 값보다 우선해요.| |`borrowFraction`|double|`0.4`|분할 방향에서 빌려온 영역이 차지하는 비율(0.1~0.9)이에요. 작업 공간의 `borrowFraction`가 우선해요.| +키보드 창 전환은 Tatami가 실행 중인 동안 작업 공간마다 최근 포커스 순서를 따로 기억해요. 다음 창 단축키를 짧게 눌렀다 놓으면 마지막으로 사용한 앱이나 창으로 돌아가요. 보조 키를 누른 채 목록을 고를 수 있고, 키를 놓거나 취소할 때까지 목록 순서는 바뀌지 않아요. 다른 작업 공간으로 이동하거나 창 배치를 바꿔도 각 공간의 기록은 유지돼요. 창 전환 제스처는 배치 순서대로 이동해요. + ## `[settings.gestures]` diff --git a/docs/ko/README.md b/docs/ko/README.md index f4ddcea..8027029 100644 --- a/docs/ko/README.md +++ b/docs/ko/README.md @@ -9,6 +9,8 @@ BSP 창 타일링을 갖춘 macOS 작업 공간 관리자예요. +*다다미*(畳)는 일본 전통 방의 바닥에 맞춰 까는 매트예요. 다다미를 맞춰 방을 채우듯, Tatami는 창을 배치해 Mac의 작업 공간을 정리해요. + Tatami는 앱을 가상 작업 공간에 모으고, 단축키나 설정한 트랙패드 제스처로 전환해요. BSP 엔진이 창을 자동으로 정리해줘요. SIP 설정을 바꾸거나 셸 스크립트를 작성할 필요가 없어요. diff --git a/docs/zh-Hans/CONFIGURATION.md b/docs/zh-Hans/CONFIGURATION.md index 737c4ee..05e9a14 100644 --- a/docs/zh-Hans/CONFIGURATION.md +++ b/docs/zh-Hans/CONFIGURATION.md @@ -169,6 +169,8 @@ overlayAwareApps = ["notion.id"] |`borrowDefaultEdge`|string?|_(未设置)_|默认借用停靠位置:`top`、`bottom`、`left`、`right`。未设置时等待 h/j/k/l 或方向键;工作区的 `borrowEdge` 会覆盖此值。| |`borrowFraction`|double|`0.4`|借用区域沿分割轴占屏幕的比例(0.1…0.9),工作区的 `borrowFraction` 会覆盖此值。| +Tatami 运行期间,键盘窗口切换会为每个工作区分别记住最近的焦点顺序。短按并松开“下一个窗口”快捷键,即可返回上次使用的应用或窗口。按住修饰键可浏览列表,列表顺序保持不变,直到松开或取消。切换工作区或重新排列窗口不会清除该工作区的记录。窗口切换手势仍按布局顺序移动。 + ## `[settings.gestures]` diff --git a/docs/zh-Hans/README.md b/docs/zh-Hans/README.md index cf38ac2..4ac35a0 100644 --- a/docs/zh-Hans/README.md +++ b/docs/zh-Hans/README.md @@ -9,6 +9,8 @@ 支持 BSP 窗口平铺的 macOS 工作区管理器。 +*Tatami*(畳)是日本传统房间里拼铺的榻榻米。就像榻榻米铺满房间,Tatami 将 Mac 上的窗口拼排成工作区。 + Tatami 把应用组织到虚拟工作区,通过快捷键或自定义触控板手势切换,并用二叉空间分割(BSP)引擎自动平铺窗口。无需修改 SIP,也无需编写 shell 脚本。 diff --git a/docs/zh-Hant/CONFIGURATION.md b/docs/zh-Hant/CONFIGURATION.md index a3265ac..a705b3e 100644 --- a/docs/zh-Hant/CONFIGURATION.md +++ b/docs/zh-Hant/CONFIGURATION.md @@ -169,6 +169,8 @@ overlayAwareApps = ["notion.id"] |`borrowDefaultEdge`|string?|_(未設定)_|預設借用停靠位置:`top`、`bottom`、`left`、`right`。未設定時等待 h/j/k/l 或方向鍵;工作空間的 `borrowEdge` 會覆寫此值。| |`borrowFraction`|double|`0.4`|借用區域沿分割軸佔螢幕的比例(0.1…0.9),工作空間的 `borrowFraction` 會覆寫此值。| +Tatami 執行期間,鍵盤視窗切換會為每個工作空間分別記住最近的焦點順序。短按並放開「下一個視窗」快速鍵,即可回到上次使用的 App 或視窗。按住修飾鍵可瀏覽清單,清單順序會維持不變,直到放開或取消。切換工作空間或重新排列視窗不會清除該空間的紀錄。視窗切換手勢仍依排列順序移動。 + ## `[settings.gestures]` diff --git a/docs/zh-Hant/README.md b/docs/zh-Hant/README.md index 92398e9..779cbe7 100644 --- a/docs/zh-Hant/README.md +++ b/docs/zh-Hant/README.md @@ -9,6 +9,8 @@ 支援 BSP 視窗並排的 macOS 工作空間管理工具。 +*Tatami*(畳)是日本傳統房間裡拼鋪的榻榻米。如同榻榻米鋪滿房間,Tatami 將 Mac 上的視窗排列成工作空間。 + Tatami 把 App 整理到虛擬工作空間,透過快速鍵或自訂觸控式軌跡板手勢切換,並用二元空間分割(BSP)引擎自動並排視窗。不必修改 SIP,也不必撰寫 shell 指令稿。 From cc4c6b0cfc704f0cd839a87b1ed759f45c25cd01 Mon Sep 17 00:00:00 2001 From: PangMo5 Date: Thu, 10 Sep 2026 15:46:16 +0900 Subject: [PATCH 2/2] docs: explain Tatami workflows and development origins Describe workspace restoration, BSP tiling, responsiveness under load, Borrow, Always on Top, and configuration depth on the website and in the README. Share the same development history across both surfaces, with contextual links to FlashSpace contributions and the workspace-layout proposal. Update all five locales and remove the personal CPU-usage anecdote. Validate 20 generated site pages, 35 localized documents, matching origin text, and contextual links. --- Localization/Docs.json | 70 ++++++++++++++++++++++++ Localization/Web.json | 119 +++++++++++++++++++++++++++++++++++++++++ README.md | 30 +++++++++++ docs/ja/README.md | 19 +++++++ docs/ko/README.md | 19 +++++++ docs/zh-Hans/README.md | 19 +++++++ docs/zh-Hant/README.md | 19 +++++++ web/index.html | 32 +++++++++++ web/style.css | 8 +++ 9 files changed, 335 insertions(+) diff --git a/Localization/Docs.json b/Localization/Docs.json index 8d09c2f..63d9870 100644 --- a/Localization/Docs.json +++ b/Localization/Docs.json @@ -1,4 +1,32 @@ { + "3163637eca66aabb": { + "en": "Why Tatami?", + "ko": "왜 Tatami인가요?", + "ja": "Tatami を選ぶ理由", + "zh-Hans": "为什么选择 Tatami?", + "zh-Hant": "為什麼選擇 Tatami?" + }, + "35172d01d2428226": { + "en": "When a task spans several apps, finding and arranging its windows becomes work of its own. Tatami keeps those apps and their layout together in a workspace you can return to.", + "ko": "여러 앱으로 작업하다 보면 필요한 창을 찾고 다시 배치하는 데도 시간이 들어요. Tatami는 앱과 배치를 작업 공간에 함께 담아, 다시 돌아왔을 때 바로 이어서 작업할 수 있게 해요.", + "ja": "複数のアプリを使う作業では、必要なウインドウを探して並べ直す手間がかかります。Tatami はアプリと配置をワークスペースにまとめ、戻ったときに作業を続けられるようにします。", + "zh-Hans": "一项工作用到多个应用时,寻找和重新排列窗口也会占用时间。Tatami 将应用和布局一起保存在工作区中,方便你回来继续工作。", + "zh-Hant": "一項工作用到多個 App 時,尋找和重新排列視窗也會花費時間。Tatami 將 App 和配置一起保留在工作空間中,讓你回來就能繼續工作。" + }, + "dbb704c36a11576f": { + "en": "Configure workspaces and shortcuts in native settings. Go further with display-aware profiles, TOML, the CLI, and hooks. Each gives you another way to control the same workspaces.", + "ko": "앱의 설정 화면에서 작업 공간과 단축키를 구성하세요. 디스플레이에 맞춘 프로필, TOML, 명령줄 도구, 훅으로 더 세밀하게 다룰 수도 있어요. 모두 같은 작업 공간을 제어하는 방법이에요.", + "ja": "macOS ネイティブの設定画面で、ワークスペースとショートカットを設定できます。ディスプレイに応じたプロファイル、TOML、CLI、フックを使えば、同じワークスペースをさらに細かく制御できます。", + "zh-Hans": "在原生设置界面中配置工作区和快捷键。还可以通过适应显示器的配置方案、TOML、CLI 和钩子进一步调整。它们都是控制同一组工作区的不同方式。", + "zh-Hant": "在原生設定介面中設定工作空間和快速鍵。也能透過配合顯示器的設定組合、TOML、CLI 和掛鉤進一步調整。它們都是控制同一組工作空間的不同方式。" + }, + "b0e384f35f3da315": { + "en": "Why I built Tatami", + "ko": "Tatami를 만든 이유", + "ja": "Tatami を作った理由", + "zh-Hans": "我为什么开发 Tatami", + "zh-Hant": "我為什麼開發 Tatami" + }, "d08480de0ce2a617": { "en": "Keyboard window switching remembers recent focus separately for each workspace while Tatami is running. Tap and release the next-window shortcut to return to the last-used app or window. Hold its modifier to browse the list; its order stays fixed until you release or cancel. Switching workspaces or rearranging tiles preserves that workspace's history. Window-switching gestures keep their directional layout order.", "ko": "키보드 창 전환은 Tatami가 실행 중인 동안 작업 공간마다 최근 포커스 순서를 따로 기억해요. 다음 창 단축키를 짧게 눌렀다 놓으면 마지막으로 사용한 앱이나 창으로 돌아가요. 보조 키를 누른 채 목록을 고를 수 있고, 키를 놓거나 취소할 때까지 목록 순서는 바뀌지 않아요. 다른 작업 공간으로 이동하거나 창 배치를 바꿔도 각 공간의 기록은 유지돼요. 창 전환 제스처는 배치 순서대로 이동해요.", @@ -4871,5 +4899,47 @@ "ja": "ワークスペース切り替えとウインドウ操作の応答性を改善し、保存済みの設定に影響する変更を実行前に確認できるようにしました。", "zh-Hans": "提升工作区切换与窗口操作的响应速度,并在影响已保存设置的更改生效前进行确认。", "zh-Hant": "提升工作空間切換與視窗操作的反應速度,並在影響已儲存設定的變更生效前進行確認。" + }, + "4f2fececb2d0860d": { + "en": "I [contributed to FlashSpace]({0}) and [proposed]({1}) connecting window layouts to its workspaces. The maintainer chose to keep workspace management focused and leave tiling to other tools. I respected that direction, but kept wanting to explore how workspaces and window management could work together in one app.", + "ko": "[FlashSpace에 기여하면서]({0}) 작업 공간과 창 배치를 연결하는 아이디어도 [제안했어요]({1}). 메인테이너는 작업 공간 관리에 집중하고 타일링은 다른 도구에 맡기는 방향을 선택했어요. 그 방향을 존중하면서도, 작업 공간과 창 관리를 하나의 앱에서 함께 다루는 방법을 계속 탐구하고 싶었어요.", + "ja": "[FlashSpace に貢献する中で]({0})、ウインドウの配置とワークスペースを結び付ける[提案]({1})もしました。メンテナーはワークスペース管理に集中し、タイル表示はほかのツールに任せる方針を選びました。その方針を尊重しつつ、ワークスペースとウインドウ管理を一つのアプリで連携させる方法を探りたいと思い続けていました。", + "zh-Hans": "我曾[为 FlashSpace 贡献功能]({0}),也[提议]({1})将窗口布局与工作区结合。维护者选择专注于工作区管理,把平铺交给其他工具。我尊重这个方向,但仍想探索如何在一个应用中让工作区和窗口管理相互配合。", + "zh-Hant": "我曾[為 FlashSpace 貢獻功能]({0}),也[提議]({1})將視窗配置與工作空間結合。維護者選擇專注於工作空間管理,把並排交給其他工具。我尊重這個方向,但仍想探索如何在一個 App 中讓工作空間與視窗管理互相配合。" + }, + "f1fa6f56d4c8c1d5": { + "en": "Before building Tatami, I went back and forth between yabai, AeroSpace, and FlashSpace + Rectangle, looking for a setup that suited me. I loved yabai's speed and capabilities, but some features I wanted involved partially disabling SIP. AeroSpace's workspace behavior at the time did not fit how heavily I used Mission Control. I liked how FlashSpace combined app hiding with virtual workspaces whose apps were assigned in advance. I used Rectangle for window arrangement.", + "ko": "Tatami를 만들기 전에는 yabai, AeroSpace, FlashSpace + Rectangle 조합을 오가며 제게 맞는 작업 환경을 찾았어요. yabai의 속도와 기능이 마음에 들었지만, 원하는 기능 중 일부는 SIP를 부분적으로 꺼야 했어요. 당시 AeroSpace의 작업 공간 동작은 Mission Control을 자주 쓰는 제 방식과 맞지 않았어요. FlashSpace에서는 앱을 숨기는 방식과, 앱을 미리 등록해 둔 가상 작업 공간을 활용하는 방식이 마음에 들었어요. 창 배치는 Rectangle로 보완하며 사용했어요.", + "ja": "Tatami を作る前は、yabai、AeroSpace、FlashSpace + Rectangle を行き来しながら、自分に合う作業環境を探していました。yabai の速さと機能は気に入っていましたが、使いたい機能の一部には SIP の部分的な無効化が必要でした。当時の AeroSpace のワークスペースの動作は、Mission Control をよく使う私の作業には合いませんでした。FlashSpace では、アプリを隠す方式と、あらかじめアプリを登録した仮想ワークスペースを使う方法が気に入りました。ウインドウの配置には Rectangle を組み合わせていました。", + "zh-Hans": "开发 Tatami 之前,我在 yabai、AeroSpace 和 FlashSpace + Rectangle 之间反复尝试,寻找适合自己的工作环境。我很喜欢 yabai 的速度和功能,但我想用的部分功能需要关闭部分 SIP 保护。当时 AeroSpace 的工作区行为不太适合我频繁使用 Mission Control 的习惯。我喜欢 FlashSpace 隐藏应用的方式,也喜欢先把应用分配好,再使用虚拟工作区的方式。窗口排列则由 Rectangle 来补充。", + "zh-Hant": "開發 Tatami 之前,我在 yabai、AeroSpace 和 FlashSpace + Rectangle 之間反覆嘗試,尋找適合自己的工作環境。我很喜歡 yabai 的速度和功能,但我想用的部分功能需要關閉部分 SIP 保護。當時 AeroSpace 的工作空間行為不太適合我經常使用 Mission Control 的習慣。我喜歡 FlashSpace 隱藏 App 的方式,也喜歡先把 App 指派好,再使用虛擬工作空間的方式。視窗排列則由 Rectangle 來補足。" + }, + "c2567d2e7908705d": { + "en": "I wanted powerful workspace and window controls that felt fast and responsive, while keeping Mission Control in my daily workflow. Tatami grew from that wish: apps, layouts, displays, and automation that work together, with native settings and no SIP changes.", + "ko": "Mission Control을 계속 쓰면서 작업 공간과 창을 더 자유롭게 다루고 싶었어요. 기능뿐 아니라, 조작할 때 빠르고 쾌적하게 반응하는 경험도 중요했어요. 그래서 앱, 배치, 디스플레이, 자동화가 함께 동작하는 Tatami를 만들었어요. macOS에 익숙한 설정 화면에서 구성할 수 있고, SIP를 변경할 필요도 없어요.", + "ja": "Mission Control を日々使いながら、ワークスペースやウインドウをもっと自在に扱いたいと思っていました。機能だけでなく、操作にすばやく応え、快適に使えることも大切でした。そこから生まれたのが Tatami です。アプリ、配置、ディスプレイ、自動化を連携させ、macOS ネイティブの設定画面から使えます。SIP の変更も必要ありません。", + "zh-Hans": "我希望继续在日常工作中使用 Mission Control,同时更灵活地管理工作区和窗口。除了丰富的功能,我也重视操作时快速响应、流畅舒适的体验。Tatami 由此诞生,让应用、布局、显示器和自动化相互配合,提供原生设置界面,也无需更改 SIP。", + "zh-Hant": "我希望繼續在日常工作中使用 Mission Control,同時更靈活地管理工作空間和視窗。除了豐富的功能,我也重視操作時快速回應、流暢舒適的體驗。Tatami 因此誕生,讓 App、配置、顯示器與自動化互相配合,提供原生設定介面,也不必更改 SIP。" + }, + "c0c13920fe73a703": { + "en": "Workspace switching and window focus stay responsive during project builds and other CPU-heavy work.", + "ko": "프로젝트 빌드처럼 CPU를 많이 쓰는 작업 중에도 작업 공간 전환과 창 포커스가 빠르게 반응해요.", + "ja": "プロジェクトのビルドなど CPU に負荷がかかる作業中も、ワークスペースの切り替えやウインドウのフォーカスがすばやく応答します。", + "zh-Hans": "运行项目构建等 CPU 密集型任务时,工作区切换和窗口焦点切换依然响应迅速。", + "zh-Hant": "執行專案建置等大量使用 CPU 的工作時,工作空間切換和視窗焦點切換依然回應迅速。" + }, + "82b631ae3fe6df04": { + "en": "Borrow a reference or conversation workspace beside the current task, or keep a window visible with Always on Top. Borrowed layout changes stay with the workspace when you return it. Shared Apps can keep an always-on-top window with you across workspaces.", + "ko": "참고 자료나 대화가 있는 작업 공간을 옆으로 빌려오거나, 계속 확인할 창을 항상 위에 두세요. 빌려온 공간의 배치를 바꾸면 돌려보내도 변경 내용이 남아요. 공용 앱으로 등록하면 작업 공간을 옮겨도 그 창을 항상 위에 둘 수 있어요.", + "ja": "資料や会話のワークスペースを隣に借りたり、確認し続けたいウインドウを「常に手前」に置いたりできます。借りた配置を変えても、戻した後に変更が残ります。共有アプリに登録すれば、ワークスペースを切り替えてもそのウインドウを常に手前に表示できます。", + "zh-Hans": "将参考资料或聊天所在的工作区借到当前任务旁边,或将需要持续查看的窗口置顶。借用期间的布局更改会在归还后保留。将应用设为共享应用后,切换工作区时也能让该窗口保持置顶。", + "zh-Hant": "將參考資料或對話所在的工作空間借到目前工作旁邊,或將需要持續查看的視窗置頂。借用期間的配置變更會在歸還後保留。將 App 設為共用 App 後,切換工作空間時也能讓該視窗維持置頂。" + }, + "7e839c0e3de80373": { + "en": "Give writing, coding, or reviewing its own workspace. Automatic BSP tiling makes room as windows open. Return to the apps and layout you left, ready to pick up the task.", + "ko": "글쓰기, 개발, 검토처럼 작업마다 공간을 만들어 보세요. BSP 타일링이 새 창을 열 때마다 자리를 잡아주고, 작업 공간에 돌아오면 사용하던 앱과 배치로 바로 이어서 작업할 수 있어요.", + "ja": "執筆、開発、レビューなど、作業ごとにワークスペースを作れます。BSP タイル表示が新しいウインドウの場所を自動で整えます。戻ったときは、使っていたアプリと配置で作業を再開できます。", + "zh-Hans": "为写作、编程或审阅分别建立工作区。打开新窗口时,BSP 平铺会自动为它安排位置。回到工作区,就能用原来的应用和布局继续工作。", + "zh-Hant": "為寫作、開發或審閱分別建立工作空間。開啟新視窗時,BSP 並排會自動為它安排位置。回到工作空間,就能用原來的 App 和配置繼續工作。" } } diff --git a/Localization/Web.json b/Localization/Web.json index bda797f..52adb12 100644 --- a/Localization/Web.json +++ b/Localization/Web.json @@ -1,4 +1,60 @@ { + "3163637eca66aabb": { + "en": "Why Tatami?", + "ko": "왜 Tatami인가요?", + "ja": "Tatami を選ぶ理由", + "zh-Hans": "为什么选择 Tatami?", + "zh-Hant": "為什麼選擇 Tatami?" + }, + "33d2bf2874c85c39": { + "en": "Keep the work together.", + "ko": "작업에 필요한 창을 한곳에 모아요.", + "ja": "作業に必要なウインドウを、ひとまとめに。", + "zh-Hans": "把工作所需的窗口放在一起。", + "zh-Hant": "把工作所需的視窗放在一起。" + }, + "35172d01d2428226": { + "en": "When a task spans several apps, finding and arranging its windows becomes work of its own. Tatami keeps those apps and their layout together in a workspace you can return to.", + "ko": "여러 앱으로 작업하다 보면 필요한 창을 찾고 다시 배치하는 데도 시간이 들어요. Tatami는 앱과 배치를 작업 공간에 함께 담아, 다시 돌아왔을 때 바로 이어서 작업할 수 있게 해요.", + "ja": "複数のアプリを使う作業では、必要なウインドウを探して並べ直す手間がかかります。Tatami はアプリと配置をワークスペースにまとめ、戻ったときに作業を続けられるようにします。", + "zh-Hans": "一项工作用到多个应用时,寻找和重新排列窗口也会占用时间。Tatami 将应用和布局一起保存在工作区中,方便你回来继续工作。", + "zh-Hant": "一項工作用到多個 App 時,尋找和重新排列視窗也會花費時間。Tatami 將 App 和配置一起保留在工作空間中,讓你回來就能繼續工作。" + }, + "75a6424a99225163": { + "en": "Return to a task, ready to work.", + "ko": "돌아오면 바로 이어서 작업해요.", + "ja": "戻ったら、そのまま作業を再開。", + "zh-Hans": "回到任务,接着工作。", + "zh-Hant": "回到工作,接著繼續。" + }, + "2b13c297dc24694e": { + "en": "Keep related work within reach.", + "ko": "관련 작업을 곁에 두세요.", + "ja": "関連する作業を、すぐ隣に。", + "zh-Hans": "让相关工作就在手边。", + "zh-Hant": "讓相關工作就在手邊。" + }, + "a599b8e6144c10eb": { + "en": "Shape the setup around you.", + "ko": "내 방식에 맞게 확장해요.", + "ja": "自分の使い方に合わせて。", + "zh-Hans": "按你的习惯设置。", + "zh-Hant": "依你的習慣設定。" + }, + "dbb704c36a11576f": { + "en": "Configure workspaces and shortcuts in native settings. Go further with display-aware profiles, TOML, the CLI, and hooks. Each gives you another way to control the same workspaces.", + "ko": "앱의 설정 화면에서 작업 공간과 단축키를 구성하세요. 디스플레이에 맞춘 프로필, TOML, 명령줄 도구, 훅으로 더 세밀하게 다룰 수도 있어요. 모두 같은 작업 공간을 제어하는 방법이에요.", + "ja": "macOS ネイティブの設定画面で、ワークスペースとショートカットを設定できます。ディスプレイに応じたプロファイル、TOML、CLI、フックを使えば、同じワークスペースをさらに細かく制御できます。", + "zh-Hans": "在原生设置界面中配置工作区和快捷键。还可以通过适应显示器的配置方案、TOML、CLI 和钩子进一步调整。它们都是控制同一组工作区的不同方式。", + "zh-Hant": "在原生設定介面中設定工作空間和快速鍵。也能透過配合顯示器的設定組合、TOML、CLI 和掛鉤進一步調整。它們都是控制同一組工作空間的不同方式。" + }, + "b0e384f35f3da315": { + "en": "Why I built Tatami", + "ko": "Tatami를 만든 이유", + "ja": "Tatami を作った理由", + "zh-Hans": "我为什么开发 Tatami", + "zh-Hant": "我為什麼開發 Tatami" + }, "1377264ba3b0a717": { "en": "Workspaces", "ko": "작업 공간", @@ -847,5 +903,68 @@ "ja": "関連する設定", "zh-Hans": "相关设置", "zh-Hant": "相關設定" + }, + "dea43757997c4def": { + "en": "contributed to FlashSpace", + "ko": "FlashSpace에 기여하면서", + "ja": "FlashSpace に貢献する中で", + "zh-Hans": "为 FlashSpace 贡献功能", + "zh-Hant": "為 FlashSpace 貢獻功能" + }, + "81e766d8978e574d": { + "en": "proposed", + "ko": "제안했어요", + "ja": "提案", + "zh-Hans": "提议", + "zh-Hant": "提議" + }, + "1e27a788de533d64": { + "en": "I {0} and {1} connecting window layouts to its workspaces. The maintainer chose to keep workspace management focused and leave tiling to other tools. I respected that direction, but kept wanting to explore how workspaces and window management could work together in one app.", + "ko": "{0} 작업 공간과 창 배치를 연결하는 아이디어도 {1}. 메인테이너는 작업 공간 관리에 집중하고 타일링은 다른 도구에 맡기는 방향을 선택했어요. 그 방향을 존중하면서도, 작업 공간과 창 관리를 하나의 앱에서 함께 다루는 방법을 계속 탐구하고 싶었어요.", + "ja": "{0}、ウインドウの配置とワークスペースを結び付ける{1}もしました。メンテナーはワークスペース管理に集中し、タイル表示はほかのツールに任せる方針を選びました。その方針を尊重しつつ、ワークスペースとウインドウ管理を一つのアプリで連携させる方法を探りたいと思い続けていました。", + "zh-Hans": "我曾{0},也{1}将窗口布局与工作区结合。维护者选择专注于工作区管理,把平铺交给其他工具。我尊重这个方向,但仍想探索如何在一个应用中让工作区和窗口管理相互配合。", + "zh-Hant": "我曾{0},也{1}將視窗配置與工作空間結合。維護者選擇專注於工作空間管理,把並排交給其他工具。我尊重這個方向,但仍想探索如何在一個 App 中讓工作空間與視窗管理互相配合。" + }, + "f1fa6f56d4c8c1d5": { + "en": "Before building Tatami, I went back and forth between yabai, AeroSpace, and FlashSpace + Rectangle, looking for a setup that suited me. I loved yabai's speed and capabilities, but some features I wanted involved partially disabling SIP. AeroSpace's workspace behavior at the time did not fit how heavily I used Mission Control. I liked how FlashSpace combined app hiding with virtual workspaces whose apps were assigned in advance. I used Rectangle for window arrangement.", + "ko": "Tatami를 만들기 전에는 yabai, AeroSpace, FlashSpace + Rectangle 조합을 오가며 제게 맞는 작업 환경을 찾았어요. yabai의 속도와 기능이 마음에 들었지만, 원하는 기능 중 일부는 SIP를 부분적으로 꺼야 했어요. 당시 AeroSpace의 작업 공간 동작은 Mission Control을 자주 쓰는 제 방식과 맞지 않았어요. FlashSpace에서는 앱을 숨기는 방식과, 앱을 미리 등록해 둔 가상 작업 공간을 활용하는 방식이 마음에 들었어요. 창 배치는 Rectangle로 보완하며 사용했어요.", + "ja": "Tatami を作る前は、yabai、AeroSpace、FlashSpace + Rectangle を行き来しながら、自分に合う作業環境を探していました。yabai の速さと機能は気に入っていましたが、使いたい機能の一部には SIP の部分的な無効化が必要でした。当時の AeroSpace のワークスペースの動作は、Mission Control をよく使う私の作業には合いませんでした。FlashSpace では、アプリを隠す方式と、あらかじめアプリを登録した仮想ワークスペースを使う方法が気に入りました。ウインドウの配置には Rectangle を組み合わせていました。", + "zh-Hans": "开发 Tatami 之前,我在 yabai、AeroSpace 和 FlashSpace + Rectangle 之间反复尝试,寻找适合自己的工作环境。我很喜欢 yabai 的速度和功能,但我想用的部分功能需要关闭部分 SIP 保护。当时 AeroSpace 的工作区行为不太适合我频繁使用 Mission Control 的习惯。我喜欢 FlashSpace 隐藏应用的方式,也喜欢先把应用分配好,再使用虚拟工作区的方式。窗口排列则由 Rectangle 来补充。", + "zh-Hant": "開發 Tatami 之前,我在 yabai、AeroSpace 和 FlashSpace + Rectangle 之間反覆嘗試,尋找適合自己的工作環境。我很喜歡 yabai 的速度和功能,但我想用的部分功能需要關閉部分 SIP 保護。當時 AeroSpace 的工作空間行為不太適合我經常使用 Mission Control 的習慣。我喜歡 FlashSpace 隱藏 App 的方式,也喜歡先把 App 指派好,再使用虛擬工作空間的方式。視窗排列則由 Rectangle 來補足。" + }, + "c2567d2e7908705d": { + "en": "I wanted powerful workspace and window controls that felt fast and responsive, while keeping Mission Control in my daily workflow. Tatami grew from that wish: apps, layouts, displays, and automation that work together, with native settings and no SIP changes.", + "ko": "Mission Control을 계속 쓰면서 작업 공간과 창을 더 자유롭게 다루고 싶었어요. 기능뿐 아니라, 조작할 때 빠르고 쾌적하게 반응하는 경험도 중요했어요. 그래서 앱, 배치, 디스플레이, 자동화가 함께 동작하는 Tatami를 만들었어요. macOS에 익숙한 설정 화면에서 구성할 수 있고, SIP를 변경할 필요도 없어요.", + "ja": "Mission Control を日々使いながら、ワークスペースやウインドウをもっと自在に扱いたいと思っていました。機能だけでなく、操作にすばやく応え、快適に使えることも大切でした。そこから生まれたのが Tatami です。アプリ、配置、ディスプレイ、自動化を連携させ、macOS ネイティブの設定画面から使えます。SIP の変更も必要ありません。", + "zh-Hans": "我希望继续在日常工作中使用 Mission Control,同时更灵活地管理工作区和窗口。除了丰富的功能,我也重视操作时快速响应、流畅舒适的体验。Tatami 由此诞生,让应用、布局、显示器和自动化相互配合,提供原生设置界面,也无需更改 SIP。", + "zh-Hant": "我希望繼續在日常工作中使用 Mission Control,同時更靈活地管理工作空間和視窗。除了豐富的功能,我也重視操作時快速回應、流暢舒適的體驗。Tatami 因此誕生,讓 App、配置、顯示器與自動化互相配合,提供原生設定介面,也不必更改 SIP。" + }, + "b631e1ce2d5afc15": { + "en": "Keep working while your Mac is busy.", + "ko": "Mac이 바쁠 때도 작업 흐름을 이어가세요.", + "ja": "Mac が忙しいときも、作業を止めずに。", + "zh-Hans": "Mac 忙碌时,也能继续工作。", + "zh-Hant": "Mac 忙碌時,也能繼續工作。" + }, + "c0c13920fe73a703": { + "en": "Workspace switching and window focus stay responsive during project builds and other CPU-heavy work.", + "ko": "프로젝트 빌드처럼 CPU를 많이 쓰는 작업 중에도 작업 공간 전환과 창 포커스가 빠르게 반응해요.", + "ja": "プロジェクトのビルドなど CPU に負荷がかかる作業中も、ワークスペースの切り替えやウインドウのフォーカスがすばやく応答します。", + "zh-Hans": "运行项目构建等 CPU 密集型任务时,工作区切换和窗口焦点切换依然响应迅速。", + "zh-Hant": "執行專案建置等大量使用 CPU 的工作時,工作空間切換和視窗焦點切換依然回應迅速。" + }, + "82b631ae3fe6df04": { + "en": "Borrow a reference or conversation workspace beside the current task, or keep a window visible with Always on Top. Borrowed layout changes stay with the workspace when you return it. Shared Apps can keep an always-on-top window with you across workspaces.", + "ko": "참고 자료나 대화가 있는 작업 공간을 옆으로 빌려오거나, 계속 확인할 창을 항상 위에 두세요. 빌려온 공간의 배치를 바꾸면 돌려보내도 변경 내용이 남아요. 공용 앱으로 등록하면 작업 공간을 옮겨도 그 창을 항상 위에 둘 수 있어요.", + "ja": "資料や会話のワークスペースを隣に借りたり、確認し続けたいウインドウを「常に手前」に置いたりできます。借りた配置を変えても、戻した後に変更が残ります。共有アプリに登録すれば、ワークスペースを切り替えてもそのウインドウを常に手前に表示できます。", + "zh-Hans": "将参考资料或聊天所在的工作区借到当前任务旁边,或将需要持续查看的窗口置顶。借用期间的布局更改会在归还后保留。将应用设为共享应用后,切换工作区时也能让该窗口保持置顶。", + "zh-Hant": "將參考資料或對話所在的工作空間借到目前工作旁邊,或將需要持續查看的視窗置頂。借用期間的配置變更會在歸還後保留。將 App 設為共用 App 後,切換工作空間時也能讓該視窗維持置頂。" + }, + "7e839c0e3de80373": { + "en": "Give writing, coding, or reviewing its own workspace. Automatic BSP tiling makes room as windows open. Return to the apps and layout you left, ready to pick up the task.", + "ko": "글쓰기, 개발, 검토처럼 작업마다 공간을 만들어 보세요. BSP 타일링이 새 창을 열 때마다 자리를 잡아주고, 작업 공간에 돌아오면 사용하던 앱과 배치로 바로 이어서 작업할 수 있어요.", + "ja": "執筆、開発、レビューなど、作業ごとにワークスペースを作れます。BSP タイル表示が新しいウインドウの場所を自動で整えます。戻ったときは、使っていたアプリと配置で作業を再開できます。", + "zh-Hans": "为写作、编程或审阅分别建立工作区。打开新窗口时,BSP 平铺会自动为它安排位置。回到工作区,就能用原来的应用和布局继续工作。", + "zh-Hant": "為寫作、開發或審閱分別建立工作空間。開啟新視窗時,BSP 並排會自動為它安排位置。回到工作空間,就能用原來的 App 和配置繼續工作。" } } diff --git a/README.md b/README.md index 37a3d49..18ff61a 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,36 @@ Captured on an earlier version. Some labels have changed since these screenshots +## Why Tatami? + +When a task spans several apps, finding and arranging its windows becomes work of its +own. Tatami keeps those apps and their layout together in a workspace you can return to. + +- Give writing, coding, or reviewing its own workspace. Automatic BSP tiling makes room as windows open. Return to the apps and layout you left, ready to pick up the task. +- Workspace switching and window focus stay responsive during project builds and other CPU-heavy work. +- Borrow a reference or conversation workspace beside the current task, or keep a window visible with Always on Top. Borrowed layout changes stay with the workspace when you return it. Shared Apps can keep an always-on-top window with you across workspaces. +- Configure workspaces and shortcuts in native settings. Go further with display-aware profiles, TOML, the CLI, and hooks. Each gives you another way to control the same workspaces. + +## Why I built Tatami + +Before building Tatami, I went back and forth between yabai, AeroSpace, and FlashSpace + +Rectangle, looking for a setup that suited me. I loved yabai's speed and capabilities, +but some features I wanted involved partially disabling SIP. AeroSpace's workspace +behavior at the time did not fit how heavily I used Mission Control. I liked how +FlashSpace combined app hiding with virtual workspaces whose apps were assigned in +advance. I used Rectangle for window arrangement. + +I [contributed to +FlashSpace](https://github.com/wojciech-kulik/FlashSpace/pulls?q=is%3Apr+author%3APangMo5) +and [proposed](https://github.com/wojciech-kulik/FlashSpace/discussions/9) connecting +window layouts to its workspaces. The maintainer chose to keep workspace management +focused and leave tiling to other tools. I respected that direction, but kept wanting to +explore how workspaces and window management could work together in one app. + +I wanted powerful workspace and window controls that felt fast and responsive, while +keeping Mission Control in my daily workflow. Tatami grew from that wish: apps, layouts, +displays, and automation that work together, with native settings and no SIP changes. + ## Features ### Workspaces diff --git a/docs/ja/README.md b/docs/ja/README.md index 0cec70b..e6b706e 100644 --- a/docs/ja/README.md +++ b/docs/ja/README.md @@ -31,6 +31,25 @@ Tatami はアプリを仮想ワークスペースにまとめ、キーや設定 + +## Tatami を選ぶ理由 + +複数のアプリを使う作業では、必要なウインドウを探して並べ直す手間がかかります。Tatami はアプリと配置をワークスペースにまとめ、戻ったときに作業を続けられるようにします。 + +- 執筆、開発、レビューなど、作業ごとにワークスペースを作れます。BSP タイル表示が新しいウインドウの場所を自動で整えます。戻ったときは、使っていたアプリと配置で作業を再開できます。 +- プロジェクトのビルドなど CPU に負荷がかかる作業中も、ワークスペースの切り替えやウインドウのフォーカスがすばやく応答します。 +- 資料や会話のワークスペースを隣に借りたり、確認し続けたいウインドウを「常に手前」に置いたりできます。借りた配置を変えても、戻した後に変更が残ります。共有アプリに登録すれば、ワークスペースを切り替えてもそのウインドウを常に手前に表示できます。 +- macOS ネイティブの設定画面で、ワークスペースとショートカットを設定できます。ディスプレイに応じたプロファイル、TOML、CLI、フックを使えば、同じワークスペースをさらに細かく制御できます。 + + +## Tatami を作った理由 + +Tatami を作る前は、yabai、AeroSpace、FlashSpace + Rectangle を行き来しながら、自分に合う作業環境を探していました。yabai の速さと機能は気に入っていましたが、使いたい機能の一部には SIP の部分的な無効化が必要でした。当時の AeroSpace のワークスペースの動作は、Mission Control をよく使う私の作業には合いませんでした。FlashSpace では、アプリを隠す方式と、あらかじめアプリを登録した仮想ワークスペースを使う方法が気に入りました。ウインドウの配置には Rectangle を組み合わせていました。 + +[FlashSpace に貢献する中で](https://github.com/wojciech-kulik/FlashSpace/pulls?q=is%3Apr+author%3APangMo5)、ウインドウの配置とワークスペースを結び付ける[提案](https://github.com/wojciech-kulik/FlashSpace/discussions/9)もしました。メンテナーはワークスペース管理に集中し、タイル表示はほかのツールに任せる方針を選びました。その方針を尊重しつつ、ワークスペースとウインドウ管理を一つのアプリで連携させる方法を探りたいと思い続けていました。 + +Mission Control を日々使いながら、ワークスペースやウインドウをもっと自在に扱いたいと思っていました。機能だけでなく、操作にすばやく応え、快適に使えることも大切でした。そこから生まれたのが Tatami です。アプリ、配置、ディスプレイ、自動化を連携させ、macOS ネイティブの設定画面から使えます。SIP の変更も必要ありません。 + ## 主な機能 diff --git a/docs/ko/README.md b/docs/ko/README.md index 8027029..7d7adbf 100644 --- a/docs/ko/README.md +++ b/docs/ko/README.md @@ -31,6 +31,25 @@ Tatami는 앱을 가상 작업 공간에 모으고, 단축키나 설정한 트 + +## 왜 Tatami인가요? + +여러 앱으로 작업하다 보면 필요한 창을 찾고 다시 배치하는 데도 시간이 들어요. Tatami는 앱과 배치를 작업 공간에 함께 담아, 다시 돌아왔을 때 바로 이어서 작업할 수 있게 해요. + +- 글쓰기, 개발, 검토처럼 작업마다 공간을 만들어 보세요. BSP 타일링이 새 창을 열 때마다 자리를 잡아주고, 작업 공간에 돌아오면 사용하던 앱과 배치로 바로 이어서 작업할 수 있어요. +- 프로젝트 빌드처럼 CPU를 많이 쓰는 작업 중에도 작업 공간 전환과 창 포커스가 빠르게 반응해요. +- 참고 자료나 대화가 있는 작업 공간을 옆으로 빌려오거나, 계속 확인할 창을 항상 위에 두세요. 빌려온 공간의 배치를 바꾸면 돌려보내도 변경 내용이 남아요. 공용 앱으로 등록하면 작업 공간을 옮겨도 그 창을 항상 위에 둘 수 있어요. +- 앱의 설정 화면에서 작업 공간과 단축키를 구성하세요. 디스플레이에 맞춘 프로필, TOML, 명령줄 도구, 훅으로 더 세밀하게 다룰 수도 있어요. 모두 같은 작업 공간을 제어하는 방법이에요. + + +## Tatami를 만든 이유 + +Tatami를 만들기 전에는 yabai, AeroSpace, FlashSpace + Rectangle 조합을 오가며 제게 맞는 작업 환경을 찾았어요. yabai의 속도와 기능이 마음에 들었지만, 원하는 기능 중 일부는 SIP를 부분적으로 꺼야 했어요. 당시 AeroSpace의 작업 공간 동작은 Mission Control을 자주 쓰는 제 방식과 맞지 않았어요. FlashSpace에서는 앱을 숨기는 방식과, 앱을 미리 등록해 둔 가상 작업 공간을 활용하는 방식이 마음에 들었어요. 창 배치는 Rectangle로 보완하며 사용했어요. + +[FlashSpace에 기여하면서](https://github.com/wojciech-kulik/FlashSpace/pulls?q=is%3Apr+author%3APangMo5) 작업 공간과 창 배치를 연결하는 아이디어도 [제안했어요](https://github.com/wojciech-kulik/FlashSpace/discussions/9). 메인테이너는 작업 공간 관리에 집중하고 타일링은 다른 도구에 맡기는 방향을 선택했어요. 그 방향을 존중하면서도, 작업 공간과 창 관리를 하나의 앱에서 함께 다루는 방법을 계속 탐구하고 싶었어요. + +Mission Control을 계속 쓰면서 작업 공간과 창을 더 자유롭게 다루고 싶었어요. 기능뿐 아니라, 조작할 때 빠르고 쾌적하게 반응하는 경험도 중요했어요. 그래서 앱, 배치, 디스플레이, 자동화가 함께 동작하는 Tatami를 만들었어요. macOS에 익숙한 설정 화면에서 구성할 수 있고, SIP를 변경할 필요도 없어요. + ## 주요 기능 diff --git a/docs/zh-Hans/README.md b/docs/zh-Hans/README.md index 4ac35a0..b67498a 100644 --- a/docs/zh-Hans/README.md +++ b/docs/zh-Hans/README.md @@ -31,6 +31,25 @@ Tatami 把应用组织到虚拟工作区,通过快捷键或自定义触控板 + +## 为什么选择 Tatami? + +一项工作用到多个应用时,寻找和重新排列窗口也会占用时间。Tatami 将应用和布局一起保存在工作区中,方便你回来继续工作。 + +- 为写作、编程或审阅分别建立工作区。打开新窗口时,BSP 平铺会自动为它安排位置。回到工作区,就能用原来的应用和布局继续工作。 +- 运行项目构建等 CPU 密集型任务时,工作区切换和窗口焦点切换依然响应迅速。 +- 将参考资料或聊天所在的工作区借到当前任务旁边,或将需要持续查看的窗口置顶。借用期间的布局更改会在归还后保留。将应用设为共享应用后,切换工作区时也能让该窗口保持置顶。 +- 在原生设置界面中配置工作区和快捷键。还可以通过适应显示器的配置方案、TOML、CLI 和钩子进一步调整。它们都是控制同一组工作区的不同方式。 + + +## 我为什么开发 Tatami + +开发 Tatami 之前,我在 yabai、AeroSpace 和 FlashSpace + Rectangle 之间反复尝试,寻找适合自己的工作环境。我很喜欢 yabai 的速度和功能,但我想用的部分功能需要关闭部分 SIP 保护。当时 AeroSpace 的工作区行为不太适合我频繁使用 Mission Control 的习惯。我喜欢 FlashSpace 隐藏应用的方式,也喜欢先把应用分配好,再使用虚拟工作区的方式。窗口排列则由 Rectangle 来补充。 + +我曾[为 FlashSpace 贡献功能](https://github.com/wojciech-kulik/FlashSpace/pulls?q=is%3Apr+author%3APangMo5),也[提议](https://github.com/wojciech-kulik/FlashSpace/discussions/9)将窗口布局与工作区结合。维护者选择专注于工作区管理,把平铺交给其他工具。我尊重这个方向,但仍想探索如何在一个应用中让工作区和窗口管理相互配合。 + +我希望继续在日常工作中使用 Mission Control,同时更灵活地管理工作区和窗口。除了丰富的功能,我也重视操作时快速响应、流畅舒适的体验。Tatami 由此诞生,让应用、布局、显示器和自动化相互配合,提供原生设置界面,也无需更改 SIP。 + ## 主要功能 diff --git a/docs/zh-Hant/README.md b/docs/zh-Hant/README.md index 779cbe7..6c9597d 100644 --- a/docs/zh-Hant/README.md +++ b/docs/zh-Hant/README.md @@ -31,6 +31,25 @@ Tatami 把 App 整理到虛擬工作空間,透過快速鍵或自訂觸控式 + +## 為什麼選擇 Tatami? + +一項工作用到多個 App 時,尋找和重新排列視窗也會花費時間。Tatami 將 App 和配置一起保留在工作空間中,讓你回來就能繼續工作。 + +- 為寫作、開發或審閱分別建立工作空間。開啟新視窗時,BSP 並排會自動為它安排位置。回到工作空間,就能用原來的 App 和配置繼續工作。 +- 執行專案建置等大量使用 CPU 的工作時,工作空間切換和視窗焦點切換依然回應迅速。 +- 將參考資料或對話所在的工作空間借到目前工作旁邊,或將需要持續查看的視窗置頂。借用期間的配置變更會在歸還後保留。將 App 設為共用 App 後,切換工作空間時也能讓該視窗維持置頂。 +- 在原生設定介面中設定工作空間和快速鍵。也能透過配合顯示器的設定組合、TOML、CLI 和掛鉤進一步調整。它們都是控制同一組工作空間的不同方式。 + + +## 我為什麼開發 Tatami + +開發 Tatami 之前,我在 yabai、AeroSpace 和 FlashSpace + Rectangle 之間反覆嘗試,尋找適合自己的工作環境。我很喜歡 yabai 的速度和功能,但我想用的部分功能需要關閉部分 SIP 保護。當時 AeroSpace 的工作空間行為不太適合我經常使用 Mission Control 的習慣。我喜歡 FlashSpace 隱藏 App 的方式,也喜歡先把 App 指派好,再使用虛擬工作空間的方式。視窗排列則由 Rectangle 來補足。 + +我曾[為 FlashSpace 貢獻功能](https://github.com/wojciech-kulik/FlashSpace/pulls?q=is%3Apr+author%3APangMo5),也[提議](https://github.com/wojciech-kulik/FlashSpace/discussions/9)將視窗配置與工作空間結合。維護者選擇專注於工作空間管理,把並排交給其他工具。我尊重這個方向,但仍想探索如何在一個 App 中讓工作空間與視窗管理互相配合。 + +我希望繼續在日常工作中使用 Mission Control,同時更靈活地管理工作空間和視窗。除了豐富的功能,我也重視操作時快速回應、流暢舒適的體驗。Tatami 因此誕生,讓 App、配置、顯示器與自動化互相配合,提供原生設定介面,也不必更改 SIP。 + ## 主要功能 diff --git a/web/index.html b/web/index.html index 9c35b43..5caf5b2 100644 --- a/web/index.html +++ b/web/index.html @@ -74,6 +74,38 @@

Make room for the whole workflow.

+
+
+

Why Tatami?

+

Keep the work together.

+

When a task spans several apps, finding and arranging its windows becomes work of its own. Tatami keeps those apps and their layout together in a workspace you can return to.

+
+
+

Return to a task, ready to work.

+

Give writing, coding, or reviewing its own workspace. Automatic BSP tiling makes room as windows open. Return to the apps and layout you left, ready to pick up the task.

+
+
+

Keep working while your Mac is busy.

+

Workspace switching and window focus stay responsive during project builds and other CPU-heavy work.

+
+
+

Keep related work within reach.

+

Borrow a reference or conversation workspace beside the current task, or keep a window visible with Always on Top. Borrowed layout changes stay with the workspace when you return it. Shared Apps can keep an always-on-top window with you across workspaces.

+
+
+

Shape the setup around you.

+

Configure workspaces and shortcuts in native settings. Go further with display-aware profiles, TOML, the CLI, and hooks. Each gives you another way to control the same workspaces.

+
+
+
+

Why I built Tatami

+

Before building Tatami, I went back and forth between yabai, AeroSpace, and FlashSpace + Rectangle, looking for a setup that suited me. I loved yabai's speed and capabilities, but some features I wanted involved partially disabling SIP. AeroSpace's workspace behavior at the time did not fit how heavily I used Mission Control. I liked how FlashSpace combined app hiding with virtual workspaces whose apps were assigned in advance. I used Rectangle for window arrangement.

+

I contributed to FlashSpace and proposed connecting window layouts to its workspaces. The maintainer chose to keep workspace management focused and leave tiling to other tools. I respected that direction, but kept wanting to explore how workspaces and window management could work together in one app.

+

I wanted powerful workspace and window controls that felt fast and responsive, while keeping Mission Control in my daily workflow. Tatami grew from that wish: apps, layouts, displays, and automation that work together, with native settings and no SIP changes.

+
+
+
+

Virtual workspaces

diff --git a/web/style.css b/web/style.css index c04fac5..8eb1709 100644 --- a/web/style.css +++ b/web/style.css @@ -333,3 +333,11 @@ footer p { line-height: 1.5; } } @media (max-width: 600px) { .md h1 { font-size: 32px; line-height: 1.2; } } + +/* Why Tatami */ +#why-tatami .sub { max-width: 800px; margin-inline: auto; } +@media (min-width: 761px) { #why-tatami .features { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.origin-note { max-width: 740px; margin: 48px auto 0; padding-top: 32px; border-top: 1px solid var(--hairline); text-align: left; } +.origin-note h3 { font-size: 22px; font-weight: 600; } +.origin-note p { margin-top: 12px; font-size: 18px; line-height: 1.6; color: var(--text-secondary); } +.origin-note a { text-decoration: underline; text-underline-offset: 0.15em; }