From 976a883280fbcb3f4f408875321ef9992c166ba0 Mon Sep 17 00:00:00 2001 From: Tavric Date: Sat, 26 Sep 2026 17:00:33 +0800 Subject: [PATCH 1/4] i18n(zh-TW): use Taiwan UI wording (#301) Complete the Traditional Chinese catalog and align Taiwan UI terminology. Preserve message IDs and placeholders, correct the search coverage wording, and label backup contents consistently. --- nebula_app/i18n/zh-TW.json | 831 +++++++++++++++++++++++++++++++++++-- 1 file changed, 800 insertions(+), 31 deletions(-) diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index b827ae9..74bb6b4 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -1,11 +1,24 @@ { + "notification": { + "bell": "終端機響鈴", + "turn_failed_reason": "請求失敗:{error}", + "turn_failed": "請求失敗,請查看終端機中的錯誤詳情。", + "turn_incomplete": "回覆未完成,請查看終端機後再繼續。", + "turn_ended": "回合已結束,請查看終端機中的結果。" + }, "language": { - "system": "跟隨系統", "self_name": "繁體中文", + "system": "跟隨系統", "zh_cn": "简体中文", "en_us": "English" }, "settings": { + "scrolling": { + "history": "回捲行數", + "history_description": "每個窗格保留的歷史行數。僅適用於新窗格。", + "speed": "滾動速度", + "speed_description": "滑鼠滾輪滾動的倍率。即時生效,不影響觸控板滾動。" + }, "sidebar": { "application": "應用程式", "appearance": "外觀", @@ -19,6 +32,21 @@ "backup": "備份", "agents": "Agents" }, + "notifications": { + "duration": "通知顯示時間", + "duration_description": "選擇應用程式內通知多久後自動關閉,也可以讓通知持續顯示。", + "duration_details": "適用於新顯示或重新整理的應用程式內通知。預設時間為:簡短提示 5 秒、訊息提醒 90 秒;更新通知持續顯示。畫面最多保留最新的 3 則通知,即使設為持續顯示也是如此。關閉或取代通知不會代你同意或拒絕確認請求,也不會改變任務狀態。系統通知不受影響。", + "duration_default": "使用預設顯示時間", + "duration_five": "5 秒", + "duration_ten": "10 秒", + "duration_thirty": "30 秒", + "duration_ninety": "90 秒", + "duration_persistent": "持續顯示", + "ai_messages": "AI 浮動通知", + "ai_messages_description": "在右下角顯示 AI 浮動通知;系統通知不受影響。", + "ai_messages_details": "關閉後,現有的應用程式內 AI 浮動通知會收起,也不再顯示新的完成及確認通知。Windows 系統通知和分頁狀態提示仍會保留;終端機輸出及其中的確認請求不受影響。", + "save_failed": "無法儲存通知設定:{error}" + }, "network": { "title": "網路代理", "mode": { @@ -30,25 +58,116 @@ "address": { "label": "代理位址" }, + "status": { + "idle": "測試目前設定能否連上網路", + "running": "正在使用目前設定測試網路…", + "success": "網路連線正常 · {route} · {elapsed_ms} ms", + "failed": "測試失敗:{error}", + "unexpected_end": "網路測試意外中止,請重試", + "start_failed": "無法啟動網路測試:{error}" + }, "action": { "test": "測試網路", "testing": "測試中…" }, "route": { "direct": "直接連線", - "direct_address": "直連位址", - "custom_command": "自訂命令" + "direct_address": "直接連線位址", + "custom_command": "自訂命令", + "proxy_server": "代理 {server}", + "ssh_jump": "SSH {target}" + }, + "failure": { + "load_settings": "讀取代理設定失敗:{error}", + "invalid_settings": "代理設定無效:{error}", + "timeout": "網路測試逾時({seconds} 秒無回應)", + "send_request": "傳送網頁測試請求失敗:{error}", + "read_response": "讀取網頁測試回應失敗:{error}", + "invalid_http_status_line": "目標網站未傳回有效的 HTTP 狀態行:{line}", + "http_status": "目標網站傳回 HTTP {status}", + "proxy_server": "無法透過代理伺服器 {server} 連上網路:{error}", + "custom_command": "無法透過自訂代理命令連上網路:{error}", + "jump_task": "無法解析跳板主機任務:{error}", + "jump_resolve": "無法解析跳板主機 {target}:{error}", + "jump_connect": "無法連線至跳板主機 {target}:{error}", + "jump_channel": "無法透過跳板主機 {target} 開啟對外連線通道:{error}", + "direct": "無法直接連線至測試網站:{error}" } }, + "appearance": { + "background_color_description": "覆蓋主題的終端機背景色。變更後,OSC 11 回報給程式的背景色也會跟著改變;TUI 會根據這個值判斷明暗模式。", + "background_cover_title": "讓背景圖覆蓋視窗控制項區域?", + "background_cover_description": "背景圖片會延伸到標題列、視窗按鈕、分頁和 SSH 側邊欄下方。對比不足的圖片可能讓控制項不易辨識;介面仍會維持最低不透明度。" + }, "font": { - "loading": "正在列舉系統字型…", - "no_matches": "沒有符合的字型" + "ligatures": "字型連字", + "ligatures_description": "將 ->、!= 等字元組合顯示為字型支援的連字。變更會立即套用至已開啟的終端機。", + "ligatures_on": "開啟", + "ligatures_off": "關閉", + "ligatures_theme": "跟隨主題", + "ui_family": "介面字型", + "ui_family_description": "輸入已安裝的字型名稱;留空則使用預設字型。按 Enter 或離開輸入欄位後生效。", + "ui_size": "介面字型大小", + "ui_size_description": "獨立調整介面文字和控制項字型大小,不影響終端機文字。", + "english": "英文終端機字型", + "chinese": "中文終端機字型", + "chinese_description": "用於中文等全形字元。輸入字型名稱,多種字型請以逗號分隔;留空則使用 Maple Mono NF CN。按 Enter 或離開輸入欄位後生效。", + "fallback_description": "優先使用清單中的第一種字型;若缺少字形,會依序嘗試後續字型,最後使用內建的 Maple 字型。", + "group_description": "第一種為主要字型,其餘依序作為備用字型", + "loading": "正在讀取系統字型…", + "no_matches": "沒有符合的字型", + "expand": "展開字型清單", + "collapse": "收合字型清單" + }, + "keymap": { + "conflict": "{combo} 同時指定給「{first}」和「{second}」;只有排在前面的「{first}」會生效", + "help": "點選快速鍵後,按下新的組合鍵。按 Backspace 清除,按 Esc 取消;也可以使用右側按鈕清除或還原預設值。", + "clear": "清除快速鍵(按鍵會傳送至終端機)", + "restore": "還原此動作的預設快速鍵" + }, + "input": { + "backup_secret": "認證資訊只會儲存至系統認證管理員" + }, + "ssh": { + "default_keys": "未指定時,將使用 IdentityFile 和預設的 id_* 私鑰", + "address_hint": "支援 user@host,也可貼上 ssh://host:2222", + "auth_auto_description": "依序嘗試可用的私鑰;若都無法使用,再詢問密碼。多數情況下選這個即可。", + "auth_interactive_description": "不預先儲存認證資訊。連線時請依終端機提示輸入,支援雙重驗證。", + "save_password": "儲存到 Windows 認證管理員", + "hide_config_host": "隱藏(無法刪除 ~/.ssh/config 中的主機)", + "empty_hint": "按「+ 新增主機」,或在 ~/.ssh/config 設定主機別名", + "shared_data_hint": "與舊版介面共用資料;刪除來自 ~/.ssh/config 的主機別名只會將其隱藏,之後仍可還原" + }, + "quick_terminal": { + "mode": "快速終端機開啟方式", + "description": "獨立視窗會記住大小;叫出現有視窗時,不會新增分頁。", + "dedicated": "獨立視窗", + "existing": "叫出現有視窗" + }, + "search_empty": "沒有符合的設定", + "mouse": { + "focus_follows_mouse": "焦點跟隨滑鼠", + "focus_follows_mouse_description": "滑鼠移到終端機窗格時自動切換焦點。預設關閉。" + }, + "panes": { + "dim_inactive": "調暗非使用中窗格", + "dim_inactive_description": "調暗其他分割窗格,讓目前使用的窗格更顯眼。" + }, + "save_failed": "無法儲存設定:{error}", + "reset_title": "還原預設設定?", + "reset_description": "這會重設設定和快速鍵,但保留 SSH 主機、認證資訊與歷史紀錄,並備份目前的設定。要繼續嗎?", + "startup": { + "launch_at_login": "登入時自動啟動", + "launch_at_login_help": "登入 Windows 後啟動 Pebrel;使用與安裝程式相同的啟動捷徑。", + "silent_start": "在背景啟動", + "silent_start_help": "啟動後在背景執行,不顯示視窗。啟用時也會開啟系統匣,可點選圖示重新顯示視窗。此設定只在系統匣啟用時生效;若指定了啟動目錄則不適用。", + "save_failed": "無法變更啟動設定:{error}" }, "agents": { - "title": "程式設計智能體", + "title": "程式設計代理程式", "description": "在 Pebrel 中顯示執行狀態、完成通知和授權請求。Claude Code 與 Codex 預設開啟。", "refresh": "重新整理", - "platform_unsupported": "目前僅 Windows 支援自動安裝 hooks。", + "platform_unsupported": "目前只有 Windows 支援自動安裝 hooks。", "checking": "正在偵測工具與 hooks…", "applying": "處理中…", "installed": "Hook 已安裝", @@ -58,9 +177,9 @@ "unsupported": "尚未提供 hook 整合", "unavailable": "無法使用", "needs_attention": "需要檢查", - "restart": "變更將在重新啟動 Agent 後生效。這裡管理安裝在本機的 Agent。", - "enabled": "Hooks 已安裝,請重新啟動 agent 使其生效。", - "disabled": "Hooks 已移除,請重新啟動 agent 使其生效。", + "restart": "變更會在重新啟動 Agent 後生效。此處管理安裝在本機的 Agent。", + "enabled": "Hooks 已安裝。請重新啟動 Agent 讓變更生效。", + "disabled": "Hooks 已移除。請重新啟動 Agent 讓變更生效。", "failed": "無法更新 hooks:{error}", "helper_missing": "缺少 hook 輔助程式,請修復 Pebrel 安裝後再啟用。", "needs_repair": "Hook 已安裝 · 需修復", @@ -68,6 +187,27 @@ "config_path": "設定:{path}" } }, + "provider": { + "test": { + "success": "連線成功(HTTP {status})", + "invalid_endpoint": "請求網址必須以 http:// 或 https:// 開頭", + "missing_model": "請先填寫預設模型", + "missing_api_key": "請先儲存 API Key", + "credential_read_failed": "無法從認證管理員讀取 API Key", + "invalid_credential_encoding": "認證管理員中的 API Key 編碼無效", + "timeout": "連線逾時", + "host_not_found": "找不到伺服器", + "connection_failed": "無法連線至伺服器", + "io_error": "網路錯誤:{kind}", + "tls_failed": "TLS 握手失敗", + "request_failed": "請求失敗", + "auth_failed": "身分驗證失敗(HTTP {status}),請檢查 API Key", + "endpoint_not_found": "端點不存在(HTTP {status}),請檢查請求位址", + "rate_limited": "服務已限制請求頻率(HTTP {status});可以連線,但目前無法驗證額度", + "http_status": "服務傳回 HTTP {status}", + "start_failed": "無法開始測試供應商連線:{error}" + } + }, "common": { "link_command_click": "Command+左鍵開啟", "link_ctrl_click": "Ctrl+左鍵開啟", @@ -165,12 +305,12 @@ "opacity": "不透明度", "background": "背景", "background_image": "背景圖片", - "background_color": "背景色彩", + "background_color": "背景色", "foreground": "前景", "text": "文字", "standard": "標準", - "compact": "緊湊", - "relaxed": "寬鬆", + "compact": "緊密", + "relaxed": "寬敞", "left": "左側", "right": "右側", "top": "頂端", @@ -191,39 +331,616 @@ "command": "命令", "run": "執行", "stop": "停止", - "history": "歷程", + "history": "歷史紀錄", "saved_commands": "已儲存的命令", "session": "工作階段", "key": "按鍵", "action": "動作", - "update_notice": "Pebrel v{latest} 已發布(目前 v{current})", + "update_notice": "Pebrel v{latest} 已推出(目前為 v{current})", "copied_lines": "已複製 {lines} 行到剪貼簿", - "copy_working_directory": "複製工作目錄", - "cwd_copied": "工作目錄已複製", + "copy_working_directory": "複製目前工作目錄", + "cwd_copied": "已複製工作目錄", "paste_lines": "貼上 {lines} 行文字?", - "sent_to": "已傳送至 {target}" + "drop_paths_invalid": "無法貼上這些路徑,請檢查特殊字元或減少拖入的檔案數量。", + "drop_paths_busy": "上一批拖入的路徑仍在轉換中。", + "drop_paths_expired": "輸入狀態已變更,已取消貼上路徑。", + "drop_paths_failed": "無法為此 WSL 工作階段轉換路徑,請重試。", + "drop_paths_remote": "無法在 SSH 工作階段直接使用本機路徑。請將檔案拖至遠端檔案樹上傳。", + "image_paste_failed": "無法貼上圖片:{error}", + "image_paste_busy": "上一張圖片仍在處理。", + "image_paste_expired": "輸入狀態已變更,已取消貼上圖片。", + "image_paste_limit": "目前分頁的暫存圖片數量已達上限。", + "image_paste_disconnected": "請先連線 SSH 工作階段再貼上圖片。", + "sent_to": "已傳送至 {target}", + "yes": "是", + "no": "否", + "confirmation_expired": "確認請求已變更或結束。", + "link_missing_cwd": "無法開啟相對路徑(缺少工作目錄):{path}", + "link_file_not_found": "找不到檔案:{path}", + "link_open_failed": "無法開啟檔案:{error}", + "link_unrecognized": "無法辨識連結格式:{target}", + "link_open_url_failed": "無法開啟連結:{error}" + }, + "editor": { + "bold_selection": "將選取文字設為粗體(Ctrl+B / ⌘B)", + "italic_selection": "將選取文字設為斜體(Ctrl+I / ⌘I)", + "release_to_close_sidebar": "放開以收合側邊欄", + "document_actions": "檔案操作", + "source": "原始碼", + "edit_in_place": "編輯", + "finish_editing": "完成", + "finish_editing_shortcut": "結束編輯(Ctrl+Enter / ⌘Enter)", + "edit_block": "編輯此段落", + "right_sidebar": "展開或收合右側邊欄", + "loading": "正在讀取檔案…", + "save": "儲存", + "saving": "正在儲存…", + "saved": "已儲存", + "unsaved": "未儲存", + "edit": "編輯", + "preview": "預覽", + "reading": "閱讀", + "statistics": "檔案統計", + "properties": "檔案屬性", + "details_toggle": "切換檔案側邊欄", + "details_close": "隱藏檔案側邊欄", + "fullscreen": "進入全螢幕", + "exit_fullscreen": "離開全螢幕", + "focus_reading": "專注閱讀", + "exit_focus_reading": "離開專注閱讀模式", + "search_code_language": "搜尋語言…", + "copy_code": "複製程式碼", + "copy_math_latex": "複製 LaTeX 原始碼", + "copy_math_image": "複製公式圖片", + "math_image_unavailable": "公式圖片仍在產生中,或包含只能以文字呈現的字元", + "math_latex_copied": "LaTeX 原始碼已複製", + "math_image_copied": "公式圖片已複製", + "markdown_document": "Markdown 檔案", + "outline": "目錄", + "no_headings": "這份檔案沒有標題", + "copy_heading": "複製標題", + "reload": "重新讀取檔案", + "copy_path": "複製檔案路徑", + "read_only": "唯讀檔案", + "truncated": "檔案超過 8 MB,只能預覽開頭部分,無法編輯或儲存", + "encoding": "檔案不是完整的 UTF-8 文字,只能預覽", + "conflict": "檔案已由其他程式修改。你的編輯內容仍在,請先複製要保留的內容,再重新讀取檔案。", + "read_failed": "讀取失敗:{error}", + "save_failed": "儲存失敗:{error}", + "discard_title": "放棄未儲存的修改?", + "close_title": "儲存檔案修改?", + "discard": "放棄修改", + "cancel": "取消", + "lines": "{count} 行", + "save_shortcut": "儲存(Ctrl+S / ⌘S)", + "info": "檔案資訊", + "type": "檔案類型", + "size": "檔案大小", + "modified": "修改時間", + "characters": "字元數", + "line_count": "行數", + "encoding_label": "編碼", + "line_ending": "換行字元", + "status": "狀態", + "copy_name": "複製檔名", + "reveal": "在檔案總管中顯示", + "open_external": "使用預設應用程式開啟", + "preview_limited": "檔案較大,僅顯示部分格式化預覽。切換至編輯模式,可查看已載入的原始碼。" + }, + "commands": { + "add_command": "新增命令", + "root": "所有命令", + "back_to_root": "回到所有命令", + "group_count": "{count} 個命令", + "add_group": "新增群組", + "group_name": "群組名稱", + "builtin_group": "內建命令", + "ungrouped": "未分類", + "remove_from_group": "移出群組", + "move_to_group": "移至群組", + "delete_group": "刪除群組,但保留其中的命令", + "group_save_failed": "無法儲存命令群組", + "builtin": { + "python_install_windows": "安裝 Python 3.14 · Windows", + "python_install_mac": "安裝 Python · macOS", + "python_install_ubuntu": "安裝 Python · Ubuntu/Debian", + "python_install_fedora": "安裝 Python · Fedora", + "git_install_windows": "安裝 Git · Windows", + "git_install_mac": "安裝 Git · macOS", + "git_install_ubuntu": "安裝 Git · Ubuntu/Debian", + "git_install_fedora": "安裝 Git · Fedora", + "docker_install_windows": "安裝 Docker Desktop · Windows", + "docker_install_mac": "安裝 Docker Desktop · macOS", + "docker_install_ubuntu": "安裝 Docker · Ubuntu 24.04", + "docker_install_fedora": "安裝 Docker · Fedora", + "docker_ps": "Docker · 查看所有容器", + "docker_images": "Docker · 查看映像檔", + "docker_build": "Docker · 建置目前專案映像檔", + "docker_run": "Docker · 執行並對應連接埠", + "docker_logs": "Docker · 持續查看容器紀錄", + "docker_exec": "Docker · 進入容器 Shell", + "compose_up": "Docker Compose · 啟動開發服務", + "compose_logs": "Docker Compose · 查看服務紀錄", + "compose_down": "Docker Compose · 停止服務(保留磁碟區)", + "conda_install_windows": "安裝 Miniconda · Windows", + "conda_install_mac": "安裝 Miniconda · macOS", + "conda_install_linux": "安裝 Miniconda · Linux", + "conda_list": "Conda · 查看環境清單", + "conda_create": "Conda · 建立 Python 開發環境", + "conda_activate": "Conda · 啟用開發環境", + "conda_deactivate": "Conda · 停用目前環境", + "conda_install": "Conda · 安裝常用資料科學套件", + "conda_export": "Conda · 匯出環境相依性", + "conda_restore": "Conda · 從檔案建立環境", + "python_venv": "Python · 建立虛擬環境", + "python_activate_windows": "Python · 啟用虛擬環境 · PowerShell", + "python_activate_posix": "Python · 啟用虛擬環境 · Bash/Zsh", + "pip_install": "Python · 安裝專案相依性", + "uv_sync": "uv · 同步專案環境與相依性", + "npm_install": "npm · 安裝鎖定相依性", + "npm_dev": "npm · 啟動開發服務", + "pnpm_install": "pnpm · 安裝專案相依性", + "git_status": "Git · 檢視工作區狀態", + "git_diff": "Git · 檢視未暫存修改", + "git_log": "Git · 查看提交紀錄圖", + "git_branch": "Git · 建立並切換開發分支", + "cargo_test": "Rust · 執行專案測試", + "cargo_check": "Rust · 檢查所有目標", + "ssh_key": "SSH · 建立 Ed25519 金鑰" + }, + "builtin_label": "內建", + "save_copy": "儲存副本" + }, + "files": { + "path_unavailable": "路徑已不存在", + "delete_failed": "刪除失敗:{error}", + "ignore_add": "加入 .gitignore", + "ignore_remove": "從 .gitignore 移除", + "ignore_added": "已寫入 .gitignore:{entry}", + "ignore_removed": "已取消忽略:{entry}", + "ignore_present": ".gitignore 已有這項規則:{entry}", + "ignore_visible": "此路徑未被忽略:{entry}", + "ignore_failed": "無法修改 Git 忽略規則:{error}", + "preview_loading": "正在產生預覽…", + "preview_unavailable": "此檔案暫時無法預覽", + "preview_pages": "第 1 頁 · 共 {pages} 頁", + "open_terminal_here": "在此處開啟終端機", + "tree": { + "open_in_explorer": "在檔案總管中開啟", + "follow_refresh": "跟隨目前終端機並重新整理(Alt+R)" + }, + "copy_linux_path": "複製 Linux 路徑", + "reveal_in_explorer": "在檔案總管中顯示", + "copy_path": "複製路徑", + "empty": { + "reading_title": "正在讀取目錄", + "reading_body": "正在讀取目前工作目錄的內容。", + "reading_hint": "請稍候,或點選右上角的跟隨按鈕重新讀取。", + "read_failed_title": "讀取目錄失敗", + "read_failed_body": "無法完整讀取此目錄,請檢查存取權限或重試。", + "read_failed_hint": "點選右上角的跟隨按鈕重試,或改選其他目錄。", + "no_directory_title": "沒有可瀏覽的目錄", + "no_directory_body": "目前終端機尚未提供工作目錄。", + "no_directory_hint": "先在終端機切換至目錄,再點選右上角的跟隨按鈕。", + "directory_empty_title": "此目錄為空", + "directory_empty_body": "目前工作目錄沒有可顯示的檔案。", + "directory_empty_hint": "在終端機建立檔案,或選擇其他目錄。" + }, + "delete": { + "title": "刪除 {name}?", + "folder_body": "資料夾及其全部內容會移入資源回收筒。", + "file_body": "檔案會移入資源回收筒。" + } + }, + "recipe": { + "title": "版面配置範本", + "name": "範本名稱", + "save": "儲存版面配置", + "tab": "目前分頁", + "window": "目前視窗", + "empty": "尚未儲存任何版面配置。排好分頁和分割窗格後,幫它取個名字。", + "description": "儲存分頁、分割窗格比例及工作目錄。還原時會在新視窗建立終端機。", + "open": "還原", + "delete": "刪除", + "loading": "正在讀取版面配置…", + "saved": "已儲存版面配置", + "failed": "操作失敗:{error}", + "delete_title": "刪除此版面配置範本?", + "open_failed": "無法還原版面配置", + "count": "{tabs} 個分頁 · {panes} 個分割窗格", + "save_window": "儲存目前視窗的版面配置…", + "open_library": "開啟版面配置範本…" + }, + "chemistry": { + "loading": "正在繪製分子結構…", + "invalid": "目前無法繪製此 SMILES,將保留原始碼。" + }, + "markdown": { + "plain_text": "純文字", + "choose_language": "選擇此程式碼區塊的語法醒目提示語言" + }, + "transfer": { + "not_ready": "遠端目錄尚未就緒", + "target_changed": "目標位置已變更,請在所需目錄重新開始傳輸。", + "busy": "已有傳輸正在進行,請等待完成或先取消。", + "checking": "正在檢查傳輸目標…", + "downloaded": "下載完成", + "cancelled": "傳輸已取消", + "failed": "傳輸失敗", + "upload_hint": "拖入檔案以上傳", + "download_hint": "拖至檔案總管中的資料夾即可下載", + "conflict_title": "已有同名項目", + "conflict_body": "目標位置已有 {count} 個同名項目。", + "incompatible": "部分項目的檔案與資料夾類型不符,無法覆寫。", + "symlink_warning": "覆寫符號連結會直接修改其目標,失敗或取消可能留下部分內容。", + "overwrite": "覆寫", + "skip": "略過", + "keep_both": "保留兩者", + "cancel": "取消", + "cancelling": "正在取消操作…", + "busy_at": "{host} 已有傳輸正在進行", + "destination": "目標位置:{destination}", + "copied_item": "已複製遠端項目:{name}", + "choose_files": "選擇要上傳的檔案", + "choose_upload_directory": "選擇要上傳的資料夾", + "choose_download_directory": "選擇下載位置", + "drag_hint": "將檔案拖進來即可上傳;拖至檔案總管即可下載。" + }, + "hosts": { + "search": "搜尋名稱、位址、群組、標籤或備註", + "group": "群組", + "tags": "標籤", + "notes": "備註", + "tags_hint": "用逗號分隔多個標籤", + "all_groups": "所有群組", + "ungrouped": "未分類", + "all": "全部主機", + "managed": "長期主機", + "recent": "最近連線", + "no_matches": "沒有符合的主機", + "import": "匯入 CSV", + "export": "匯出 CSV", + "import_preview": "將新增 {added} 台主機;已有的 {skipped} 台主機會保留認證資訊和連線設定。原本隱藏的符合主機也會重新顯示。", + "invalid": "主機資訊無效:{error}", + "processing": "正在處理主機資訊…", + "reloaded": "已重新讀取主機資訊", + "exported": "主機資訊已匯出,不含認證資訊", + "imported": "已新增 {added} 台主機,略過 {skipped} 台現有主機", + "imported_partial": "已新增 {added} 台主機,但顯示狀態未能更新。請重試匯入:{error}", + "title": "SSH 主機", + "subtitle": "整理已儲存的主機、管理登入方式,並直接開啟終端機工作階段。", + "add": "新增主機", + "connect": "連線", + "exchange": "匯入 / 匯出", + "pinned": "置頂", + "config_empty": "可讀取 ~/.ssh/config,與已儲存的主機一起使用。", + "config_shared": "共用 ~/.ssh/config 中的 {count} 個主機別名。", + "reload": "重新讀取", + "auth_auto": "自動認證", + "auth_password": "密碼", + "auth_key": "私鑰", + "auth_interactive": "互動式認證" + }, + "vcs": { + "changes": "變更", + "staged": "已暫存", + "merge_conflicts": "合併衝突", + "no_conflicts": "沒有待解決的衝突", + "no_changes": "沒有更改", + "no_history": "還沒有提交歷史", + "no_branch": "(沒有分支)", + "confirm_discard": "確認捨棄變更", + "discard": "捨棄變更", + "stage": "暫存", + "unstage": "取消暫存", + "resolve_in_merge_editor": "在三欄合併編輯器中解決衝突", + "commit_changes": "提交 / 變更", + "graph": "提交紀錄圖", + "history_graph": "提交歷史圖", + "conflicts": "衝突", + "resolve_conflicts": "解決衝突", + "commit": "提交", + "commit_placeholder": "提交資訊…", + "stage_all": "全部暫存", + "pull": "拉取", + "push": "推送", + "push_ahead": "推送 ↑{count}", + "follow_directory": "回到終端機目前所在的目錄", + "not_repository": "目前目錄不在 Git 或 SVN 儲存庫中", + "refresh_status": "重新整理 {vcs} 狀態", + "close_status": "關閉 {vcs} 狀態", + "toggle_git": "Git 狀態 (Ctrl+Shift+G)", + "time_now": "剛剛", + "time_minute": "1 分鐘前", + "time_minutes": "{count} 分鐘前", + "time_hour": "1 小時前", + "time_hours": "{count} 小時前", + "time_day": "1 天前", + "time_days": "{count} 天前", + "time_month": "1 個月前", + "time_months": "{count} 個月前", + "time_year": "1 年前", + "time_years": "{count} 年前", + "directory_unavailable": "所選目錄無法使用", + "following_directory": "所選目錄無法使用,已改為跟隨目前目錄", + "command_failed": "{program} 失敗", + "task_start_failed": "無法啟動版本控制任務:{error}" + }, + "launcher": { + "open_admin": "以系統管理員身分開啟", + "set_default": "設為預設 Shell", + "current_default": "目前預設 Shell", + "connect": "連線", + "edit": "編輯", + "delete": "刪除", + "default_saved": "已將 {shell} 設為預設 Shell。", + "admin_started": "已要求在新的 Pebrel 系統管理員視窗中開啟 {shell}。", + "admin_cancelled": "已取消以系統管理員身分啟動。", + "admin_failed": "無法開啟系統管理員終端機:{error}" + }, + "theme_transfer": { + "import_title": "匯入主題", + "export_title": "匯出主題", + "import_description": "選擇主題檔案,預覽後作為範本使用。", + "export_description": "選擇格式,查看哪些設定可以保留。", + "cancel": "取消", + "close": "關閉", + "candidates": "主題", + "diagnostics": "檔案資訊", + "completed": "完成", + "error": "需要處理", + "export": "匯出", + "exported": "已匯出至", + "inspecting": "正在讀取主題檔案…", + "losses": "此格式無法保留的設定", + "no_candidates": "找不到可匯入的主題。", + "output_format": "格式", + "picking": "選擇檔案", + "preparing": "正在準備匯出…", + "preview": "預覽", + "template_ready": "範本已載入編輯器", + "use_template": "用作範本", + "warnings": "匯入說明", + "writing": "正在寫入檔案…" + }, + "theme_editor": { + "title": "自訂主題", + "description": "從現有主題開始,調整成自己的配色。", + "template": "範本", + "name": "主題名稱", + "background": "終端機背景", + "foreground": "文字顏色", + "accent": "強調色", + "cursor": "游標顏色", + "font_family": "字型", + "font_size": "字型大小", + "line_height": "行高", + "opacity": "不透明度", + "advanced": "進階設定", + "reset": "重設變更", + "back": "返回上一頁", + "save_only": "僅儲存", + "apply": "儲存並套用", + "import": "匯入", + "export": "匯出", + "common": "常用設定", + "preview_terminal": "終端機", + "preview_interface": "介面", + "preview_ansi": "ANSI 色彩", + "invalid_value": "請檢查標示的顏色或數值。", + "new_name": "{name} · 我的配色", + "discard_title": "放棄未儲存修改?", + "discard_body": "目前草稿尚未儲存。", + "discard": "放棄修改", + "keep_editing": "繼續編輯", + "loading": "正在讀取主題…", + "saving": "正在儲存…", + "library_error": "無法讀取主題庫:{error}", + "save_error": "無法儲存主題:{error}", + "use_template": "用作範本", + "saved": "已儲存至自訂主題", + "preview_only": "變更只會在此處預覽,套用後才會顯示在終端機。", + "advanced_hide": "收合進階設定", + "ansi": "ANSI 16 色", + "selection": "選取區域與游標文字", + "selection_background": "選取範圍背景", + "selection_foreground": "選取範圍文字", + "cursor_text": "游標文字", + "inherit": "留空以使用自動配色", + "layout": "卡片版面配置", + "radius": "圓角", + "gutter": "間距", + "shadow": "陰影", + "divider": "分割線", + "invalid_color": "顏色格式不正確", + "invalid_number": "數值格式不正確", + "change_template_title": "更換範本?", + "change_template_body": "更換範本會捨棄目前未儲存的變更。", + "change_template": "更換範本", + "current_setting": "目前設定", + "live_preview": "即時預覽", + "selected_line": "選取的文字行", + "based_on": "以 {name} 為基礎,原主題不會變更。", + "editing_status": "主題草稿", + "editing_unchanged": "目前設定沒有變更", + "editing_changed": "預覽包含尚未儲存的變更", + "reset_short": "重設" + }, + "theme_picker": { + "title": "選擇主題", + "all": "全部", + "light": "淺色", + "dark": "深色", + "custom": "自訂", + "customize": "自訂主題", + "text_original": "原主題文字", + "text_cool": "推薦冷色文字", + "text_warm": "推薦暖色文字", + "text_custom": "自訂文字顏色", + "discard_body": "外觀變更尚未套用。", + "apply": "使用此主題", + "cancel": "取消", + "color_title": "文字顏色", + "hex_color": "十六進位色碼", + "invalid_color": "請輸入 #aabbcc 格式的顏色代碼。", + "preview_hint": "文字顏色會即時顯示在預覽中,套用後才會變更終端機。", + "loading": "正在讀取主題…", + "empty": "儲存的自訂主題會顯示在這裡。", + "save_error": "無法套用主題:{error}", + "manual_hint": "套用後會使用這些顏色,並關閉「跟隨系統」。", + "preview_ready": "準備好開始新的工作。", + "apply_icon": "使用此圖示", + "icon_title": "選擇應用程式圖示", + "description": "挑選喜歡的配色,預覽後再套用。", + "icon_description": "保留原本的圖案,挑選喜歡的配色。", + "selected": "已選取", + "text_suggestion": "輸入顏色或選擇推薦色", + "color_hint": "顏色會即時顯示在預覽中;套用主題後才會顯示在終端機。", + "saturation_value": "飽和度和明度", + "hue": "色相", + "theme_count": "{count} 款主題", + "icon_count": "{count} 款配色", + "neutral": "中性", + "blue": "藍色", + "violet": "紫色", + "green": "青綠" + }, + "wallpaper": { + "load_failed": "無法載入背景圖。請選擇可正常讀取的 PNG、JPEG、WebP、GIF 或 BMP 圖片。", + "too_large": "背景圖片超過記憶體限制,請選擇尺寸較小的圖片。" + }, + "update": { + "downloading_hint": "正在背景下載並驗證適用於此裝置的更新安裝檔。", + "no_verified_package": "此版本沒有適用於此裝置、可供驗證的安裝檔。請開啟發行頁面手動下載。", + "check_fallback_failed": "GitHub API 存取受限(HTTP {status}),備用發行頁面也無法查詢:{error}。請稍後重試或開啟發行頁面。", + "check_unrecognized_release": "官方發行頁面未傳回可辨識的穩定版本。請稍後重試或開啟發行頁面。", + "installation_failed": "更新安裝失敗", + "installation_failed_hint": "更新未完成,原本的工作階段快照仍在。檢查錯誤後,可以重新驗證安裝檔,再確認安裝。", + "recheck_package": "重新驗證安裝檔", + "view_details": "檢視詳情", + "download": { + "dns": "無法解析更新伺服器或代理位址。請檢查網路和代理設定後重試。", + "tls": "無法為更新建立安全連線。請檢查系統時間,以及代理是否攔截了 TLS 連線。", + "refused": "更新連線被拒絕。請檢查網路;如果使用代理,請確認代理正在執行且埠正確。", + "timeout": "更新下載逾時。請檢查網路和代理連線後重試。", + "interrupted": "更新下載中斷。請檢查連線後重新下載。", + "proxy": "無法透過代理連線更新伺服器。請檢查代理位址、協議和認證資訊後重試。", + "http": "更新伺服器傳回 HTTP {status}。請稍後重試,或至發行頁面手動下載安裝檔。", + "network": "無法下載更新。請檢查網路和代理設定,或至發行頁面手動下載。" + }, + "auto_download": "在背景預先下載更新", + "stop_download": "停止下載", + "skip_version": "略過此版本", + "restart_install": "重新啟動並安裝", + "install_next_launch": "下次啟動時安裝", + "ready_hint": "更新已準備好。Pebrel 會儲存工作區、安裝更新,然後重新啟動。", + "later": "稍後", + "prepare_failed": "更新準備失敗:{error}。Pebrel 將保持開啟。", + "draft_changed": "準備關閉期間檔案已變更。請儲存或捨棄最新變更後重試。", + "in_progress": "正在安裝更新。請稍候,完成後 Pebrel 會自動重新開啟。" + }, + "files.search_failed": "無法完成搜尋", + "files.search_running": "正在搜尋…", + "files.search_limited": "顯示 {count} 筆結果 · 縮小搜尋範圍可查看更多", + "files.search_count": "{count} 筆結果", + "files.search_retry": "請檢查目錄和關鍵字,或關閉搜尋選項後重試。", + "files.search_progress": "找到的檔案會陸續顯示。", + "files.search_continue": "可以繼續輸入關鍵字或切換目錄。", + "files.search_incomplete": "尚未搜尋所有檔案", + "files.search_limit_reason": "已達搜尋上限,或部分目錄無法讀取。", + "files.search_narrow": "請選擇範圍較小的目錄,或輸入更具體的關鍵字。", + "files.search_empty": "沒有符合的檔案", + "files.search_empty_reason": "目前目錄中沒有符合查詢條件的檔案或資料夾。", + "session": { + "choose_conversation": "選擇要繼續的對話…", + "exit_incomplete_title": "仍有 AI 工作階段無法辨識。確定要關閉嗎?", + "exit_incomplete_body": "關閉前會儲存視窗版面配置及已知的對話 ID,並停止執行中的命令。尚未取得 ID 的對話,下次開啟時可能需要手動選擇。", + "exit_anyway": "儲存已知狀態並關閉", + "save_failed": "無法儲存工作區,Pebrel 會保持開啟。請檢查剩餘空間和目錄權限後重試。", + "identity_pending": "部分執行中的 AI 工作階段尚未提供可用於恢復的識別資訊。請等它們完成啟動後再試。", + "restore_failed": "無法恢復此 AI 對話,原本的恢復紀錄已保留。你可以在分頁選單重試,或到工作階段歷史中尋找。", + "restore_choose_codex": "儲存的 Codex 對話已無法使用。請在 Codex 的工作階段清單中選擇要繼續的對話。", + "restore_ambiguous": "有多個原生工作階段檔案符合此對話,原本的恢復紀錄已保留。請在工作階段歷史中選擇正確的對話。", + "retry": "重試工作階段恢復" + }, + "ssh.agent.unavailable": "無法在偵測時限內存取系統 SSH Agent。", + "ssh.agent.empty": "SSH Agent 未提供任何身分資訊。", + "ssh.agent.rejected": "伺服器拒絕 SSH Agent 提供的身分資訊(已嘗試 {attempted} 個,共提供 {available} 個)。", + "ssh.agent.partial": "SSH Agent 身分已通過驗證,但仍需其他認證方式(已嘗試 {attempted} 個,共提供 {available} 個)。", + "ssh.agent.fatal": "SSH Agent 驗證或簽章失敗,本次連線已中止。請檢查 Agent 後重新連線。{detail}", + "cloud": { + "title": "雲備份", + "snapshots": "備份紀錄", + "settings": "雲端儲存", + "configure": "設定雲端儲存", + "scope": "備份內容", + "provider": "儲存服務", + "off": "未設定", + "folder": "本機 / 網路目錄", + "nutstore": "堅果雲", + "webdav": "WebDAV", + "s3": "S3 相容儲存", + "sftp": "SSH 檔案儲存", + "checking": "檢查中…", + "unreachable": "無法存取", + "reachable": "可存取", + "not_checked": "尚未檢查", + "retention": "保留最近 {count} 份備份", + "refresh": "重新整理備份", + "empty": "尚無備份", + "load_history": "重新整理以取得備份紀錄", + "restore": "還原", + "restore_warning": "這會覆寫備份涵蓋的本機設定和資料。要繼續嗎?", + "restore_latest": "還原最近的備份", + "backup_now": "立即建立備份", + "encrypted_archive": "加密完整備份檔", + "latest": "最近備份", + "export": "匯出備份檔案", + "import": "匯入備份檔案", + "folder_path": "目錄路徑", + "address": "儲存位址", + "username": "帳號", + "s3_address": "服務位址", + "region": "區域", + "bucket": "儲存貯體", + "access_key": "存取金鑰 ID", + "ssh_host": "已儲存的 SSH 主機", + "remote_path": "遠端目錄", + "credential": "密碼 / 存取金鑰", + "credential_set": "已安全儲存", + "credential_needed": "未儲存", + "store_credential": "儲存認證資訊", + "saved": "已儲存", + "save_failed": "儲存失敗", + "saving": "儲存中…", + "auto_save": "修改自動儲存", + "test": "檢查連線", + "retry_save": "重試儲存", + "passphrase": "備份密碼", + "passphrase_hint": "至少 8 個字元,只供本次使用。還原時需輸入相同密碼。", + "scope_hint": "不包含 SSH 私鑰。歷史紀錄可能含有敏感資訊。", + "appearance": "外觀", + "terminal": "終端機設定", + "hosts": "SSH 主機", + "sync": "同步設定", + "assistant": "助手設定", + "sessions": "工作區中的工作階段", + "directories": "目錄歷史紀錄", + "commands": "命令歷史紀錄", + "fonts": "自訂字型" }, "ai_hooks": { - "announced": "已接入 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。撤銷:pebrel setup-ai --remove" + "announced": "已啟用 AI 回合通知(Claude / Codex / Kimi / Pi / opencode)。移除方式:pebrel setup-ai --remove" }, - "notification.command_finished": "命令結束,耗時 {seconds} 秒", - "notification.command_failed": "命令失敗(結束代碼 {code}),耗時 {seconds} 秒", - "commands.add_command": "新增命令", - "commands.root": "全部命令", - "commands.back_to_root": "返回全部命令", - "commands.group_count": "{count} 條命令", + "notification.command_finished": "命令已結束,耗時 {seconds} 秒", + "notification.command_failed": "命令失敗(結束碼 {code}),耗時 {seconds} 秒", "files.path_edit": "編輯目錄路徑", "files.path_placeholder": "輸入絕對路徑或相對路徑", "files.path_choose": "選擇目錄", "files.path_open": "開啟目錄", "files.path_failed": "無法開啟這個目錄:{error}", - "session.restore_choose_codex": "儲存的 Codex 對話已無法使用。請在 Codex 的工作階段清單中選擇要繼續的對話。", - "session.choose_conversation": "選擇要繼續的對話…", - "session.exit_incomplete_title": "仍有 AI 工作階段無法識別,繼續關閉?", - "session.exit_incomplete_body": "關閉前會儲存視窗配置及已知的對話 ID,並停止正在執行的命令。尚未取得 ID 的對話,重新開啟後可能需要手動選擇。", - "session.exit_anyway": "儲存已知狀態並關閉", "tab_menu": { - "fork_ai_session": "分叉 AI 工作階段", + "fork_ai_session": "建立 AI 工作階段分支", "move_to_new_window": "移到新視窗", "export_as_workspace": "匯出為工作區…", "split_left_right": "左右分割", @@ -231,5 +948,57 @@ "move_left": "向左移動", "move_right": "向右移動", "tab_color": "分頁顏色" + }, + "chrome": { + "new_terminal_ctrl_shift_t": "新增終端機(Ctrl+Shift+T)", + "more": "更多", + "scroll_tabs_left": "向左捲動分頁", + "scroll_tabs_right": "向右捲動分頁", + "select_terminal": "選擇終端機", + "command_list": "命令清單", + "toggle_sidebar": "收合或展開側邊欄(Ctrl+Shift+B)", + "settings_shortcut": "設定(Ctrl+,)" + }, + "workspace": { + "pane": { + "stop_broadcast_input": "停止廣播輸入", + "broadcast_input_tooltip": "將輸入廣播至目前分頁的所有窗格", + "close": "關閉此窗格", + "extract_release": "放開滑鼠:移至獨立分頁", + "extract_drag_hint": "拖出終端機區域可移至獨立分頁", + "broadcast_enabled": "已開啟廣播輸入:輸入內容會同步至目前分頁的 {count} 個窗格", + "restore_layout": "還原窗格版面配置(Ctrl+Shift+Enter)", + "zoom": "放大此窗格(Ctrl+Shift+Enter)" + }, + "close": { + "running_process": "{process} 仍在執行,關閉會中止它。", + "running_window_process": "{process} 仍在執行,關閉視窗會中止它。" + }, + "close_window": { + "title": "關閉視窗?" + }, + "close_pane": { + "title": "關閉此窗格?" + }, + "close_tab": { + "title": "關閉此分頁?" + } + }, + "remote": { + "locating": "正在定位遠端目錄…", + "parent": "上一級", + "reread": "重新讀取", + "upload_files": "上傳檔案", + "upload_folder": "上傳資料夾", + "download": "下載到本機", + "copy_item": "複製遠端項目", + "paste_here": "貼至目前遠端目錄", + "skip_unchanged": "略過未變", + "cancel": "取消傳輸", + "preparing": "正在準備傳輸…", + "reading": "正在讀取遠端目錄…", + "empty": "此目錄為空。", + "error_retry": "{error}(點選右上角重新讀取)", + "busy_other": "另一主機正在傳輸:{destination}" } } From d30d1c9bbc226f0eddfec1d369970d3601326cde Mon Sep 17 00:00:00 2001 From: Kuddev Date: Sat, 26 Sep 2026 17:23:44 +0800 Subject: [PATCH 2/4] fix(terminal): keep color report subscriptions silent (#302) Keep DECSET 2031 enable/re-enable silent while retaining reports for actual palette changes. Cover repeated subscriptions and byte boundaries. Fixes #283. --- ...09-26-silent-color-report-subscriptions.md | 70 +++++++++++++++++++ nebula_terminal/src/term/mod.rs | 8 +-- nebula_terminal/src/term/tests.rs | 45 +++++++++--- 3 files changed, 109 insertions(+), 14 deletions(-) create mode 100644 architecture/notes/nebula_terminal/term/2026-09-26-silent-color-report-subscriptions.md diff --git a/architecture/notes/nebula_terminal/term/2026-09-26-silent-color-report-subscriptions.md b/architecture/notes/nebula_terminal/term/2026-09-26-silent-color-report-subscriptions.md new file mode 100644 index 0000000..93573b6 --- /dev/null +++ b/architecture/notes/nebula_terminal/term/2026-09-26-silent-color-report-subscriptions.md @@ -0,0 +1,70 @@ +# Keep color-report subscriptions silent + +## Status + +Reviewed for integration on 2026-09-26. + +## Context + +Issue [#283](https://github.com/Kuddev/pebrel/issues/283) reports literal +`^[[?997;1n` input around Fish prompts in native SSH panes, sometimes twice per +command. The terminal emitted this report every time DECSET 2031 was enabled, +even when the palette had not changed. + +## Evidence + +- [Fish's compatibility contract](https://github.com/fish-shell/fish-shell/blob/966dbbbd4de0b0efcf27275e465d081de900d9a8/doc_src/terminal-compatibility.rst#L226-L233) + defines `CSI ? 2031 h` as enabling future color-theme reports and expects + `CSI ? 997;1n` / `CSI ? 997;2n` when the theme changes. Enabling a + subscription is not itself a change or a `CSI ? 996 n` query. +- [Fish's terminal handoff implementation](https://github.com/fish-shell/fish-shell/blob/966dbbbd4de0b0efcf27275e465d081de900d9a8/src/tty_handoff.rs#L194-L195) + enables and disables this mode around terminal ownership changes. +- A production-core reproducer with two enables and an unchanged palette emitted + two `ESC[?997;1n` replies. The regression added to `term/tests.rs` also failed on + the original implementation before the fix. + +## Decision + +DECSET 2031 updates subscription state without emitting a report. The existing +palette-change path retains its change detection and only notifies subscribers. +Both first-time and repeated enables stay silent, including fragmented input. + +## Rejected alternatives + +The original immediate reply tried to compensate for vte 0.15 not routing the +private DSR query to `Handler::device_status`. Unsolicited input is not a valid +substitute for a query response: it may reach the shell during terminal handoff. +Filtering these bytes only in SSH would leave the shared terminal behavior +incorrect for other transports. Changing the parser dependency is unnecessary +for this notification regression. + +## Consequences + +The subscription path no longer formats or queues a response. No per-byte parser +work, timer, thread or stored state is added. Actual dark/light changes continue +to notify subscribed programs; disabled notifications and same-color updates +remain silent. The existing private DSR query limitation is unchanged. + +## Validation + +The regression covers both starting color schemes and every two-chunk split of +repeated enable/disable sequences. It also verifies a real color flip still +reports, repeated application stays silent, and disabling suppresses reports. +Existing direct-handler and parser tests retain their mode-state and real-change +assertions with the corrected subscription expectation. + +Local Windows validation on the updated integration base passed: 277 unit tests, +45 replay cases, the connection +transport test, the bundled ConPTY lifecycle test and one doctest. The native +fixtures used the repository-pinned console runtime and an explicit Git Bash. +The original Windows-to-Debian Fish SSH session has not been replayed locally. + +## Supersedes + +The immediate-report rationale in `Term::set_private_mode` and its regression +expectations; no previous decision note exists. + +## Revisit when + +Revisit query support separately when the VT parser exposes private DSR handling. +Keep query replies separate from change subscriptions. diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index 9517799..c9fa81c 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -2094,14 +2094,10 @@ impl Handler for Term { self.mode.insert(TermMode::WIN32_INPUT_MODE); return; } - // DECSET 2031。订阅的同一刻先答一次当前值:规范里取初值靠 `CSI ? 996 n` - // 查询,但 vte 0.15 只把**没有** `?` 中间字节的 `CSI n` 路由到 - // `device_status`,私有 DSR 根本到不了 handler,我们答不了那条查询。 - // 订阅即回报把这个洞补上——多一条报告对订阅方无害(它本来就要处理这个 - // 序列),少一条则意味着 app 在主题变化之前永远不知道当前是亮还是暗。 + // DECSET 2031 只订阅后续配色变化,不是 CSI ? 996 n 查询; + // 在这里回报会在 shell 交接终端时注入它未请求的输入。 if matches!(mode, PrivateMode::Unknown(2031)) { self.mode.insert(TermMode::COLOR_SCHEME_UPDATES); - self.report_color_scheme(); return; } let mode = match mode { diff --git a/nebula_terminal/src/term/tests.rs b/nebula_terminal/src/term/tests.rs index 719fa65..336dc62 100644 --- a/nebula_terminal/src/term/tests.rs +++ b/nebula_terminal/src/term/tests.rs @@ -69,24 +69,53 @@ fn keyboard_queries_follow_live_flags_after_replace_union_and_difference() { } } -/// DECSET 2031 订阅的同一刻就要收到当前亮暗:规范里取初值靠 -/// `CSI ? 996 n`,但 vte 0.15 不把私有 DSR 路由给 handler,我们答不了那条 -/// 查询。订阅即回报是这个洞的替代品,掉了它 app 在下次换主题前无从得知。 +/// 订阅后续变化不等于查询当前配色,启用时应保持静默。 #[test] -fn decset_2031_reports_the_current_scheme_immediately() { +fn decset_2031_subscribes_without_reporting_the_current_scheme() { let size = TermSize::new(5, 5); let events = WriteRecorder::default(); let mut term = Term::new(Config::default(), &size, events.clone()); term.set_private_mode(PrivateMode::Unknown(2031)); assert!(term.mode().contains(TermMode::COLOR_SCHEME_UPDATES)); - // 构造默认是暗底 → 1。 - assert_eq!(events.take(), vec!["\x1b[?997;1n".to_owned()]); + assert!(events.take().is_empty()); term.unset_private_mode(PrivateMode::Unknown(2031)); assert!(!term.mode().contains(TermMode::COLOR_SCHEME_UPDATES)); } +/// Fish 在命令交接时会重新订阅;这些操作不是配色变化,也不是当前配色查询。 +/// 遍历分块边界,避免网络拆包改变应答行为。 +#[test] +fn repeated_color_subscriptions_are_silent_at_every_stream_boundary() { + let sequence = b"\x1b[?2031h\x1b[?2031h\x1b[?2031l\x1b[?2031h"; + for dark in [true, false] { + for split in 0..=sequence.len() { + let events = WriteRecorder::default(); + let mut term = Term::new(Config::default(), &TermSize::new(5, 5), events.clone()); + term.set_color_scheme(dark); + let mut parser: ansi::Processor = ansi::Processor::new(); + parser.advance(&mut term, &sequence[..split]); + parser.advance(&mut term, &sequence[split..]); + assert!( + events.take().is_empty(), + "unexpected prompt input: dark={dark}, split={split}" + ); + assert!(term.mode().contains(TermMode::COLOR_SCHEME_UPDATES)); + + term.set_color_scheme(!dark); + let expected = if dark { "\x1b[?997;2n" } else { "\x1b[?997;1n" }; + assert_eq!(events.take(), [expected], "a real palette change still reports"); + parser.advance(&mut term, b"\x1b[?2031h"); + term.set_color_scheme(!dark); + assert!(events.take().is_empty(), "same-color reapplication stays silent"); + parser.advance(&mut term, b"\x1b[?2031l"); + term.set_color_scheme(dark); + assert!(events.take().is_empty(), "disabled notifications stay silent"); + } + } +} + /// 没订阅的程序不该收到这串字节——它会当成用户敲进来的输入。 #[test] fn color_scheme_change_stays_quiet_without_a_subscriber() { @@ -107,7 +136,7 @@ fn color_scheme_reports_only_on_a_real_flip() { let mut term = Term::new(Config::default(), &size, events.clone()); term.set_private_mode(PrivateMode::Unknown(2031)); - assert_eq!(events.take(), vec!["\x1b[?997;1n".to_owned()]); + assert!(events.take().is_empty()); // 暗 → 亮:一条 `;2n`。 term.set_color_scheme(false); @@ -151,7 +180,7 @@ fn decset_2031_survives_the_real_parser() { term.mode().contains(TermMode::COLOR_SCHEME_UPDATES), "`\\e[?2031h` 必须经解析器落到 PrivateMode::Unknown(2031)" ); - assert_eq!(events.take(), vec!["\x1b[?997;1n".to_owned()]); + assert!(events.take().is_empty()); // 主题翻成浅色:订阅方收到 `;2n`。 term.set_color_scheme(false); From bdee8d22c55038d8de072e90c46aacc558de9b5b Mon Sep 17 00:00:00 2001 From: galact-byte <94279960+galact-byte@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:55:14 +0800 Subject: [PATCH 3/4] fix(terminal): preserve Pi reading position across synchronized redraws (#234) Reanchor only uniquely matched content after complete synchronized Pi redraws. Preserve standard clearing and cancel stale anchors on user actions or interrupted updates. Related to #159. --- .../terminal/view/activity_tests.rs | 40 +++ .../terminal/view/agent_activity.rs | 5 + .../src/gpui_shell/terminal/view/pointer.rs | 3 + .../src/gpui_shell/terminal/view/runtime.rs | 3 + nebula_terminal/src/event_loop.rs | 18 +- nebula_terminal/src/term/clear.rs | 57 ++++ nebula_terminal/src/term/mod.rs | 80 +---- nebula_terminal/src/term/redraw_anchor.rs | 144 +++++++++ nebula_terminal/tests/pi_redraw_anchor.rs | 273 ++++++++++++++++++ 9 files changed, 551 insertions(+), 72 deletions(-) create mode 100644 nebula_terminal/src/term/clear.rs create mode 100644 nebula_terminal/src/term/redraw_anchor.rs create mode 100644 nebula_terminal/tests/pi_redraw_anchor.rs diff --git a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs index c1552ed..dec49d6 100644 --- a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs @@ -14,6 +14,46 @@ fn hook(session: &str, name: &str, sequence: u64) -> AiHookEvent { crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(42)).unwrap() } +#[gpui::test] +fn pi_redraw_anchor_follows_accepted_hook_and_command_lifecycle(cx: &mut TestAppContext) { + use nebula_terminal::term::test::TermSize; + use nebula_terminal::vte::ansi::Processor; + + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let event = |kind: &str, seq: u64| { + crate::ai_hook::parse_remote_envelope( + format!("nebula-hook/1 source=pi pane=42\n{{\"kind\":\"{kind}\",\"session_id\":\"redraw-policy\",\"bridge_sequence\":{seq}}}").as_bytes(), + Some(42), + ).unwrap() + }; + let check = |view: &mut TerminalView, expected: usize| { + let mut term = view.session.as_ref().unwrap().term.lock(); + term.resize(TermSize::new(60, 5)); + let mut stream = Processor::::default(); + let text = (0..12).map(|n| format!("ROW_{n:03}")).collect::>().join("\r\n"); + let frame = format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{text}\x1b[?2026l"); + term.scroll_display(Scroll::Bottom); + stream.advance(&mut *term, frame.as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + stream.advance(&mut *term, frame.as_bytes()); + assert_eq!(term.grid().display_offset(), expected); + }; + check(view, 0); + assert!(view.handle_ai_hook(&event("session-start", 1), cx)); + check(view, 5); + assert!(view.handle_ai_hook(&event("done", 2), cx)); + check(view, 5); // 回合结束不是 Pi 进程退出。 + assert!(view.handle_ai_hook(&event("session-end", 3), cx)); + check(view, 0); + assert!(view.handle_ai_hook(&event("session-start", 4), cx)); + check(view, 5); + view.finish_foreground_command(Some(0), cx); + check(view, 0); + }); +} + fn screen(view: &mut TerminalView, text: &str) { feed(view, format!("\x1b[2J\x1b[H{}", text.replace('\n', "\r\n")).as_bytes()); } diff --git a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs index 625e23d..14b7617 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -67,6 +67,11 @@ impl TerminalView { | crate::ai_hook::AiTurnOutcome::Incomplete ); } + if let Some(session) = &self.session { + session.term.lock().set_redraw_anchor_enabled( + event.source == "pi" && event.kind != AiHookKind::SessionEnd, + ); + } if let Some(target) = target { let previous = self.recovery.target.clone(); self.recovery.confirm(target); diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 0ae3802..a5f6221 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -853,6 +853,9 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + if let Some(session) = &self.session { + session.term.lock().cancel_redraw_anchor(); + } let delta_y = event.delta.pixel_delta(self.line_height).y.as_f32(); // 旧壳 `mouse_wheel_input`:Ctrl+滚轮先于一切滚动消费者,一步 1 // 逻辑像素,钳在 4–64。设置页步进会写盘;这里同样写 `font_size=`, diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index bf8b158..1940225 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -127,6 +127,9 @@ impl TerminalView { /// the primary edge; the cached-prompt path calls the same reset so the two /// lifecycle routes cannot drift apart. pub(super) fn clear_foreground_agent_state(&mut self, cx: &mut Context) -> bool { + if let Some(session) = &self.session { + session.term.lock().set_redraw_anchor_enabled(false); + } self.confirmation.observe_waiting(false); self.recovery.command_ended(); self.answers.close(); diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 120a20e..3f455f3 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -185,9 +185,21 @@ impl StreamProcessor { } pub fn stop_sync(&mut self, terminal: &mut Term) { + terminal.cancel_redraw_anchor(); self.parser.stop_sync(terminal); } + fn advance(&mut self, terminal: &mut Term, bytes: &[u8]) { + // VTE 0.15 在约 2 MiB 时强制提交同步缓冲;提前取消兼容锚点, + // 但不强制结束/截断输出。分块保证单次大输入也不会绕过检查。 + for chunk in bytes.chunks(4096) { + if self.parser.sync_bytes_count() >= 1024 * 1024 { + terminal.cancel_redraw_anchor(); + } + self.parser.advance(terminal, chunk); + } + } + pub fn feed( &mut self, terminal: &mut Term, @@ -200,7 +212,7 @@ impl StreamProcessor { // Titles and shell identity/cwd reports must retain wire order: // coalescing a remote cwd past a parent prompt would attribute that // directory to the parent shell's completion history. - self.parser.advance(terminal, &bytes[advanced..offset]); + self.advance(terminal, &bytes[advanced..offset]); advanced = offset; match event { OscEvent::Cwd(cwd) => event_proxy.send_event(Event::CwdReport(cwd)), @@ -239,7 +251,7 @@ impl StreamProcessor { let rows = (disp_h / cell_h).ceil().max(1.0) as usize; let abs_line = terminal.nebula_cursor_abs_line(); for _ in 0..=rows { - self.parser.advance(terminal, b"\r\n"); + self.advance(terminal, b"\r\n"); } event_proxy.send_event(Event::InlineImage { data: std::sync::Arc::new(data), @@ -250,7 +262,7 @@ impl StreamProcessor { }, } } - self.parser.advance(terminal, &bytes[advanced..]); + self.advance(terminal, &bytes[advanced..]); } } diff --git a/nebula_terminal/src/term/clear.rs b/nebula_terminal/src/term/clear.rs new file mode 100644 index 0000000..ef6805b --- /dev/null +++ b/nebula_terminal/src/term/clear.rs @@ -0,0 +1,57 @@ +//! 清屏与滚动历史清除的网格、选区及阅读位置合同。 +use super::{Boundary, Column, Dimensions, EventListener, Line, Term, TermMode, ansi, cmp}; + +impl Term { + pub(super) fn clear_screen_contents(&mut self, mode: ansi::ClearMode) { + self.observe_redraw_clear(&mode); + log::trace!("Clearing screen: {mode:?}"); + let bg = self.grid.cursor.template.bg; + let screen_lines = self.screen_lines(); + + match mode { + ansi::ClearMode::Above => { + let cursor = self.grid.cursor.point; + if cursor.line > 1 { + self.grid.reset_region(..cursor.line); + } + let end = cmp::min(cursor.column + 1, Column(self.columns())); + for cell in &mut self.grid[cursor.line][..end] { + *cell = bg.into(); + } + let range = Line(0)..=cursor.line; + self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); + }, + ansi::ClearMode::Below => { + let cursor = self.grid.cursor.point; + for cell in &mut self.grid[cursor.line][cursor.column..] { + *cell = bg.into(); + } + if (cursor.line.0 as usize) < screen_lines - 1 { + self.grid.reset_region((cursor.line + 1)..); + } + let range = cursor.line..Line(screen_lines as i32); + self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); + }, + ansi::ClearMode::All => { + if self.mode.contains(TermMode::ALT_SCREEN) { + self.grid.reset_region(..); + } else { + let old_offset = self.grid.display_offset(); + self.grid.clear_viewport(); + let lines = self.grid.display_offset().saturating_sub(old_offset); + self.vi_mode_cursor.point.line = + (self.vi_mode_cursor.point.line - lines).grid_clamp(self, Boundary::Grid); + } + self.selection = None; + }, + ansi::ClearMode::Saved if self.history_size() > 0 => { + self.grid.clear_history(); + self.vi_mode_cursor.point.line = + self.vi_mode_cursor.point.line.grid_clamp(self, Boundary::Cursor); + self.selection = self.selection.take().filter(|s| !s.intersects_range(..Line(0))); + }, + ansi::ClearMode::Saved => (), + } + self.mark_fully_damaged(); + } +} diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index c9fa81c..cffb9dc 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -28,12 +28,14 @@ use crate::vte::ansi::{ }; pub mod cell; +mod clear; pub mod color; mod damage; mod keyboard; #[cfg(test)] mod keyboard_contract_tests; mod prompt; +mod redraw_anchor; mod renderable; pub mod search; @@ -158,6 +160,7 @@ pub fn viewport_to_point_from(origin: Line, point: Point) -> Point { } pub struct Term { + redraw_anchor: redraw_anchor::RedrawAnchor, /// Terminal focus controlling the cursor shape. pub is_focused: bool, @@ -342,6 +345,7 @@ impl Term { where T: EventListener, { + self.cancel_redraw_anchor(); let old_display_offset = self.grid.display_offset(); self.grid.scroll_display(scroll); self.event_proxy.send_event(Event::MouseCursorDirty); @@ -382,6 +386,7 @@ impl Term { let damage = TermDamageState::new(num_cols, num_lines); Term { + redraw_anchor: Default::default(), inactive_grid, scroll_region, event_proxy, @@ -727,6 +732,7 @@ impl Term { /// Resize terminal to new dimensions. pub fn resize(&mut self, size: S) { + self.cancel_redraw_anchor(); let old_cols = self.columns(); let old_lines = self.screen_lines(); @@ -804,6 +810,7 @@ impl Term { /// Swap primary and alternate screen buffer. pub fn swap_alt(&mut self) { + self.cancel_redraw_anchor(); if !self.mode.contains(TermMode::ALT_SCREEN) { // Set alt screen cursor to the current primary screen cursor. self.inactive_grid.cursor = self.grid.cursor.clone(); @@ -1902,73 +1909,7 @@ impl Handler for Term { #[inline] fn clear_screen(&mut self, mode: ansi::ClearMode) { - trace!("Clearing screen: {mode:?}"); - let bg = self.grid.cursor.template.bg; - - let screen_lines = self.screen_lines(); - - match mode { - ansi::ClearMode::Above => { - let cursor = self.grid.cursor.point; - - // If clearing more than one line. - if cursor.line > 1 { - // Fully clear all lines before the current line. - self.grid.reset_region(..cursor.line); - } - - // Clear up to the current column in the current line. - let end = cmp::min(cursor.column + 1, Column(self.columns())); - for cell in &mut self.grid[cursor.line][..end] { - *cell = bg.into(); - } - - let range = Line(0)..=cursor.line; - self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); - }, - ansi::ClearMode::Below => { - let cursor = self.grid.cursor.point; - for cell in &mut self.grid[cursor.line][cursor.column..] { - *cell = bg.into(); - } - - if (cursor.line.0 as usize) < screen_lines - 1 { - self.grid.reset_region((cursor.line + 1)..); - } - - let range = cursor.line..Line(screen_lines as i32); - self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); - }, - ansi::ClearMode::All => { - if self.mode.contains(TermMode::ALT_SCREEN) { - self.grid.reset_region(..); - } else { - let old_offset = self.grid.display_offset(); - - self.grid.clear_viewport(); - - // Compute number of lines scrolled by clearing the viewport. - let lines = self.grid.display_offset().saturating_sub(old_offset); - - self.vi_mode_cursor.point.line = - (self.vi_mode_cursor.point.line - lines).grid_clamp(self, Boundary::Grid); - } - - self.selection = None; - }, - ansi::ClearMode::Saved if self.history_size() > 0 => { - self.grid.clear_history(); - - self.vi_mode_cursor.point.line = - self.vi_mode_cursor.point.line.grid_clamp(self, Boundary::Cursor); - - self.selection = self.selection.take().filter(|s| !s.intersects_range(..Line(0))); - }, - // We have no history to clear. - ansi::ClearMode::Saved => (), - } - - self.mark_fully_damaged(); + self.clear_screen_contents(mode); } #[inline] @@ -1987,6 +1928,7 @@ impl Handler for Term { /// Reset all important fields in the term struct. #[inline] fn reset_state(&mut self) { + self.cancel_redraw_anchor(); if self.mode.contains(TermMode::ALT_SCREEN) { mem::swap(&mut self.grid, &mut self.inactive_grid); } @@ -2156,7 +2098,7 @@ impl Handler for Term { self.cursor_blinking_override = Some(true); self.event_proxy.send_event(Event::CursorBlinkingChange); }, - NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::SyncUpdate => self.begin_redraw_anchor(), } } @@ -2212,7 +2154,7 @@ impl Handler for Term { self.cursor_blinking_override = Some(false); self.event_proxy.send_event(Event::CursorBlinkingChange); }, - NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::SyncUpdate => self.finish_redraw_anchor(), } } diff --git a/nebula_terminal/src/term/redraw_anchor.rs b/nebula_terminal/src/term/redraw_anchor.rs new file mode 100644 index 0000000..11546cb --- /dev/null +++ b/nebula_terminal/src/term/redraw_anchor.rs @@ -0,0 +1,144 @@ +//! 同步重绘后的保守阅读定位;只查找新网格,不恢复被应用清掉的内容。 +use super::{Cell, EventListener, Flags, Term, TermMode}; +use crate::grid::{Dimensions, Grid, Scroll}; +use crate::index::{Column, Line}; + +const ANCHOR_ROWS: usize = 3; +const MAX_COLUMNS: usize = 1024; +const MAX_SCAN_CELLS: usize = 4 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct RedrawAnchor { + enabled: bool, + active: bool, + rows: Option>, + cleared_viewport: bool, + cleared_history: bool, +} + +impl Term { + /// 由宿主根据已确认的应用生命周期选择;默认不改变终端滚动语义。 + pub fn set_redraw_anchor_enabled(&mut self, enabled: bool) { + if self.redraw_anchor.enabled != enabled { + self.cancel_redraw_anchor(); + self.redraw_anchor.enabled = enabled; + } + } + + /// 用户操作或不完整同步更新优先,迟到的输出不得恢复旧阅读位置。 + pub fn cancel_redraw_anchor(&mut self) { + self.redraw_anchor.rows = None; + } + + pub(super) fn begin_redraw_anchor(&mut self) { + let previous_active = self.redraw_anchor.active; + self.redraw_anchor = RedrawAnchor { + enabled: self.redraw_anchor.enabled, + active: true, + ..Default::default() + }; + if previous_active + || !self.redraw_anchor.enabled + || self.grid.display_offset() == 0 + || self.mode.intersects(TermMode::ALT_SCREEN | TermMode::VI) + || self.selection.is_some() + || self.columns() > MAX_COLUMNS + || self.screen_lines() < ANCHOR_ROWS + { + return; + } + let start = -(self.grid.display_offset() as i32); + let rows: Option> = (0..ANCHOR_ROWS) + .map(|offset| row_key(&self.grid, Line(start + offset as i32))) + .collect(); + self.redraw_anchor.rows = rows.filter(|rows| rows.iter().any(|row| !row.is_empty())); + } + + pub(super) fn observe_redraw_clear(&mut self, mode: &super::ansi::ClearMode) { + if !self.redraw_anchor.active { + return; + } + match mode { + super::ansi::ClearMode::All => self.redraw_anchor.cleared_viewport = true, + super::ansi::ClearMode::Saved => self.redraw_anchor.cleared_history = true, + _ => (), + } + } +} + +impl Term { + pub(super) fn finish_redraw_anchor(&mut self) { + let enabled = self.redraw_anchor.enabled; + let state = std::mem::replace( + &mut self.redraw_anchor, + RedrawAnchor { enabled, ..Default::default() }, + ); + let Some(rows) = state.rows else { return }; + if !state.cleared_viewport + || !state.cleared_history + || self.mode.intersects(TermMode::ALT_SCREEN | TermMode::VI) + || self.selection.is_some() + || self.grid.total_lines().saturating_mul(self.columns()) > MAX_SCAN_CELLS + { + return; + } + let start = -(self.history_size() as i32); + let end = self.screen_lines() as i32 - ANCHOR_ROWS as i32; + let mut matched = None; + for line in start..=end { + if rows.iter().enumerate().all(|(offset, expected)| { + row_key(&self.grid, Line(line + offset as i32)).as_ref() == Some(expected) + }) { + if matched.is_some() { + return; + } + matched = Some(line); + } + } + if let Some(line) = matched.filter(|line| *line <= 0) { + self.scroll_display(Scroll::Bottom); + self.scroll_display(Scroll::Delta(-line)); + } + } +} + +fn row_key(grid: &Grid, line: Line) -> Option { + let mut text = String::new(); + let mut space = false; + for column in 0..grid.columns() { + let cell = &grid[line][Column(column)]; + // 换行重排没有可靠的行身份,不以不完整片段猜测原位置。 + if cell.flags.contains(Flags::WRAPLINE) { + return None; + } + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + if cell.c == ' ' && cell.zerowidth().is_none() { + space = !text.is_empty(); + continue; + } + if space { + text.push(' '); + space = false; + } + text.push(cell.c); + if let Some(chars) = cell.zerowidth() { + text.extend(chars); + } + } + // Pi 表格的纯边框随列宽伸长;只归一纯边框,不折叠正文中的重复字符。 + if text.contains('─') + && text + .chars() + .all(|c| matches!(c, '─' | '┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘')) + { + let mut previous = None; + text.retain(|c| { + let keep = c != '─' || previous != Some(c); + previous = Some(c); + keep + }); + } + Some(text) +} diff --git a/nebula_terminal/tests/pi_redraw_anchor.rs b/nebula_terminal/tests/pi_redraw_anchor.rs new file mode 100644 index 0000000..2744698 --- /dev/null +++ b/nebula_terminal/tests/pi_redraw_anchor.rs @@ -0,0 +1,273 @@ +use nebula_terminal::Term; +use nebula_terminal::event::VoidListener; +use nebula_terminal::event_loop::StreamProcessor; +use nebula_terminal::grid::{Dimensions, Scroll}; +use nebula_terminal::index::{Column, Line}; +use nebula_terminal::term::{Config, test::TermSize}; + +type Terminal = Term; + +fn feed(stream: &mut StreamProcessor, term: &mut Terminal, bytes: &[u8]) { + stream.feed(term, &VoidListener, bytes); +} + +fn numbered(count: usize) -> String { + (0..count).map(|n| format!("ROW_{n:03}")).collect::>().join("\r\n") +} + +fn frame(text: &str) -> String { + format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{text}\x1b[?2026l") +} + +fn row(term: &Terminal, line: i32) -> String { + (0..term.columns()) + .map(|col| term.grid()[Line(line)][Column(col)].c) + .collect::() + .trim_end() + .to_owned() +} + +fn top(term: &Terminal) -> String { + row(term, -(term.grid().display_offset() as i32)) +} + +fn initial(enabled: bool) -> (Terminal, StreamProcessor) { + let mut term = Term::new(Config::default(), &TermSize::new(60, 5), VoidListener); + let mut stream = StreamProcessor::default(); + term.set_redraw_anchor_enabled(enabled); + feed(&mut stream, &mut term, format!("SECRET_OLD\r\n{}", numbered(10)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-3)); + assert_eq!(top(&term), "ROW_002"); + (term, stream) +} + +#[test] +fn complete_sync_redraw_reanchors_new_content_without_resurrecting_history() { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes()); + assert_eq!(top(&term), "ROW_002"); + assert_eq!(term.history_size(), 7); + let rows: Vec<_> = (-7..5).map(|line| row(&term, line)).collect(); + assert_eq!(rows, numbered(12).split("\r\n").collect::>()); +} + +#[test] +fn disabled_policy_preserves_standard_clear_behavior() { + let (mut term, mut stream) = initial(false); + feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes()); + assert_eq!(term.grid().display_offset(), 0); + assert_eq!(top(&term), "ROW_007"); +} + +#[test] +fn byte_boundaries_do_not_change_reanchoring() { + let bytes = frame(&numbered(12)).into_bytes(); + for split in 0..=bytes.len() { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, &bytes[..split]); + feed(&mut stream, &mut term, &bytes[split..]); + assert_eq!(top(&term), "ROW_002", "split={split}"); + } +} + +#[test] +fn plain_clear_and_missing_or_ambiguous_anchor_do_not_reanchor() { + for bytes in [ + "\x1b[3J".to_owned(), + frame("new\r\ncontent"), + frame(&format!("{}\r\n{}", numbered(12), numbered(12))), + format!("\x1b[2J\x1b[H\x1b[3J{}", numbered(12)), + ] { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, bytes.as_bytes()); + assert_eq!(term.grid().display_offset(), 0); + } +} + +#[test] +fn user_scroll_or_disable_during_sync_cancels_pending_anchor() { + for disable in [false, true] { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, b"\x1b[?2026h"); + if disable { + term.set_redraw_anchor_enabled(false); + } else { + term.scroll_display(Scroll::Bottom); + } + feed( + &mut stream, + &mut term, + format!("\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", numbered(12)).as_bytes(), + ); + assert_eq!(term.grid().display_offset(), 0); + } +} + +#[test] +fn forced_sync_stop_does_not_restore_a_partial_redraw() { + let (mut term, mut stream) = initial(true); + feed( + &mut stream, + &mut term, + format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{}", numbered(12)).as_bytes(), + ); + stream.stop_sync(&mut term); + assert_eq!(term.grid().display_offset(), 0); + feed(&mut stream, &mut term, b"\x1b[?2026l"); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn cancellation_resize_reset_and_alt_screen_do_not_restore_old_anchor() { + for operation in 0..4 { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, b"\x1b[?2026h"); + match operation { + 0 => term.cancel_redraw_anchor(), + 1 => term.resize(TermSize::new(61, 5)), + 2 => term.swap_alt(), + _ => (), + } + let reset = if operation == 3 { "\x1bc" } else { "" }; + feed( + &mut stream, + &mut term, + format!("{reset}\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", numbered(12)).as_bytes(), + ); + assert_eq!(term.grid().display_offset(), 0, "operation={operation}"); + } +} + +#[test] +fn oversized_sync_update_cancels_even_for_single_large_input() { + let (mut term, mut stream) = initial(true); + let bytes = format!( + "\x1b[?2026h{}\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", + "\x00".repeat(2 * 1024 * 1024), + numbered(12) + ); + feed(&mut stream, &mut term, bytes.as_bytes()); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn bottom_follow_and_append_remain_unchanged() { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, b"\r\nAPPEND"); + assert_eq!(top(&term), "ROW_002"); + term.scroll_display(Scroll::Bottom); + feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes()); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn paragraph_spacing_preserves_anchor_including_blank_top_row() { + for blank_top in [false, true] { + let (mut term, mut stream) = initial(true); + let paragraphs = + (0..12).map(|n| format!("PARAGRAPH_{n:03}")).collect::>().join("\r\n\r\n"); + feed(&mut stream, &mut term, frame(¶graphs).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(if blank_top { -5 } else { -4 })); + let before: Vec<_> = + (0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect(); + feed( + &mut stream, + &mut term, + frame(&format!("{paragraphs}\r\n\r\nNEW_PARAGRAPH")).as_bytes(), + ); + let after: Vec<_> = + (0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect(); + assert_eq!(after, before, "blank_top={blank_top}"); + } +} + +#[test] +fn table_rules_widen_without_losing_the_reading_row() { + let table = |padding: usize| { + (0..12) + .map(|n| { + format!( + "│ ROW_{n:03}{}│ value {n} │\r\n├{}┼──────────┤", + " ".repeat(padding), + "─".repeat(9 + padding) + ) + }) + .collect::>() + .join("\r\n") + }; + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, frame(&table(1)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-4)); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); + feed(&mut stream, &mut term, frame(&table(6)).as_bytes()); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); +} + +#[test] +fn partial_clear_does_not_trigger_compatibility_relocation() { + for clear in ["\x1b[2J\x1b[H", "\x1b[3J"] { + let (mut enabled, mut stream) = initial(true); + let (mut standard, mut control) = initial(false); + let bytes = format!("\x1b[?2026h{clear}{}\x1b[?2026l", numbered(12)); + feed(&mut stream, &mut enabled, bytes.as_bytes()); + feed(&mut control, &mut standard, bytes.as_bytes()); + assert_eq!(top(&enabled), top(&standard)); + assert_eq!(enabled.grid().display_offset(), standard.grid().display_offset()); + } +} + +#[test] +fn repeated_sync_frames_recapture_the_current_reading_position() { + let (mut term, mut stream) = initial(true); + for count in 12..20 { + feed(&mut stream, &mut term, frame(&numbered(count)).as_bytes()); + assert_eq!(top(&term), "ROW_002"); + } +} + +#[test] +fn wide_and_combining_characters_survive_reanchoring() { + let content = |count| { + (0..count).map(|n| format!("中文_{n:03} e\u{301}")).collect::>().join("\r\n") + }; + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, frame(&content(12)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + let before = top(&term); + feed(&mut stream, &mut term, frame(&content(15)).as_bytes()); + assert_eq!(top(&term), before); + assert_eq!(term.grid().display_offset(), 8); +} + +#[test] +fn ambiguous_normalized_padding_does_not_choose_a_matching_copy() { + let (mut term, mut stream) = initial(true); + let first = (0..12).map(|n| format!("ROW_{n:03} value")).collect::>().join("\r\n"); + feed(&mut stream, &mut term, frame(&first).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + let second = first.replace(" value", " value"); + feed(&mut stream, &mut term, frame(&format!("{first}\r\n{second}")).as_bytes()); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn table_padding_changes_preserve_three_row_anchor() { + let (mut term, mut stream) = initial(true); + let table = |padding: usize| { + (0..12) + .map(|n| format!("│ ROW_{n:03}{}│ value {n} │", " ".repeat(padding))) + .collect::>() + .join("\r\n") + }; + feed(&mut stream, &mut term, frame(&table(1)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); + feed(&mut stream, &mut term, frame(&table(6)).as_bytes()); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); +} From 3ad289e3e2a793d57cc3fc7614c7a127e1e92e97 Mon Sep 17 00:00:00 2001 From: JoyLin <72300219+JoyElliot@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:20:16 +0800 Subject: [PATCH 4/4] fix(windows): preserve Acrylic blur at edges and on deactivation (#250) Use a supported installed Acrylic controller with owned window and runtime lifetimes. Retain the existing Accent fallback and pixel-opacity behavior. Addresses the Acrylic portion of #236; the Aero issue remains open. --- .../platform/2026-09-22-acrylic-controller.md | 103 +++++++ .../2026-09-22-acrylic-window-transitions.md | 110 +++++++ nebula_app/AGENTS.md | 2 + nebula_app/Cargo.toml | 2 +- nebula_app/src/gpui_shell/mod.rs | 2 + nebula_app/src/gpui_shell/wallpaper.rs | 54 +++- nebula_app/src/platform/acrylic.rs | 32 +++ nebula_app/src/platform/acrylic/bindings.rs | 154 ++++++++++ .../src/platform/acrylic/composition.rs | 206 +++++++++++++ nebula_app/src/platform/acrylic/controller.rs | 94 ++++++ nebula_app/src/platform/acrylic/gpui_tests.rs | 114 ++++++++ nebula_app/src/platform/acrylic/runtime.rs | 105 +++++++ nebula_app/src/platform/acrylic/tests.rs | 225 +++++++++++++++ .../src/platform/acrylic/transitions.rs | 270 ++++++++++++++++++ nebula_app/src/platform/mod.rs | 2 + 15 files changed, 1467 insertions(+), 8 deletions(-) create mode 100644 architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md create mode 100644 architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md create mode 100644 nebula_app/src/platform/acrylic.rs create mode 100644 nebula_app/src/platform/acrylic/bindings.rs create mode 100644 nebula_app/src/platform/acrylic/composition.rs create mode 100644 nebula_app/src/platform/acrylic/controller.rs create mode 100644 nebula_app/src/platform/acrylic/gpui_tests.rs create mode 100644 nebula_app/src/platform/acrylic/runtime.rs create mode 100644 nebula_app/src/platform/acrylic/tests.rs create mode 100644 nebula_app/src/platform/acrylic/transitions.rs diff --git a/architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md new file mode 100644 index 0000000..94a7125 --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md @@ -0,0 +1,103 @@ +# Windows Acrylic controller ownership and compatibility + +## Status + +Implemented on the GPUI Windows path; local native acceptance on Windows 11 +build 26200.9457, x64 Windows App Runtime 1.8 version 8000.946.1701.0. + +## Context + +AccentPolicy Acrylic leaves a measurable pattern near screen edges. Dark edge +overlays hide that pattern by changing the visual design. DesktopAcrylicController +reduces it, but its default target policy falls back to opaque gray on deactivation. +Pebrel must retain its existing pixel-alpha opacity model and fast opacity slider. + +## Evidence + +- A local 2560x1600 / 150% DPI comparison used colored bands and an 8 px checker + behind floating, screen-edge-aligned and maximized GPUI windows. Configuration + applied before SetTarget alone still went gray. Applying it after SetTarget, + followed by zero tint/luminosity opacity, preserved the blurred background. +- The production build's clean background ROI matched that successful prototype + pixel-for-pixel, including active/inactive/reactivated states. It is slightly + brighter than Accent, not a promise of identical pixels across implementations. +- The original 16 valid alternating prototype measurements isolated the target + PID/window-thread CPU and PID GPU counters. Window-thread cycles medians rose + about 5–10%, working set about 3 MiB; CPU time was noisy. Shared DWM cost cannot + be attributed reliably to this HWND. These are local prototype observations, + not a product performance guarantee. +- GPUI's pinned Windows platform runs its quit callback after GetMessage exits; + App shutdown borrows App and its async wait does not pump Windows messages. +- [SetTarget requirements](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.composition.systembackdrops.desktopacryliccontroller.settarget?view=windows-app-sdk-1.8) + require a DispatcherQueue and host backdrop opt-in. The + [native dynamic dependency API](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/framework-packages/use-the-dynamic-dependency-api) + supports an installed Windows App SDK framework on Windows 11 22H2+. + +## Decision + +- [acrylic.rs](../../../../nebula_app/src/platform/acrylic.rs) owns controller/target + pairs by GPUI WindowId on the UI thread. One optional compositor/queue is shared + across material changes; there is no worker, frame callback or focus polling. +- Clear Accent, blur-behind and system backdrop before attaching the lower + DesktopWindowTarget. GPUI keeps its upper target. Configuration sets input + active after attachment; tint and luminosity opacity are zero. Window pixel + alpha continues to supply the user's opacity and theme color. +- Close per-window resources before changing material or destroying the HWND. + The synchronous app-quit callback closes remaining targets. A guard around + Application::run drains the owned queue only after run returns, outside the + App borrow. Its own RoInitialize reference survives GPUI's OleUninitialize. + An existing external queue is borrowed and never shut down here. +- Keep runtime activation valid until owned resources are released. Queue drain + failure retains the context until process exit instead of invalidating pending + callbacks. The drain has a two-second deadline; it is not on the render path. +- Resolve native package-graph APIs from the already loaded OS module. Require + build 22621+, the 1.8 framework family and minimum tested 8000.946.1701.0. Select + a matching installed architecture/version; never install or search for DLLs. + Missing runtime, unsupported API or failed attachment uses existing Accent. +- Use a private, narrow WinAppSDK 1.8 ABI plus existing Windows crate projections. + No bootstrap DLL, new NuGet build step or bundled runtime enters the installer. + SDK activation factories are local rather than process-cached interface pointers. +- Keep the existing per-mode/per-window application gate. Failed GPUI updates + are not cached as successful; opacity changes do not recreate native resources. + +## Rejected alternatives + +- Edge masks, larger opaque borders and CPU/capture blur change the visual design + or add recurring work. +- DWMSBT_TRANSIENTWINDOW/default controller policy does not preserve unfocused + transparency. Reapplying focus policy before SetTarget does not fix that order. +- Bundling the complete runtime exceeds the existing installer budget. Bootstrap + alone still requires runtime deployment. An opportunistic installed-runtime + path preserves startup compatibility without introducing an installer action. +- A new composition thread complicates HWND teardown and adds a thread lifetime. +- Pumping a shutdown queue inside on_app_quit can reenter an already borrowed App. + +## Consequences + +Machines without the supported runtime retain the old edge behavior. Windows 10, +other GPUs, mixed-DPI monitors, accessibility and system transparency-policy +changes need their own native acceptance; this local run does not certify them. +The selected minimum may be widened only with evidence for that runtime version. + +## Validation + +- `cargo build -p nebula --locked` +- `cargo test -p nebula --bin pebrel platform::acrylic:: -- --include-ignored --test-threads=1` + exercises missing-package failure, invalid HWND rollback, independent targets, + repeated close/reattach, full-size roots and actual DispatcherQueue shutdown. +- `cargo test -p nebula --bin pebrel native_acrylic_gpui_material_switches_and_window_lifecycle -- --ignored --test-threads=1` + opens real GPUI windows, switches all five material modes, creates a second + window under an unchanged mode, verifies opacity refreshes do not reattach, + closes one window and quits with the other still alive. +- Production captures cover floating/left/right/maximized and focus transitions; + isolated processes exited normally and removed their runtime endpoints. +- Architecture gate uses the upstream-synchronized base commit. + +## Supersedes + +None. Accent remains the compatibility implementation. + +## Revisit when + +GPUI owns a system-backdrop lifecycle, the supported runtime changes, or portable +runtime distribution can meet the existing size and platform contracts. diff --git a/architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md new file mode 100644 index 0000000..718f87f --- /dev/null +++ b/architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md @@ -0,0 +1,110 @@ +# Acrylic during native window transitions + +## Status + +Implemented as a compatibility fallback on the GPUI Windows controller path. +Local native acceptance uses Windows 11 build 26200.9457 and Windows App Runtime +1.8 version 8000.946.1701.0; other compositor/driver combinations need acceptance. + +## Context + +Desktop Acrylic fixes the Accent edge artifact and preserves unfocused blur, but +its old blurred background can stretch or crop during maximize and restore to a +floating window. Ordinary window movement does not reproduce this incident. +The previous Accent path becomes temporarily clear during those animations. +Taskbar minimize/restore keeps native behavior: clearing before shrink introduces +a more noticeable flash than its existing, brief blurred snapshot. + +## Evidence + +- Colored horizontal bands and repeated text behind the same GPUI window expose + the stale blur geometry. A flat background does not establish correctness. +- An Accent/controller comparison keeps the GPUI renderer, theme and opacity + constant. The controller's old sample remains visible during the transition; + Accent exposes the clear background and resumes blur afterward. No application + code explicitly pauses Accent during these transitions. +- Native message traces put WINDOWPOSCHANGED and SIZE only a few milliseconds + after the command, while the visible transition continues. Neither message is + a completion notification for the compositor animation. +- The root already fills the desktop target. The public controller APIs provide + no force-recompute or per-HWND animation-completion operation. This does not + establish a documented internal DWM defect. +- [DWMWINDOWATTRIBUTE](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute) + documents TRANSITIONS_FORCEDISABLED for setting only. A local native probe + confirms readback returns E_INVALIDARG, so it cannot identify animation state. + +## Decision + +- [transitions.rs](../../../../nebula_app/src/platform/acrylic/transitions.rs) + belongs to the per-window Backdrop and installs a subclass on its owning UI + thread. Pause only the system-backdrop brush before a meaningful system command + or a maximize state change. The latter also covers ShowWindow calls. Minimize + and return from the taskbar are tracked but never initiate a material pause. + Hidden-window startup, ordinary movement and interactive geometry changes + retain their material. +- Resume after a nonblocking 300 ms settling interval, measured to cover the local + native animation. This is a bounded fallback, not an exact completion signal. + Repeated transitions extend the deadline; a queued old timer cannot shorten it. + If a window is minimized during a pending maximum-size transition, the timer + still restores its saved brush without delaying or replaying the minimize. +- Preserve a newer SDK brush instead of overwriting a policy/accessibility update + with the saved brush. Respect the system's SPI_GETANIMATION setting. Do not + change animation settings, focus policy, GPUI content, opacity or startup flow. +- Remove the timer/subclass before closing the controller/target. The subclass + holds its own Rc until removal or HWND destruction; callbacks also hold a local + Rc for nested teardown. No RefCell borrow spans a COM call or DefSubclassProc. +- Timer allocation failure restores usable material and logs the failure; it + must not leave a visible window permanently clear. Failed subclass removal + retains an inert callback allocation until HWND destruction. +- There is no new worker or per-frame update. Additional native work occurs on + state changes and their settling timer, not continuously during rendering. + +## Rejected alternatives + +- SYSTEMBACKDROP AUTO and a transparent Accent alongside the controller did not + remove the stretched sample in the native comparison. +- Disabling GPUI DirectComposition still reproduced the sample artifact and + removed visible GPUI content in the probe; it is not a production solution. +- Disabling Windows animations or adding edge masks changes the requested visual + behavior. A rendering-backend replacement exceeds this focused repair. +- Restoring at WINDOWPOSCHANGED exposes stale material while DWM is still animating. +- Polling a write-only DWM attribute cannot detect disabled transitions. +- For taskbar minimization, RequestCommitAsync, DwmFlush and earlier CBT notification + still retained the old snapshot in local probes. A 32 ms asynchronous preparation + did clear it, but added a visible clear frame before shrinking. That tradeoff is + rejected: there is no CBT hook, minimize veto/replay or synchronous DWM wait in + production. Preserve the less distracting native minimize animation. + +## Consequences + +The fallback deliberately exposes a clear background during maximize/windowed +restore transitions, then +restores normal blur. It does not make Acrylic resample throughout the animation. +An unusually long system animation may outlast the measured settling interval. +The system animation setting is supported; per-HWND external suppression has no +supported readback and is not inferred. Runtime changes to accessibility policy +still require their own native visual acceptance. +If the composition target itself fails during restoration, the error is logged; +the guard does not retry COM calls indefinitely or overwrite an unknown SDK state. + +## Validation + +- Native tests exercise ordinary geometry, system commands, ShowWindowAsync, + queued stale timers, successive transitions, unchanged taskbar material, a newer SDK + brush, owner teardown and HWND destruction before the Rust owner. +- Colored-background recordings compare all four transition directions with the + old Accent and unguarded controller. Static material/focus acceptance remains + documented in the original controller note. +- Test processes have isolated configuration and are closed by their own HWND; + existing user sessions are not used as test targets. + +## Supersedes + +None. Extends [controller ownership](2026-09-22-acrylic-controller.md) with the +animation compatibility boundary. + +## Revisit when + +The Windows controller exposes supported transition synchronization, GPUI shares +ownership of the backdrop target, or a tested direct solution preserves material +through the animation without changing the existing rendering behavior. diff --git a/nebula_app/AGENTS.md b/nebula_app/AGENTS.md index 96d053e..0ced932 100644 --- a/nebula_app/AGENTS.md +++ b/nebula_app/AGENTS.md @@ -8,3 +8,5 @@ - 测试真实控件和布局路径;编译或状态单测不能代替 hover、命中区域、键盘路径和视觉验收。 - 只有跨层所有权、持久化、协议、线程/生命周期或重要性能取舍才写 note,路径镜像到 `architecture/notes/nebula_app//`。 - Agent 集成、改键输入与 SSH 认证的因果记录分别位于 [`ai_hook`](../architecture/notes/nebula_app/ai_hook/)、[`input`](../architecture/notes/nebula_app/input/) 和 [`ssh_session`](../architecture/notes/nebula_app/ssh_session/)。 +- Windows Acrylic 的运行库回退与窗口生命周期见 [`platform`](../architecture/notes/nebula_app/platform/2026-09-22-acrylic-controller.md)。 +- Windows Acrylic 的动画期间透明回退与计时边界见 [`窗口状态切换`](../architecture/notes/nebula_app/platform/2026-09-22-acrylic-window-transitions.md)。 diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index 9ef3160..c1a84a9 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -193,7 +193,7 @@ objc2-app-kit = { version = "0.3.1", default-features = false, features = [ [target.'cfg(windows)'.dependencies] windows-core = "=0.61.2" -windows = { version = "=0.61.3", features = ["Data_Xml_Dom", "UI_Notifications", "Data_Pdf", "Storage", "Storage_Streams", "Foundation", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] } +windows = { version = "=0.61.3", features = ["Data_Xml_Dom", "UI_Notifications", "Data_Pdf", "Storage", "Storage_Streams", "Foundation", "System", "UI_Composition", "UI_Composition_Desktop", "Win32_System_WinRT_Composition", "Win32_System_WinRT", "Win32_Graphics_Gdi", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_DataExchange", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_SystemServices", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_System_Variant", "Win32_Storage_FileSystem", "Win32_UI_Input_KeyboardAndMouse"] } dirs = "6.0.0" dwrote = "0.11.5" # Registry reads for installed-shell detection (WSL distros under Lxss, Git / diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index b2d8a95..4b0bdba 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -164,6 +164,7 @@ pub fn run_shell( } } let _runtime_server = runtime_server; + let _acrylic = crate::platform::acrylic::RunGuard::default(); // GPUI 默认只有 macOS 在最后一扇窗关掉后仍驻留(Dock 里留个没有窗口的 // 进程)。Nebula 没有 Dock 重开入口,也没法在 Mac 上验证那个无窗状态, // 首版三端统一:最后一扇窗关闭即退出(驻留另有 keep_session 能力位管)。 @@ -189,6 +190,7 @@ pub fn run_shell( /// 组件库/主题/快捷键/用户配置的一次性初始化。 fn init(cx: &mut App) { + crate::platform::acrylic::init(cx); // 三端都注册内嵌 Maple:Linux/macOS 的系统等宽字体没有 NF 图标码点, // 侧栏与提示符会出方框;字形同源也是跨平台截图能互相比对的前提。 register_bundled_fonts(cx); diff --git a/nebula_app/src/gpui_shell/wallpaper.rs b/nebula_app/src/gpui_shell/wallpaper.rs index 981f449..5336a64 100644 --- a/nebula_app/src/gpui_shell/wallpaper.rs +++ b/nebula_app/src/gpui_shell/wallpaper.rs @@ -400,17 +400,22 @@ fn apply_window_effects(cx: &mut App) { .into_iter() .filter(|handle| !already_applied.contains(&handle.window_id())) .collect::>(); - cx.set_global(AppliedBlur { blur, windows: window_ids }); + let mut applied = + already_applied.intersection(&window_ids).copied().collect::>(); for handle in pending { if let Err(err) = handle.update(cx, |_, window, _| { + crate::platform::acrylic::remove(window.window_handle().window_id()); window.set_background_appearance(appearance); #[cfg(windows)] apply_windows_accent_policy(window, blur, appearance); window.refresh(); }) { log::warn!("failed to apply window visual effects: {err}"); + } else { + applied.insert(handle.window_id()); } } + cx.set_global(AppliedBlur { blur, windows: applied }); }); } @@ -424,11 +429,14 @@ fn apply_window_effects(cx: &mut App) { /// | `Aero` | state 3 + 玻璃色调 | `DWMSBT_NONE` | 整窗实时玻璃模糊 | /// | `Mica` | 全零 | `DWMSBT_MAINWINDOW` | 系统壁纸 backdrop | /// | `Mica Alt` | 全零 | `DWMSBT_TABBEDWINDOW` | 强色调系统壁纸 backdrop | -/// | `Acrylic` | state 4 + 非零 alpha | `DWMSBT_NONE` | 实时模糊 + tint/噪点/饱和 | +/// | `Acrylic` | controller 成功时清零,否则 state 4 | `DWMSBT_NONE` | 实时 Acrylic | /// /// `Mica` / `Mica Alt` 使用公开 DWM 属性。Nebula 不读取 /// `SPI_GETDESKWALLPAPER` 或 `TranscodedWallpaper`;显示器选择、壁纸排布、模糊和 /// 色调全部交给系统合成器。Windows 不支持该属性时退回 Acrylic,避免透明空洞。 +/// Acrylic 在 Windows 11 22H2+ 优先使用可用的 DesktopAcrylicController;缺少 +/// Windows App Runtime 或绑定失败时保留 AccentPolicy 回退。控制器只随窗口和 +/// 材质档位变化创建/释放,不跟随透明度滑块或焦点重建。 /// /// 两条通道**必须互斥**:同时开 Acrylic 与 system backdrop 时 DWM 的行为未 /// 定义(实测表现为 backdrop 赢,Acrylic 被吞)。所以每档都要把另一条显式 @@ -494,7 +502,7 @@ fn apply_windows_accent_policy( } let apply_accent = |mut accent: AccentPolicy, phase: &str| { - let Some(set_attribute) = set_attribute else { return }; + let Some(set_attribute) = set_attribute else { return false }; let mut data = WindowCompositionAttributeData { attribute: 19, // WCA_ACCENT_POLICY data: &mut accent as *mut _ as *mut core::ffi::c_void, @@ -503,7 +511,9 @@ fn apply_windows_accent_policy( // SAFETY: hwnd 来自当前存活的 GPUI 窗口,数据在调用期间保持有效。 if unsafe { set_attribute(hwnd, &mut data) } == 0 { log::warn!("SetWindowCompositionAttribute({phase}) failed"); + return false; } + true }; let disabled_accent = AccentPolicy { @@ -520,9 +530,11 @@ fn apply_windows_accent_policy( // 必须先移除旧 WCA 层。反过来先写 DWMSBT 时,Aero/Acrylic 的 // AccentPolicy 会阻止 DWM 接纳新材质,事后再清也不会自动重算 frame。 - if system_material_requested { - apply_accent(disabled_accent, "clear-before-system-backdrop"); - } + let accent_cleared = if system_material_requested || blur == BlurModeName::Acrylic { + apply_accent(disabled_accent, "clear-before-system-backdrop") + } else { + false + }; let blur_behind = DWM_BLURBEHIND { dwFlags: DWM_BB_ENABLE, @@ -550,6 +562,17 @@ fn apply_windows_accent_policy( let system_material_available = system_material_requested && backdrop_result.is_some_and(|result| result >= 0); + let acrylic_requested = blur == BlurModeName::Acrylic + || (matches!(blur, BlurModeName::Mica | BlurModeName::MicaAlt) + && !system_material_available + && appearance != WindowBackgroundAppearance::Transparent); + let controller_available = acrylic_requested + && windows_build_number() >= 22_621 + && accent_cleared + && backdrop_result.is_some_and(|result| result >= 0) + && blur_behind_result >= 0 + && crate::platform::acrylic::apply(window.window_handle().window_id(), hwnd as isize); + let accent = match blur { BlurModeName::Acrylic => AccentPolicy { state: 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND @@ -576,7 +599,7 @@ fn apply_windows_accent_policy( BlurModeName::Mica | BlurModeName::MicaAlt | BlurModeName::None => disabled_accent, }; - if !(system_material_requested && system_material_available) { + if !controller_available && !(system_material_requested && system_material_available) { apply_accent(accent, "final"); } @@ -813,3 +836,20 @@ fn image_corners(bounds: Bounds, image: Bounds, radius: Pixels) bottom_right: if right && bottom { radius } else { px(0.0) }, } } + +/// Initialize only the visual state needed by native material acceptance tests. +#[cfg(test)] +pub(crate) fn test_install_visual_effects(cx: &mut App, opacity: f32, blur: BlurModeName) { + cx.set_global(VisualEffects { + opacity, + blur, + wallpaper: None, + generation: Arc::new(AtomicU64::new(0)), + loading: false, + }); +} + +#[cfg(test)] +pub(crate) fn test_apply_window_effects(cx: &mut App) { + apply_window_effects(cx); +} diff --git a/nebula_app/src/platform/acrylic.rs b/nebula_app/src/platform/acrylic.rs new file mode 100644 index 0000000..c8be510 --- /dev/null +++ b/nebula_app/src/platform/acrylic.rs @@ -0,0 +1,32 @@ +//! Optional native Acrylic lifecycle behind a platform-neutral shell interface. +//! Windows owns the material; other platforms retain their existing GPUI backdrop. + +#[cfg(windows)] +mod bindings; +#[cfg(windows)] +mod composition; +#[cfg(windows)] +mod controller; +#[cfg(all(test, windows))] +mod gpui_tests; +#[cfg(windows)] +mod runtime; +#[cfg(all(test, windows))] +mod tests; +#[cfg(windows)] +mod transitions; + +#[cfg(all(test, windows))] +pub(crate) use controller::test_snapshot; +#[cfg(windows)] +pub(crate) use controller::{RunGuard, apply, init, remove}; + +#[cfg(not(windows))] +#[derive(Default)] +pub(crate) struct RunGuard; + +#[cfg(not(windows))] +pub(crate) fn init(_: &mut gpui::App) {} + +#[cfg(not(windows))] +pub(crate) fn remove(_: gpui::WindowId) {} diff --git a/nebula_app/src/platform/acrylic/bindings.rs b/nebula_app/src/platform/acrylic/bindings.rs new file mode 100644 index 0000000..d25abee --- /dev/null +++ b/nebula_app/src/platform/acrylic/bindings.rs @@ -0,0 +1,154 @@ +//! Narrow Windows App SDK 1.8 ABI, absent from the OS-only `windows` crate. +//! Interface identities and slot order come from Microsoft.UI.winmd 1.8. +//! Use local activation factories: no SDK COM pointer may outlive our runtime lease. + +use std::ffi::c_void; + +use windows::UI::Color; +use windows::UI::Composition::CompositionTarget; +use windows::Win32::System::WinRT::{RoActivateInstance, RoGetActivationFactory}; +use windows_core::{HRESULT, HSTRING, IInspectable_Vtbl, Interface, Result}; + +windows_core::imp::define_interface!(Acrylic, AcrylicVtbl, 0x7c20a6af_8eb3_5f08_bdfc_6d35e35dfe45); +#[repr(C)] +pub struct AcrylicVtbl { + base: IInspectable_Vtbl, + get_fallback: unsafe extern "system" fn(*mut c_void, *mut Color) -> HRESULT, + set_fallback: unsafe extern "system" fn(*mut c_void, Color) -> HRESULT, + get_luminosity: unsafe extern "system" fn(*mut c_void, *mut f32) -> HRESULT, + set_luminosity: unsafe extern "system" fn(*mut c_void, f32) -> HRESULT, + get_tint_color: unsafe extern "system" fn(*mut c_void, *mut Color) -> HRESULT, + set_tint_color: unsafe extern "system" fn(*mut c_void, Color) -> HRESULT, + get_tint: unsafe extern "system" fn(*mut c_void, *mut f32) -> HRESULT, + set_tint: unsafe extern "system" fn(*mut c_void, f32) -> HRESULT, +} + +windows_core::imp::define_interface!(Statics, StaticsVtbl, 0xa9e8f790_79ef_5416_9b67_6bcfe867c8b7); +#[repr(C)] +pub struct StaticsVtbl { + base: IInspectable_Vtbl, + is_supported: unsafe extern "system" fn(*mut c_void, *mut bool) -> HRESULT, +} + +windows_core::imp::define_interface!( + Configuration, + ConfigurationVtbl, + 0xebcce1b9_0e0c_5431_ab0e_00f3f0669962 +); +#[repr(C)] +pub struct ConfigurationVtbl { + base: IInspectable_Vtbl, + get_high_contrast_color: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + set_high_contrast_color: unsafe extern "system" fn(*mut c_void, *mut c_void) -> HRESULT, + get_high_contrast: unsafe extern "system" fn(*mut c_void, *mut bool) -> HRESULT, + set_high_contrast: unsafe extern "system" fn(*mut c_void, bool) -> HRESULT, + get_input_active: unsafe extern "system" fn(*mut c_void, *mut bool) -> HRESULT, + set_input_active: unsafe extern "system" fn(*mut c_void, bool) -> HRESULT, + get_theme: unsafe extern "system" fn(*mut c_void, *mut i32) -> HRESULT, + set_theme: unsafe extern "system" fn(*mut c_void, i32) -> HRESULT, +} + +windows_core::imp::define_interface!( + Controller, + ControllerVtbl, + 0x5632d76c_0b74_5b52_aa33_80262068aeb2 +); +#[repr(C)] +pub struct ControllerVtbl { + base: IInspectable_Vtbl, + set_window_target: + unsafe extern "system" fn(*mut c_void, WindowId, *mut c_void, *mut bool) -> HRESULT, + set_core_target: + unsafe extern "system" fn(*mut c_void, *mut c_void, *mut c_void, *mut bool) -> HRESULT, +} + +#[repr(C)] +struct WindowId { + value: u64, +} + +windows_core::imp::define_interface!( + WithTargets, + WithTargetsVtbl, + 0x9c56fe7c_98eb_5f89_ad97_dad57fc30c8c +); +#[repr(C)] +pub struct WithTargetsVtbl { + base: IInspectable_Vtbl, + get_state: unsafe extern "system" fn(*mut c_void, *mut i32) -> HRESULT, + add_target: unsafe extern "system" fn(*mut c_void, *mut c_void, *mut bool) -> HRESULT, + remove_all_targets: unsafe extern "system" fn(*mut c_void) -> HRESULT, + remove_target: unsafe extern "system" fn(*mut c_void, *mut c_void, *mut bool) -> HRESULT, + set_configuration: unsafe extern "system" fn(*mut c_void, *mut c_void) -> HRESULT, + add_state_changed: unsafe extern "system" fn(*mut c_void, *mut c_void, *mut i64) -> HRESULT, + remove_state_changed: unsafe extern "system" fn(*mut c_void, i64) -> HRESULT, +} + +const CLASS: &str = "Microsoft.UI.Composition.SystemBackdrops.DesktopAcrylicController"; + +impl Acrylic { + pub(super) fn new() -> Result { + unsafe { + let factory: Statics = RoGetActivationFactory(&HSTRING::from(CLASS))?; + let mut supported = false; + (factory.vtable().is_supported)(factory.as_raw(), &mut supported).ok()?; + if !supported { + return Err(windows_core::Error::new( + HRESULT(0x80004001_u32 as i32), + "Acrylic unsupported", + )); + } + RoActivateInstance(&HSTRING::from(CLASS))?.cast() + } + } + + pub(super) fn attach(&self, hwnd: isize, target: &CompositionTarget) -> Result<()> { + let controller: Controller = self.cast()?; + let mut attached = false; + unsafe { + (controller.vtable().set_window_target)( + controller.as_raw(), + WindowId { value: hwnd as u64 }, + target.as_raw(), + &mut attached, + ) + .ok()?; + } + if !attached { + return Err(windows_core::Error::new( + HRESULT(0x80004005_u32 as i32), + "Acrylic target rejected", + )); + } + Ok(()) + } + + pub(super) fn configure(&self) -> Result { + unsafe { + let config: Configuration = RoActivateInstance(&HSTRING::from( + "Microsoft.UI.Composition.SystemBackdrops.SystemBackdropConfiguration", + ))? + .cast()?; + (config.vtable().set_input_active)(config.as_raw(), true).ok()?; + let targets: WithTargets = self.cast()?; + // SetTarget installs its own focus policy. Override AFTER attaching, + // otherwise deactivation reinstates opaque gray. Keep OS contrast and + // transparency policies; only the focus-derived fallback is disabled. + (targets.vtable().set_configuration)(targets.as_raw(), config.as_raw()).ok()?; + (self.vtable().set_tint_color)(self.as_raw(), Color { A: 255, R: 46, G: 52, B: 64 }) + .ok()?; + (self.vtable().set_tint)(self.as_raw(), 0.0).ok()?; + (self.vtable().set_luminosity)(self.as_raw(), 0.0).ok()?; + Ok(config) + } + } + + pub(super) fn close(&self) { + match self.cast::().and_then(|object| object.Close()) { + Ok(()) => {}, + Err(error) => { + log::warn!(target: "nebula", "closing Acrylic controller failed: {error}") + }, + } + } +} diff --git a/nebula_app/src/platform/acrylic/composition.rs b/nebula_app/src/platform/acrylic/composition.rs new file mode 100644 index 0000000..ecf1173 --- /dev/null +++ b/nebula_app/src/platform/acrylic/composition.rs @@ -0,0 +1,206 @@ +//! STA-affine composition resources. No background worker or per-frame updates. + +use std::marker::PhantomData; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use windows::System::{DispatcherQueue, DispatcherQueueController}; +use windows::UI::Composition::Compositor; +use windows::UI::Composition::Desktop::DesktopWindowTarget; +use windows::Win32::Foundation::HWND; +use windows::Win32::System::WinRT::Composition::ICompositorDesktopInterop; +use windows::Win32::System::WinRT::{ + CreateDispatcherQueueController, DQTAT_COM_NONE, DQTYPE_THREAD_CURRENT, DispatcherQueueOptions, + RO_INIT_SINGLETHREADED, RoInitialize, RoUninitialize, +}; +use windows_core::{HRESULT, Interface, Result}; +use windows_sys::Win32::Graphics::Dwm::{DWMWA_USE_HOSTBACKDROPBRUSH, DwmSetWindowAttribute}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, IsWindow, MSG, PM_REMOVE, PeekMessageW, TranslateMessage, WM_QUIT, +}; + +use super::bindings::{Acrylic, Configuration}; +use super::runtime::Runtime; +use super::transitions::Transitions; + +struct Apartment; +impl Drop for Apartment { + fn drop(&mut self) { + unsafe { RoUninitialize() }; + } +} + +pub(super) struct Context { + compositor: Option, + queue: Option, + // Drop order: SDK objects/queue, runtime package lease, then our own STA ref. + _runtime: Runtime, + _apartment: Apartment, + _thread: PhantomData>, +} + +impl Context { + pub(super) fn new() -> Result { + unsafe { RoInitialize(RO_INIT_SINGLETHREADED)? }; + let apartment = Apartment; + let runtime = Runtime::load()?; + let queue = if DispatcherQueue::GetForCurrentThread().is_ok() { + None + } else { + Some(unsafe { + CreateDispatcherQueueController(DispatcherQueueOptions { + dwSize: std::mem::size_of::() as u32, + threadType: DQTYPE_THREAD_CURRENT, + apartmentType: DQTAT_COM_NONE, + })? + }) + }; + // Delay fallible compositor activation until State owns this context; + // even a failed attach must drain an owned queue at application shutdown. + Ok(Self { + compositor: None, + queue, + _runtime: runtime, + _apartment: apartment, + _thread: PhantomData, + }) + } + + pub(super) fn attach(&mut self, hwnd: isize) -> Result { + if self.compositor.is_none() { + self.compositor = Some(Compositor::new()?); + } + let compositor = self.compositor.as_ref().unwrap(); + let host = HostBackdrop::new(hwnd)?; + let interop: ICompositorDesktopInterop = compositor.cast()?; + // GPUI owns the topmost target; the material occupies the lower target. + let target = unsafe { interop.CreateDesktopWindowTarget(HWND(hwnd as _), false)? }; + let mut backdrop = + Backdrop { transitions: None, controller: None, config: None, target, _host: host }; + let root = compositor.CreateContainerVisual()?; + let mut relative_size = root.RelativeSizeAdjustment()?; + relative_size.X = 1.0; + relative_size.Y = 1.0; + root.SetRelativeSizeAdjustment(relative_size)?; + backdrop.target.SetRoot(&root)?; + backdrop.controller = Some(Acrylic::new()?); + let controller = backdrop.controller.as_ref().unwrap(); + controller.attach(hwnd, &backdrop.target.cast()?)?; + backdrop.config = Some(controller.configure()?); + backdrop.transitions = Some(Transitions::new(hwnd, backdrop.target.cast()?)?); + Ok(backdrop) + } + + /// Called outside GPUI's App borrow, AFTER Application::run returns. Never + /// pump messages in a window-close callback or on_app_quit (re-entrancy). + pub(super) fn shutdown(mut self) -> bool { + if let Some(compositor) = self.compositor.take() { + if let Err(error) = compositor.Close() { + log::warn!(target: "nebula", "closing Acrylic compositor failed: {error}"); + } + } + let Some(queue) = self.queue.as_ref() else { return true }; + let shutdown = match queue.ShutdownQueueAsync() { + Ok(shutdown) => shutdown, + Err(error) => { + log::warn!(target: "nebula", "Acrylic queue shutdown failed: {error}"); + // Keep package resolution and our STA alive for outstanding + // callbacks until process exit. This is not a live-window leak. + std::mem::forget(self); + return false; + }, + }; + let deadline = Instant::now() + Duration::from_secs(2); + while shutdown.Status().is_ok_and(|status| status.0 == 0) && Instant::now() < deadline { + let mut message: MSG = unsafe { std::mem::zeroed() }; + // The outer application loop has ended; WM_QUIT needs no repost. + unsafe { + while Instant::now() < deadline + && PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 + { + if message.message != WM_QUIT { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + } + std::thread::sleep(Duration::from_millis(1)); + } + if shutdown.Status().is_ok_and(|status| status.0 == 1) { + if let Err(error) = shutdown.GetResults() { + log::warn!(target: "nebula", "Acrylic queue shutdown result: {error}"); + std::mem::forget(shutdown); + std::mem::forget(self); + return false; + } + true + } else { + log::warn!(target: "nebula", "Acrylic queue did not drain; retaining runtime until process exit"); + std::mem::forget(shutdown); + std::mem::forget(self); + false + } + } +} + +pub(super) struct Backdrop { + transitions: Option, + controller: Option, + config: Option, + target: DesktopWindowTarget, + _host: HostBackdrop, +} + +#[cfg(test)] +impl Backdrop { + pub(super) fn has_live_full_size_root(&self) -> bool { + self.target + .Root() + .and_then(|root| root.RelativeSizeAdjustment()) + .is_ok_and(|size| size.X == 1.0 && size.Y == 1.0) + } +} + +impl Drop for Backdrop { + fn drop(&mut self) { + // The HWND callback must stop using its target before the SDK closes it. + self.transitions.take(); + if let Some(controller) = self.controller.take() { + controller.close(); + } + self.config.take(); + if let Err(error) = self.target.Close() { + log::warn!(target: "nebula", "closing Acrylic desktop target failed: {error}"); + } + } +} + +struct HostBackdrop(isize); +impl HostBackdrop { + fn new(hwnd: isize) -> Result { + Self::set(hwnd, true)?; + Ok(Self(hwnd)) + } + + fn set(hwnd: isize, enabled: bool) -> Result<()> { + let value = i32::from(enabled); + unsafe { + HRESULT(DwmSetWindowAttribute( + hwnd as _, + DWMWA_USE_HOSTBACKDROPBRUSH as u32, + &value as *const _ as _, + std::mem::size_of_val(&value) as u32, + )) + .ok() + } + } +} +impl Drop for HostBackdrop { + fn drop(&mut self) { + if unsafe { IsWindow(self.0 as _) } != 0 { + if let Err(error) = Self::set(self.0, false) { + log::warn!(target: "nebula", "clearing Acrylic host backdrop failed: {error}"); + } + } + } +} diff --git a/nebula_app/src/platform/acrylic/controller.rs b/nebula_app/src/platform/acrylic/controller.rs new file mode 100644 index 0000000..ba247e3 --- /dev/null +++ b/nebula_app/src/platform/acrylic/controller.rs @@ -0,0 +1,94 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::marker::PhantomData; +use std::rc::Rc; + +use super::composition::{Backdrop, Context}; + +#[derive(Default)] +struct State { + windows: HashMap, + context: Option, + unavailable: bool, + #[cfg(test)] + attachments: usize, +} + +thread_local! { + static STATE: RefCell = RefCell::new(State::default()); +} + +/// Owns final queue drain outside the App's shutdown borrow. Must surround run. +#[derive(Default)] +pub(crate) struct RunGuard(PhantomData>); + +impl Drop for RunGuard { + fn drop(&mut self) { + let mut state = STATE.with(|state| std::mem::take(&mut *state.borrow_mut())); + state.windows.clear(); + if let Some(context) = state.context.take() { + context.shutdown(); + } + } +} + +pub(crate) fn init(cx: &mut gpui::App) { + cx.on_window_closed(|_, id| remove(id)).detach(); + cx.on_app_quit(|_| { + STATE.with(|state| state.borrow_mut().windows.clear()); + async {} + }) + .detach(); +} + +pub(crate) fn remove(id: gpui::WindowId) { + STATE.with(|state| { + state.borrow_mut().windows.remove(&id); + }); +} + +/// Called only for a new HWND or material change, never an opacity/frame update. +pub(crate) fn apply(id: gpui::WindowId, hwnd: isize) -> bool { + STATE.with(|state| { + let mut state = state.borrow_mut(); + if state.windows.contains_key(&id) { + return true; + } + if state.unavailable { + return false; + } + if state.context.is_none() { + match Context::new() { + Ok(context) => state.context = Some(context), + Err(error) => { + state.unavailable = true; + log::info!(target: "nebula", "Desktop Acrylic unavailable; using Accent fallback: {error}"); + return false; + }, + } + } + match state.context.as_mut().unwrap().attach(hwnd) { + Ok(backdrop) => { + state.windows.insert(id, backdrop); + #[cfg(test)] + { + state.attachments += 1; + } + log::debug!(target: "nebula", "Desktop Acrylic attached to HWND {hwnd:#x}"); + true + }, + Err(error) => { + log::warn!(target: "nebula", "Desktop Acrylic attach failed; using Accent fallback: {error}"); + false + }, + } + }) +} + +#[cfg(test)] +pub(crate) fn test_snapshot() -> (usize, usize) { + STATE.with(|state| { + let state = state.borrow(); + (state.windows.len(), state.attachments) + }) +} diff --git a/nebula_app/src/platform/acrylic/gpui_tests.rs b/nebula_app/src/platform/acrylic/gpui_tests.rs new file mode 100644 index 0000000..726ff01 --- /dev/null +++ b/nebula_app/src/platform/acrylic/gpui_tests.rs @@ -0,0 +1,114 @@ +//! Opt-in real GPUI/WinRT lifecycle acceptance; no terminal, settings or user data. + +use crate::gpui_shell::wallpaper::{ + VisualEffects, test_apply_window_effects as apply_window_effects, test_install_visual_effects, +}; +use crate::platform::acrylic; +use gpui::{App, AppContext, AsyncApp, Context, Render, WindowHandle, WindowOptions}; +use gpui::{IntoElement, Styled, Window, WindowBackgroundAppearance, div}; +use nebula_settings::BlurModeName; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +struct EmptyWindow; +impl Render for EmptyWindow { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + div().size_full() + } +} + +fn open(cx: &mut App) -> WindowHandle { + cx.open_window( + WindowOptions { + window_background: WindowBackgroundAppearance::Transparent, + ..Default::default() + }, + |_, cx| cx.new(|_| EmptyWindow), + ) + .unwrap() +} + +async fn settle(cx: &AsyncApp, expected: usize) -> Result<(), String> { + cx.background_executor().timer(Duration::from_millis(180)).await; + let actual = acrylic::test_snapshot().0; + if actual != expected { + return Err(format!("expected {expected} native backdrops, observed {actual}")); + } + Ok(()) +} + +async fn exercise(first: WindowHandle, cx: &AsyncApp) -> Result<(), String> { + settle(cx, 1).await?; + let second = cx.update(|cx| { + let window = open(cx); + apply_window_effects(cx); + window + }); + settle(cx, 2).await?; + for mode in [ + BlurModeName::Aero, + BlurModeName::Acrylic, + BlurModeName::Mica, + BlurModeName::Acrylic, + BlurModeName::MicaAlt, + BlurModeName::Acrylic, + BlurModeName::None, + BlurModeName::Acrylic, + ] { + cx.update(|cx| { + cx.global_mut::().blur = mode; + apply_window_effects(cx); + }); + settle(cx, if mode == BlurModeName::Acrylic { 2 } else { 0 }).await?; + } + let before = acrylic::test_snapshot(); + for value in 0..=20 { + cx.update(|cx| { + cx.global_mut::().opacity = value as f32 / 20.0; + apply_window_effects(cx); + }); + } + settle(cx, 2).await?; + if acrylic::test_snapshot() != before { + return Err("opacity refresh recreated native resources".into()); + } + cx.update(|cx| first.update(cx, |_, window, _| window.remove_window())) + .map_err(|error| error.to_string())?; + settle(cx, 1).await?; + cx.update(|cx| second.update(cx, |_, window, _| window.refresh())) + .map_err(|error| error.to_string())?; + Ok(()) +} + +#[test] +#[ignore = "opens real GPUI windows; requires Windows 11 and Windows App Runtime 1.8"] +fn native_acrylic_gpui_material_switches_and_window_lifecycle() { + let result = Rc::new(RefCell::new(None)); + let shared_result = result.clone(); + let timed_out = Rc::new(Cell::new(false)); + let watchdog = timed_out.clone(); + let guard = acrylic::RunGuard::default(); + gpui_platform::application().run(move |cx| { + acrylic::init(cx); + test_install_visual_effects(cx, 0.4, BlurModeName::Acrylic); + let first = open(cx); + apply_window_effects(cx); + cx.spawn(async move |cx| { + *shared_result.borrow_mut() = Some(exercise(first, cx).await); + cx.update(|cx| cx.quit()); + }) + .detach(); + cx.spawn(async move |cx| { + cx.background_executor().timer(Duration::from_secs(15)).await; + watchdog.set(true); + cx.update(|cx| cx.quit()); + }) + .detach(); + }); + drop(guard); + assert!(!timed_out.get(), "normal GPUI quit must finish before the watchdog"); + assert_eq!(result.borrow_mut().take(), Some(Ok(()))); + assert_eq!(acrylic::test_snapshot().0, 0); + assert!(windows::System::DispatcherQueue::GetForCurrentThread().is_err()); +} diff --git a/nebula_app/src/platform/acrylic/runtime.rs b/nebula_app/src/platform/acrylic/runtime.rs new file mode 100644 index 0000000..ea900e4 --- /dev/null +++ b/nebula_app/src/platform/acrylic/runtime.rs @@ -0,0 +1,105 @@ +//! Optional, process-scoped Windows App Runtime package graph lease. +//! +//! Resolve the Windows 11 API dynamically so older Windows can still load Pebrel. +//! No bootstrap DLL, installation, filesystem probing or global package mutation. + +use std::ffi::c_void; +use std::ptr::{null, null_mut}; + +use windows_core::{Error, HRESULT, Result}; +use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; +use windows_sys::Win32::System::Memory::{GetProcessHeap, HeapFree}; + +type Create = unsafe extern "system" fn( + *const c_void, + *const u16, + u64, + i32, + i32, + *const u16, + u32, + *mut *mut u16, +) -> i32; +type Add = unsafe extern "system" fn(*const u16, i32, u32, *mut *mut c_void, *mut *mut u16) -> i32; +type Remove = unsafe extern "system" fn(*mut c_void) -> i32; +type Delete = unsafe extern "system" fn(*const u16) -> i32; + +pub(super) struct Runtime { + context: *mut c_void, + id: *mut u16, + remove: Remove, + delete: Delete, +} + +impl Runtime { + pub(super) fn load() -> Result { + Self::load_family(windows_core::w!("Microsoft.WindowsAppRuntime.1.8_8wekyb3d8bbwe")) + } + + fn load_family(family: windows_core::PCWSTR) -> Result { + // SAFETY: kernelbase is an OS module already loaded for this process. Each + // export has the signature in the Windows SDK's appmodel.h (22621+). + unsafe { + let module = GetModuleHandleW(windows_core::w!("kernelbase.dll").as_ptr()); + let proc = |name: &'static std::ffi::CStr| { + GetProcAddress(module, name.as_ptr().cast()).ok_or_else(Error::from_win32) + }; + if module.is_null() { + return Err(Error::from_win32()); + } + let create: Create = std::mem::transmute(proc(c"TryCreatePackageDependency")?); + let add: Add = std::mem::transmute(proc(c"AddPackageDependency")?); + let remove: Remove = std::mem::transmute(proc(c"RemovePackageDependency")?); + let delete: Delete = std::mem::transmute(proc(c"DeletePackageDependency")?); + let mut lease = Self { context: null_mut(), id: null_mut(), remove, delete }; + // Pin the minimum runtime actually validated, allowing newer patches + // in the same 1.8 family. Process lifetime; default architecture. + HRESULT(create( + null(), + family.as_ptr(), + (8000_u64 << 48) | (946_u64 << 32) | (1701_u64 << 16), + 0, + 0, + null(), + 0, + &mut lease.id, + )) + .ok()?; + let mut full_name = null_mut(); + let result = HRESULT(add(lease.id, 0, 0, &mut lease.context, &mut full_name)); + if !full_name.is_null() { + if let Ok(name) = windows_core::PCWSTR(full_name).to_string() { + log::debug!(target: "nebula", "Acrylic runtime: {name}"); + } + HeapFree(GetProcessHeap(), 0, full_name.cast()); + } + result.ok()?; + Ok(lease) + } + } +} + +#[cfg(test)] +#[test] +fn absent_runtime_is_a_recoverable_error() { + assert!( + Runtime::load_family(windows_core::w!("Pebrel.MissingAcrylicRuntime_8wekyb3d8bbwe")) + .is_err() + ); +} + +impl Drop for Runtime { + fn drop(&mut self) { + // Release owned SDK objects before removing their activation context. + // Removing the graph entry does not unload already loaded runtime DLLs. + unsafe { + if !self.context.is_null() { + let _ = (self.remove)(self.context); + } + if !self.id.is_null() { + let _ = (self.delete)(self.id); + HeapFree(GetProcessHeap(), 0, self.id.cast()); + } + } + } +} diff --git a/nebula_app/src/platform/acrylic/tests.rs b/nebula_app/src/platform/acrylic/tests.rs new file mode 100644 index 0000000..4b2ca69 --- /dev/null +++ b/nebula_app/src/platform/acrylic/tests.rs @@ -0,0 +1,225 @@ +use super::composition::Context; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, DispatchMessageW, PM_REMOVE, PeekMessageW, TranslateMessage, + WS_EX_NOREDIRECTIONBITMAP, WS_OVERLAPPEDWINDOW, +}; + +struct TestWindow(isize); +impl TestWindow { + fn new() -> Self { + let hwnd = unsafe { + CreateWindowExW( + WS_EX_NOREDIRECTIONBITMAP, + windows_core::w!("STATIC").as_ptr(), + windows_core::w!("Pebrel Acrylic lifecycle test").as_ptr(), + WS_OVERLAPPEDWINDOW, + 0, + 0, + 320, + 240, + std::ptr::null_mut(), + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + std::ptr::null(), + ) + }; + assert!(!hwnd.is_null()); + Self(hwnd as isize) + } +} +impl Drop for TestWindow { + fn drop(&mut self) { + assert_ne!(unsafe { DestroyWindow(self.0 as _) }, 0); + } +} + +#[test] +#[ignore = "requires interactive Windows 11 and Windows App Runtime 1.8 >= 8000.946.1701.0"] +fn native_targets_rollback_switch_independently_and_drain_before_sta_exit() { + // Real COM/WinRT calls protect ABI, failure rollback, two HWND ownership and + // shutdown. This does not substitute for GPUI focus/edge screenshot checks. + let mut context = Context::new().expect("required native runtime"); + let first = TestWindow::new(); + let second = TestWindow::new(); + assert!(context.attach(0).is_err()); + for _ in 0..8 { + let a = context.attach(first.0).expect("first native target"); + let b = context.attach(second.0).expect("independent second target"); + assert!(a.has_live_full_size_root() && b.has_live_full_size_root()); + drop(a); + assert!(b.has_live_full_size_root()); + // Reattach the same HWND while the other remains alive (material toggle). + let a = context.attach(first.0).expect("reattach after Close"); + let mut message = unsafe { std::mem::zeroed() }; + unsafe { + while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + drop(b); + assert!(a.has_live_full_size_root()); + drop(a); + } + drop(first); + drop(second); + assert!(context.shutdown(), "owned queue must actually complete shutdown"); + assert!(windows::System::DispatcherQueue::GetForCurrentThread().is_err()); +} + +#[test] +#[ignore = "opens a native window; requires interactive Windows 11 and Windows App Runtime 1.8"] +fn native_transition_guard_preserves_moves_restarts_deadlines_and_releases_its_hook() { + use std::time::{Duration, Instant}; + use windows::UI::Color; + use windows::UI::Composition::{Compositor, ICompositionSupportsSystemBackdrop}; + use windows::Win32::Foundation::HWND; + use windows::Win32::System::WinRT::Composition::ICompositorDesktopInterop; + use windows_core::Interface; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + PM_NOREMOVE, SC_MAXIMIZE, SC_MINIMIZE, SC_RESTORE, SW_MAXIMIZE, SW_MINIMIZE, SW_RESTORE, + SW_SHOWNOACTIVATE, SWP_NOACTIVATE, SWP_NOZORDER, SendMessageW, SetWindowPos, ShowWindow, + ShowWindowAsync, WM_SYSCOMMAND, WM_TIMER, + }; + + let pump = |millis| { + let until = Instant::now() + Duration::from_millis(millis); + while Instant::now() < until { + let mut message = unsafe { std::mem::zeroed() }; + unsafe { + while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + std::thread::sleep(Duration::from_millis(2)); + } + }; + let context = Context::new().unwrap(); + let window = TestWindow::new(); + let compositor = Compositor::new().unwrap(); + let interop: ICompositorDesktopInterop = compositor.cast().unwrap(); + let target = unsafe { interop.CreateDesktopWindowTarget(HWND(window.0 as _), false).unwrap() }; + let root = compositor.CreateContainerVisual().unwrap(); + target.SetRoot(&root).unwrap(); + let support: ICompositionSupportsSystemBackdrop = target.cast().unwrap(); + let original = + compositor.CreateColorBrushWithColor(Color { A: 255, R: 40, G: 70, B: 90 }).unwrap(); + support.SetSystemBackdrop(&original).unwrap(); + let guard = super::transitions::Transitions::new(window.0, support.clone()).unwrap(); + unsafe { + ShowWindow(window.0 as _, SW_SHOWNOACTIVATE); + SetWindowPos( + window.0 as _, + std::ptr::null_mut(), + 100, + 100, + 360, + 260, + SWP_NOACTIVATE | SWP_NOZORDER, + ); + } + assert_eq!( + support.SystemBackdrop().unwrap(), + original.cast().unwrap(), + "ordinary geometry keeps the material" + ); + + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0) }; + assert!(support.SystemBackdrop().is_err(), "maximization removes the stale sample"); + // Make a WM_TIMER from the old transition pending without dispatching it. + std::thread::sleep(Duration::from_millis(340)); + let mut message = unsafe { std::mem::zeroed() }; + assert_ne!( + unsafe { PeekMessageW(&mut message, window.0 as _, WM_TIMER, WM_TIMER, PM_NOREMOVE) }, + 0 + ); + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_RESTORE as usize, 0) }; + pump(70); + assert!( + support.SystemBackdrop().is_err(), + "an old queued timer must not end the new transition" + ); + pump(320); + assert_eq!(support.SystemBackdrop().unwrap(), original.cast().unwrap()); + + // GPUI's titlebar takes the ShowWindow path, without WM_SYSCOMMAND. + for action in [SW_MAXIMIZE, SW_RESTORE] { + unsafe { + ShowWindowAsync(window.0 as _, action); + } + pump(20); + assert!(support.SystemBackdrop().is_err(), "programmatic transitions also pause"); + pump(340); + assert_eq!(support.SystemBackdrop().unwrap(), original.cast().unwrap()); + } + + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MINIMIZE as usize, 0) }; + assert_eq!( + support.SystemBackdrop().unwrap(), + original.cast().unwrap(), + "minimize keeps native material" + ); + pump(340); + assert_eq!(support.SystemBackdrop().unwrap(), original.cast().unwrap()); + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_RESTORE as usize, 0) }; + pump(340); + assert_eq!( + support.SystemBackdrop().unwrap(), + original.cast().unwrap(), + "restore from taskbar keeps native material" + ); + + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0) }; + assert!(support.SystemBackdrop().is_err()); + let newer = compositor.CreateColorBrushWithColor(Color { A: 255, R: 0, G: 0, B: 0 }).unwrap(); + support.SetSystemBackdrop(&newer).unwrap(); + pump(340); + assert_eq!( + support.SystemBackdrop().unwrap(), + newer.cast().unwrap(), + "a newer SDK policy brush wins" + ); + + for action in [SW_MINIMIZE, SW_RESTORE] { + unsafe { + ShowWindowAsync(window.0 as _, action); + } + pump(340); + assert_eq!( + support.SystemBackdrop().unwrap(), + newer.cast().unwrap(), + "maximized taskbar transitions keep native material" + ); + } + + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_RESTORE as usize, 0) }; + assert!(support.SystemBackdrop().is_err()); + drop(guard); + support.SetSystemBackdrop(&newer).unwrap(); + pump(340); + assert_eq!( + support.SystemBackdrop().unwrap(), + newer.cast().unwrap(), + "dropping the owner cancels pending restoration" + ); + + // Native HWND destruction can precede the Rust owner during reentrant close. + let guard = super::transitions::Transitions::new(window.0, support.clone()).unwrap(); + unsafe { SendMessageW(window.0 as _, WM_SYSCOMMAND, SC_MAXIMIZE as usize, 0) }; + assert!(support.SystemBackdrop().is_err()); + drop(window); + pump(340); + drop(guard); + drop(support); + target.Close().unwrap(); + drop(target); + drop(interop); + drop(root); + drop(original); + drop(newer); + compositor.Close().unwrap(); + drop(compositor); + assert!(context.shutdown()); +} diff --git a/nebula_app/src/platform/acrylic/transitions.rs b/nebula_app/src/platform/acrylic/transitions.rs new file mode 100644 index 0000000..a5897c8 --- /dev/null +++ b/nebula_app/src/platform/acrylic/transitions.rs @@ -0,0 +1,270 @@ +//! Keep a stale Acrylic sample out of maximize/restore-to-window animations. +//! +//! Unlike Accent, the controller's composition target remains visible while DWM +//! scales the old window representation. Temporarily clear only that backdrop; +//! the GPUI scene, pixel opacity, focus policy and Windows animation stay intact. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use windows::UI::Composition::{CompositionBrush, ICompositionSupportsSystemBackdrop}; +use windows_core::{Error, Result}; +use windows_sys::Win32::Foundation::HWND; +use windows_sys::Win32::UI::Shell::{ + DefSubclassProc, GetWindowSubclass, RemoveWindowSubclass, SetWindowSubclass, +}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + ANIMATIONINFO, IsIconic, IsWindowVisible, IsZoomed, KillTimer, SC_MAXIMIZE, SC_RESTORE, + SPI_GETANIMATION, SetTimer, SystemParametersInfoW, WM_NCDESTROY, WM_SETTINGCHANGE, + WM_SYSCOMMAND, WM_TIMER, WM_WINDOWPOSCHANGING, +}; + +// WM_WINDOWPOSCHANGED precedes the compositor animation's completion. Windows +// exposes no per-HWND completion event here. This bounded, nonblocking settling +// interval covers the native transition measured in our acceptance probes; it is +// not a claim to observe DWM completion. Repeated transitions extend the deadline. +const SETTLE: Duration = Duration::from_millis(300); + +#[derive(Clone, Copy, PartialEq, Eq)] +struct ShowState { + maximized: bool, + minimized: bool, +} + +impl ShowState { + fn read(hwnd: HWND) -> Self { + Self { + maximized: unsafe { IsZoomed(hwnd) != 0 }, + minimized: unsafe { IsIconic(hwnd) != 0 }, + } + } + + fn changes_for(self, command: usize) -> bool { + match command as u32 & 0xfff0 { + SC_MAXIMIZE => !self.maximized && !self.minimized, + SC_RESTORE => self.maximized && !self.minimized, + _ => false, + } + } +} + +struct State { + target: ICompositionSupportsSystemBackdrop, + saved: RefCell>, + show: Cell, + deadline: Cell>, + attached: Cell, + native_ref: Cell, +} + +/// Owned by one Backdrop, on its HWND's thread; no global registry or worker. +pub(super) struct Transitions { + hwnd: isize, + state: Rc, +} + +impl Transitions { + pub(super) fn new(hwnd: isize, target: ICompositionSupportsSystemBackdrop) -> Result { + let state = Rc::new(State { + target, + saved: RefCell::new(None), + show: Cell::new(ShowState::read(hwnd as _)), + deadline: Cell::new(None), + attached: Cell::new(false), + native_ref: Cell::new(false), + }); + let id = Rc::as_ptr(&state) as usize; + let native = Rc::into_raw(state.clone()); + // SAFETY: the subclass owns a strong reference until successful removal + // or WM_NCDESTROY. Each invocation also takes an Rc for nested teardown. + if unsafe { SetWindowSubclass(hwnd as _, Some(window_proc), id, id) } == 0 { + let error = Error::from_win32(); + unsafe { + drop(Rc::from_raw(native)); + } + return Err(error); + } + state.native_ref.set(true); + state.attached.set(true); + Ok(Self { hwnd, state }) + } +} + +impl Drop for Transitions { + fn drop(&mut self) { + self.state.detach(self.hwnd as _, Rc::as_ptr(&self.state) as usize, false); + } +} + +impl State { + fn detach(&self, hwnd: HWND, id: usize, destroying: bool) { + self.attached.set(false); + if self.native_ref.get() { + let mut data = 0; + let removed = unsafe { + KillTimer(hwnd, id); + RemoveWindowSubclass(hwnd, Some(window_proc), id) != 0 + || destroying + || GetWindowSubclass(hwnd, Some(window_proc), id, &mut data) == 0 + }; + if removed && self.native_ref.replace(false) { + // The caller still holds the owner or callback Rc here. + unsafe { + Rc::decrement_strong_count(self as *const State); + } + } else { + // Keep an inert failed-to-remove callback alive until destruction. + log::warn!(target: "nebula", "Acrylic transition hook removal failed; retaining it until HWND destruction"); + } + } + self.deadline.set(None); + let saved = self.saved.borrow_mut().take(); + drop(saved); + } + + fn cancel(&self, hwnd: HWND, id: usize) { + unsafe { + KillTimer(hwnd, id); + } + self.deadline.set(None); + self.resume(); + } + + fn pause(&self, hwnd: HWND, id: usize) { + if !self.attached.get() || unsafe { IsWindowVisible(hwnd) } == 0 { + return; + } + if !animations_enabled() { + self.cancel(hwnd, id); + return; + } + // Arm before clearing; a failed timer must not leave a live window clear. + if unsafe { SetTimer(hwnd, id, SETTLE.as_millis() as u32, None) } == 0 { + log::warn!(target: "nebula", "Acrylic transition timer failed; retaining the normal material"); + self.cancel(hwnd, id); + return; + } + self.deadline.set(Some(Instant::now() + SETTLE)); + let needs_brush = self.saved.borrow().is_none(); + if needs_brush { + let Ok(brush) = self.target.SystemBackdrop() else { return }; + if !self.attached.get() { + return; + } + match self.target.SetSystemBackdrop(None::<&CompositionBrush>) { + Ok(()) if self.attached.get() => { + let previous = self.saved.replace(Some(brush)); + drop(previous); + }, + Ok(()) => {}, + Err(error) => { + log::warn!(target: "nebula", "pausing Acrylic transition failed: {error}") + }, + } + } + } + + fn resume(&self) { + let saved = self.saved.borrow_mut().take(); + if let Some(brush) = saved { + if !self.attached.get() { + return; + } + // Null WinRT interfaces map to Error::empty (code 0). A real COM + // error does not prove that the SDK's current material is empty. + match self.target.SystemBackdrop() { + Err(error) if error.code().0 == 0 && self.attached.get() => { + if let Err(error) = self.target.SetSystemBackdrop(&brush) { + log::warn!(target: "nebula", "resuming Acrylic transition failed: {error}"); + } + }, + Err(error) => { + log::warn!(target: "nebula", "reading Acrylic transition material failed: {error}") + }, + // A newer SDK brush, including an accessibility fallback, wins. + Ok(_) => {}, + } + } + } + + fn on_timer(&self, hwnd: HWND, id: usize) { + if !self.attached.get() { + return; + } + unsafe { KillTimer(hwnd, id) }; + let Some(deadline) = self.deadline.get() else { return }; + if let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + // KillTimer does not remove queued WM_TIMER messages. A stale message + // from an earlier transition must not reveal the backdrop too soon. + let millis = remaining.as_millis().saturating_add(1).min(u32::MAX as u128) as u32; + if unsafe { SetTimer(hwnd, id, millis, None) } != 0 { + return; + } + // On resource failure, a usable material is preferable to leaving + // the window permanently clear. The original artifact can recur. + log::warn!(target: "nebula", "Acrylic transition timer rearm failed; restoring material"); + } + self.deadline.set(None); + self.resume(); + } +} + +fn animations_enabled() -> bool { + let mut animation = + ANIMATIONINFO { cbSize: std::mem::size_of::() as u32, iMinAnimate: 1 }; + unsafe { + if SystemParametersInfoW( + SPI_GETANIMATION, + animation.cbSize, + &mut animation as *mut _ as _, + 0, + ) != 0 + && animation.iMinAnimate == 0 + { + return false; + } + } + true +} + +unsafe extern "system" fn window_proc( + hwnd: HWND, + message: u32, + wparam: usize, + lparam: isize, + id: usize, + data: usize, +) -> isize { + if !matches!( + message, + WM_SYSCOMMAND | WM_WINDOWPOSCHANGING | WM_TIMER | WM_NCDESTROY | WM_SETTINGCHANGE + ) { + return unsafe { DefSubclassProc(hwnd, message, wparam, lparam) }; + } + // SAFETY: the installed subclass retains this raw Rc allocation. + // No mutable borrow or borrowed state crosses DefSubclassProc/COM callbacks. + let state = unsafe { + Rc::increment_strong_count(data as *const State); + Rc::from_raw(data as *const State) + }; + if message == WM_NCDESTROY { + state.detach(hwnd, id, true); + } else if message == WM_TIMER && wparam == id { + state.on_timer(hwnd, id); + return 0; + } else if message == WM_SETTINGCHANGE && state.attached.get() && !animations_enabled() { + state.cancel(hwnd, id); + } else if message == WM_WINDOWPOSCHANGING { + let show = ShowState::read(hwnd); + let previous = state.show.replace(show); + // Taskbar minimize/restore retains the native material and animation. + // Pausing before minimize would add a visible flash before shrinking. + if !previous.minimized && !show.minimized && previous.maximized != show.maximized { + state.pause(hwnd, id); + } + } else if message == WM_SYSCOMMAND && ShowState::read(hwnd).changes_for(wparam) { + state.pause(hwnd, id); + } + unsafe { DefSubclassProc(hwnd, message, wparam, lparam) } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index a82c44a..4b832d6 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -9,6 +9,8 @@ //! 切模块 + 模块内 `#[cfg]` 分派」,只保留真正值钱的两点:单一入口,以及 //! 能力探测(让 UI 隐藏入口,而不是让功能在别的平台报错)。 +#[cfg(feature = "gpui-shell")] +pub(crate) mod acrylic; pub(crate) mod ai_session_identity; pub mod capabilities; pub mod credentials;