From 3cb25a98ab812c71cf9c542a8146f5597309e09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:46:16 +0800 Subject: [PATCH 1/4] Prepare CodexNotes 1.4.83 --- AppBundle/Info.plist | 4 +- README.md | 10 +- .../CodexRightPanelStateReader.swift | 144 ++ Sources/CodexNotesCore/Localization.swift | 12 + .../Resources/en.lproj/Localizable.strings | 12 + .../zh-Hans.lproj/Localizable.strings | 12 + .../RightPanelAvoidancePreference.swift | 37 + .../CodexAccessibilityRightPanelReader.swift | 617 ++++++ .../CodexNotesProbe/CodexNotesProbeApp.swift | 8 + Sources/CodexNotesProbe/ContentView.swift | 254 ++- .../MainWindowChromePolicy.swift | 5 +- .../MainWindowCompactController.swift | 976 +++++++++ .../MainWindowCompactState.swift | 174 ++ .../PlainMarkdownTextView.swift | 24 + Sources/CodexNotesProbe/ProbeViewModel.swift | 106 + Sources/CodexNotesProbe/SettingsView.swift | 130 ++ .../CodexRightPanelStateReaderTests.swift | 363 ++++ .../RightPanelAvoidancePreferenceTests.swift | 79 + ...exAccessibilityRightPanelReaderTests.swift | 382 ++++ .../MainWindowChromePolicyTests.swift | 24 + .../MainWindowCompactControllerTests.swift | 1800 +++++++++++++++++ .../MainWindowCompactStateTests.swift | 510 +++++ .../PlainMarkdownTextViewTests.swift | 51 +- ...obeViewModelRightPanelAvoidanceTests.swift | 31 + ...RightPanelAvoidancePresentationTests.swift | 85 + 25 files changed, 5823 insertions(+), 27 deletions(-) create mode 100644 Sources/CodexNotesCore/CodexRightPanelStateReader.swift create mode 100644 Sources/CodexNotesCore/RightPanelAvoidancePreference.swift create mode 100644 Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift create mode 100644 Sources/CodexNotesProbe/MainWindowCompactController.swift create mode 100644 Sources/CodexNotesProbe/MainWindowCompactState.swift create mode 100644 Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift create mode 100644 Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift create mode 100644 Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift create mode 100644 Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift create mode 100644 Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift create mode 100644 Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift create mode 100644 Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift diff --git a/AppBundle/Info.plist b/AppBundle/Info.plist index a02de10..d6d99c2 100644 --- a/AppBundle/Info.plist +++ b/AppBundle/Info.plist @@ -24,9 +24,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.71 + 1.4.83 CFBundleVersion - 91 + 103 LSUIElement LSMinimumSystemVersion diff --git a/README.md b/README.md index 6774e4e..f49eb00 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ CodexNotes 是一个跟随 Codex 当前任务自动切换的原生 macOS Markdow - Apple Silicon(arm64)或 Intel(x86_64)Mac - macOS 14.0 或更高版本 - 正式 Release 为 Universal 2,同一份应用同时包含 arm64 与 x86_64 -- 当前已验证兼容 Codex `26.803.41515` +- 当前已验证兼容 Codex `26.803.81509` CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背书关系。 @@ -21,6 +21,8 @@ CodexNotes 是非官方第三方工具,与 OpenAI 没有隶属、授权或背 2. 解压后,把 `CodexNotes.app` 移到 `/Applications` 或 `~/Applications`。 3. 首次打开后,CodexNotes 会常驻菜单栏,不会在 Dock 显示图标。 +“自动避让 Codex 侧边栏”默认关闭,可在设置中主动开启。若要精确识别尚未打开任何网页、终端或文件的空侧边栏,需要按设置页提示授予辅助功能权限;即使不开启自动避让,仍可使用笔记窗口右上角按钮手动折叠和展开。 + 正式 Release 使用 Developer ID 签名并通过 Apple notarization。你可以在终端验证: ```sh @@ -42,6 +44,8 @@ spctl --assess --type exec --verbose=4 /Applications/CodexNotes.app - 支持系统原色及六套自定义主题,并可调整字号与行间距 - 支持跟随系统、简体中文和 English 三种语言选项 - 支持自定义全局显示/隐藏快捷键;只在 Codex 正在运行且未隐藏时生效 +- 支持手动把笔记窗口折叠为右上角锚定的半透明紧凑胶囊,只保留应用名称和展开按钮 +- 可选“自动避让 Codex 侧边栏”:每轮侧边栏打开时自动收起一次,关闭时优先恢复打开侧边栏前的展开状态 - 可选择登录 Mac 时启动;该功能默认关闭并使用 macOS 原生登录项 - 记住窗口位置和大小;隐藏、重新显示或重启后不会漂移 - 关于页显示版本、作者与项目链接,并支持手动检查或主动开启每日 GitHub Release 更新检查 @@ -61,6 +65,8 @@ Markdown 原文是唯一数据源。图形复选框、图片预览、粗体和 CodexNotes 不上传笔记、不提供云同步,也不包含遥测或分析服务。应用只读取 Codex 写在本机的状态与导航日志,用来识别当前任务。 +自动避让开启后,CodexNotes 会优先通过 macOS 辅助功能读取 Codex 主窗口的布局,以判断右侧边栏是否展开;它不读取键盘输入,也不会把窗口结构发送到网络。未授予权限时会降级读取 Codex 本机状态文件,但无法可靠识别没有标签内容的空侧边栏。该功能默认关闭,关闭后不会进行上述侧边栏检测,手动折叠仍然可用。 + 只有当你手动点击“检查更新”,或主动开启“自动检查更新”后,应用才会访问 GitHub Releases API。成功检查后间隔 24 小时;网络失败时会静默退避重试。应用只检查版本,不会自动下载或安装;请求不包含笔记内容、设备标识或遥测数据。 ## 快捷键 @@ -103,7 +109,7 @@ zsh scripts/release-notarized.sh ## 兼容性说明 -自动跟随目前依赖 Codex `26.803.41515` 写出的本机主窗口导航日志。该日志不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试。 +自动跟随和侧边栏避让目前依赖 Codex `26.803.81509` 的本机导航状态及窗口结构。它们不是公开稳定接口,因此 Codex 大版本更新后需要重新进行兼容性测试;识别失败时,侧边栏避让会保持窗口展开,不会猜测性收起。 Universal 2 产物会验证 arm64 与 x86_64 两个架构。当前 Codex 联动验证以 Apple Silicon 真机为主;Intel 版已包含原生 x86_64 架构代码,完整联动兼容性将在真实 Intel Mac 上持续验证,交叉编译或 Rosetta 启动仅作为补充。 diff --git a/Sources/CodexNotesCore/CodexRightPanelStateReader.swift b/Sources/CodexNotesCore/CodexRightPanelStateReader.swift new file mode 100644 index 0000000..2919b4c --- /dev/null +++ b/Sources/CodexNotesCore/CodexRightPanelStateReader.swift @@ -0,0 +1,144 @@ +import CoreFoundation +import Foundation + +public enum CodexRightPanelState: String, Codable, Equatable, Sendable { + case unknown + case closed + case open +} + +/// Parses Codex's persisted right-panel state for one exact thread. +/// +/// The generic panel state is authoritative when it contains a strict Boolean. +/// If that private schema is unavailable, the browser-only state is a backward +/// compatible fallback. A valid atom store with neither exact-thread key means +/// the panel is still in its default closed state. Present but drifted values +/// remain unknown and never borrow another thread's state or rely on truthiness. +public enum CodexRightPanelStateParser { + private static let atomStateKey = "electron-persisted-atom-state" + private static let genericThreadKeyPrefix = "thread-tab-routes-v1:" + private static let browserThreadKeyPrefix = "thread-browser-tabs-v1:" + + public static func state( + for threadID: String, + in data: Data + ) -> CodexRightPanelState { + guard !threadID.isEmpty, + let root = try? JSONSerialization.jsonObject(with: data), + let rootObject = root as? [String: Any], + let atomState = rootObject[atomStateKey] as? [String: Any] + else { + return .unknown + } + + let genericKey = genericPersistedKey(for: threadID) + let browserKey = browserPersistedKey(for: threadID) + let genericValue = atomState[genericKey] + let browserValue = atomState[browserKey] + + if let genericThreadState = genericValue as? [String: Any], + let topology = genericThreadState["topology"] as? [String: Any], + let right = topology["right"] as? [String: Any], + let state = strictBooleanState(right["open"]) { + return state + } + + if let browserThreadState = browserValue as? [String: Any], + let state = strictBooleanState( + browserThreadState["rightPanelOpen"] + ) { + return state + } + + if genericValue == nil, browserValue == nil { + return .closed + } + + return .unknown + } + + /// The legacy browser-only key, retained for source compatibility. + public static func persistedKey(for threadID: String) -> String { + browserPersistedKey(for: threadID) + } + + /// Matches Codex's generic right-panel persisted key. + public static func genericPersistedKey(for threadID: String) -> String { + genericThreadKeyPrefix + encodeURIComponent(threadID) + } + + /// Matches Codex's browser-only right-panel persisted key. + public static func browserPersistedKey(for threadID: String) -> String { + browserThreadKeyPrefix + encodeURIComponent(threadID) + } + + private static func strictBooleanState( + _ value: Any? + ) -> CodexRightPanelState? { + guard let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { + return nil + } + + return number.boolValue ? .open : .closed + } + + private static func encodeURIComponent(_ value: String) -> String { + var result = "" + result.reserveCapacity(value.utf8.count) + + for byte in value.utf8 { + if isEncodeURIComponentUnescaped(byte) { + result.unicodeScalars.append(UnicodeScalar(byte)) + } else { + result.append(String(format: "%%%02X", byte)) + } + } + return result + } + + private static func isEncodeURIComponentUnescaped(_ byte: UInt8) -> Bool { + switch byte { + case 0x30 ... 0x39, // 0-9 + 0x41 ... 0x5A, // A-Z + 0x61 ... 0x7A, // a-z + 0x21, // ! + 0x27, // ' + 0x28, // ( + 0x29, // ) + 0x2A, // * + 0x2D, // - + 0x2E, // . + 0x5F, // _ + 0x7E: // ~ + return true + default: + return false + } + } +} + +/// Reads a fresh snapshot on each call so atomic replacements of Codex's state +/// file are observed without retaining stale panel state. +public actor CodexRightPanelStateReader { + private let globalStateURL: URL + + public init( + globalStateURL: URL = CodexEnvironment.defaultStateDatabase + .deletingLastPathComponent() + .appendingPathComponent(".codex-global-state.json") + ) { + self.globalStateURL = globalStateURL + } + + public func state(for threadID: String) -> CodexRightPanelState { + guard let data = try? Data( + contentsOf: globalStateURL, + options: .mappedIfSafe + ) else { + return .unknown + } + return CodexRightPanelStateParser.state(for: threadID, in: data) + } +} diff --git a/Sources/CodexNotesCore/Localization.swift b/Sources/CodexNotesCore/Localization.swift index 284e80a..9aaa76c 100644 --- a/Sources/CodexNotesCore/Localization.swift +++ b/Sources/CodexNotesCore/Localization.swift @@ -69,6 +69,14 @@ public enum L10n { case settingsLaunchAtLoginRequiresApproval = "settings.launch_at_login.requires_approval" case settingsLaunchAtLoginOpenSystemSettings = "settings.launch_at_login.open_system_settings" case settingsLaunchAtLoginError = "settings.launch_at_login.error" + case settingsRightPanelAvoidanceTitle = "settings.right_panel_avoidance.title" + case settingsRightPanelAvoidanceDescription = "settings.right_panel_avoidance.description" + case settingsRightPanelAvoidancePermissionTitle = "settings.right_panel_avoidance.permission_title" + case settingsRightPanelAvoidanceAuthorized = "settings.right_panel_avoidance.authorized" + case settingsRightPanelAvoidanceNotAuthorized = "settings.right_panel_avoidance.not_authorized" + case settingsRightPanelAvoidancePermissionDescription = "settings.right_panel_avoidance.permission_description" + case settingsRightPanelAvoidanceRequestPermission = "settings.right_panel_avoidance.request_permission" + case settingsRightPanelAvoidanceOpenSystemSettings = "settings.right_panel_avoidance.open_system_settings" case settingsGlobalHotKeyTitle = "settings.global_hot_key.title" case settingsGlobalHotKeyDescription = "settings.global_hot_key.description" case settingsGlobalHotKeyRecord = "settings.global_hot_key.record" @@ -349,6 +357,10 @@ public enum L10n { case statusItemQuit = "status_item.quit" case mainWindowCloseAccessibilityLabel = "main_window.close.accessibility_label" case mainWindowCloseAccessibilityHelp = "main_window.close.accessibility_help" + case mainWindowCollapseAccessibilityLabel = "main_window.collapse.accessibility_label" + case mainWindowCollapseAccessibilityHelp = "main_window.collapse.accessibility_help" + case mainWindowExpandAccessibilityLabel = "main_window.expand.accessibility_label" + case mainWindowExpandAccessibilityHelp = "main_window.expand.accessibility_help" case windowCloseHoverHint = "window.close.hover_hint" case appCommandSaveNow = "app.command.save_now" diff --git a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings index f19ab58..508658c 100644 --- a/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexNotesCore/Resources/en.lproj/Localizable.strings @@ -9,6 +9,14 @@ "settings.launch_at_login.requires_approval" = "CodexNotes was added as a login item but isn’t active yet. Allow it in System Settings."; "settings.launch_at_login.open_system_settings" = "Open System Settings"; "settings.launch_at_login.error" = "Couldn’t change the launch-at-login setting. Try again."; +"settings.right_panel_avoidance.title" = "Automatically Avoid the Codex Sidebar"; +"settings.right_panel_avoidance.description" = "When enabled, CodexNotes collapses once per right-sidebar cycle and gives priority to the pre-sidebar expanded state when the sidebar closes. When automatic avoidance is off, the top-right button still collapses and expands CodexNotes manually."; +"settings.right_panel_avoidance.permission_title" = "Accessibility Permission"; +"settings.right_panel_avoidance.authorized" = "Allowed"; +"settings.right_panel_avoidance.not_authorized" = "Not Allowed"; +"settings.right_panel_avoidance.permission_description" = "Detecting an empty sidebar requires Accessibility permission. CodexNotes only reads the Codex window layout; it does not read keyboard input."; +"settings.right_panel_avoidance.request_permission" = "Allow Accessibility Permission"; +"settings.right_panel_avoidance.open_system_settings" = "Open System Settings"; "settings.global_hot_key.title" = "Show/Hide CodexNotes"; "settings.global_hot_key.description" = "Available from any app while Codex is running and visible. If another app also responds, or nothing happens, choose a different shortcut."; "settings.global_hot_key.record" = "Set Shortcut"; @@ -289,6 +297,10 @@ "status_item.quit" = "Quit CodexNotes"; "main_window.close.accessibility_label" = "Hide CodexNotes"; "main_window.close.accessibility_help" = "Hides the notes window. CodexNotes will keep running in the menu bar."; +"main_window.collapse.accessibility_label" = "Collapse CodexNotes"; +"main_window.collapse.accessibility_help" = "Collapses the note content and leaves only the title bar."; +"main_window.expand.accessibility_label" = "Expand CodexNotes"; +"main_window.expand.accessibility_help" = "Expands the complete notes window."; "window.close.hover_hint" = "Hide CodexNotes"; "app.command.save_now" = "Save Now"; diff --git a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings index 714e45d..fb9fdd9 100644 --- a/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexNotesCore/Resources/zh-Hans.lproj/Localizable.strings @@ -9,6 +9,14 @@ "settings.launch_at_login.requires_approval" = "CodexNotes 已添加为登录项,但尚未生效;请在系统设置中允许。"; "settings.launch_at_login.open_system_settings" = "打开系统设置"; "settings.launch_at_login.error" = "无法更改登录启动设置,请重试。"; +"settings.right_panel_avoidance.title" = "自动避让 Codex 侧边栏"; +"settings.right_panel_avoidance.description" = "开启后,右侧边栏打开时每轮自动收起一次,关闭时优先恢复打开边栏前的展开状态。关闭自动避让后,仍可使用右上角按钮手动折叠和展开。"; +"settings.right_panel_avoidance.permission_title" = "辅助功能权限"; +"settings.right_panel_avoidance.authorized" = "已允许"; +"settings.right_panel_avoidance.not_authorized" = "未允许"; +"settings.right_panel_avoidance.permission_description" = "精确识别空侧边栏需要辅助功能权限。CodexNotes 只读取 Codex 窗口布局,不读取键盘输入。"; +"settings.right_panel_avoidance.request_permission" = "允许辅助功能权限"; +"settings.right_panel_avoidance.open_system_settings" = "打开系统设置"; "settings.global_hot_key.title" = "显示/隐藏 CodexNotes"; "settings.global_hot_key.description" = "Codex 正在显示时,可在任意应用中使用;若同时触发其他应用或无响应,请换一个"; "settings.global_hot_key.record" = "设置快捷键"; @@ -289,6 +297,10 @@ "status_item.quit" = "退出 CodexNotes"; "main_window.close.accessibility_label" = "隐藏 CodexNotes"; "main_window.close.accessibility_help" = "隐藏笔记窗口,应用继续在状态栏运行"; +"main_window.collapse.accessibility_label" = "折叠 CodexNotes"; +"main_window.collapse.accessibility_help" = "收起笔记内容,只保留标题栏"; +"main_window.expand.accessibility_label" = "展开 CodexNotes"; +"main_window.expand.accessibility_help" = "展开完整笔记窗口"; "window.close.hover_hint" = "隐藏 CodexNotes"; "app.command.save_now" = "立即保存"; diff --git a/Sources/CodexNotesCore/RightPanelAvoidancePreference.swift b/Sources/CodexNotesCore/RightPanelAvoidancePreference.swift new file mode 100644 index 0000000..7356f52 --- /dev/null +++ b/Sources/CodexNotesCore/RightPanelAvoidancePreference.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Shared persistence contract for the optional Codex right-panel avoidance. +/// +/// Installations that have already saved an explicit choice retain it. Every +/// installation without this key starts with the optional behavior disabled, +/// including upgrades from public versions that predate automatic avoidance. +/// `migrateIfNeeded` persists that decision before any `@AppStorage` reader is +/// created, so every window observes one stable value for the entire launch. +public enum RightPanelAvoidancePreference { + public static let key = "rightPanelAvoidanceEnabled" + public static let defaultValue = false + + @discardableResult + public static func migrateIfNeeded( + defaults: UserDefaults = .standard, + persistentDomainName: String + ) -> Bool { + let persistentDomain = defaults.persistentDomain( + forName: persistentDomainName + ) + + if let storedValue = persistentDomain?[key] as? NSNumber { + return storedValue.boolValue + } + + defaults.set(defaultValue, forKey: key) + return defaultValue + } + + public static func load(from defaults: UserDefaults = .standard) -> Bool { + guard defaults.object(forKey: key) != nil else { + return defaultValue + } + return defaults.bool(forKey: key) + } +} diff --git a/Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift b/Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift new file mode 100644 index 0000000..0c2a6dc --- /dev/null +++ b/Sources/CodexNotesProbe/CodexAccessibilityRightPanelReader.swift @@ -0,0 +1,617 @@ +import AppKit +import ApplicationServices +import CodexNotesCore +import CoreFoundation +import Foundation + +enum CodexAccessibilityPermissionState: Equatable, Sendable { + case authorized + case denied +} + +struct CodexAccessibilityRightPanelSample: Equatable, Sendable { + let state: CodexRightPanelState + let permission: CodexAccessibilityPermissionState +} + +struct CodexAccessibilityElementSnapshot: Equatable, Sendable { + let role: String + let subrole: String? + let frame: CGRect? + let classTokens: Set + let children: [CodexAccessibilityElementSnapshot] + + init( + role: String, + subrole: String? = nil, + frame: CGRect? = nil, + classTokens: Set = [], + children: [CodexAccessibilityElementSnapshot] = [] + ) { + self.role = role + self.subrole = subrole + self.frame = frame + self.classTokens = classTokens + self.children = children + } +} + +enum CodexAccessibilityRightPanelClassifier { + static let shellRowClassTokens: Set = [ + "relative", "isolate", "flex", "min-h-0", "flex-1", + "overflow-hidden", + ] + static let asideClassTokens: Set = [ + "relative", "z-[41]", "h-full", "min-h-0", "min-w-0", + "shrink-0", "overflow-visible", + ] + private static let mainSubrole = "AXLandmarkMain" + private static let asideSubrole = "AXLandmarkComplementary" + private static let minimumMainWidth: CGFloat = 600 + private static let minimumMainHeight: CGFloat = 400 + private static let minimumAsideWidth: CGFloat = 240 + private static let maximumAsideWidth: CGFloat = 1_200 + private static let frameTolerance: CGFloat = 3 + + static func state( + in roots: [CodexAccessibilityElementSnapshot] + ) -> CodexRightPanelState { + let mains = descendants(in: roots).filter { element in + element.subrole == mainSubrole + && element.frame.map { + $0.width >= minimumMainWidth + && $0.height >= minimumMainHeight + } == true + } + guard let main = mains.max(by: { + frameArea($0.frame) < frameArea($1.frame) + }), let mainFrame = main.frame else { + return .unknown + } + + let shellRows = shellRows(in: main) + guard shellRows.count == 1, let shellRow = shellRows.first else { + return .unknown + } + + let viewports = shellRow.children.filter { + $0.classTokens.contains(where: { + $0.hasPrefix("_MainContentViewport_") + }) + } + guard viewports.count == 1, + let viewport = viewports.first, + let viewportFrame = viewport.frame, + isVerticallyAligned(viewportFrame, with: mainFrame) + else { + return .unknown + } + + let asideCandidates = shellRow.children.filter { + $0.subrole == asideSubrole + || asideClassTokens.isSubset(of: $0.classTokens) + } + if asideCandidates.isEmpty { + return framesApproximatelyEqual(viewportFrame, mainFrame) + ? .closed + : .unknown + } + + guard asideCandidates.count == 1, + let aside = asideCandidates.first, + aside.subrole == asideSubrole, + asideClassTokens.isSubset(of: aside.classTokens), + let asideFrame = aside.frame, + validOpenGeometry( + main: mainFrame, + viewport: viewportFrame, + aside: asideFrame + ) else { + return .unknown + } + return .open + } + + static func shellRows( + in main: CodexAccessibilityElementSnapshot + ) -> [CodexAccessibilityElementSnapshot] { + guard let mainFrame = main.frame else { return [] } + return descendants(in: main.children).filter { + shellRowClassTokens.isSubset(of: $0.classTokens) + && framesApproximatelyEqual($0.frame, mainFrame) + } + } + + private static func descendants( + in roots: [CodexAccessibilityElementSnapshot] + ) -> [CodexAccessibilityElementSnapshot] { + var result: [CodexAccessibilityElementSnapshot] = [] + var queue = roots + while !queue.isEmpty { + let element = queue.removeFirst() + result.append(element) + queue.append(contentsOf: element.children) + } + return result + } + + private static func validOpenGeometry( + main: CGRect, + viewport: CGRect, + aside: CGRect + ) -> Bool { + guard aside.width >= minimumAsideWidth, + aside.width <= min(maximumAsideWidth, main.width * 0.75), + viewport.width > 0, + isVerticallyAligned(viewport, with: main), + isVerticallyAligned(aside, with: main), + approximatelyEqual(viewport.minX, main.minX), + approximatelyEqual(viewport.maxX, aside.minX), + approximatelyEqual(aside.maxX, main.maxX), + approximatelyEqual(viewport.width + aside.width, main.width) + else { return false } + return true + } + + private static func isVerticallyAligned( + _ frame: CGRect, + with container: CGRect + ) -> Bool { + approximatelyEqual(frame.minY, container.minY) + && approximatelyEqual(frame.maxY, container.maxY) + } + + private static func framesApproximatelyEqual( + _ lhs: CGRect?, + _ rhs: CGRect + ) -> Bool { + guard let lhs else { return false } + return approximatelyEqual(lhs.minX, rhs.minX) + && approximatelyEqual(lhs.minY, rhs.minY) + && approximatelyEqual(lhs.maxX, rhs.maxX) + && approximatelyEqual(lhs.maxY, rhs.maxY) + } + + private static func approximatelyEqual( + _ lhs: CGFloat, + _ rhs: CGFloat + ) -> Bool { + abs(lhs - rhs) <= frameTolerance + } + + private static func frameArea(_ frame: CGRect?) -> CGFloat { + guard let frame else { return 0 } + return frame.width * frame.height + } +} + +struct CodexRightPanelDebouncer: Equatable, Sendable { + private let requiredConsecutive: Int + private var candidate: CodexRightPanelState? + private var consecutiveCount = 0 + private(set) var lastPublished: CodexRightPanelState? + + init(requiredConsecutive: Int = 2) { + precondition(requiredConsecutive > 0) + self.requiredConsecutive = requiredConsecutive + } + + mutating func observe( + _ state: CodexRightPanelState + ) -> CodexRightPanelState? { + guard state != .unknown else { + candidate = nil + consecutiveCount = 0 + return nil + } + guard state != lastPublished else { + candidate = nil + consecutiveCount = 0 + return nil + } + if candidate == state { + consecutiveCount += 1 + } else { + candidate = state + consecutiveCount = 1 + } + guard consecutiveCount >= requiredConsecutive else { return nil } + lastPublished = state + candidate = nil + consecutiveCount = 0 + return state + } + + mutating func reset() { + candidate = nil + consecutiveCount = 0 + lastPublished = nil + } +} + +protocol CodexAccessibilityReading: Sendable { + func isTrusted() -> Bool + func codexProcessIdentifier() -> pid_t? + func rootSnapshots( + for processIdentifier: pid_t, + budget: CodexAccessibilityTraversalBudget + ) -> [CodexAccessibilityElementSnapshot]? +} + +struct CodexAccessibilityTraversalBudget: Sendable { + let maximumNodes: Int + let maximumDepth: Int + let maximumDuration: Duration + + init( + maximumNodes: Int = 80, + maximumDepth: Int = 18, + maximumDuration: Duration = .milliseconds(20) + ) { + precondition(maximumNodes > 0) + precondition(maximumDepth >= 0) + self.maximumNodes = maximumNodes + self.maximumDepth = maximumDepth + self.maximumDuration = maximumDuration + } +} + +enum CodexAccessibilityTargetedTraversal { + static func uniqueMatch( + among roots: [Node], + startingDepth: Int, + nodeCount: inout Int, + budget: CodexAccessibilityTraversalBudget, + isBeforeDeadline: () -> Bool, + isMatch: (Node) -> Bool, + children: (Node) -> [Node]?, + shouldTraverse: (Node) -> Bool + ) -> Node? { + guard startingDepth <= budget.maximumDepth else { return nil } + var queue = roots.map { (node: $0, depth: startingDepth) } + var match: Node? + + while !queue.isEmpty { + guard nodeCount < budget.maximumNodes, + isBeforeDeadline() else { return nil } + let item = queue.removeFirst() + nodeCount += 1 + + if isMatch(item.node) { + guard match == nil else { return nil } + match = item.node + continue + } + + guard item.depth < budget.maximumDepth, + let descendants = children(item.node) else { continue } + for descendant in descendants where shouldTraverse(descendant) { + queue.append((descendant, item.depth + 1)) + } + } + + guard isBeforeDeadline() else { return nil } + return match + } +} + +actor CodexAccessibilityRightPanelReader { + private let backend: any CodexAccessibilityReading + private let budget: CodexAccessibilityTraversalBudget + + init() { + backend = SystemCodexAccessibilityBackend() + budget = CodexAccessibilityTraversalBudget() + } + + init( + backend: any CodexAccessibilityReading, + budget: CodexAccessibilityTraversalBudget = .init() + ) { + self.backend = backend + self.budget = budget + } + + func sample() -> CodexAccessibilityRightPanelSample { + guard backend.isTrusted() else { + return CodexAccessibilityRightPanelSample( + state: .unknown, + permission: .denied + ) + } + guard let processIdentifier = backend.codexProcessIdentifier(), + let roots = backend.rootSnapshots( + for: processIdentifier, + budget: budget + ) else { + return CodexAccessibilityRightPanelSample( + state: .unknown, + permission: .authorized + ) + } + return CodexAccessibilityRightPanelSample( + state: CodexAccessibilityRightPanelClassifier.state(in: roots), + permission: .authorized + ) + } +} + +private final class SystemCodexAccessibilityBackend: + CodexAccessibilityReading, + @unchecked Sendable +{ + private let clock = ContinuousClock() + + func isTrusted() -> Bool { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] + as CFDictionary + return AXIsProcessTrustedWithOptions(options) + } + + func codexProcessIdentifier() -> pid_t? { + NSRunningApplication.runningApplications( + withBundleIdentifier: "com.openai.codex" + ).first(where: { !$0.isTerminated })?.processIdentifier + } + + func rootSnapshots( + for processIdentifier: pid_t, + budget: CodexAccessibilityTraversalBudget + ) -> [CodexAccessibilityElementSnapshot]? { + let application = AXUIElementCreateApplication(processIdentifier) + _ = AXUIElementSetMessagingTimeout(application, 0.05) + guard let window = selectedStandardWindow(in: application), + let windowFrame = frameValue(window) else { + return nil + } + + let deadline = clock.now.advanced(by: budget.maximumDuration) + var nodeCount = 0 + guard let mainResult = findMainLandmark( + from: window, + inside: windowFrame, + nodeCount: &nodeCount, + deadline: deadline, + budget: budget + ), let mainFrame = frameValue(mainResult.element), + let shellRow = findUniqueShellRow( + below: mainResult.element, + mainFrame: mainFrame, + startingDepth: mainResult.depth, + nodeCount: &nodeCount, + deadline: deadline, + budget: budget + ), + let shellChildren = elementArrayValue( + shellRow, + kAXChildrenAttribute + ) + else { return nil } + + guard nodeCount + shellChildren.count <= budget.maximumNodes, + clock.now < deadline else { return nil } + nodeCount += shellChildren.count + + let childSnapshots = shellChildren.map { shallowSnapshot($0) } + let shellSnapshot = shallowSnapshot( + shellRow, + children: childSnapshots + ) + guard clock.now < deadline else { return nil } + let mainSnapshot = shallowSnapshot( + mainResult.element, + children: [shellSnapshot] + ) + return [mainSnapshot] + } + + private func selectedStandardWindow( + in application: AXUIElement + ) -> AXUIElement? { + let focused = elementValue(application, kAXFocusedWindowAttribute) + if let focused, isStandardWindow(focused) { + return focused + } + guard let windows = elementArrayValue(application, kAXWindowsAttribute) + else { return nil } + return windows.filter(isStandardWindow).max(by: { + area(frameValue($0)) < area(frameValue($1)) + }) + } + + private func findMainLandmark( + from root: AXUIElement, + inside windowFrame: CGRect, + nodeCount: inout Int, + deadline: ContinuousClock.Instant, + budget: CodexAccessibilityTraversalBudget + ) -> (element: AXUIElement, depth: Int)? { + var queue: [(element: AXUIElement, depth: Int)] = [(root, 0)] + while !queue.isEmpty { + guard nodeCount < budget.maximumNodes, + clock.now < deadline else { return nil } + let item = queue.removeFirst() + nodeCount += 1 + + let role = stringValue(item.element, kAXRoleAttribute) + let subrole = stringValue(item.element, kAXSubroleAttribute) + let frame = frameValue(item.element) + if subrole == "AXLandmarkMain", + let frame, + frame.width >= windowFrame.width * 0.5, + frame.height >= windowFrame.height * 0.5 { + return item + } + + guard item.depth < budget.maximumDepth, + role == kAXWindowRole + || role == kAXGroupRole + || role == "AXWebArea" + || role == kAXScrollAreaRole, + let children = elementArrayValue( + item.element, + kAXChildrenAttribute + ) else { continue } + for child in children where shouldTraverse( + child, + inside: windowFrame + ) { + queue.append((child, item.depth + 1)) + } + } + return nil + } + + private func findUniqueShellRow( + below main: AXUIElement, + mainFrame: CGRect, + startingDepth: Int, + nodeCount: inout Int, + deadline: ContinuousClock.Instant, + budget: CodexAccessibilityTraversalBudget + ) -> AXUIElement? { + guard startingDepth < budget.maximumDepth, + let children = elementArrayValue(main, kAXChildrenAttribute) + else { return nil } + + return CodexAccessibilityTargetedTraversal.uniqueMatch( + among: children, + startingDepth: startingDepth + 1, + nodeCount: &nodeCount, + budget: budget, + isBeforeDeadline: { self.clock.now < deadline }, + isMatch: { element in + let classTokens = Set( + self.stringArrayValue(element, "AXDOMClassList") ?? [] + ) + return CodexAccessibilityRightPanelClassifier + .shellRowClassTokens.isSubset(of: classTokens) + && self.approximatelyEqual( + self.frameValue(element), + mainFrame + ) + }, + children: { + self.elementArrayValue($0, kAXChildrenAttribute) + }, + shouldTraverse: { + self.shouldTraverse($0, inside: mainFrame) + } + ) + } + + private func shouldTraverse( + _ element: AXUIElement, + inside windowFrame: CGRect + ) -> Bool { + guard let role = stringValue(element, kAXRoleAttribute), + role == kAXGroupRole + || role == "AXWebArea" + || role == kAXScrollAreaRole else { return false } + guard let frame = frameValue(element) else { return true } + guard frame.intersects(windowFrame), frame.width > 0, frame.height > 0 + else { return false } + if stringValue(element, kAXSubroleAttribute) == "AXLandmarkMain" { + return true + } + return area(frame) >= area(windowFrame) * 0.08 + } + + private func shallowSnapshot( + _ element: AXUIElement, + children: [CodexAccessibilityElementSnapshot] = [] + ) -> CodexAccessibilityElementSnapshot { + CodexAccessibilityElementSnapshot( + role: stringValue(element, kAXRoleAttribute) ?? "", + subrole: stringValue(element, kAXSubroleAttribute), + frame: frameValue(element), + classTokens: Set( + stringArrayValue(element, "AXDOMClassList") ?? [] + ), + children: children + ) + } + + private func approximatelyEqual( + _ lhs: CGRect?, + _ rhs: CGRect, + tolerance: CGFloat = 3 + ) -> Bool { + guard let lhs else { return false } + return abs(lhs.minX - rhs.minX) <= tolerance + && abs(lhs.minY - rhs.minY) <= tolerance + && abs(lhs.maxX - rhs.maxX) <= tolerance + && abs(lhs.maxY - rhs.maxY) <= tolerance + } + + private func isStandardWindow(_ element: AXUIElement) -> Bool { + stringValue(element, kAXRoleAttribute) == kAXWindowRole + && stringValue(element, kAXSubroleAttribute) + == kAXStandardWindowSubrole + } + + private func stringValue( + _ element: AXUIElement, + _ attribute: String + ) -> String? { + copyValue(element, attribute) as? String + } + + private func stringArrayValue( + _ element: AXUIElement, + _ attribute: String + ) -> [String]? { + copyValue(element, attribute) as? [String] + } + + private func elementValue( + _ element: AXUIElement, + _ attribute: String + ) -> AXUIElement? { + guard let value = copyValue(element, attribute), + CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } + return unsafeBitCast(value, to: AXUIElement.self) + } + + private func elementArrayValue( + _ element: AXUIElement, + _ attribute: String + ) -> [AXUIElement]? { + copyValue(element, attribute) as? [AXUIElement] + } + + private func frameValue(_ element: AXUIElement) -> CGRect? { + guard let positionValue = copyValue(element, kAXPositionAttribute), + let sizeValue = copyValue(element, kAXSizeAttribute), + CFGetTypeID(positionValue) == AXValueGetTypeID(), + CFGetTypeID(sizeValue) == AXValueGetTypeID() else { return nil } + let positionAXValue = unsafeBitCast( + positionValue, + to: AXValue.self + ) + let sizeAXValue = unsafeBitCast(sizeValue, to: AXValue.self) + var position = CGPoint.zero + var size = CGSize.zero + guard AXValueGetValue(positionAXValue, .cgPoint, &position), + AXValueGetValue(sizeAXValue, .cgSize, &size) else { return nil } + return CGRect(origin: position, size: size) + } + + private func copyValue( + _ element: AXUIElement, + _ attribute: String + ) -> CFTypeRef? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue( + element, + attribute as CFString, + &value + ) == .success else { return nil } + return value + } + + private func area(_ frame: CGRect?) -> CGFloat { + guard let frame else { return 0 } + return frame.width * frame.height + } +} diff --git a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift index b145d3d..7100a34 100644 --- a/Sources/CodexNotesProbe/CodexNotesProbeApp.swift +++ b/Sources/CodexNotesProbe/CodexNotesProbeApp.swift @@ -58,6 +58,14 @@ struct CodexNotesProbeApp: App { @AppStorage(AppLanguagePreference.key) private var storedLanguagePreference = AppLanguagePreference.defaultValue.rawValue + init() { + if let bundleIdentifier = Bundle.main.bundleIdentifier { + RightPanelAvoidancePreference.migrateIfNeeded( + persistentDomainName: bundleIdentifier + ) + } + } + private var languagePreference: AppLanguagePreference { AppLanguagePreference.normalized(storedLanguagePreference) } diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift index aad12de..0b57a8d 100644 --- a/Sources/CodexNotesProbe/ContentView.swift +++ b/Sources/CodexNotesProbe/ContentView.swift @@ -2,20 +2,53 @@ import AppKit import CodexNotesCore import SwiftUI +@MainActor +enum MainWindowContentSizingPolicy { + static let idealHeight: CGFloat = 660 + + static func minimumWidth(isCollapsed: Bool) -> CGFloat { + isCollapsed + ? MainWindowCompactController.compactWindowWidth + : MainWindowCompactController.minimumExpandedContentSize.width + } + + static func minimumHeight(isCollapsed: Bool) -> CGFloat { + isCollapsed + ? MainWindowCompactController.compactContentHeight + : 520 + } + + /// AppKit owns the main window's compact/expanded frame. Returning no fixed + /// SwiftUI height prevents `.windowResizability(.contentMinSize)` from + /// performing a second resize anchored to the opposite window edge after + /// the controller has already applied its top-anchored frame. + static func fixedHeight( + isCompactPresentationActive _: Bool + ) -> CGFloat? { + nil + } +} + struct ContentView: View { @ObservedObject var model: ProbeViewModel @ObservedObject var updateCoordinator: UpdateCheckCoordinator @ObservedObject var globalHotKeyController: GlobalHotKeyController let languagePreference: AppLanguagePreference @StateObject private var editorController = MarkdownEditorController() + @StateObject private var compactController = MainWindowCompactController() @Environment(\.colorScheme) private var inheritedColorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.accessibilityReduceTransparency) + private var reduceTransparency @AppStorage(EditorFontSizePreference.key) private var storedEditorFontSize = EditorFontSizePreference.defaultValue @AppStorage(EditorLineSpacingPreference.key) private var storedEditorLineSpacing = EditorLineSpacingPreference.defaultValue @AppStorage(NoteThemePreference.key) private var storedThemeID = NoteThemePreference.defaultValue.rawValue + @AppStorage(RightPanelAvoidancePreference.key) + private var rightPanelAvoidanceEnabled = + RightPanelAvoidancePreference.defaultValue @State private var isShortcutReferencePresented = false private var activeTheme: NoteThemeID { @@ -63,11 +96,24 @@ struct ContentView: View { .padding(.top, BottomBarActionMetrics.topContentInset) .padding(.bottom, BottomBarActionMetrics.bottomPadding) .frame( - minWidth: BottomBarActionMetrics.minimumWindowWidth, + minWidth: MainWindowContentSizingPolicy.minimumWidth( + isCollapsed: compactController.isCollapsed + ), idealWidth: 400, - minHeight: 520, - idealHeight: 660 + minHeight: MainWindowContentSizingPolicy.minimumHeight( + isCollapsed: compactController.isCollapsed + ), + idealHeight: MainWindowContentSizingPolicy.idealHeight + ) + .frame( + height: MainWindowContentSizingPolicy.fixedHeight( + isCompactPresentationActive: + compactController.isCompactContentPresentationActive + ), + alignment: .top ) + .clipped() + .allowsHitTesting(!compactController.isCollapsed) .foregroundStyle(palette.primaryText.color) .tint(palette.accent.color) .environment(\.locale, resolvedLanguage.locale) @@ -86,11 +132,24 @@ struct ContentView: View { BordeauxCottonPaperTextureOverlay() } } + // Keep the AppKit configurator alive while compact, but hide every + // SwiftUI visual layer above it. Applying opacity before the theme + // background left the 3pt compact content sliver fully opaque, which + // appeared as a pale line and also covered content behind the bar. + .opacity(compactController.isCompactContentPresentationActive ? 0 : 1) .background( WindowConfigurator( appearanceName: palette.appearanceName, backgroundColor: palette.windowBackground.nsColor, - languageRevision: languageRevision + languageRevision: languageRevision, + compactController: compactController, + editorController: editorController, + rightPanelSelectionStableKey: + model.rightPanelObservation.selectionStableKey, + rightPanelState: model.rightPanelObservation.state, + rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency ) ) .background( @@ -102,7 +161,13 @@ struct ContentView: View { .frame(width: 0, height: 0) .accessibilityHidden(true) ) - .onAppear { model.start() } + .onAppear { + model.setRightPanelAvoidanceEnabled(rightPanelAvoidanceEnabled) + model.start() + } + .onChange(of: rightPanelAvoidanceEnabled) { _, isEnabled in + model.setRightPanelAvoidanceEnabled(isEnabled) + } .alert( L10n.text(.moveSelectionAlertTitle), isPresented: Binding( @@ -1950,6 +2015,13 @@ struct WindowConfigurator: NSViewRepresentable { let appearanceName: NSAppearance.Name? let backgroundColor: NSColor let languageRevision: String + let compactController: MainWindowCompactController + let editorController: MarkdownEditorController + let rightPanelSelectionStableKey: String? + let rightPanelState: CodexRightPanelState + let rightPanelAvoidanceEnabled: Bool + let reduceMotion: Bool + let reduceTransparency: Bool @MainActor final class Coordinator: NSObject, NSWindowDelegate { @@ -1965,6 +2037,9 @@ struct WindowConfigurator: NSViewRepresentable { private let codexAvailabilityMonitor: CodexApplicationAvailabilityObserving private let closeButtonHoverHintController = CloseButtonHoverHintController() private let resizeCursorController = MainWindowResizeCursorController() + private weak var compactController: MainWindowCompactController? + private var compactSelectionStableKey: String? + private var compactRightPanelState: CodexRightPanelState? private var appliedLanguageRevision: String? private var isInvalidated = false @@ -1976,10 +2051,32 @@ struct WindowConfigurator: NSViewRepresentable { super.init() } - func attach(to window: NSWindow, languageRevision: String) { + func attach( + to window: NSWindow, + languageRevision: String, + compactController: MainWindowCompactController? = nil, + editorController: MarkdownEditorController? = nil, + backgroundColor: NSColor = .windowBackgroundColor, + rightPanelSelectionStableKey: String? = nil, + rightPanelState: CodexRightPanelState = .unknown, + rightPanelAvoidanceEnabled: Bool = + RightPanelAvoidancePreference.defaultValue, + reduceMotion: Bool = false, + reduceTransparency: Bool = false + ) { guard !isInvalidated, window.identifier == CodexNotesWindowIdentifier.main else { return } if self.window === window { + configureCompactBehavior( + compactController: compactController, + editorController: editorController, + backgroundColor: backgroundColor, + rightPanelSelectionStableKey: rightPanelSelectionStableKey, + rightPanelState: rightPanelState, + rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency + ) refreshLocalizationIfNeeded( for: window, languageRevision: languageRevision @@ -1994,6 +2091,16 @@ struct WindowConfigurator: NSViewRepresentable { self.window = window originalWindowDelegate = window.delegate window.delegate = self + configureCompactBehavior( + compactController: compactController, + editorController: editorController, + backgroundColor: backgroundColor, + rightPanelSelectionStableKey: rightPanelSelectionStableKey, + rightPanelState: rightPanelState, + rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency + ) codexAvailabilityMonitor.start { [weak self] isAvailable in self?.codexAvailabilityDidChange(isAvailable) } @@ -2079,13 +2186,18 @@ struct WindowConfigurator: NSViewRepresentable { ) } restoreDefaultSizeObserver = nil - if let window { + if let window, + compactController?.shouldPersistCurrentFrame != false { MainWindowFramePersistence.persistIfVisible(window: window) } framePersistenceObservers.forEach( NotificationCenter.default.removeObserver ) framePersistenceObservers.removeAll() + compactController?.detach() + compactController = nil + compactSelectionStableKey = nil + compactRightPanelState = nil if window?.delegate === self { window?.delegate = originalWindowDelegate } @@ -2105,7 +2217,8 @@ struct WindowConfigurator: NSViewRepresentable { func refreshCloseButtonHoverHint(for window: NSWindow) { guard !isInvalidated, self.window === window else { return } - guard window.identifier == CodexNotesWindowIdentifier.main, + guard compactController?.isCollapsed != true, + window.identifier == CodexNotesWindowIdentifier.main, let closeButton = window.standardWindowButton(.closeButton) else { closeButtonHoverHintController.detach() return @@ -2115,7 +2228,11 @@ struct WindowConfigurator: NSViewRepresentable { func refreshResizeCursorTracking(for window: NSWindow) { guard !isInvalidated, self.window === window else { return } - resizeCursorController.attach(to: window) + if compactController?.isCollapsed == true { + resizeCursorController.detach() + } else { + resizeCursorController.attach(to: window) + } } func refreshLocalizationIfNeeded( @@ -2129,6 +2246,74 @@ struct WindowConfigurator: NSViewRepresentable { closeButtonHoverHintController.cancelAndDismiss() } + func configureCompactBehavior( + compactController: MainWindowCompactController?, + editorController: MarkdownEditorController?, + backgroundColor: NSColor, + rightPanelSelectionStableKey: String?, + rightPanelState: CodexRightPanelState, + rightPanelAvoidanceEnabled: Bool, + reduceMotion: Bool, + reduceTransparency: Bool + ) { + guard !isInvalidated, let window = self.window else { return } + + if let previous = self.compactController, + previous !== compactController { + previous.detach() + compactSelectionStableKey = nil + compactRightPanelState = nil + } + self.compactController = compactController + + guard let compactController, let editorController else { return } + compactController.attach( + to: window, + editorController: editorController, + backgroundColor: backgroundColor, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency + ) + compactController.setAutomaticAvoidanceEnabled( + rightPanelAvoidanceEnabled + ) + compactController.compactStateDidChange = { [weak self, weak window] _ in + guard let self, let window else { return } + self.refreshCloseButtonHoverHint(for: window) + self.refreshResizeCursorTracking(for: window) + } + observeRightPanel( + rightPanelState, + selectionStableKey: rightPanelSelectionStableKey + ) + } + + private func observeRightPanel( + _ state: CodexRightPanelState, + selectionStableKey: String? + ) { + guard let compactController, let selectionStableKey else { return } + + if compactRightPanelState == nil { + compactSelectionStableKey = selectionStableKey + compactRightPanelState = state + compactController.observeRightPanel(state) + return + } + + if compactSelectionStableKey != selectionStableKey { + compactSelectionStableKey = selectionStableKey + compactRightPanelState = state + compactController.selectionDidChange(seeding: state) + return + } + + guard compactRightPanelState != state else { return } + compactSelectionStableKey = selectionStableKey + compactRightPanelState = state + compactController.observeRightPanel(state) + } + func windowShouldZoom( _ window: NSWindow, toFrame newFrame: NSRect @@ -2157,6 +2342,7 @@ struct WindowConfigurator: NSViewRepresentable { } private func restoreDefaultWindowSize() { + compactController?.prepareForDefaultSizeRestore() guard let window, MainWindowFramePersistence.restoreDefaultSize( window: window @@ -2168,6 +2354,7 @@ struct WindowConfigurator: NSViewRepresentable { } private func presentMainWindow(_ window: NSWindow) { + compactController?.userDidShowWindow() let frontmostApplication = NSWorkspace.shared.frontmostApplication let frontmostBundleIdentifier = frontmostApplication?.bundleIdentifier let companionBundleIdentifier = Bundle.main.bundleIdentifier @@ -2206,7 +2393,9 @@ struct WindowConfigurator: NSViewRepresentable { private func hideMainWindow(_ window: NSWindow) { cancelCodexActivationTimeout() visibilityState.recordManualHide() - MainWindowFramePersistence.persist(window: window) + if compactController?.shouldPersistCurrentFrame != false { + MainWindowFramePersistence.persist(window: window) + } orderOutMainWindow(window) } @@ -2233,9 +2422,11 @@ struct WindowConfigurator: NSViewRepresentable { ? NSApp : window, queue: .main - ) { [weak window] _ in - guard let window else { return } + ) { [weak self, weak window] _ in + guard let self, let window else { return } MainActor.assumeIsolated { + guard self.compactController? + .shouldPersistCurrentFrame != false else { return } MainWindowFramePersistence.persistIfVisible( window: window ) @@ -2368,7 +2559,11 @@ struct WindowConfigurator: NSViewRepresentable { if NSApp.isHidden { NSApp.unhideWithoutActivation() } - MainWindowFramePersistence.showPreservingFrame(window: window) + if compactController?.isCollapsed == true { + compactController?.presentPreservingTransientFrame() + } else { + MainWindowFramePersistence.showPreservingFrame(window: window) + } case .hide: orderOutMainWindow(window) case .none: @@ -2399,7 +2594,10 @@ struct WindowConfigurator: NSViewRepresentable { DispatchQueue.main.async { guard let window = view.window else { return } applyTheme(to: window) - MainWindowChromePolicy.apply(to: window) + MainWindowChromePolicy.apply( + to: window, + isCollapsed: compactController.isCollapsed + ) window.level = .floating window.hidesOnDeactivate = false window.collectionBehavior.insert(.fullScreenAuxiliary) @@ -2411,7 +2609,15 @@ struct WindowConfigurator: NSViewRepresentable { } coordinator.attach( to: window, - languageRevision: languageRevision + languageRevision: languageRevision, + compactController: compactController, + editorController: editorController, + backgroundColor: backgroundColor, + rightPanelSelectionStableKey: rightPanelSelectionStableKey, + rightPanelState: rightPanelState, + rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency ) } return view @@ -2421,12 +2627,25 @@ struct WindowConfigurator: NSViewRepresentable { DispatchQueue.main.async { guard let window = nsView.window else { return } applyTheme(to: window) - MainWindowChromePolicy.apply(to: window) + MainWindowChromePolicy.apply( + to: window, + isCollapsed: compactController.isCollapsed + ) context.coordinator.refreshLocalizationIfNeeded( for: window, languageRevision: languageRevision ) context.coordinator.refreshCloseButtonHoverHint(for: window) + context.coordinator.configureCompactBehavior( + compactController: compactController, + editorController: editorController, + backgroundColor: backgroundColor, + rightPanelSelectionStableKey: rightPanelSelectionStableKey, + rightPanelState: rightPanelState, + rightPanelAvoidanceEnabled: rightPanelAvoidanceEnabled, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency + ) context.coordinator.refreshResizeCursorTracking(for: window) } } @@ -2443,7 +2662,8 @@ struct WindowConfigurator: NSViewRepresentable { } else if window.appearance != nil { window.appearance = nil } - if !window.backgroundColor.isEqual(backgroundColor) { + if !compactController.isCollapsed, + !window.backgroundColor.isEqual(backgroundColor) { window.backgroundColor = backgroundColor } } diff --git a/Sources/CodexNotesProbe/MainWindowChromePolicy.swift b/Sources/CodexNotesProbe/MainWindowChromePolicy.swift index 00122d7..19a8d93 100644 --- a/Sources/CodexNotesProbe/MainWindowChromePolicy.swift +++ b/Sources/CodexNotesProbe/MainWindowChromePolicy.swift @@ -7,13 +7,14 @@ enum MainWindowChromePolicy { @MainActor static func apply( to window: NSWindow, + isCollapsed: Bool = false, localization: AppLocalization = AppLocalization( preference: AppLanguagePreference.load() ) ) { if let closeButton = window.standardWindowButton(.closeButton) { - closeButton.isEnabled = true - closeButton.isHidden = false + closeButton.isEnabled = !isCollapsed + closeButton.isHidden = isCollapsed // AppKit's native tooltip intentionally waits several seconds. // The hover controller supplies a fast, local hint instead. closeButton.toolTip = nil diff --git a/Sources/CodexNotesProbe/MainWindowCompactController.swift b/Sources/CodexNotesProbe/MainWindowCompactController.swift new file mode 100644 index 0000000..bb6d3f7 --- /dev/null +++ b/Sources/CodexNotesProbe/MainWindowCompactController.swift @@ -0,0 +1,976 @@ +import AppKit +import Combine +import CodexNotesCore +import QuartzCore + +enum MainWindowCompactGeometry { + static func compactFrame( + from expandedFrame: NSRect, + compactWidth: CGFloat, + compactHeight: CGFloat + ) -> NSRect { + let width = max(1, min(compactWidth, expandedFrame.width)) + let height = max(1, min(compactHeight, expandedFrame.height)) + return NSRect( + x: expandedFrame.maxX - width, + y: expandedFrame.maxY - height, + width: width, + height: height + ) + } + + static func expandedFrame( + from compactFrame: NSRect, + cachedExpandedFrame: NSRect + ) -> NSRect { + NSRect( + x: compactFrame.maxX - cachedExpandedFrame.width, + y: compactFrame.maxY - cachedExpandedFrame.height, + width: cachedExpandedFrame.width, + height: cachedExpandedFrame.height + ) + } + + static func expandedFrame( + from compactFrame: NSRect, + cachedExpandedFrame: NSRect, + constrainedTo visibleFrame: NSRect + ) -> NSRect { + let unconstrained = expandedFrame( + from: compactFrame, + cachedExpandedFrame: cachedExpandedFrame + ) + let size = NSSize( + width: min(unconstrained.width, visibleFrame.width), + height: min(unconstrained.height, visibleFrame.height) + ) + let maximumX = max(visibleFrame.minX, visibleFrame.maxX - size.width) + let maximumY = max(visibleFrame.minY, visibleFrame.maxY - size.height) + return NSRect( + x: min(max(unconstrained.minX, visibleFrame.minX), maximumX), + y: min(max(unconstrained.minY, visibleFrame.minY), maximumY), + width: size.width, + height: size.height + ) + } +} + +private enum MainWindowExpandedFramePositioning { + /// A deliberate user expansion follows the current compact bar, so moving + /// the bar remains a useful way to choose where the full window reappears. + case followCompactBar + + /// An automatic expansion is the inverse of an automatic collapse. Restore + /// the complete cached frame without treating incidental compact-bar layout + /// changes as a user move. Fall back to the bar only when that cached frame + /// is no longer visible on any current display. + case restoreCachedFrameIfVisible +} + +@MainActor +final class MainWindowCompactController: ObservableObject { + static let minimumExpandedContentSize = NSSize(width: 340, height: 520) + /// The compact window is a top-right anchored pill containing only the + /// centered app title and the expand chevron. This leaves comfortable + /// native-titlebar spacing without covering a full column of Codex. + static let compactWindowWidth: CGFloat = 190 + // Keep a small content sliver below the native titlebar. A 1pt sliver makes + // the 28pt titlebar accessory extend past the compact window by roughly one + // point on current macOS builds, which can clip the chevron while AppKit is + // laying out the final frame. + static let compactContentHeight: CGFloat = 3 + // AppKit briefly composites a near-zero-height native titlebar as a bare + // line when an NSWindow frame is animated down to titlebar-only height. + // The artifact survives ordering and SwiftUI layout fixes, so compact frame + // changes are deliberately atomic. Other accessibility motion behavior is + // still respected by the rest of the app. + static let animatesCompactFrameTransitions = false + static let transitionDuration: TimeInterval = 0.18 + static let unknownFailOpenDelay: TimeInterval = 1 + // Window-level opacity is required here: changing only backgroundColor does + // not affect AppKit's native titlebar chrome. Hover or keyboard focus raises + // contrast while the resting bar stays translucent enough to reveal Codex. + static let restingWindowOpacity: CGFloat = 0.42 + static let activeWindowOpacity: CGFloat = 0.68 + + @Published private(set) var isCollapsed = false + @Published private(set) var isCompactContentPresentationActive = false + private(set) var isAutomaticAvoidanceEnabled = + RightPanelAvoidancePreference.defaultValue + + private weak var window: NSWindow? + private weak var editorController: MarkdownEditorController? + private var interactionState = MainWindowCompactState() + private var expandedFrame: NSRect? + private var isTransitioning = false + private var transitionTargetIsCollapsed: Bool? + private var pendingTransitionAction: ( + action: MainWindowCompactAction, + automatic: Bool + )? + private var pendingAutomaticCollapse = false + private var pendingManualCollapse = false + private var pendingRetryWorkItem: DispatchWorkItem? + private var unknownFailOpenWorkItem: DispatchWorkItem? + private var latestRightPanelObservation: CodexRightPanelState = .unknown + private var windowObservers: [NSObjectProtocol] = [] + private var backgroundColor = NSColor.windowBackgroundColor + private var expandedWindowAlpha: CGFloat? + private var expandedTitlebarSeparatorStyle: NSTitlebarSeparatorStyle? + private var reduceMotion = false + private var reduceTransparency = false + private var isPointerInside = false + private var transitionID = UUID() + private let framePersistenceDefaults: UserDefaults + private let accessoryController = MainWindowCompactAccessoryController() + private let hoverController = MainWindowCompactHoverController() + + init(framePersistenceDefaults: UserDefaults = .standard) { + self.framePersistenceDefaults = framePersistenceDefaults + } + + var compactStateDidChange: ((Bool) -> Void)? + + var shouldPersistCurrentFrame: Bool { + !isCollapsed && !isTransitioning + } + + var isRightPanelOpen: Bool { + interactionState.lastDefiniteRightPanelState == .open + } + + private var effectiveIsCollapsed: Bool { + transitionTargetIsCollapsed ?? isCollapsed + } + + /// The compact chrome is intentionally a resting-state appearance. Applying + /// transparent titlebar chrome while AppKit is still shrinking the window + /// leaves an empty, near-transparent frame whose border reads as a single + /// line. Keep ordinary chrome throughout both animation directions and only + /// switch to the translucent compact bar after the frame has settled. + private var usesCompactAppearance: Bool { + isCollapsed && !isTransitioning + } + + func attach( + to window: NSWindow, + editorController: MarkdownEditorController, + backgroundColor: NSColor, + reduceMotion: Bool, + reduceTransparency: Bool + ) { + if self.editorController !== editorController { + self.editorController?.textCompositionDidEnd = nil + } + if self.window !== window { + detach() + self.window = window + self.editorController = editorController + expandedWindowAlpha = window.alphaValue + expandedTitlebarSeparatorStyle = window.titlebarSeparatorStyle + installWindowObservers(for: window) + accessoryController.attach(to: window) { [weak self] in + self?.toggleFromTitlebar() + } + hoverController.attach(to: window) { [weak self] isInside in + guard let self else { return } + self.isPointerInside = isInside + self.applyAppearance() + } + } else { + self.editorController = editorController + accessoryController.ensureAttached(to: window) + hoverController.ensureAttached(to: window) + } + editorController.textCompositionDidEnd = { [weak self] in + self?.retryPendingCollapseIfPossible() + } + + updateConfiguration( + backgroundColor: backgroundColor, + reduceMotion: reduceMotion, + reduceTransparency: reduceTransparency + ) + accessoryController.update( + isCollapsed: isCollapsed, + isEnabled: !isTransitioning + ) + } + + func detach() { + pendingRetryWorkItem?.cancel() + pendingRetryWorkItem = nil + cancelUnknownFailOpen() + pendingAutomaticCollapse = false + pendingManualCollapse = false + pendingTransitionAction = nil + transitionTargetIsCollapsed = nil + compactStateDidChange = nil + transitionID = UUID() + if let window, isCollapsed { + let cachedFrame = expandedFrame ?? window.frame + let positioning: MainWindowExpandedFramePositioning = + interactionState.collapseOrigin == .automatic + ? .restoreCachedFrameIfVisible + : .followCompactBar + let restoredFrame = targetExpandedFrame( + for: window, + cachedFrame: cachedFrame, + positioning: positioning + ) + window.styleMask.insert(.resizable) + window.contentMinSize = Self.minimumExpandedContentSize + setFrameImmediatelyCancellingAnimation( + restoredFrame, + on: window, + display: false + ) + isCollapsed = false + isCompactContentPresentationActive = false + applyStandardButtonVisibility(isCompact: false) + applyAppearance() + } + isCompactContentPresentationActive = false + applyStandardButtonVisibility(isCompact: false) + windowObservers.forEach(NotificationCenter.default.removeObserver) + windowObservers.removeAll() + accessoryController.detach() + hoverController.detach() + editorController?.textCompositionDidEnd = nil + window = nil + editorController = nil + expandedFrame = nil + expandedWindowAlpha = nil + expandedTitlebarSeparatorStyle = nil + isPointerInside = false + isTransitioning = false + interactionState = MainWindowCompactState() + } + + func updateConfiguration( + backgroundColor: NSColor, + reduceMotion: Bool, + reduceTransparency: Bool + ) { + self.backgroundColor = backgroundColor + self.reduceMotion = reduceMotion + self.reduceTransparency = reduceTransparency + accessoryController.update( + isCollapsed: isCollapsed, + isEnabled: !isTransitioning + ) + applyStandardButtonVisibility(isCompact: isCollapsed) + applyAppearance() + } + + func seedRightPanel(_ state: CodexRightPanelState) { + cancelUnknownFailOpen() + latestRightPanelObservation = state + pendingAutomaticCollapse = false + guard isAutomaticAvoidanceEnabled else { return } + interactionState.seedRightPanel(state) + } + + /// Enables or disables only Codex-driven compact actions. The title-bar + /// toggle remains wired independently, so manual collapse/expand is always + /// available. Disabling also cancels every deferred automatic action and + /// restores a compact frame only when automatic avoidance owns it. + func setAutomaticAvoidanceEnabled(_ isEnabled: Bool) { + guard isAutomaticAvoidanceEnabled != isEnabled else { return } + isAutomaticAvoidanceEnabled = isEnabled + cancelUnknownFailOpen() + pendingAutomaticCollapse = false + if pendingTransitionAction?.automatic == true { + pendingTransitionAction = nil + } + + guard isEnabled else { + let action = interactionState.disableAutomaticAvoidance( + isCollapsed: effectiveIsCollapsed + ) + perform(action, automatic: true) + return + } + + // Do not consume the last pre-disable sample. Settings changes and the + // asynchronous SwiftUI window update have no guaranteed ordering, so a + // cached `.open` could otherwise collapse the window after the sidebar + // had already closed. The detector publishes a fresh observation for + // the new preference generation. + latestRightPanelObservation = .unknown + interactionState.seedRightPanel(.unknown) + } + + func selectionDidChange(seeding state: CodexRightPanelState) { + // The right panel is physical window state, not task state. Preserve + // the pre-open snapshot across task changes and only end the cycle on a + // definite close. This branch is retained for compatibility with older + // observation identities; the current producer uses one window identity. + latestRightPanelObservation = state + guard isAutomaticAvoidanceEnabled else { return } + + if state != .unknown { + observeRightPanel(state) + return + } + + cancelUnknownFailOpen() + latestRightPanelObservation = .unknown + pendingAutomaticCollapse = false + guard isCollapsed, + interactionState.collapseOrigin == .automatic else { return } + interactionState.releaseAutomaticCollapseForUncertainty() + perform(.expand, automatic: true) + } + + func observeRightPanel(_ state: CodexRightPanelState) { + latestRightPanelObservation = state + guard isAutomaticAvoidanceEnabled, let window else { return } + if state == .unknown { + let hasAutomaticOwnership = + interactionState.collapseOrigin == .automatic + pendingAutomaticCollapse = false + if pendingTransitionAction?.automatic == true, + pendingTransitionAction?.action == .collapse { + pendingTransitionAction = nil + } + if hasAutomaticOwnership { + scheduleUnknownFailOpenIfNeeded() + } + } else { + cancelUnknownFailOpen() + } + let action = interactionState.observeRightPanel( + state, + isWindowVisible: window.isVisible, + isCollapsed: effectiveIsCollapsed + ) + if state == .closed { + pendingAutomaticCollapse = false + } + if isTransitioning, + action == .none, + let transitionTargetIsCollapsed, + pendingTransitionAction?.automatic == true, + (state == .open && transitionTargetIsCollapsed + || state == .closed && !transitionTargetIsCollapsed) { + pendingTransitionAction = nil + } + perform(action, automatic: true) + } + + func userDidShowWindow() { + cancelUnknownFailOpen() + pendingAutomaticCollapse = false + let action = interactionState.recordManualShow( + isCollapsed: effectiveIsCollapsed + ) + perform(action, automatic: false) + } + + func prepareForDefaultSizeRestore() { + cancelUnknownFailOpen() + pendingAutomaticCollapse = false + pendingManualCollapse = false + guard isCollapsed else { return } + let action = interactionState.recordManualToggle(isCollapsed: true) + if action == .expand { + expand( + animated: false, + positioning: .followCompactBar + ) + } + } + + func presentPreservingTransientFrame( + _ present: @MainActor (NSWindow) -> Void = { $0.orderFrontRegardless() } + ) { + guard let window else { return } + let transientFrame = window.frame + _ = window.setFrameAutosaveName("") + present(window) + window.setFrame(transientFrame, display: false) + _ = window.setFrameAutosaveName("") + } + + func toggleFromTitlebar() { + cancelUnknownFailOpen() + let action = interactionState.recordManualToggle( + isCollapsed: effectiveIsCollapsed + ) + pendingAutomaticCollapse = false + perform(action, automatic: false) + } + + private func perform( + _ action: MainWindowCompactAction, + automatic: Bool + ) { + guard action != .none else { return } + if isTransitioning { + pendingTransitionAction = (action, automatic) + return + } + switch action { + case .none: + break + case .collapse: + requestCollapse(automatic: automatic) + case .expand: + pendingManualCollapse = false + expand( + animated: true, + positioning: automatic + ? .restoreCachedFrameIfVisible + : .followCompactBar + ) + } + } + + private func requestCollapse(automatic: Bool) { + guard let window, !isCollapsed else { return } + + if automatic, + editorController?.blocksAutomaticWindowCollapse(in: window) == true { + pendingAutomaticCollapse = true + return + } + + if !automatic, + editorController?.hasActiveTextComposition == true { + pendingManualCollapse = true + schedulePendingCollapseRetry() + return + } + + pendingAutomaticCollapse = false + pendingManualCollapse = false + collapse(animated: true) + } + + private func retryPendingCollapseIfPossible() { + guard let window, !isCollapsed else { + pendingAutomaticCollapse = false + pendingManualCollapse = false + return + } + + if pendingAutomaticCollapse { + guard isAutomaticAvoidanceEnabled, + interactionState.lastDefiniteRightPanelState == .open, + interactionState.collapseOrigin == .automatic else { + pendingAutomaticCollapse = false + return + } + guard editorController?.blocksAutomaticWindowCollapse(in: window) + != true else { return } + pendingAutomaticCollapse = false + collapse(animated: true) + return + } + + if pendingManualCollapse { + guard editorController?.hasActiveTextComposition != true else { + schedulePendingCollapseRetry() + return + } + pendingManualCollapse = false + collapse(animated: true) + } + } + + private func schedulePendingCollapseRetry() { + pendingRetryWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + self?.pendingRetryWorkItem = nil + self?.retryPendingCollapseIfPossible() + } + } + pendingRetryWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: workItem) + } + + private func scheduleUnknownFailOpenIfNeeded() { + guard isAutomaticAvoidanceEnabled, + unknownFailOpenWorkItem == nil else { return } + let workItem = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + self.unknownFailOpenWorkItem = nil + guard self.isAutomaticAvoidanceEnabled, + self.latestRightPanelObservation == .unknown, + self.interactionState.collapseOrigin == .automatic else { + return + } + + self.pendingAutomaticCollapse = false + if self.pendingTransitionAction?.automatic == true, + self.pendingTransitionAction?.action == .collapse { + self.pendingTransitionAction = nil + } + self.interactionState + .releaseAutomaticCollapseForUncertainty() + self.perform(.expand, automatic: true) + } + } + unknownFailOpenWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.unknownFailOpenDelay, + execute: workItem + ) + } + + private func cancelUnknownFailOpen() { + unknownFailOpenWorkItem?.cancel() + unknownFailOpenWorkItem = nil + } + + private func collapse(animated: Bool) { + guard let window, !isCollapsed else { return } + + let fullFrame = window.frame + expandedFrame = fullFrame + expandedWindowAlpha = window.alphaValue + expandedTitlebarSeparatorStyle = window.titlebarSeparatorStyle + MainWindowFramePersistence.persist( + window: window, + defaults: framePersistenceDefaults + ) + let titlebarOnlyHeight = compactWindowHeight( + for: window, + expandedFrame: fullFrame + ) + let targetFrame = MainWindowCompactGeometry.compactFrame( + from: fullFrame, + compactWidth: Self.compactWindowWidth, + compactHeight: titlebarOnlyHeight + ) + + isCollapsed = true + isCompactContentPresentationActive = false + window.contentMinSize = NSSize( + width: Self.compactWindowWidth, + height: Self.compactContentHeight + ) + applyStandardButtonVisibility(isCompact: true) + accessoryController.update(isCollapsed: true, isEnabled: false) + compactStateDidChange?(true) + transition( + to: targetFrame, + targetIsCollapsed: true, + animated: animated + ) { [weak self, weak window] in + guard let self else { return } + // Changing the style mask also rebuilds native titlebar chrome. Do + // it only after the resize animation so it cannot race the titlebar + // layout and amplify the one-line intermediate frame. + window?.styleMask.remove(.resizable) + self.isCompactContentPresentationActive = true + } + } + + private func compactWindowHeight( + for window: NSWindow, + expandedFrame: NSRect + ) -> CGFloat { + // `contentLayoutRect` excludes the titlebar even when the content view + // itself extends underneath it via `.fullSizeContentView`. Its height + // difference from the expanded frame is therefore the native chrome + // that must remain visible in compact mode. + let titlebarHeight = max( + 0, + expandedFrame.height - window.contentLayoutRect.height + ) + return min( + expandedFrame.height, + ceil( + max( + Self.compactContentHeight, + titlebarHeight + Self.compactContentHeight + ) + ) + ) + } + + private func expand( + animated: Bool, + positioning: MainWindowExpandedFramePositioning + ) { + guard let window, isCollapsed else { return } + + let cachedFrame = expandedFrame ?? window.frame + let targetFrame = targetExpandedFrame( + for: window, + cachedFrame: cachedFrame, + positioning: positioning + ) + isCompactContentPresentationActive = false + window.styleMask.insert(.resizable) + window.contentMinSize = Self.minimumExpandedContentSize + transition( + to: targetFrame, + targetIsCollapsed: false, + animated: animated + ) { [weak self, weak window] in + guard let self, let window else { return } + self.isCollapsed = false + self.applyStandardButtonVisibility(isCompact: false) + self.expandedFrame = window.frame + self.accessoryController.update(isCollapsed: false) + self.compactStateDidChange?(false) + if self.pendingTransitionAction?.action != .collapse { + MainWindowFramePersistence.persist( + window: window, + defaults: self.framePersistenceDefaults + ) + } + } + } + + private func transition( + to frame: NSRect, + targetIsCollapsed: Bool, + animated: Bool, + completion: @escaping @MainActor () -> Void = {} + ) { + guard let window else { return } + transitionID = UUID() + let currentTransitionID = transitionID + isTransitioning = true + transitionTargetIsCollapsed = targetIsCollapsed + accessoryController.update( + isCollapsed: isCollapsed, + isEnabled: false + ) + applyAppearance() + + let finish = { [weak self] in + guard let self, self.transitionID == currentTransitionID else { + return + } + self.isTransitioning = false + self.transitionTargetIsCollapsed = nil + completion() + self.accessoryController.update( + isCollapsed: self.isCollapsed, + isEnabled: true + ) + self.applyAppearance() + if let pendingAction = self.pendingTransitionAction { + self.pendingTransitionAction = nil + self.perform( + pendingAction.action, + automatic: pendingAction.automatic + ) + } + } + + if !animated || reduceMotion || !Self.animatesCompactFrameTransitions { + setFrameImmediatelyCancellingAnimation( + frame, + on: window, + display: window.isVisible + ) + finish() + return + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.transitionDuration + context.timingFunction = CAMediaTimingFunction( + name: .easeInEaseOut + ) + window.animator().setFrame(frame, display: true) + } completionHandler: { + MainActor.assumeIsolated { + finish() + } + } + } + + /// A plain `setFrame` does not supersede an in-flight `window.animator()` + /// resize: AppKit can finish the older animation later and put the window + /// back at its stale target. A zero-duration animator transaction replaces + /// that animation first; the direct write then makes the lifecycle operation + /// synchronously observable to its caller. + private func setFrameImmediatelyCancellingAnimation( + _ frame: NSRect, + on window: NSWindow, + display: Bool + ) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + context.allowsImplicitAnimation = false + window.animator().setFrame(frame, display: display) + } + window.setFrame(frame, display: display) + } + + private func targetExpandedFrame( + for window: NSWindow, + cachedFrame: NSRect, + positioning: MainWindowExpandedFramePositioning + ) -> NSRect { + let compactFrame = window.frame + var visibleFrames = NSScreen.screens.map(\.visibleFrame) + for fallbackFrame in [ + window.screen?.visibleFrame, + NSScreen.main?.visibleFrame, + ].compactMap({ $0 }) where !visibleFrames.contains(fallbackFrame) { + visibleFrames.append(fallbackFrame) + } + + if positioning == .restoreCachedFrameIfVisible { + let cachedFrameIsVisible = MainWindowInitialPlacementPolicy + .visibleFrame( + containingMostOf: cachedFrame, + among: visibleFrames + ) != nil + if cachedFrameIsVisible || visibleFrames.isEmpty { + return cachedFrame + } + } + + let visibleFrame = MainWindowInitialPlacementPolicy.visibleFrame( + containingMostOf: compactFrame, + among: visibleFrames + ) ?? visibleFrames.first + + guard let visibleFrame else { + return MainWindowCompactGeometry.expandedFrame( + from: compactFrame, + cachedExpandedFrame: cachedFrame + ) + } + return MainWindowCompactGeometry.expandedFrame( + from: compactFrame, + cachedExpandedFrame: cachedFrame, + constrainedTo: visibleFrame + ) + } + + private func installWindowObservers(for window: NSWindow) { + let center = NotificationCenter.default + windowObservers = [ + center.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.applyAppearance() + DispatchQueue.main.async { [weak self] in + self?.retryPendingCollapseIfPossible() + } + } + }, + center.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.applyAppearance() + } + }, + ] + } + + private func applyAppearance() { + guard let window else { return } + if usesCompactAppearance { + let baselineAlpha = expandedWindowAlpha ?? 1 + let compactOpacity = Self.compactWindowOpacity( + isActive: isPointerInside || window.isKeyWindow, + reduceTransparency: reduceTransparency + ) + // The retained 3pt content sliver exists only to keep the titlebar + // accessory from clipping. It must not paint the note paper color. + window.backgroundColor = .clear + window.isOpaque = false + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.alphaValue = baselineAlpha * compactOpacity + } else { + window.alphaValue = expandedWindowAlpha ?? window.alphaValue + window.isOpaque = true + window.titlebarAppearsTransparent = false + if let expandedTitlebarSeparatorStyle { + window.titlebarSeparatorStyle = expandedTitlebarSeparatorStyle + } + window.backgroundColor = backgroundColor + } + window.invalidateShadow() + } + + private func applyStandardButtonVisibility(isCompact: Bool) { + guard let window else { return } + if let closeButton = window.standardWindowButton(.closeButton) { + closeButton.isHidden = isCompact + closeButton.isEnabled = !isCompact + } + // These controls are hidden by MainWindowChromePolicy in the expanded + // window and must never reappear inside the compact pill. + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + } + + static func compactWindowOpacity( + isActive: Bool, + reduceTransparency: Bool + ) -> CGFloat { + if reduceTransparency { return 1 } + return isActive ? activeWindowOpacity : restingWindowOpacity + } +} + +@MainActor +private final class MainWindowCompactAccessoryController: NSObject { + private weak var window: NSWindow? + private let accessoryViewController = NSTitlebarAccessoryViewController() + private let button = NSButton() + private var action: (() -> Void)? + + override init() { + super.init() + let container = NSView() + container.frame = NSRect(x: 0, y: 0, width: 36, height: 28) + button.translatesAutoresizingMaskIntoConstraints = false + button.bezelStyle = .accessoryBarAction + button.isBordered = false + button.focusRingType = .none + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.contentTintColor = .secondaryLabelColor + button.target = self + button.action = #selector(buttonPressed) + container.addSubview(button) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 28), + button.heightAnchor.constraint(equalToConstant: 28), + button.centerXAnchor.constraint(equalTo: container.centerXAnchor), + button.centerYAnchor.constraint(equalTo: container.centerYAnchor), + ]) + accessoryViewController.layoutAttribute = .right + accessoryViewController.view = container + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func attach(to window: NSWindow, action: @escaping () -> Void) { + if self.window !== window { + detach() + self.window = window + } + self.action = action + ensureAttached(to: window) + } + + func ensureAttached(to window: NSWindow) { + guard !window.titlebarAccessoryViewControllers.contains(where: { + $0 === accessoryViewController + }) else { return } + window.addTitlebarAccessoryViewController(accessoryViewController) + } + + func update(isCollapsed: Bool, isEnabled: Bool = true) { + let labelKey: L10n.Key = isCollapsed + ? .mainWindowExpandAccessibilityLabel + : .mainWindowCollapseAccessibilityLabel + let helpKey: L10n.Key = isCollapsed + ? .mainWindowExpandAccessibilityHelp + : .mainWindowCollapseAccessibilityHelp + let label = L10n.text(labelKey) + button.image = NSImage( + systemSymbolName: isCollapsed ? "chevron.down" : "chevron.up", + accessibilityDescription: label + ) + button.toolTip = label + button.isEnabled = isEnabled + button.setAccessibilityLabel(label) + button.setAccessibilityHelp(L10n.text(helpKey)) + } + + func detach() { + if let window, + let index = window.titlebarAccessoryViewControllers.firstIndex( + where: { $0 === accessoryViewController } + ) { + window.removeTitlebarAccessoryViewController(at: index) + } + window = nil + action = nil + } + + @objc private func buttonPressed() { + action?() + } +} + +@MainActor +private final class MainWindowCompactHoverController: NSResponder { + private weak var window: NSWindow? + private weak var frameView: NSView? + private var trackingArea: NSTrackingArea? + private var changeHandler: ((Bool) -> Void)? + + func attach( + to window: NSWindow, + changeHandler: @escaping (Bool) -> Void + ) { + guard let frameView = window.contentView?.superview else { + detach() + return + } + if self.window !== window || self.frameView !== frameView { + detach() + self.window = window + self.frameView = frameView + } + self.changeHandler = changeHandler + ensureAttached(to: window) + } + + func ensureAttached(to window: NSWindow) { + guard let frameView = window.contentView?.superview else { return } + if let trackingArea, frameView.trackingAreas.contains(trackingArea) { + return + } + if let trackingArea { + self.frameView?.removeTrackingArea(trackingArea) + } + self.frameView = frameView + let trackingArea = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + frameView.addTrackingArea(trackingArea) + self.trackingArea = trackingArea + } + + func detach() { + if let frameView, let trackingArea { + frameView.removeTrackingArea(trackingArea) + } + trackingArea = nil + frameView = nil + window = nil + changeHandler = nil + } + + override func mouseEntered(with event: NSEvent) { + guard event.trackingArea === trackingArea else { return } + changeHandler?(true) + } + + override func mouseExited(with event: NSEvent) { + guard event.trackingArea === trackingArea else { return } + changeHandler?(false) + } +} diff --git a/Sources/CodexNotesProbe/MainWindowCompactState.swift b/Sources/CodexNotesProbe/MainWindowCompactState.swift new file mode 100644 index 0000000..f8ef14c --- /dev/null +++ b/Sources/CodexNotesProbe/MainWindowCompactState.swift @@ -0,0 +1,174 @@ +import CodexNotesCore + +enum MainWindowCompactAction: Equatable, Sendable { + case none + case collapse + case expand +} + +enum MainWindowCollapseOrigin: Equatable, Sendable { + case automatic + case manual +} + +/// Pure interaction state for coordinating Codex's right panel with the notes +/// window. The caller remains responsible for applying the returned window +/// action and for reporting every user-initiated toggle or show operation. +struct MainWindowCompactState: Equatable, Sendable { + private(set) var lastDefiniteRightPanelState: CodexRightPanelState = .unknown + private(set) var collapseOrigin: MainWindowCollapseOrigin? + private(set) var hasHandledCurrentOpenCycle = false + private(set) var userOverrodeCurrentOpenCycle = false + /// Snapshot of the compact/expanded state immediately before the current + /// right-panel cycle began. Window visibility is deliberately separate: + /// restoring an expanded frame must never show a window the user hid. + private(set) var wasExpandedBeforeCurrentOpenCycle: Bool? + + /// Establishes a low-level baseline without producing a window action. + /// User-visible initial observations should go through `observeRightPanel` + /// so an already-open panel can claim its one automatic action. + mutating func seedRightPanel(_ state: CodexRightPanelState) { + lastDefiniteRightPanelState = state + collapseOrigin = nil + userOverrodeCurrentOpenCycle = false + hasHandledCurrentOpenCycle = state == .open + wasExpandedBeforeCurrentOpenCycle = nil + } + + /// Observes either a bootstrap snapshot or an incremental panel event. + /// The first definite `open` in each open cycle is eligible for one + /// automatic collapse, including an initial observation or a recovery from + /// `unknown`. `unknown` never resets an already-handled open cycle. + @discardableResult + mutating func observeRightPanel( + _ state: CodexRightPanelState, + isWindowVisible: Bool, + isCollapsed: Bool, + isInitialObservation: Bool = false + ) -> MainWindowCompactAction { + switch state { + case .unknown: + return .none + + case .closed: + // Treat the right panel as a temporary workspace. If CodexNotes was + // expanded before that workspace opened, restore it even when the + // user briefly expanded and re-collapsed the notes while inspecting + // the panel. A window that was already compact before the cycle only + // remains expanded when it is still expanded at close time. + let shouldRestorePreOpenExpandedState = + lastDefiniteRightPanelState == .open + && wasExpandedBeforeCurrentOpenCycle == true + + lastDefiniteRightPanelState = .closed + hasHandledCurrentOpenCycle = false + userOverrodeCurrentOpenCycle = false + wasExpandedBeforeCurrentOpenCycle = nil + + if shouldRestorePreOpenExpandedState + || collapseOrigin == .automatic { + collapseOrigin = nil + } else if !isCollapsed { + collapseOrigin = nil + } + + // Return an idempotent expand even when the window still appears + // expanded. The controller also uses this action to cancel a manual + // collapse that may be waiting for an IME composition to finish. + return shouldRestorePreOpenExpandedState ? .expand : .none + + case .open: + lastDefiniteRightPanelState = .open + guard !hasHandledCurrentOpenCycle else { + return .none + } + + hasHandledCurrentOpenCycle = true + userOverrodeCurrentOpenCycle = false + wasExpandedBeforeCurrentOpenCycle = !isCollapsed + + guard isWindowVisible, !isCollapsed else { + return .none + } + + collapseOrigin = .automatic + return .collapse + } + } + + /// Records a click on the compact/expand title-bar control. The argument is + /// the state before the click; the return value is the requested new state. + @discardableResult + mutating func recordManualToggle( + isCollapsed: Bool + ) -> MainWindowCompactAction { + if isCollapsed { + collapseOrigin = nil + recordUserOverrideIfPanelIsOpen() + return .expand + } + + collapseOrigin = .manual + recordUserOverrideIfPanelIsOpen() + return .collapse + } + + /// A deliberate show while the right panel is open means the user wants + /// access to the notes. It cancels automatic ownership and expands an + /// already-collapsed window, while leaving an expanded window unchanged. + @discardableResult + mutating func recordManualShow( + isCollapsed: Bool + ) -> MainWindowCompactAction { + guard lastDefiniteRightPanelState == .open else { + return .none + } + + collapseOrigin = nil + hasHandledCurrentOpenCycle = true + userOverrodeCurrentOpenCycle = true + return isCollapsed ? .expand : .none + } + + /// Releases a collapse that CodexNotes owned when panel detection remains + /// unavailable. Keep the current open cycle handled so a transient AX + /// outage cannot make the same still-open sidebar collapse the window a + /// second time after detection recovers. + mutating func releaseAutomaticCollapseForUncertainty() { + guard collapseOrigin == .automatic else { return } + collapseOrigin = nil + hasHandledCurrentOpenCycle = true + } + + /// Stops participating in the current automatic avoidance cycle. A frame + /// owned by automatic avoidance is restored only when the notes were + /// expanded before the panel opened. Manual compact state is deliberately + /// retained, because disabling the feature must not undo a title-bar click. + @discardableResult + mutating func disableAutomaticAvoidance( + isCollapsed: Bool + ) -> MainWindowCompactAction { + let shouldRestoreAutomaticallyCollapsedWindow = + collapseOrigin == .automatic + && wasExpandedBeforeCurrentOpenCycle == true + && isCollapsed + + if collapseOrigin == .automatic { + collapseOrigin = nil + } + lastDefiniteRightPanelState = .unknown + hasHandledCurrentOpenCycle = false + userOverrodeCurrentOpenCycle = false + wasExpandedBeforeCurrentOpenCycle = nil + + return shouldRestoreAutomaticallyCollapsedWindow ? .expand : .none + } + + private mutating func recordUserOverrideIfPanelIsOpen() { + guard lastDefiniteRightPanelState == .open else { + return + } + hasHandledCurrentOpenCycle = true + userOverrodeCurrentOpenCycle = true + } +} diff --git a/Sources/CodexNotesProbe/PlainMarkdownTextView.swift b/Sources/CodexNotesProbe/PlainMarkdownTextView.swift index 04baad9..b8d1814 100644 --- a/Sources/CodexNotesProbe/PlainMarkdownTextView.swift +++ b/Sources/CodexNotesProbe/PlainMarkdownTextView.swift @@ -99,6 +99,7 @@ final class MarkdownEditorController: ObservableObject { @Published private(set) var canToggleHighlightFormat = false private weak var textView: CheckboxTextView? private var pendingSelectionAfterExternalTextUpdate: PendingSelection? + var textCompositionDidEnd: (() -> Void)? private struct PendingSelection { let range: NSRange @@ -129,6 +130,20 @@ final class MarkdownEditorController: ObservableObject { textView?.moveCurrentSelection() } + func blocksAutomaticWindowCollapse(in window: NSWindow) -> Bool { + guard let textView else { return false } + return textView.hasMarkedText() + || (window.isKeyWindow && window.firstResponder === textView) + } + + var hasActiveTextComposition: Bool { + textView?.hasMarkedText() == true + } + + fileprivate func notifyTextCompositionDidEnd() { + textCompositionDidEnd?() + } + func setPendingSelectionAfterExternalTextUpdate( _ range: NSRange, scrollToVisible: Bool = true @@ -2762,6 +2777,9 @@ final class CheckboxTextView: NSTextView, NSTextStorageDelegate { { refreshChecklistPresentation(forceLayout: true) } + if !hasMarkedText() { + editorController?.notifyTextCompositionDidEnd() + } } private func applyChecklistParagraphStyles( @@ -3371,6 +3389,12 @@ final class CheckboxTextView: NSTextView, NSTextStorageDelegate { } override func insertText(_ insertString: Any, replacementRange: NSRange) { + let wasComposing = hasMarkedText() + defer { + if wasComposing, !hasMarkedText() { + editorController?.notifyTextCompositionDidEnd() + } + } dismissSelectionMovePill() let replacementRange = normalizedInlineInputRange(replacementRange) if !isApplyingMarkdownEditPlan, diff --git a/Sources/CodexNotesProbe/ProbeViewModel.swift b/Sources/CodexNotesProbe/ProbeViewModel.swift index 8c08afa..451b641 100644 --- a/Sources/CodexNotesProbe/ProbeViewModel.swift +++ b/Sources/CodexNotesProbe/ProbeViewModel.swift @@ -72,6 +72,16 @@ private enum SelectionMoveCoordinationError: LocalizedError { } } +struct CodexRightPanelObservation: Equatable, Sendable { + let selectionStableKey: String? + let state: CodexRightPanelState + + static let unknown = CodexRightPanelObservation( + selectionStableKey: nil, + state: .unknown + ) +} + @MainActor final class ProbeViewModel: ObservableObject { typealias SelectionMoveNoticeScheduledAction = @MainActor () -> Void @@ -97,6 +107,10 @@ final class ProbeViewModel: ObservableObject { @Published private(set) var state: State = .starting @Published private(set) var selection: CodexSelection? + @Published private(set) var rightPanelObservation = + CodexRightPanelObservation.unknown + private(set) var isRightPanelAvoidanceEnabled = + RightPanelAvoidancePreference.defaultValue @Published private(set) var metadata: CodexThreadMetadata? @Published private(set) var history: [ProbeHistoryItem] = [] @Published private(set) var lastLatencyMilliseconds: Int? @@ -130,6 +144,9 @@ final class ProbeViewModel: ObservableObject { let noteImageStore: NoteImageStore private let monitor = CodexLogMonitor() + private let rightPanelStateReader = CodexRightPanelStateReader() + private let accessibilityRightPanelReader = + CodexAccessibilityRightPanelReader() private let metadataProvider: any CodexThreadMetadataProviding private let globalProjectCandidateProvider: any CodexGlobalProjectCandidateProviding private let metadataClock = ContinuousClock() @@ -152,6 +169,14 @@ final class ProbeViewModel: ObservableObject { private var confirmedNewTaskProject: CodexGlobalProjectCandidate? private var requiresReloadBeforeWrite = false private var shouldResumeMonitoringAfterSafetyReload = false + private var rightPanelDebouncer = CodexRightPanelDebouncer( + requiredConsecutive: 2 + ) + private var rightPanelAccessibilityPermission: + CodexAccessibilityPermissionState? + private var rightPanelAvoidanceGeneration: UInt = 0 + + private static let primaryRightPanelIdentity = "codex-primary-window" private struct PendingSelectionMove { let result: NoteSelectionMoveResult @@ -240,6 +265,21 @@ final class ProbeViewModel: ObservableObject { } } + /// Controls the detector as well as the window response. Every preference + /// edge publishes unknown so a pre-disable sample cannot be treated as + /// current when the feature is enabled later. + func setRightPanelAvoidanceEnabled(_ isEnabled: Bool) { + guard isRightPanelAvoidanceEnabled != isEnabled else { return } + isRightPanelAvoidanceEnabled = isEnabled + rightPanelAvoidanceGeneration &+= 1 + rightPanelDebouncer.reset() + rightPanelAccessibilityPermission = nil + // Every preference generation begins from unknown. This prevents a + // pre-disable `.open` sample from being replayed after off -> on, and + // makes the controller wait for a fresh detector result. + publishUnknownRightPanelObservation() + } + func retry() { cancelMetadataRefresh() if requiresReloadBeforeWrite { @@ -501,8 +541,10 @@ final class ProbeViewModel: ObservableObject { do { let initial = try await monitor.bootstrap() apply(initial, recordLatency: false) + await refreshRightPanelState() } catch { suspendEditing(message: error.localizedDescription) + publishUnknownRightPanelObservation() } while !Task.isCancelled { @@ -510,11 +552,75 @@ final class ProbeViewModel: ObservableObject { try await Task.sleep(for: .milliseconds(250)) let latest = try await monitor.poll() apply(latest, recordLatency: true) + await refreshRightPanelState() } catch is CancellationError { return } catch { suspendEditing(message: error.localizedDescription) + publishUnknownRightPanelObservation() + } + } + } + + private func refreshRightPanelState() async { + guard isRightPanelAvoidanceEnabled else { return } + let avoidanceGeneration = rightPanelAvoidanceGeneration + let observedSelection = selection + let accessibilitySample = await accessibilityRightPanelReader.sample() + guard isRightPanelAvoidanceEnabled, + rightPanelAvoidanceGeneration == avoidanceGeneration, + selection?.stableKey == observedSelection?.stableKey else { return } + + if rightPanelAccessibilityPermission != accessibilitySample.permission { + rightPanelAccessibilityPermission = accessibilitySample.permission + rightPanelDebouncer.reset() + } + + let sampledState: CodexRightPanelState + switch accessibilitySample.permission { + case .authorized: + sampledState = accessibilitySample.state + case .denied: + guard let threadID = observedSelection?.threadID else { + publishUnknownRightPanelObservation() + return } + sampledState = await rightPanelStateReader.state(for: threadID) + guard isRightPanelAvoidanceEnabled, + rightPanelAvoidanceGeneration == avoidanceGeneration, + selection?.stableKey == observedSelection?.stableKey else { + return + } + } + + if sampledState == .unknown { + // Publishing unknown starts the controller's bounded fail-open + // timer. Reset the edge debouncer as well so the same definite + // state can be published again after Accessibility recovers. + rightPanelDebouncer.reset() + publishUnknownRightPanelObservation() + return + } + + guard let observedState = rightPanelDebouncer.observe(sampledState) else { + return + } + let observation = CodexRightPanelObservation( + selectionStableKey: Self.primaryRightPanelIdentity, + state: observedState + ) + if rightPanelObservation != observation { + rightPanelObservation = observation + } + } + + private func publishUnknownRightPanelObservation() { + let observation = CodexRightPanelObservation( + selectionStableKey: Self.primaryRightPanelIdentity, + state: .unknown + ) + if rightPanelObservation != observation { + rightPanelObservation = observation } } diff --git a/Sources/CodexNotesProbe/SettingsView.swift b/Sources/CodexNotesProbe/SettingsView.swift index 018b46c..7a582fd 100644 --- a/Sources/CodexNotesProbe/SettingsView.swift +++ b/Sources/CodexNotesProbe/SettingsView.swift @@ -56,6 +56,7 @@ struct SettingsView: View { @StateObject private var loginItemService = LoginItemService() @State private var globalHotKeyErrorKey: L10n.Key? @State private var windowSizeRestoreConfirmationID: UUID? + @State private var isAccessibilityAuthorized = AXIsProcessTrusted() @AppStorage(AppLanguagePreference.key) private var storedLanguage = AppLanguagePreference.defaultValue.rawValue @AppStorage(EditorFontSizePreference.key) @@ -66,6 +67,9 @@ struct SettingsView: View { private var storedThemeID = NoteThemePreference.defaultValue.rawValue @AppStorage(StatusBarIconPreference.key) private var storedStatusBarIconID = StatusBarIconPreference.defaultValue.rawValue + @AppStorage(RightPanelAvoidancePreference.key) + private var rightPanelAvoidanceEnabled = + RightPanelAvoidancePreference.defaultValue init( updateCoordinator: UpdateCheckCoordinator, @@ -176,6 +180,11 @@ struct SettingsView: View { .overlay(palette.separator.color) launchAtLoginControls + + Divider() + .overlay(palette.separator.color) + + rightPanelAvoidanceControls } .frame(maxWidth: .infinity) } @@ -329,6 +338,7 @@ struct SettingsView: View { ) .onAppear { loginItemService.refresh() + refreshAccessibilityPermission() } .onReceive( NotificationCenter.default.publisher( @@ -336,6 +346,7 @@ struct SettingsView: View { ) ) { _ in loginItemService.refresh() + refreshAccessibilityPermission() } .onReceive( NotificationCenter.default.publisher( @@ -597,6 +608,125 @@ struct SettingsView: View { .frame(maxWidth: .infinity) } + private var rightPanelAvoidanceControls: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L10n.text(.settingsRightPanelAvoidanceTitle)) + .font(.subheadline.weight(.medium)) + Text(L10n.text(.settingsRightPanelAvoidanceDescription)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityHidden(true) + + Spacer(minLength: 24) + + Toggle( + L10n.text(.settingsRightPanelAvoidanceTitle), + isOn: $rightPanelAvoidanceEnabled + ) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityLabel(Text(L10n.text( + .settingsRightPanelAvoidanceTitle + ))) + .accessibilityValue(Text(L10n.text( + rightPanelAvoidanceEnabled + ? .accessibilityValueOn + : .accessibilityValueOff + ))) + .accessibilityHint(Text(L10n.text( + .settingsRightPanelAvoidanceDescription + ))) + } + + HStack(alignment: .center, spacing: 7) { + Image(systemName: isAccessibilityAuthorized + ? "checkmark.circle.fill" + : "exclamationmark.triangle.fill") + .foregroundStyle(isAccessibilityAuthorized + ? palette.success.color + : palette.warning.color) + .accessibilityHidden(true) + + Text(L10n.text(.settingsRightPanelAvoidancePermissionTitle)) + .font(.caption) + .foregroundStyle(palette.secondaryText.color) + + Spacer(minLength: 12) + + Text(L10n.text( + isAccessibilityAuthorized + ? .settingsRightPanelAvoidanceAuthorized + : .settingsRightPanelAvoidanceNotAuthorized + )) + .font(.caption.weight(.medium)) + .foregroundStyle(isAccessibilityAuthorized + ? palette.success.color + : palette.warning.color) + .fixedSize(horizontal: true, vertical: false) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(L10n.text( + .settingsRightPanelAvoidancePermissionTitle + ))) + .accessibilityValue(Text(L10n.text( + isAccessibilityAuthorized + ? .settingsRightPanelAvoidanceAuthorized + : .settingsRightPanelAvoidanceNotAuthorized + ))) + + if rightPanelAvoidanceEnabled && !isAccessibilityAuthorized { + HStack(alignment: .top, spacing: 7) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(palette.warning.color) + .accessibilityHidden(true) + Text(L10n.text(.settingsRightPanelAvoidancePermissionDescription)) + .font(.caption) + .foregroundStyle(palette.warning.color) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 12) { + Button(L10n.text(.settingsRightPanelAvoidanceRequestPermission)) { + requestAccessibilityPermission() + } + .buttonStyle(.link) + .controlSize(.small) + + Button(L10n.text(.settingsRightPanelAvoidanceOpenSystemSettings)) { + openAccessibilitySystemSettings() + } + .buttonStyle(.link) + .controlSize(.small) + } + .padding(.leading, 20) + } + } + .frame(maxWidth: .infinity) + } + + private func refreshAccessibilityPermission() { + isAccessibilityAuthorized = AXIsProcessTrusted() + } + + private func requestAccessibilityPermission() { + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() + as String + let options = [promptKey: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + refreshAccessibilityPermission() + } + + private func openAccessibilitySystemSettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) else { return } + NSWorkspace.shared.open(url) + } + private func settingsPanel( title: String, @ViewBuilder content: () -> Content diff --git a/Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift b/Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift new file mode 100644 index 0000000..bc246d2 --- /dev/null +++ b/Tests/CodexNotesCoreTests/CodexRightPanelStateReaderTests.swift @@ -0,0 +1,363 @@ +import Foundation +import XCTest +@testable import CodexNotesCore + +final class CodexRightPanelStateReaderTests: XCTestCase { + func testReadsGenericRightPanelState() throws { + let openData = try fixtureData([ + "thread-tab-routes-v1:target-thread": genericState(open: true) + ]) + let closedData = try fixtureData([ + "thread-tab-routes-v1:target-thread": genericState(open: false) + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: openData + ), + .open + ) + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: closedData + ), + .closed + ) + } + + func testGenericStateWinsWhenBrowserStateConflicts() throws { + let genericOpenData = try fixtureData([ + "thread-tab-routes-v1:target-thread": genericState(open: true), + "thread-browser-tabs-v1:target-thread": [ + "rightPanelOpen": false + ] + ]) + let genericClosedData = try fixtureData([ + "thread-tab-routes-v1:target-thread": genericState(open: false), + "thread-browser-tabs-v1:target-thread": [ + "rightPanelOpen": true + ] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: genericOpenData + ), + .open + ) + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: genericClosedData + ), + .closed + ) + } + + func testMalformedGenericStateFallsBackToBrowserState() throws { + let malformedGenericStates: [Any] = [ + NSNull(), + [:], + ["topology": NSNull()], + ["topology": [:]], + ["topology": ["right": NSNull()]], + ["topology": ["right": [:]]], + ["topology": ["right": ["open": NSNull()]]], + ["topology": ["right": ["open": 1]]], + ["topology": ["right": ["open": "true"]]] + ] + + for genericState in malformedGenericStates { + let data = try fixtureData([ + "thread-tab-routes-v1:target-thread": genericState, + "thread-browser-tabs-v1:target-thread": [ + "rightPanelOpen": true + ] + ]) + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: data + ), + .open, + "Did not fall back for malformed generic state: \(genericState)" + ) + } + } + + func testGenericStateDoesNotBorrowAnotherThreadsValue() throws { + let data = try fixtureData([ + "thread-tab-routes-v1:old-thread": genericState(open: true), + "thread-tab-routes-v1:target-thread": genericState(open: false), + "thread-browser-tabs-v1:old-thread": ["rightPanelOpen": true] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "target-thread", in: data), + .closed + ) + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "missing-thread", in: data), + .closed + ) + } + + func testValidAtomStateWithBothExactKeysAbsentIsClosed() throws { + let emptyAtomData = try fixtureData([:]) + let otherThreadData = try fixtureData([ + "thread-tab-routes-v1:other-thread": genericState(open: true), + "thread-browser-tabs-v1:other-thread": [ + "rightPanelOpen": true + ] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: emptyAtomData + ), + .closed + ) + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: otherThreadData + ), + .closed + ) + } + + func testPresentInvalidExactKeyWithoutValidFallbackIsUnknown() throws { + let fixtures = try [ + fixtureData([ + "thread-tab-routes-v1:target-thread": NSNull() + ]), + fixtureData([ + "thread-browser-tabs-v1:target-thread": NSNull() + ]), + fixtureData([ + "thread-tab-routes-v1:target-thread": [ + "topology": ["right": ["open": 1]] + ], + "thread-browser-tabs-v1:target-thread": [ + "rightPanelOpen": "false" + ] + ]) + ] + + for data in fixtures { + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: data + ), + .unknown + ) + } + } + + func testReadsTrueForExactThreadAsOpen() throws { + let data = try fixtureData([ + "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": true] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "target-thread", in: data), + .open + ) + } + + func testReadsFalseForExactThreadAsClosed() throws { + let data = try fixtureData([ + "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": false] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "target-thread", in: data), + .closed + ) + } + + func testDoesNotBorrowOpenStateFromAnotherThread() throws { + let data = try fixtureData([ + "thread-browser-tabs-v1:old-thread": ["rightPanelOpen": true], + "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": false] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "target-thread", in: data), + .closed + ) + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "missing-thread", in: data), + .closed + ) + } + + func testMissingStructuresAreUnknown() throws { + let fixtures: [Any] = [ + [:], + ["electron-persisted-atom-state": NSNull()], + ["electron-persisted-atom-state": []], + [ + "electron-persisted-atom-state": [ + "thread-browser-tabs-v1:target-thread": NSNull() + ] + ], + [ + "electron-persisted-atom-state": [ + "thread-browser-tabs-v1:target-thread": [:] + ] + ], + [ + "electron-persisted-atom-state": [ + "thread-browser-tabs-v1:target-thread": [ + "rightPanelOpen": NSNull() + ] + ] + ] + ] + + for fixture in fixtures { + let data = try JSONSerialization.data(withJSONObject: fixture) + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: data + ), + .unknown + ) + } + } + + func testMalformedJSONIsUnknown() { + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: Data("{not-json".utf8) + ), + .unknown + ) + } + + func testTypeDriftIsUnknownInsteadOfTruthy() throws { + let driftedValues: [Any] = [ + 1, + 0, + "true", + ["value": true], + [true] + ] + + for value in driftedValues { + let data = try fixtureData([ + "thread-browser-tabs-v1:target-thread": [ + "rightPanelOpen": value + ] + ]) + XCTAssertEqual( + CodexRightPanelStateParser.state( + for: "target-thread", + in: data + ), + .unknown, + "Unexpectedly accepted \(value) as a Boolean" + ) + } + } + + func testEmptyThreadIDIsUnknown() throws { + let data = try fixtureData([ + "thread-browser-tabs-v1:": ["rightPanelOpen": true] + ]) + + XCTAssertEqual( + CodexRightPanelStateParser.state(for: "", in: data), + .unknown + ) + } + + func testThreadIDUsesJavaScriptEncodeURIComponentRules() throws { + let threadID = "thread/有 空格?x=1&y!'()*~.-_" + let encodedThreadID = "thread%2F%E6%9C%89%20" + + "%E7%A9%BA%E6%A0%BC%3Fx%3D1%26y!'()*~.-_" + let expectedGenericKey = "thread-tab-routes-v1:" + encodedThreadID + let expectedBrowserKey = "thread-browser-tabs-v1:" + encodedThreadID + XCTAssertEqual( + CodexRightPanelStateParser.genericPersistedKey(for: threadID), + expectedGenericKey + ) + XCTAssertEqual( + CodexRightPanelStateParser.browserPersistedKey(for: threadID), + expectedBrowserKey + ) + XCTAssertEqual( + CodexRightPanelStateParser.persistedKey(for: threadID), + expectedBrowserKey + ) + + let data = try fixtureData([ + expectedGenericKey: genericState(open: true), + expectedBrowserKey: ["rightPanelOpen": false], + "thread-browser-tabs-v1:\(threadID)": ["rightPanelOpen": false] + ]) + XCTAssertEqual( + CodexRightPanelStateParser.state(for: threadID, in: data), + .open + ) + } + + func testFileReaderReadsFreshSnapshots() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let stateURL = directory.appendingPathComponent("state.json") + try fixtureData([ + "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": true] + ]).write(to: stateURL, options: .atomic) + let reader = CodexRightPanelStateReader(globalStateURL: stateURL) + + let openState = await reader.state(for: "target-thread") + XCTAssertEqual(openState, .open) + + try fixtureData([ + "thread-browser-tabs-v1:target-thread": ["rightPanelOpen": false] + ]).write(to: stateURL, options: .atomic) + let closedState = await reader.state(for: "target-thread") + XCTAssertEqual(closedState, .closed) + } + + func testFileReaderReturnsUnknownForMissingFile() async { + let stateURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("missing-state.json") + let reader = CodexRightPanelStateReader(globalStateURL: stateURL) + + let state = await reader.state(for: "target-thread") + XCTAssertEqual(state, .unknown) + } + + private func fixtureData(_ atomState: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "electron-persisted-atom-state": atomState + ]) + } + + private func genericState(open: Bool) -> [String: Any] { + [ + "topology": [ + "right": [ + "open": open + ] + ] + ] + } +} diff --git a/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift new file mode 100644 index 0000000..e454be2 --- /dev/null +++ b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift @@ -0,0 +1,79 @@ +import Foundation +import XCTest +@testable import CodexNotesCore + +final class RightPanelAvoidancePreferenceTests: XCTestCase { + func testFreshInstallDefaultsOffAndPersistsDecision() { + withDefaults { defaults, suiteName in + XCTAssertNil(defaults.object( + forKey: RightPanelAvoidancePreference.key + )) + XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded( + defaults: defaults, + persistentDomainName: suiteName + )) + XCTAssertEqual( + defaults.object(forKey: RightPanelAvoidancePreference.key) as? Bool, + false + ) + XCTAssertFalse(RightPanelAvoidancePreference.load(from: defaults)) + } + } + + func testExistingInstallWithoutPreferenceDefaultsOff() { + withDefaults { defaults, suiteName in + defaults.set(20, forKey: "editorFontSize") + + XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded( + defaults: defaults, + persistentDomainName: suiteName + )) + XCTAssertFalse(defaults.bool( + forKey: RightPanelAvoidancePreference.key + )) + } + } + + func testExplicitPreferenceWinsAndRoundTripsBothValues() { + withDefaults { defaults, suiteName in + defaults.set(false, forKey: RightPanelAvoidancePreference.key) + defaults.set(20, forKey: "editorFontSize") + XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded( + defaults: defaults, + persistentDomainName: suiteName + )) + XCTAssertFalse(RightPanelAvoidancePreference.load(from: defaults)) + + defaults.set(true, forKey: RightPanelAvoidancePreference.key) + XCTAssertTrue(RightPanelAvoidancePreference.migrateIfNeeded( + defaults: defaults, + persistentDomainName: suiteName + )) + XCTAssertTrue(RightPanelAvoidancePreference.load(from: defaults)) + } + } + + func testEmptyPersistentDomainStillCountsAsFreshInstall() { + withDefaults { defaults, suiteName in + defaults.set(true, forKey: "temporary") + defaults.removeObject(forKey: "temporary") + + XCTAssertEqual( + defaults.persistentDomain(forName: suiteName)?.isEmpty, + true + ) + XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded( + defaults: defaults, + persistentDomainName: suiteName + )) + } + } + + private func withDefaults(_ body: (UserDefaults, String) -> Void) { + let suiteName = "CodexNotesTests.RightPanelAvoidance.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + body(defaults, suiteName) + } +} diff --git a/Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift b/Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift new file mode 100644 index 0000000..72c17f9 --- /dev/null +++ b/Tests/CodexNotesProbeTests/CodexAccessibilityRightPanelReaderTests.swift @@ -0,0 +1,382 @@ +import CodexNotesCore +import XCTest +@testable import CodexNotesProbe + +final class CodexAccessibilityRightPanelReaderTests: XCTestCase { + func testClassifiesClosedExactLayout() { + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state(in: [tree(open: false)]), + .closed + ) + } + + func testClassifiesOpenExactLayoutIncludingEmptyBrowserPage() { + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state(in: [tree(open: true)]), + .open + ) + } + + func testFindsShellRowBelowSeveralWrappers() { + let viewport = element( + frame: mainFrame, + classes: ["_MainContentViewport_1e9gb_72"] + ) + let shell = element( + frame: mainFrame, + classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens, + children: [viewport] + ) + let main = element( + subrole: "AXLandmarkMain", + frame: mainFrame, + children: [ + element(children: [element(children: [shell])]), + ] + ) + + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.shellRows(in: main), + [shell] + ) + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state(in: [main]), + .closed + ) + } + + func testInjectedReaderClassifiesNestedOpenTree() async { + let reader = CodexAccessibilityRightPanelReader( + backend: FakeAccessibilityBackend( + roots: [tree(open: true)] + ) + ) + + let sample = await reader.sample() + XCTAssertEqual( + sample, + CodexAccessibilityRightPanelSample( + state: .open, + permission: .authorized + ) + ) + } + + func testInjectedReaderDeniedPermissionFailsUnknownWithoutRoots() async { + let reader = CodexAccessibilityRightPanelReader( + backend: FakeAccessibilityBackend( + isTrusted: false, + processIdentifier: nil, + roots: nil + ) + ) + + let sample = await reader.sample() + XCTAssertEqual( + sample, + CodexAccessibilityRightPanelSample( + state: .unknown, + permission: .denied + ) + ) + } + + func testTargetedTraversalFindsUniqueShellBelowWrappers() { + let shell = element( + frame: mainFrame, + classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens + ) + let roots = [element(children: [element(children: [shell])])] + var nodeCount = 0 + let budget = CodexAccessibilityTraversalBudget( + maximumNodes: 8, + maximumDepth: 4, + maximumDuration: .milliseconds(20) + ) + + let match = CodexAccessibilityTargetedTraversal.uniqueMatch( + among: roots, + startingDepth: 1, + nodeCount: &nodeCount, + budget: budget, + isBeforeDeadline: { true }, + isMatch: { + CodexAccessibilityRightPanelClassifier.shellRowClassTokens + .isSubset(of: $0.classTokens) + }, + children: { $0.children }, + shouldTraverse: { _ in true } + ) + + XCTAssertEqual(match, shell) + XCTAssertEqual(nodeCount, 3) + } + + func testTargetedTraversalFailsUnknownWhenBudgetExpires() { + let shell = element( + frame: mainFrame, + classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens + ) + let roots = [element(children: [element(children: [shell])])] + var nodeCount = 0 + let budget = CodexAccessibilityTraversalBudget( + maximumNodes: 2, + maximumDepth: 4, + maximumDuration: .milliseconds(20) + ) + + let match = CodexAccessibilityTargetedTraversal.uniqueMatch( + among: roots, + startingDepth: 1, + nodeCount: &nodeCount, + budget: budget, + isBeforeDeadline: { true }, + isMatch: { + CodexAccessibilityRightPanelClassifier.shellRowClassTokens + .isSubset(of: $0.classTokens) + }, + children: { $0.children }, + shouldTraverse: { _ in true } + ) + + XCTAssertNil(match) + XCTAssertEqual(nodeCount, 2) + } + + func testMissingShellRowIsUnknown() { + let main = element( + subrole: "AXLandmarkMain", + frame: mainFrame, + children: [] + ) + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state(in: [main]), + .unknown + ) + } + + func testNarrowViewportWithoutAsideIsUnknownNotClosed() { + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state( + in: [tree(open: false, viewportFrame: openViewportFrame)] + ), + .unknown + ) + } + + func testSummaryOverlayDoesNotCountAsRightPanel() { + let summary = element( + frame: CGRect(x: 2054, y: 124, width: 506, height: 1297), + classes: [ + "pointer-events-none", "absolute", "right-0", "z-40", + ] + ) + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state( + in: [tree(open: false, extraShellChildren: [summary])] + ), + .closed + ) + } + + func testGenericAsideTokenOnViewportDoesNotCreateFalseCandidate() { + let viewport = element( + frame: mainFrame, + classes: ["_MainContentViewport_1e9gb_72", "relative"] + ) + let shell = element( + frame: mainFrame, + classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens, + children: [viewport] + ) + let main = element( + subrole: "AXLandmarkMain", + frame: mainFrame, + children: [element(children: [shell])] + ) + + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state(in: [main]), + .closed + ) + } + + func testAsideWithWrongGeometryIsUnknown() { + let wrongAside = aside( + frame: CGRect(x: 1800, y: 31, width: 500, height: 1409) + ) + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state( + in: [tree( + open: false, + viewportFrame: openViewportFrame, + extraShellChildren: [wrongAside] + )] + ), + .unknown + ) + } + + func testPartialAsideClassDriftIsUnknown() { + let drifted = element( + subrole: "AXLandmarkComplementary", + frame: openAsideFrame, + classes: ["relative", "z-[41]", "h-full"] + ) + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state( + in: [tree( + open: false, + viewportFrame: openViewportFrame, + extraShellChildren: [drifted] + )] + ), + .unknown + ) + } + + func testLargestMainLandmarkWins() { + let smallMain = element( + subrole: "AXLandmarkMain", + frame: CGRect(x: 0, y: 0, width: 700, height: 500) + ) + XCTAssertEqual( + CodexAccessibilityRightPanelClassifier.state( + in: [smallMain, tree(open: true)] + ), + .open + ) + } + + func testDebouncerRequiresTwoMatchingDefiniteSamples() { + var debouncer = CodexRightPanelDebouncer() + XCTAssertNil(debouncer.observe(.open)) + XCTAssertEqual(debouncer.observe(.open), .open) + XCTAssertNil(debouncer.observe(.open)) + } + + func testDebouncerUnknownBreaksCandidateWithoutPublishingUnknown() { + var debouncer = CodexRightPanelDebouncer() + XCTAssertNil(debouncer.observe(.closed)) + XCTAssertNil(debouncer.observe(.unknown)) + XCTAssertNil(debouncer.observe(.closed)) + XCTAssertEqual(debouncer.observe(.closed), .closed) + } + + func testDebouncerPublishesNewOppositeStateAfterConfirmation() { + var debouncer = CodexRightPanelDebouncer() + XCTAssertNil(debouncer.observe(.closed)) + XCTAssertEqual(debouncer.observe(.closed), .closed) + XCTAssertNil(debouncer.observe(.open)) + XCTAssertEqual(debouncer.observe(.open), .open) + } + + func testDebouncerResetAllowsRepublishingSameState() { + var debouncer = CodexRightPanelDebouncer() + _ = debouncer.observe(.open) + XCTAssertEqual(debouncer.observe(.open), .open) + debouncer.reset() + XCTAssertNil(debouncer.observe(.open)) + XCTAssertEqual(debouncer.observe(.open), .open) + } + + private let mainFrame = CGRect(x: 576, y: 31, width: 1984, height: 1409) + private let openViewportFrame = CGRect( + x: 576, + y: 31, + width: 972, + height: 1409 + ) + private let openAsideFrame = CGRect( + x: 1548, + y: 31, + width: 1012, + height: 1409 + ) + + private func tree( + open: Bool, + viewportFrame: CGRect? = nil, + extraShellChildren: [CodexAccessibilityElementSnapshot] = [] + ) -> CodexAccessibilityElementSnapshot { + let viewport = element( + frame: viewportFrame ?? (open ? openViewportFrame : mainFrame), + classes: ["_MainContentViewport_1e9gb_72"] + ) + var shellChildren = [viewport] + if open { + shellChildren.append(aside(frame: openAsideFrame)) + } + shellChildren.append(contentsOf: extraShellChildren) + let shell = element( + frame: mainFrame, + classes: CodexAccessibilityRightPanelClassifier.shellRowClassTokens, + children: shellChildren + ) + return element( + subrole: "AXLandmarkMain", + frame: mainFrame, + children: [element(children: [shell])] + ) + } + + private func aside( + frame: CGRect + ) -> CodexAccessibilityElementSnapshot { + element( + subrole: "AXLandmarkComplementary", + frame: frame, + classes: CodexAccessibilityRightPanelClassifier.asideClassTokens + ) + } + + private func element( + role: String = "AXGroup", + subrole: String? = nil, + frame: CGRect? = nil, + classes: Set = [], + children: [CodexAccessibilityElementSnapshot] = [] + ) -> CodexAccessibilityElementSnapshot { + CodexAccessibilityElementSnapshot( + role: role, + subrole: subrole, + frame: frame, + classTokens: classes, + children: children + ) + } +} + +private final class FakeAccessibilityBackend: + CodexAccessibilityReading, + @unchecked Sendable +{ + private let trusted: Bool + private let processIdentifier: pid_t? + private let roots: [CodexAccessibilityElementSnapshot]? + + init( + isTrusted: Bool = true, + processIdentifier: pid_t? = 123, + roots: [CodexAccessibilityElementSnapshot]? + ) { + trusted = isTrusted + self.processIdentifier = processIdentifier + self.roots = roots + } + + func isTrusted() -> Bool { + trusted + } + + func codexProcessIdentifier() -> pid_t? { + processIdentifier + } + + func rootSnapshots( + for processIdentifier: pid_t, + budget: CodexAccessibilityTraversalBudget + ) -> [CodexAccessibilityElementSnapshot]? { + roots + } +} diff --git a/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift b/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift index a301b22..4e35828 100644 --- a/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift +++ b/Tests/CodexNotesProbeTests/MainWindowChromePolicyTests.swift @@ -79,6 +79,30 @@ final class MainWindowChromePolicyTests: XCTestCase { XCTAssertFalse(miniaturizeButton.isEnabled) } + func testCompactPolicyLeavesOnlyTitleAndAccessoryControlsVisible() throws { + let window = makeWindow() + + MainWindowChromePolicy.apply(to: window, isCollapsed: true) + + let closeButton = try XCTUnwrap( + window.standardWindowButton(.closeButton) + ) + XCTAssertTrue(closeButton.isHidden) + XCTAssertFalse(closeButton.isEnabled) + XCTAssertTrue( + try XCTUnwrap(window.standardWindowButton(.miniaturizeButton)) + .isHidden + ) + XCTAssertTrue( + try XCTUnwrap(window.standardWindowButton(.zoomButton)).isHidden + ) + + MainWindowChromePolicy.apply(to: window, isCollapsed: false) + + XCTAssertFalse(closeButton.isHidden) + XCTAssertTrue(closeButton.isEnabled) + } + func testWindowCoordinatorRejectsZoom() { let window = makeWindow() let coordinator = WindowConfigurator.Coordinator() diff --git a/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift new file mode 100644 index 0000000..a019753 --- /dev/null +++ b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift @@ -0,0 +1,1800 @@ +import AppKit +import CodexNotesCore +import SwiftUI +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class MainWindowContentSizingPolicyTests: XCTestCase { + func testIdealHeightStaysExpandedWhilePresentationBecomesCompact() { + let expandedHost = makeSizingHost( + isCollapsed: false, + isCompactPresentationActive: false + ) + let compactHost = makeSizingHost( + isCollapsed: true, + isCompactPresentationActive: true + ) + + XCTAssertEqual(MainWindowContentSizingPolicy.idealHeight, 660) + XCTAssertEqual( + expandedHost.fittingSize.height, + MainWindowContentSizingPolicy.idealHeight, + accuracy: 0.01 + ) + XCTAssertEqual( + compactHost.fittingSize.height, + MainWindowContentSizingPolicy.idealHeight, + accuracy: 0.01 + ) + } + + func testCompactPresentationNeverRequestsFixedContentHeight() { + XCTAssertNil( + MainWindowContentSizingPolicy.fixedHeight( + isCompactPresentationActive: false + ) + ) + XCTAssertNil( + MainWindowContentSizingPolicy.fixedHeight( + isCompactPresentationActive: true + ) + ) + } + + func testOnlyMinimumHeightRelaxesDuringCollapse() { + XCTAssertEqual( + MainWindowContentSizingPolicy.minimumHeight(isCollapsed: false), + MainWindowCompactController.minimumExpandedContentSize.height + ) + XCTAssertEqual( + MainWindowContentSizingPolicy.minimumHeight(isCollapsed: true), + MainWindowCompactController.compactContentHeight + ) + } + + func testMinimumWidthTracksCompactPillAndExpandedWindow() { + XCTAssertEqual( + MainWindowContentSizingPolicy.minimumWidth(isCollapsed: false), + MainWindowCompactController.minimumExpandedContentSize.width + ) + XCTAssertEqual( + MainWindowContentSizingPolicy.minimumWidth(isCollapsed: true), + MainWindowCompactController.compactWindowWidth + ) + } + + private func makeSizingHost( + isCollapsed: Bool, + isCompactPresentationActive: Bool + ) -> NSHostingView { + let host = NSHostingView( + rootView: MainWindowContentSizingHarness( + isCollapsed: isCollapsed, + isCompactPresentationActive: isCompactPresentationActive + ) + ) + host.layoutSubtreeIfNeeded() + return host + } +} + +private struct MainWindowContentSizingHarness: View { + let isCollapsed: Bool + let isCompactPresentationActive: Bool + + var body: some View { + Color.clear + .frame( + minWidth: MainWindowContentSizingPolicy.minimumWidth( + isCollapsed: isCollapsed + ), + idealWidth: 400, + minHeight: MainWindowContentSizingPolicy.minimumHeight( + isCollapsed: isCollapsed + ), + idealHeight: MainWindowContentSizingPolicy.idealHeight + ) + .frame( + height: MainWindowContentSizingPolicy.fixedHeight( + isCompactPresentationActive: + isCompactPresentationActive + ), + alignment: .top + ) + } +} + +final class MainWindowCompactGeometryTests: XCTestCase { + func testCompactFrameUsesRequestedWidthAndPreservesTopRightEdges() { + let expanded = NSRect(x: 120, y: 180, width: 440, height: 660) + + let compact = MainWindowCompactGeometry.compactFrame( + from: expanded, + compactWidth: 190, + compactHeight: 31 + ) + + XCTAssertEqual(compact.width, 190) + XCTAssertEqual(compact.maxX, expanded.maxX) + XCTAssertEqual(compact.maxY, expanded.maxY) + XCTAssertEqual(compact.height, 31) + } + + func testExpansionFollowsMovedCompactBarAndRestoresCachedSize() { + let cached = NSRect(x: 120, y: 180, width: 440, height: 660) + let movedCompact = NSRect(x: 720, y: 830, width: 190, height: 31) + + let expanded = MainWindowCompactGeometry.expandedFrame( + from: movedCompact, + cachedExpandedFrame: cached + ) + + XCTAssertEqual(expanded.maxX, movedCompact.maxX) + XCTAssertEqual(expanded.maxY, movedCompact.maxY) + XCTAssertEqual(expanded.size, cached.size) + } + + func testExpansionIsKeptInsideVisibleFrameAfterCompactBarMovesLow() { + let cached = NSRect(x: 120, y: 180, width: 440, height: 660) + let movedCompact = NSRect(x: 950, y: 25, width: 190, height: 31) + let visible = NSRect(x: 0, y: 24, width: 1_200, height: 776) + + let expanded = MainWindowCompactGeometry.expandedFrame( + from: movedCompact, + cachedExpandedFrame: cached, + constrainedTo: visible + ) + + XCTAssertEqual(expanded.minY, visible.minY) + XCTAssertTrue(visible.contains(expanded)) + XCTAssertEqual(expanded.size, cached.size) + } +} + +@MainActor +final class MainWindowCompactControllerTests: XCTestCase { + func testCompactPillUsesDeliberatelySmallFixedWidth() { + XCTAssertEqual(MainWindowCompactController.compactWindowWidth, 190) + XCTAssertLessThan( + MainWindowCompactController.compactWindowWidth, + MainWindowCompactController.minimumExpandedContentSize.width + ) + } + + func testCompactAppearanceUsesReadableTranslucencyLevels() { + XCTAssertEqual( + MainWindowCompactController.restingWindowOpacity, + 0.42, + accuracy: 0.001 + ) + XCTAssertEqual( + MainWindowCompactController.activeWindowOpacity, + 0.68, + accuracy: 0.001 + ) + XCTAssertLessThan( + MainWindowCompactController.restingWindowOpacity, + MainWindowCompactController.activeWindowOpacity + ) + XCTAssertLessThan( + MainWindowCompactController.activeWindowOpacity, + 1 + ) + XCTAssertEqual( + MainWindowCompactController.compactWindowOpacity( + isActive: false, + reduceTransparency: false + ), + MainWindowCompactController.restingWindowOpacity + ) + XCTAssertEqual( + MainWindowCompactController.compactWindowOpacity( + isActive: true, + reduceTransparency: false + ), + MainWindowCompactController.activeWindowOpacity + ) + XCTAssertEqual( + MainWindowCompactController.compactWindowOpacity( + isActive: false, + reduceTransparency: true + ), + 1 + ) + } + + func testCompactAppearanceClearsContentSliverAndRestoresWindowChrome() { + preservingStableFrameDefaults { + let window = makeWindow() + window.alphaValue = 0.75 + window.titlebarSeparatorStyle = .line + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + controller.toggleFromTitlebar() + + XCTAssertEqual( + window.alphaValue, + 0.75 * MainWindowCompactController.restingWindowOpacity, + accuracy: 0.001 + ) + XCTAssertEqual(window.backgroundColor.alphaComponent, 0, accuracy: 0.001) + XCTAssertEqual(window.titlebarSeparatorStyle, .none) + + controller.updateConfiguration( + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: true + ) + XCTAssertEqual(window.alphaValue, 0.75, accuracy: 0.001) + XCTAssertEqual(window.backgroundColor.alphaComponent, 0, accuracy: 0.001) + + controller.toggleFromTitlebar() + + XCTAssertEqual(window.alphaValue, 0.75, accuracy: 0.001) + XCTAssertEqual(window.backgroundColor.alphaComponent, 1, accuracy: 0.001) + XCTAssertEqual(window.titlebarSeparatorStyle, .line) + } + } + + func testFullSizeContentWindowCollapseKeepsTopEdgeAfterAppKitLayout() async { + await preservingStableFrameDefaultsAsync { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 420, height: 760), + styleMask: [ + .titled, + .closable, + .resizable, + .fullSizeContentView, + ], + backing: .buffered, + defer: false + ) + window.identifier = CodexNotesWindowIdentifier.main + window.alphaValue = 0 + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.contentView?.layoutSubtreeIfNeeded() + let expandedFrame = window.frame + let titlebarHeight = max( + 0, + expandedFrame.height - window.contentLayoutRect.height + ) + let expectedCompactHeight = ceil( + titlebarHeight + MainWindowCompactController.compactContentHeight + ) + XCTAssertGreaterThan(titlebarHeight, 0) + XCTAssertGreaterThan( + expectedCompactHeight, + MainWindowCompactController.compactContentHeight + ) + + controller.toggleFromTitlebar() + + XCTAssertTrue(controller.isCollapsed) + XCTAssertEqual( + window.frame.width, + MainWindowCompactController.compactWindowWidth, + accuracy: 0.01 + ) + XCTAssertEqual(window.frame.maxX, expandedFrame.maxX, accuracy: 0.01) + XCTAssertEqual(window.frame.height, expectedCompactHeight, accuracy: 0.01) + XCTAssertEqual(window.frame.maxY, expandedFrame.maxY, accuracy: 0.01) + + for _ in 0..<3 { + await Task.yield() + } + try? await Task.sleep(for: .milliseconds(100)) + + XCTAssertEqual( + window.frame.width, + MainWindowCompactController.compactWindowWidth, + accuracy: 0.01 + ) + XCTAssertEqual(window.frame.maxX, expandedFrame.maxX, accuracy: 0.01) + XCTAssertEqual(window.frame.height, expectedCompactHeight, accuracy: 0.01) + XCTAssertEqual(window.frame.maxY, expandedFrame.maxY, accuracy: 0.01) + + controller.toggleFromTitlebar() + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, expandedFrame) + XCTAssertEqual( + window.contentMinSize, + MainWindowCompactController.minimumExpandedContentSize + ) + } + } + + func testTitlebarToggleCollapsesAndExpandsWithoutChangingStableFrame() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + let initialFrame = window.frame + let originalAccessoryCount = window.titlebarAccessoryViewControllers.count + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + + XCTAssertEqual( + window.titlebarAccessoryViewControllers.count, + originalAccessoryCount + 1 + ) + + controller.toggleFromTitlebar() + + XCTAssertTrue(controller.isCollapsed) + XCTAssertEqual(window.frame.maxX, initialFrame.maxX, accuracy: 0.01) + XCTAssertEqual( + window.frame.width, + MainWindowCompactController.compactWindowWidth, + accuracy: 0.01 + ) + XCTAssertEqual(window.frame.maxY, initialFrame.maxY, accuracy: 0.01) + XCTAssertLessThan(window.frame.height, 50) + XCTAssertEqual( + window.contentRect(forFrameRect: window.frame).height, + MainWindowCompactController.compactContentHeight, + accuracy: 0.01 + ) + XCTAssertLessThanOrEqual( + window.contentMinSize.width, + MainWindowCompactController.compactWindowWidth + ) + XCTAssertFalse(window.styleMask.contains(.resizable)) + XCTAssertEqual( + UserDefaults.standard.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + initialFrameDescriptor(for: initialFrame, using: window) + ) + + controller.toggleFromTitlebar() + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, initialFrame) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertEqual(window.contentMinSize, NSSize(width: 340, height: 520)) + + controller.detach() + XCTAssertEqual( + window.titlebarAccessoryViewControllers.count, + originalAccessoryCount + ) + } + } + + func testCompactPillShowsOnlyTitleAndExpandAccessoryThenRestoresCloseButton() throws { + try preservingStableFrameDefaultsThrowing { + let window = makeWindow() + window.title = "CodexNotes" + window.titleVisibility = .visible + MainWindowChromePolicy.apply( + to: window, + localization: AppLocalization(preference: .english) + ) + let closeButton = try XCTUnwrap( + window.standardWindowButton(.closeButton) + ) + let miniaturizeButton = try XCTUnwrap( + window.standardWindowButton(.miniaturizeButton) + ) + let zoomButton = try XCTUnwrap( + window.standardWindowButton(.zoomButton) + ) + let originalAccessoryCount = + window.titlebarAccessoryViewControllers.count + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + let compactAccessory = try XCTUnwrap( + window.titlebarAccessoryViewControllers.last + ) + let accessoryButtons = compactAccessory.view.subviews.compactMap { + $0 as? NSButton + } + let accessoryButton = try XCTUnwrap(accessoryButtons.first) + + XCTAssertEqual( + window.titlebarAccessoryViewControllers.count, + originalAccessoryCount + 1 + ) + XCTAssertEqual(accessoryButtons.count, 1) + XCTAssertFalse(closeButton.isHidden) + XCTAssertTrue(miniaturizeButton.isHidden) + XCTAssertTrue(zoomButton.isHidden) + XCTAssertEqual( + accessoryButton.accessibilityLabel(), + L10n.text(.mainWindowCollapseAccessibilityLabel) + ) + + controller.toggleFromTitlebar() + + XCTAssertTrue(controller.isCollapsed) + XCTAssertTrue(closeButton.isHidden) + XCTAssertTrue(miniaturizeButton.isHidden) + XCTAssertTrue(zoomButton.isHidden) + XCTAssertEqual(window.title, "CodexNotes") + XCTAssertEqual(window.titleVisibility, .visible) + XCTAssertFalse(compactAccessory.view.isHidden) + XCTAssertFalse(accessoryButton.isHidden) + XCTAssertTrue(accessoryButton.isEnabled) + XCTAssertNotNil(accessoryButton.image) + XCTAssertEqual( + accessoryButton.accessibilityLabel(), + L10n.text(.mainWindowExpandAccessibilityLabel) + ) + XCTAssertEqual(accessoryButtons.count, 1) + + controller.toggleFromTitlebar() + + XCTAssertFalse(controller.isCollapsed) + XCTAssertFalse(closeButton.isHidden) + XCTAssertTrue(closeButton.isEnabled) + XCTAssertEqual( + accessoryButton.accessibilityLabel(), + L10n.text(.mainWindowCollapseAccessibilityLabel) + ) + + controller.toggleFromTitlebar() + XCTAssertTrue(closeButton.isHidden) + controller.detach() + XCTAssertFalse(closeButton.isHidden) + } + } + + func testCollapseTransitionIsAtomicAndAppliesCompactAppearance() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + let backgroundColor = NSColor( + calibratedRed: 0.18, + green: 0.24, + blue: 0.31, + alpha: 1 + ) + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: backgroundColor, + reduceMotion: false, + reduceTransparency: false + ) + window.orderFrontRegardless() + + assertOrdinaryTitlebarAppearance(window) + controller.toggleFromTitlebar() + + assertCompactTitlebarAppearance(window) + XCTAssertTrue(controller.isCompactContentPresentationActive) + XCTAssertFalse(window.styleMask.contains(.resizable)) + XCTAssertLessThan(window.frame.height, 50) + } + } + + func testExpandTransitionIsAtomicAndRestoresOrdinaryAppearance() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.toggleFromTitlebar() + assertCompactTitlebarAppearance(window) + XCTAssertTrue(controller.isCompactContentPresentationActive) + + controller.updateConfiguration( + backgroundColor: .windowBackgroundColor, + reduceMotion: false, + reduceTransparency: false + ) + controller.toggleFromTitlebar() + + assertOrdinaryTitlebarAppearance(window) + XCTAssertFalse(controller.isCompactContentPresentationActive) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertGreaterThan(window.frame.height, 500) + } + } + + func testCoordinatorDoesNotPersistTransientCompactResize() { + preservingStableFrameDefaults { + let availability = CompactTestCodexAvailabilityMonitor( + isCodexAvailable: false + ) + let coordinator = WindowConfigurator.Coordinator( + codexAvailabilityMonitor: availability + ) + let compactController = MainWindowCompactController() + let editor = MarkdownEditorController() + let window = makeWindow() + defer { + coordinator.detach() + window.orderOut(nil) + } + + coordinator.attach( + to: window, + languageRevision: "test", + compactController: compactController, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + MainWindowFramePersistence.persist(window: window) + let persistedExpandedDescriptor = window.frameDescriptor + + compactController.toggleFromTitlebar() + NotificationCenter.default.post( + name: NSWindow.didResizeNotification, + object: window + ) + NotificationCenter.default.post( + name: NSApplication.willTerminateNotification, + object: NSApp + ) + + XCTAssertTrue(compactController.isCollapsed) + XCTAssertEqual( + UserDefaults.standard.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + persistedExpandedDescriptor + ) + } + } + + func testCollapsedPresentationKeepsTransientFrame() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + controller.toggleFromTitlebar() + let compactFrame = window.frame + + controller.presentPreservingTransientFrame { candidate in + candidate.setFrame( + NSRect(x: 5, y: 5, width: 200, height: 200), + display: false + ) + } + + XCTAssertEqual(window.frame, compactFrame) + XCTAssertTrue(controller.isCollapsed) + } + } + + func testDetachRestoresExpandedGeometryAndRemovesAccessory() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + let initialFrame = window.frame + let originalAccessoryCount = window.titlebarAccessoryViewControllers.count + defer { window.orderOut(nil) } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + controller.toggleFromTitlebar() + + controller.detach() + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, initialFrame) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertEqual( + window.titlebarAccessoryViewControllers.count, + originalAccessoryCount + ) + } + } + + func testContinuousOpenPanelStaysCollapsedAcrossTaskSwitch() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.selectionDidChange(seeding: .open) + + XCTAssertTrue(controller.isCollapsed) + } + } + + func testInitialOpenObservationCollapsesOnce() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + + controller.observeRightPanel(.open) + + XCTAssertTrue(controller.isCollapsed) + + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + + controller.observeRightPanel(.open) + XCTAssertFalse(controller.isCollapsed) + + controller.observeRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + } + } + + func testDisablingAvoidanceRestoresAutomaticallyCollapsedWindow() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let expandedFrame = window.frame + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.setAutomaticAvoidanceEnabled(false) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, expandedFrame) + XCTAssertFalse(controller.isAutomaticAvoidanceEnabled) + } + } + + func testDisablingAvoidanceRestoresHiddenAutomaticCollapseWithoutShowingWindow() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let expandedFrame = window.frame + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + window.orderOut(nil) + + controller.setAutomaticAvoidanceEnabled(false) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, expandedFrame) + XCTAssertFalse(window.isVisible) + } + } + + func testDisabledAvoidanceIgnoresPanelButKeepsManualToggleAvailable() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.setAutomaticAvoidanceEnabled(false) + + controller.observeRightPanel(.open) + XCTAssertFalse(controller.isCollapsed) + + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + controller.observeRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + } + } + + func testDisablingAvoidanceDoesNotUndoManualCollapse() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + controller.toggleFromTitlebar() + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + + controller.setAutomaticAvoidanceEnabled(false) + + XCTAssertTrue(controller.isCollapsed) + } + } + + func testReenablingAvoidanceWaitsForAFreshOpenObservation() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.setAutomaticAvoidanceEnabled(false) + controller.observeRightPanel(.open) + XCTAssertFalse(controller.isCollapsed) + + controller.setAutomaticAvoidanceEnabled(true) + + XCTAssertFalse(controller.isCollapsed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + } + } + + func testDisablingAvoidanceCancelsPendingAutomaticCollapse() { + preservingStableFrameDefaults { + let window = makeWindow() + let scrollView = NSScrollView( + frame: NSRect(x: 0, y: 0, width: 420, height: 590) + ) + let textView = CheckboxTextView(frame: scrollView.contentView.bounds) + scrollView.documentView = textView + window.contentView = scrollView + + let controller = makeController() + let editor = MarkdownEditorController() + editor.attach(to: textView) + textView.editorController = editor + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(textView) + textView.setMarkedText( + "ni", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertFalse(controller.isCollapsed) + + controller.setAutomaticAvoidanceEnabled(false) + textView.unmarkText() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15)) + + XCTAssertFalse(controller.isCollapsed) + } + } + + func testDisablingAvoidanceKeepsPendingManualCollapse() { + preservingStableFrameDefaults { + let window = makeWindow() + let scrollView = NSScrollView( + frame: NSRect(x: 0, y: 0, width: 420, height: 590) + ) + let textView = CheckboxTextView(frame: scrollView.contentView.bounds) + scrollView.documentView = textView + window.contentView = scrollView + + let controller = makeController() + let editor = MarkdownEditorController() + editor.attach(to: textView) + textView.editorController = editor + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(textView) + textView.setMarkedText( + "ni", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + + controller.setAutomaticAvoidanceEnabled(false) + textView.unmarkText() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15)) + + XCTAssertTrue(controller.isCollapsed) + } + } + + func testClosedPanelAfterTaskSwitchRestoresAutomaticCollapse() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.selectionDidChange(seeding: .closed) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertTrue(window.styleMask.contains(.resizable)) + } + } + + func testUnknownPanelAfterTaskSwitchFailsOpen() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.selectionDidChange(seeding: .unknown) + + XCTAssertFalse(controller.isCollapsed) + } + } + + func testAutomaticCollapseWaitsForMarkedTextToEnd() { + preservingStableFrameDefaults { + let window = makeWindow() + let scrollView = NSScrollView( + frame: NSRect(x: 0, y: 0, width: 420, height: 590) + ) + let textView = CheckboxTextView( + frame: scrollView.contentView.bounds + ) + scrollView.documentView = textView + window.contentView = scrollView + + let controller = makeController() + let editor = MarkdownEditorController() + editor.attach(to: textView) + textView.editorController = editor + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(textView) + textView.setMarkedText( + "ni", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + XCTAssertTrue(textView.hasMarkedText()) + + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + + XCTAssertFalse(controller.isCollapsed) + window.makeFirstResponder(nil) + NotificationCenter.default.post( + name: NSWindow.didResignKeyNotification, + object: window + ) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + XCTAssertFalse(controller.isCollapsed) + textView.unmarkText() + XCTAssertTrue(controller.isCollapsed) + } + } + + func testPanelCloseAfterAtomicCollapseRestoresExpandedWindow() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: false, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + + controller.observeRightPanel(.open) + controller.observeRightPanel(.closed) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertTrue(window.styleMask.contains(.resizable)) + } + } + + func testPanelCloseRestoresExpandedPreOpenStateAfterManualRoundTrip() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let expandedFrameBeforePanel = window.frame + controller.seedRightPanel(.closed) + + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + + controller.observeRightPanel(.closed) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, expandedFrameBeforePanel) + } + } + + func testPanelCloseKeepsPreExistingCompactStateAfterManualRoundTrip() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + + controller.observeRightPanel(.open) + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + + controller.observeRightPanel(.closed) + + XCTAssertTrue(controller.isCollapsed) + } + } + + func testPanelCloseCancelsDelayedManualCollapseWhenPreOpenStateWasExpanded() { + preservingStableFrameDefaults { + let window = makeWindow() + let scrollView = NSScrollView( + frame: NSRect(x: 0, y: 0, width: 420, height: 590) + ) + let textView = CheckboxTextView(frame: scrollView.contentView.bounds) + scrollView.documentView = textView + window.contentView = scrollView + + let controller = makeController() + let editor = MarkdownEditorController() + editor.attach(to: textView) + textView.editorController = editor + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(textView) + textView.setMarkedText( + "ni", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + controller.toggleFromTitlebar() + XCTAssertFalse(controller.isCollapsed) + + controller.observeRightPanel(.closed) + textView.unmarkText() + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.15)) + + XCTAssertFalse(controller.isCollapsed) + } + } + + func testPanelCloseRestoresHiddenAutomaticCollapseWithoutShowingWindow() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let expandedFrameBeforePanel = window.frame + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + window.orderOut(nil) + XCTAssertFalse(window.isVisible) + + controller.observeRightPanel(.closed) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, expandedFrameBeforePanel) + XCTAssertFalse(window.isVisible) + } + } + + func testAutomaticPanelCloseRestoresExactFrameAfterCompactBarDrifts() { + let suiteName = "CodexNotesTests.AutomaticFrameRestore.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Could not create isolated test defaults") + } + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let window = makeWindow() + let controller = MainWindowCompactController( + framePersistenceDefaults: defaults + ) + controller.setAutomaticAvoidanceEnabled(true) + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let initialFrame = window.frame + let initialDescriptor = initialFrameDescriptor( + for: initialFrame, + using: window + ) + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + XCTAssertEqual( + defaults.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + initialDescriptor + ) + + let compactFrame = window.frame + window.setFrameOrigin( + NSPoint( + x: compactFrame.minX + 120, + y: compactFrame.minY + 80 + ) + ) + XCTAssertNotEqual(window.frame.origin, compactFrame.origin) + + controller.observeRightPanel(.closed) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, initialFrame) + XCTAssertEqual( + defaults.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + initialDescriptor + ) + } + + func testManualExpandStillFollowsMovedCompactBar() { + let suiteName = "CodexNotesTests.ManualFrameRestore.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Could not create isolated test defaults") + } + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let window = makeWindow() + let controller = MainWindowCompactController( + framePersistenceDefaults: defaults + ) + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let initialFrame = window.frame + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + + let compactFrame = window.frame + let movedCompactFrame = compactFrame.offsetBy(dx: 120, dy: 80) + window.setFrame(movedCompactFrame, display: false) + + controller.toggleFromTitlebar() + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame.maxX, movedCompactFrame.maxX) + XCTAssertEqual(window.frame.maxY, movedCompactFrame.maxY) + XCTAssertEqual(window.frame.size, initialFrame.size) + } + + func testTaskSwitchFailOpenRestoresExactFrameAfterCompactBarDrifts() { + let suiteName = "CodexNotesTests.TaskSwitchFrameRestore.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Could not create isolated test defaults") + } + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let window = makeWindow() + let controller = MainWindowCompactController( + framePersistenceDefaults: defaults + ) + controller.setAutomaticAvoidanceEnabled(true) + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let initialFrame = window.frame + let initialDescriptor = initialFrameDescriptor( + for: initialFrame, + using: window + ) + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + let compactFrame = window.frame + window.setFrame( + compactFrame.offsetBy(dx: 120, dy: 80), + display: false + ) + controller.selectionDidChange(seeding: .closed) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertEqual(window.frame, initialFrame) + XCTAssertEqual( + defaults.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + initialDescriptor + ) + } + + func testPanelOpenAfterAtomicExpandEndsCollapsed() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: false, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.observeRightPanel(.closed) + controller.observeRightPanel(.open) + + XCTAssertTrue(controller.isCollapsed) + XCTAssertFalse(window.styleMask.contains(.resizable)) + } + } + + func testDefaultSizeRestoreAfterAtomicCollapseKeepsStableFrame() { + let suiteName = "CodexNotesTests.CompactRestore.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Could not create isolated test defaults") + } + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + do { + let window = makeWindow() + let controller = MainWindowCompactController( + framePersistenceDefaults: defaults + ) + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: false, + reduceTransparency: false + ) + window.orderFrontRegardless() + MainWindowFramePersistence.persist( + window: window, + defaults: defaults + ) + + controller.toggleFromTitlebar() + controller.prepareForDefaultSizeRestore() + XCTAssertTrue( + MainWindowFramePersistence.restoreDefaultSize( + window: window, + defaults: defaults + ) + ) + let restoredFrame = window.frame + let restoredDescriptor = window.frameDescriptor + + XCTAssertFalse(controller.isCollapsed) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertEqual(window.frame, restoredFrame) + XCTAssertGreaterThan(window.frame.height, 500) + XCTAssertEqual( + defaults.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + restoredDescriptor + ) + } + } + + func testDetachAfterAtomicCollapseRestoresExpandedFrame() { + preservingStableFrameDefaults { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { window.orderOut(nil) } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: false, + reduceTransparency: false + ) + window.orderFrontRegardless() + let initialFrame = window.frame + let initialDescriptor = initialFrameDescriptor( + for: initialFrame, + using: window + ) + + controller.toggleFromTitlebar() + controller.detach() + + XCTAssertFalse(controller.isCollapsed) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertEqual(window.frame, initialFrame) + XCTAssertEqual( + UserDefaults.standard.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + initialDescriptor + ) + } + } + + func testSustainedUnknownExpandsAutomaticallyCollapsedWindow() async { + let suiteName = "CodexNotesTests.UnknownFrameRestore.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Could not create isolated test defaults") + } + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let window = makeWindow() + let controller = MainWindowCompactController( + framePersistenceDefaults: defaults + ) + controller.setAutomaticAvoidanceEnabled(true) + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + let initialFrame = window.frame + let initialDescriptor = initialFrameDescriptor( + for: initialFrame, + using: window + ) + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + let compactFrame = window.frame + window.setFrame( + compactFrame.offsetBy(dx: 120, dy: 80), + display: false + ) + + controller.observeRightPanel(.unknown) + try? await Task.sleep(for: .milliseconds(1_100)) + + XCTAssertFalse(controller.isCollapsed) + XCTAssertTrue(window.styleMask.contains(.resizable)) + XCTAssertEqual(window.frame, initialFrame) + XCTAssertEqual( + defaults.string( + forKey: MainWindowFramePersistence.autosaveDefaultsKey + ), + initialDescriptor + ) + + controller.observeRightPanel(.open) + XCTAssertFalse(controller.isCollapsed) + + controller.observeRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + } + + func testKnownOpenRecoveryCancelsUnknownFailOpen() async { + await preservingStableFrameDefaultsAsync { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.closed) + controller.observeRightPanel(.open) + XCTAssertTrue(controller.isCollapsed) + + controller.observeRightPanel(.unknown) + try? await Task.sleep(for: .milliseconds(100)) + controller.observeRightPanel(.open) + try? await Task.sleep(for: .milliseconds(1_050)) + + XCTAssertTrue(controller.isCollapsed) + XCTAssertFalse(window.styleMask.contains(.resizable)) + } + } + + func testSustainedUnknownDoesNotExpandManuallyCollapsedWindow() async { + await preservingStableFrameDefaultsAsync { + let window = makeWindow() + let controller = makeController() + let editor = MarkdownEditorController() + defer { + controller.detach() + window.orderOut(nil) + } + controller.attach( + to: window, + editorController: editor, + backgroundColor: .windowBackgroundColor, + reduceMotion: true, + reduceTransparency: false + ) + window.orderFrontRegardless() + controller.seedRightPanel(.open) + controller.toggleFromTitlebar() + XCTAssertTrue(controller.isCollapsed) + + controller.observeRightPanel(.unknown) + try? await Task.sleep(for: .milliseconds(1_100)) + + XCTAssertTrue(controller.isCollapsed) + XCTAssertFalse(window.styleMask.contains(.resizable)) + } + } + + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 420, height: 590), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.identifier = CodexNotesWindowIdentifier.main + window.alphaValue = 0 + return window + } + + private func makeController() -> MainWindowCompactController { + let controller = MainWindowCompactController() + controller.setAutomaticAvoidanceEnabled(true) + return controller + } + + private func preservingStableFrameDefaults(_ body: () -> Void) { + let defaults = UserDefaults.standard + let key = MainWindowFramePersistence.autosaveDefaultsKey + let originalValue = defaults.object(forKey: key) + defaults.removeObject(forKey: key) + defer { + if let originalValue { + defaults.set(originalValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + body() + } + + private func preservingStableFrameDefaultsThrowing( + _ body: () throws -> Void + ) rethrows { + let defaults = UserDefaults.standard + let key = MainWindowFramePersistence.autosaveDefaultsKey + let originalValue = defaults.object(forKey: key) + defaults.removeObject(forKey: key) + defer { + if let originalValue { + defaults.set(originalValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + try body() + } + + private func preservingStableFrameDefaultsAsync( + _ body: () async -> Void + ) async { + let defaults = UserDefaults.standard + let key = MainWindowFramePersistence.autosaveDefaultsKey + let originalValue = defaults.object(forKey: key) + defaults.removeObject(forKey: key) + defer { + if let originalValue { + defaults.set(originalValue, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + await body() + } + + private func initialFrameDescriptor( + for frame: NSRect, + using window: NSWindow + ) -> String { + let currentFrame = window.frame + window.setFrame(frame, display: false) + let descriptor = window.frameDescriptor + window.setFrame(currentFrame, display: false) + return descriptor + } + + private func assertOrdinaryTitlebarAppearance( + _ window: NSWindow, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue(window.isOpaque, file: file, line: line) + XCTAssertFalse( + window.titlebarAppearsTransparent, + file: file, + line: line + ) + XCTAssertEqual( + window.backgroundColor.alphaComponent, + 1, + accuracy: 0.001, + file: file, + line: line + ) + } + + private func assertCompactTitlebarAppearance( + _ window: NSWindow, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertFalse(window.isOpaque, file: file, line: line) + XCTAssertTrue( + window.titlebarAppearsTransparent, + file: file, + line: line + ) + XCTAssertEqual( + window.backgroundColor.alphaComponent, + 0, + accuracy: 0.001, + file: file, + line: line + ) + XCTAssertEqual( + window.titlebarSeparatorStyle, + .none, + file: file, + line: line + ) + } +} + +@MainActor +private final class CompactTestCodexAvailabilityMonitor: + CodexApplicationAvailabilityObserving +{ + let isCodexAvailable: Bool + + init(isCodexAvailable: Bool) { + self.isCodexAvailable = isCodexAvailable + } + + func start(onChange: @escaping (Bool) -> Void) {} + + func stop() {} +} diff --git a/Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift b/Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift new file mode 100644 index 0000000..74c532b --- /dev/null +++ b/Tests/CodexNotesProbeTests/MainWindowCompactStateTests.swift @@ -0,0 +1,510 @@ +import XCTest +@testable import CodexNotesProbe + +final class MainWindowCompactStateTests: XCTestCase { + func testUnknownObservationDoesNotTriggerOrBecomeAClosedBaseline() { + var state = MainWindowCompactState() + + XCTAssertEqual( + state.observeRightPanel( + .unknown, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + } + + func testFirstOpenObservationCollapsesVisibleExpandedWindow() { + var state = MainWindowCompactState() + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .collapse + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .open) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertEqual(state.collapseOrigin, .automatic) + } + + func testExplicitInitialOpenObservationCollapsesVisibleExpandedWindow() { + var state = MainWindowCompactState() + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false, + isInitialObservation: true + ), + .collapse + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .open) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertEqual(state.collapseOrigin, .automatic) + } + + func testIncrementalClosedToOpenCollapsesVisibleExpandedWindow() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .collapse + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .open) + XCTAssertEqual(state.collapseOrigin, .automatic) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testRepeatedOpenWithinSameCycleDoesNotCollapseAgain() { + var state = automaticallyCollapsedState() + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertEqual(state.collapseOrigin, .automatic) + } + + func testUnknownDuringOpenCycleDoesNotResetCycleOrCauseRetrigger() { + var state = automaticallyCollapsedState() + + XCTAssertEqual( + state.observeRightPanel( + .unknown, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .open) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + } + + func testUnknownThenFirstOpenCollapsesOnceInCurrentCycle() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual( + state.observeRightPanel( + .unknown, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .collapse + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .open) + XCTAssertEqual(state.collapseOrigin, .automatic) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + } + + func testFailOpenReleaseDoesNotRehandleTheSameOpenCycle() { + var state = automaticallyCollapsedState() + + state.releaseAutomaticCollapseForUncertainty() + + XCTAssertNil(state.collapseOrigin) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: false + ), + .expand + ) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .collapse + ) + } + + func testOpenObservationForAnotherSelectionDoesNotResetHandledCycle() { + var state = automaticallyCollapsedState() + + // A task-selection change is not a right-panel close. Consumers must + // preserve this state and report the still-open panel normally. + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertEqual(state.collapseOrigin, .automatic) + } + + func testExpandedBeforeOpenAndCollapsedAtCloseRestoresExpandedState() { + var state = automaticallyCollapsedState() + + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: true + ), + .expand + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .closed) + XCTAssertNil(state.collapseOrigin) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testExpandedBeforeOpenAndExpandedAtCloseStaysExpanded() { + var state = automaticallyCollapsedState() + + XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: false + ), + .expand + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .closed) + XCTAssertNil(state.collapseOrigin) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testCollapsedBeforeOpenAndCollapsedAtCloseStaysCollapsed() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .closed) + XCTAssertNil(state.collapseOrigin) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testCollapsedBeforeOpenAndExpandedAtCloseStaysExpanded() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .closed) + XCTAssertNil(state.collapseOrigin) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testExpandedBeforeOpenIsRestoredAfterManualExpandThenCollapse() { + var state = automaticallyCollapsedState() + + XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand) + XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: true + ), + .expand + ) + XCTAssertEqual(state.lastDefiniteRightPanelState, .closed) + XCTAssertNil(state.collapseOrigin) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testPanelCanAutomaticallyCollapseAgainInANewOpenCycle() { + var state = automaticallyCollapsedState() + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: true + ), + .expand + ) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .collapse + ) + XCTAssertEqual(state.collapseOrigin, .automatic) + } + + func testManualExpandDuringOpenCycleKeepsWindowExpandedAtClose() { + var state = automaticallyCollapsedState() + + XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand) + XCTAssertTrue(state.userOverrodeCurrentOpenCycle) + XCTAssertNil(state.collapseOrigin) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: false + ), + .expand + ) + } + + func testManualCollapseWithNoExpandedPreOpenBaselineStaysCollapsed() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand) + + XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse) + XCTAssertEqual(state.collapseOrigin, .manual) + XCTAssertTrue(state.userOverrodeCurrentOpenCycle) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual(state.collapseOrigin, .manual) + } + + func testManualShowExpandsAutomaticCollapseAndOverridesOpenCycle() { + var state = automaticallyCollapsedState() + + XCTAssertEqual(state.recordManualShow(isCollapsed: true), .expand) + XCTAssertNil(state.collapseOrigin) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertTrue(state.userOverrodeCurrentOpenCycle) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: false + ), + .expand + ) + } + + func testManualShowLeavesExpandedWindowExpandedAndOverridesOpenCycle() { + var state = MainWindowCompactState() + state.seedRightPanel(.open) + + XCTAssertEqual(state.recordManualShow(isCollapsed: false), .none) + XCTAssertTrue(state.userOverrodeCurrentOpenCycle) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + } + + func testManualShowOutsideOpenCycleDoesNotChangeCompactState() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual(state.recordManualShow(isCollapsed: true), .none) + XCTAssertEqual(state.lastDefiniteRightPanelState, .closed) + XCTAssertFalse(state.userOverrodeCurrentOpenCycle) + } + + func testOpenWhileWindowHiddenConsumesCycleWithoutCollapsingLater() { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: false, + isCollapsed: false + ), + .none + ) + XCTAssertTrue(state.hasHandledCurrentOpenCycle) + XCTAssertNil(state.collapseOrigin) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .none + ) + XCTAssertEqual(state.recordManualShow(isCollapsed: false), .none) + XCTAssertTrue(state.userOverrodeCurrentOpenCycle) + } + + func testOpenWhileAlreadyCollapsedDoesNotClaimAutomaticOwnership() { + var state = MainWindowCompactState() + XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse) + state.seedRightPanel(.closed) + XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse) + + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + XCTAssertEqual(state.collapseOrigin, .manual) + XCTAssertEqual( + state.observeRightPanel( + .closed, + isWindowVisible: true, + isCollapsed: true + ), + .none + ) + } + + func testDisablingAvoidanceRestoresAutomaticallyOwnedExpandedBaseline() { + var state = automaticallyCollapsedState() + + XCTAssertEqual( + state.disableAutomaticAvoidance(isCollapsed: true), + .expand + ) + XCTAssertNil(state.collapseOrigin) + XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown) + XCTAssertNil(state.wasExpandedBeforeCurrentOpenCycle) + XCTAssertFalse(state.hasHandledCurrentOpenCycle) + } + + func testDisablingAvoidanceCancelsPendingAutomaticCollapseWithoutExpanding() { + var state = automaticallyCollapsedState() + + XCTAssertEqual( + state.disableAutomaticAvoidance(isCollapsed: false), + .none + ) + XCTAssertNil(state.collapseOrigin) + XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown) + } + + func testDisablingAvoidanceDoesNotUndoManualCollapseDuringOpenCycle() { + var state = automaticallyCollapsedState() + XCTAssertEqual(state.recordManualToggle(isCollapsed: true), .expand) + XCTAssertEqual(state.recordManualToggle(isCollapsed: false), .collapse) + XCTAssertEqual(state.collapseOrigin, .manual) + + XCTAssertEqual( + state.disableAutomaticAvoidance(isCollapsed: true), + .none + ) + XCTAssertEqual(state.collapseOrigin, .manual) + XCTAssertEqual(state.lastDefiniteRightPanelState, .unknown) + XCTAssertNil(state.wasExpandedBeforeCurrentOpenCycle) + } + + private func automaticallyCollapsedState() -> MainWindowCompactState { + var state = MainWindowCompactState() + state.seedRightPanel(.closed) + XCTAssertEqual( + state.observeRightPanel( + .open, + isWindowVisible: true, + isCollapsed: false + ), + .collapse + ) + return state + } +} diff --git a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift index 026a605..e034fcd 100644 --- a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift +++ b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift @@ -1010,6 +1010,15 @@ final class PlainMarkdownTextViewTests: XCTestCase { selectedRange: NSRange(location: 5, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0) ) + let expectedSelection = NSRange( + location: ((first.markdown + "\r\nceshi") as NSString).length, + length: 0 + ) + var selectionAtCompositionEnd: NSRange? + let markedTextView = markedHarness.textView + markedHarness.controller.textCompositionDidEnd = { + selectionAtCompositionEnd = markedTextView.selectedRange() + } markedHarness.textView.unmarkText() XCTAssertEqual( markedHarness.textView.string, @@ -1018,11 +1027,9 @@ final class PlainMarkdownTextViewTests: XCTestCase { XCTAssertEqual(MarkdownImage.matches(in: markedHarness.textView.string).count, 2) XCTAssertEqual( markedHarness.textView.selectedRange(), - NSRange( - location: ((first.markdown + "\r\nceshi") as NSString).length, - length: 0 - ) + expectedSelection ) + XCTAssertEqual(selectionAtCompositionEnd, expectedSelection) markedHarness.textView.insertText( "后", replacementRange: markedHarness.textView.selectedRange() @@ -1555,6 +1562,42 @@ final class PlainMarkdownTextViewTests: XCTestCase { XCTAssertEqual(textView.string, text + "拼") } + func testCompositionEndCallbackRunsAfterPendingPresentationCleanup() throws { + let text = "- [ ] 这是一条需要换行的待办内容。" + let harness = makeHarness(text: text, textWidth: 170, refresh: false) + let textView = harness.textView + let sourceLength = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: sourceLength, length: 0)) + textView.setMarkedText( + "拼", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: sourceLength, length: 0) + ) + textView.refreshChecklistPresentation(forceLayout: true) + + var callbackCount = 0 + var callbackSawMarkedText = true + var callbackHeadIndent: CGFloat? + harness.controller.textCompositionDidEnd = { + callbackCount += 1 + callbackSawMarkedText = textView.hasMarkedText() + callbackHeadIndent = ( + textView.textStorage?.attribute( + .paragraphStyle, + at: 0, + effectiveRange: nil + ) as? NSParagraphStyle + )?.headIndent + } + + textView.unmarkText() + + XCTAssertEqual(callbackCount, 1) + XCTAssertFalse(callbackSawMarkedText) + XCTAssertGreaterThan(try XCTUnwrap(callbackHeadIndent), 0) + XCTAssertEqual(textView.string, text + "拼") + } + func testMarkedTextKeepsFollowingCheckboxPrefixAtItsCurrentRange() throws { let firstTodo = "- [x] 第一条已完成待办" let secondTodo = "- [x] 第二条已完成待办" diff --git a/Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift b/Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift new file mode 100644 index 0000000..e2ff527 --- /dev/null +++ b/Tests/CodexNotesProbeTests/ProbeViewModelRightPanelAvoidanceTests.swift @@ -0,0 +1,31 @@ +import CodexNotesCore +import Foundation +import XCTest +@testable import CodexNotesProbe + +@MainActor +final class ProbeViewModelRightPanelAvoidanceTests: XCTestCase { + func testDetectorCanBeDisabledAndReenabledIndependently() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let model = ProbeViewModel(noteStore: NoteStore(rootURL: root)) + + XCTAssertFalse(model.isRightPanelAvoidanceEnabled) + + model.setRightPanelAvoidanceEnabled(true) + XCTAssertTrue(model.isRightPanelAvoidanceEnabled) + XCTAssertEqual(model.rightPanelObservation.state, .unknown) + XCTAssertNotNil(model.rightPanelObservation.selectionStableKey) + + model.setRightPanelAvoidanceEnabled(false) + XCTAssertFalse(model.isRightPanelAvoidanceEnabled) + XCTAssertEqual(model.rightPanelObservation.state, .unknown) + XCTAssertNotNil(model.rightPanelObservation.selectionStableKey) + + model.setRightPanelAvoidanceEnabled(true) + XCTAssertTrue(model.isRightPanelAvoidanceEnabled) + XCTAssertEqual(model.rightPanelObservation.state, .unknown) + XCTAssertNotNil(model.rightPanelObservation.selectionStableKey) + } +} diff --git a/Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift b/Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift new file mode 100644 index 0000000..e3bf1ca --- /dev/null +++ b/Tests/CodexNotesProbeTests/SettingsRightPanelAvoidancePresentationTests.swift @@ -0,0 +1,85 @@ +import CodexNotesCore +import XCTest +@testable import CodexNotesProbe + +final class SettingsRightPanelAvoidancePresentationTests: XCTestCase { + func testSettingsUsesTheSharedPersistentPreferenceForTheToggle() throws { + let source = try settingsSource() + + XCTAssertTrue(source.contains( + "@AppStorage(RightPanelAvoidancePreference.key)" + )) + XCTAssertTrue(source.contains( + "RightPanelAvoidancePreference.defaultValue" + )) + XCTAssertTrue(source.contains("isOn: $rightPanelAvoidanceEnabled")) + XCTAssertTrue(source.contains(".toggleStyle(.switch)")) + XCTAssertTrue(source.contains( + ".accessibilityLabel(Text(L10n.text(\n" + + " .settingsRightPanelAvoidanceTitle" + )) + XCTAssertTrue(source.contains( + ".accessibilityHint(Text(L10n.text(\n" + + " .settingsRightPanelAvoidanceDescription" + )) + } + + func testPermissionStatusIsSeparateFromTheFeatureToggle() throws { + let source = try settingsSource() + + XCTAssertTrue(source.contains( + ".settingsRightPanelAvoidancePermissionTitle" + )) + XCTAssertTrue(source.contains( + ".settingsRightPanelAvoidanceAuthorized" + )) + XCTAssertTrue(source.contains( + ".settingsRightPanelAvoidanceNotAuthorized" + )) + XCTAssertTrue(source.contains( + "if rightPanelAvoidanceEnabled && !isAccessibilityAuthorized" + )) + XCTAssertFalse(source.contains("if !isAccessibilityAuthorized {")) + XCTAssertTrue(source.contains( + ".settingsRightPanelAvoidanceRequestPermission" + )) + XCTAssertTrue(source.contains( + ".settingsRightPanelAvoidanceOpenSystemSettings" + )) + } + + func testCopyExplainsAutomaticBehaviorAndIndependentManualCollapse() { + let chinese = AppLocalization(preference: .simplifiedChinese) + let english = AppLocalization(preference: .english) + + XCTAssertEqual( + chinese.text(.settingsRightPanelAvoidancePermissionTitle), + "辅助功能权限" + ) + XCTAssertEqual( + english.text(.settingsRightPanelAvoidancePermissionTitle), + "Accessibility Permission" + ) + XCTAssertTrue( + chinese.text(.settingsRightPanelAvoidanceDescription) + .contains("手动折叠和展开") + ) + XCTAssertTrue( + english.text(.settingsRightPanelAvoidanceDescription) + .contains("manually") + ) + } + + private func settingsSource() throws -> String { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: repositoryRoot.appendingPathComponent( + "Sources/CodexNotesProbe/SettingsView.swift" + ), + encoding: .utf8 + ) + } +} From a88a27455286e8aab899928d7a6b2e141dd1559c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:55:11 +0800 Subject: [PATCH 2/4] Fix Xcode 15.4 actor isolation --- Sources/CodexNotesProbe/ContentView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodexNotesProbe/ContentView.swift b/Sources/CodexNotesProbe/ContentView.swift index 0b57a8d..c19ddfd 100644 --- a/Sources/CodexNotesProbe/ContentView.swift +++ b/Sources/CodexNotesProbe/ContentView.swift @@ -2654,6 +2654,7 @@ struct WindowConfigurator: NSViewRepresentable { coordinator.invalidate() } + @MainActor private func applyTheme(to window: NSWindow) { if let appearanceName { if window.appearance?.name != appearanceName { From c1a259f16fc5218c7cf5dad1e4272db268380b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:10:15 +0800 Subject: [PATCH 3/4] Stabilize compact window CI tests --- .../RightPanelAvoidancePreferenceTests.swift | 5 +- .../MainWindowCompactControllerTests.swift | 48 ++++++++++++++++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift index e454be2..5d3a4c6 100644 --- a/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift +++ b/Tests/CodexNotesCoreTests/RightPanelAvoidancePreferenceTests.swift @@ -58,9 +58,8 @@ final class RightPanelAvoidancePreferenceTests: XCTestCase { defaults.set(true, forKey: "temporary") defaults.removeObject(forKey: "temporary") - XCTAssertEqual( - defaults.persistentDomain(forName: suiteName)?.isEmpty, - true + XCTAssertTrue( + defaults.persistentDomain(forName: suiteName)?.isEmpty ?? true ) XCTAssertFalse(RightPanelAvoidancePreference.migrateIfNeeded( defaults: defaults, diff --git a/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift index a019753..716a684 100644 --- a/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift +++ b/Tests/CodexNotesProbeTests/MainWindowCompactControllerTests.swift @@ -249,10 +249,25 @@ final class MainWindowCompactControllerTests: XCTestCase { } } - func testFullSizeContentWindowCollapseKeepsTopEdgeAfterAppKitLayout() async { + func testFullSizeContentWindowCollapseKeepsTopEdgeAfterAppKitLayout() async throws { + guard let visibleFrame = NSScreen.main?.visibleFrame else { + throw XCTSkip("AppKit window integration requires a visible screen") + } + guard visibleFrame.width >= 380, visibleFrame.height >= 560 else { + throw XCTSkip("Visible screen is too small for the expanded-window contract") + } await preservingStableFrameDefaultsAsync { + let windowSize = NSSize( + width: min(420, visibleFrame.width - 40), + height: min(590, visibleFrame.height - 40) + ) let window = NSWindow( - contentRect: NSRect(x: 100, y: 100, width: 420, height: 760), + contentRect: NSRect( + x: visibleFrame.midX - windowSize.width / 2, + y: visibleFrame.midY - windowSize.height / 2, + width: windowSize.width, + height: windowSize.height + ), styleMask: [ .titled, .closable, @@ -1323,7 +1338,7 @@ final class MainWindowCompactControllerTests: XCTestCase { ) } - func testManualExpandStillFollowsMovedCompactBar() { + func testManualExpandStillFollowsMovedCompactBar() throws { let suiteName = "CodexNotesTests.ManualFrameRestore.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { return XCTFail("Could not create isolated test defaults") @@ -1332,6 +1347,23 @@ final class MainWindowCompactControllerTests: XCTestCase { defer { defaults.removePersistentDomain(forName: suiteName) } let window = makeWindow() + guard let visibleFrame = NSScreen.main?.visibleFrame else { + throw XCTSkip("AppKit window integration requires a visible screen") + } + guard visibleFrame.width >= window.frame.width + 4, + visibleFrame.height >= window.frame.height + 4 + else { + throw XCTSkip("Visible screen cannot contain the expanded test window") + } + window.setFrame( + NSRect( + x: visibleFrame.midX - window.frame.width / 2, + y: visibleFrame.midY - window.frame.height / 2, + width: window.frame.width, + height: window.frame.height + ), + display: false + ) let controller = MainWindowCompactController( framePersistenceDefaults: defaults ) @@ -1354,14 +1386,18 @@ final class MainWindowCompactControllerTests: XCTestCase { XCTAssertTrue(controller.isCollapsed) let compactFrame = window.frame - let movedCompactFrame = compactFrame.offsetBy(dx: 120, dy: 80) + let dx = min(120, max(0, visibleFrame.maxX - compactFrame.maxX - 1)) + let dy = min(80, max(0, visibleFrame.maxY - compactFrame.maxY - 1)) + XCTAssertGreaterThan(dx, 0) + XCTAssertGreaterThan(dy, 0) + let movedCompactFrame = compactFrame.offsetBy(dx: dx, dy: dy) window.setFrame(movedCompactFrame, display: false) controller.toggleFromTitlebar() XCTAssertFalse(controller.isCollapsed) - XCTAssertEqual(window.frame.maxX, movedCompactFrame.maxX) - XCTAssertEqual(window.frame.maxY, movedCompactFrame.maxY) + XCTAssertEqual(window.frame.maxX, movedCompactFrame.maxX, accuracy: 0.01) + XCTAssertEqual(window.frame.maxY, movedCompactFrame.maxY, accuracy: 0.01) XCTAssertEqual(window.frame.size, initialFrame.size) } From 07086cb7c4cc6ba2cb9c45c078eb465255ac5a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9Fsir=E7=88=B1=E6=95=B0=E7=A0=81?= <66911217+jiangsir-tech@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:21:48 +0800 Subject: [PATCH 4/4] Stabilize IME performance regression test --- .../PlainMarkdownTextViewTests.swift | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift index e034fcd..cc8f4c4 100644 --- a/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift +++ b/Tests/CodexNotesProbeTests/PlainMarkdownTextViewTests.swift @@ -1388,9 +1388,11 @@ final class PlainMarkdownTextViewTests: XCTestCase { textView.setSelectedRange(NSRange(location: insertionLocation, length: 0)) let lengths = Array(1...12) + Array((1...11).reversed()) + var updateDurations: [Double] = [] let updateStart = CFAbsoluteTimeGetCurrent() for (index, length) in lengths.enumerated() { let markedText = String(repeating: "n", count: length) + let singleUpdateStart = CFAbsoluteTimeGetCurrent() textView.setMarkedText( markedText, selectedRange: NSRange(location: length, length: 0), @@ -1398,13 +1400,30 @@ final class PlainMarkdownTextViewTests: XCTestCase { ? NSRange(location: insertionLocation, length: 0) : NSRange(location: NSNotFound, length: 0) ) + updateDurations.append( + (CFAbsoluteTimeGetCurrent() - singleUpdateStart) * 1_000 + ) } let updateMilliseconds = (CFAbsoluteTimeGetCurrent() - updateStart) * 1_000 + let sortedDurations = updateDurations.sorted() + let percentile90Index = max( + 0, + Int(ceil(Double(sortedDurations.count) * 0.9)) - 1 + ) + let percentile90Milliseconds = sortedDurations[percentile90Index] + let maximumUpdateMilliseconds = sortedDurations.last ?? 0 XCTAssertTrue(textView.hasMarkedText()) - XCTAssertLessThan(updateMilliseconds, 1_000) + XCTAssertEqual(updateDurations.count, lengths.count) + XCTAssertLessThan(percentile90Milliseconds, 100) + XCTAssertLessThan(maximumUpdateMilliseconds, 1_000) + XCTAssertLessThan(updateMilliseconds, 3_000) textView.unmarkText() - print("TEXTKIT_IME_BENCHMARK updates_ms=\(updateMilliseconds)") + print( + "TEXTKIT_IME_BENCHMARK updates_ms=\(updateMilliseconds) " + + "p90_ms=\(percentile90Milliseconds) " + + "max_ms=\(maximumUpdateMilliseconds)" + ) } func testChecklistLayoutAcrossFontSpacingAndCompletionMatrix() throws {