Merge upstream main into fix/windows-ctrl-slash

This commit is contained in:
GeekMr committed 2026-09-26 18:36:35 +08:00
commit c1289b6f1d
27 files changed
+2927 -125

No files matched your search

@@ -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.
@@ -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.
@@ -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.
+2
View File
@@ -8,3 +8,5 @@
- 测试真实控件和布局路径;编译或状态单测不能代替 hover、命中区域、键盘路径和视觉验收。
- 只有跨层所有权、持久化、协议、线程/生命周期或重要性能取舍才写 note,路径镜像到 `architecture/notes/nebula_app/<capability>/`。
- 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)。
+1 -1
View File
@@ -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 /
+800 -31
View File
@@ -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}"
}
}
+2
View File
@@ -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);
@@ -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::<nebula_terminal::vte::ansi::StdSyncHandler>::default();
let text = (0..12).map(|n| format!("ROW_{n:03}")).collect::<Vec<_>>().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());
}
@@ -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);
@@ -853,6 +853,9 @@ impl TerminalView {
_window: &mut Window,
cx: &mut Context<Self>,
) {
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=`,
@@ -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<Self>) -> 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();
+47 -7
View File
@@ -400,17 +400,22 @@ fn apply_window_effects(cx: &mut App) {
.into_iter()
.filter(|handle| !already_applied.contains(&handle.window_id()))
.collect::<Vec<_>>();
cx.set_global(AppliedBlur { blur, windows: window_ids });
let mut applied =
already_applied.intersection(&window_ids).copied().collect::<HashSet<_>>();
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<Pixels>, image: Bounds<Pixels>, 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);
}
+32
View File
@@ -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) {}
+154
View File
@@ -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<Self> {
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<Configuration> {
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::<windows::Foundation::IClosable>().and_then(|object| object.Close()) {
Ok(()) => {},
Err(error) => {
log::warn!(target: "nebula", "closing Acrylic controller failed: {error}")
},
}
}
}
@@ -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<Compositor>,
queue: Option<DispatcherQueueController>,
// Drop order: SDK objects/queue, runtime package lease, then our own STA ref.
_runtime: Runtime,
_apartment: Apartment,
_thread: PhantomData<Rc<()>>,
}
impl Context {
pub(super) fn new() -> Result<Self> {
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::<DispatcherQueueOptions>() 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<Backdrop> {
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<Transitions>,
controller: Option<Acrylic>,
config: Option<Configuration>,
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> {
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}");
}
}
}
}
@@ -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<gpui::WindowId, Backdrop>,
context: Option<Context>,
unavailable: bool,
#[cfg(test)]
attachments: usize,
}
thread_local! {
static STATE: RefCell<State> = RefCell::new(State::default());
}
/// Owns final queue drain outside the App's shutdown borrow. Must surround run.
#[derive(Default)]
pub(crate) struct RunGuard(PhantomData<Rc<()>>);
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)
})
}
@@ -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<EmptyWindow> {
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<EmptyWindow>, 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::<VisualEffects>().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::<VisualEffects>().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());
}
+105
View File
@@ -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> {
Self::load_family(windows_core::w!("Microsoft.WindowsAppRuntime.1.8_8wekyb3d8bbwe"))
}
fn load_family(family: windows_core::PCWSTR) -> Result<Self> {
// 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());
}
}
}
}
+225
View File
@@ -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());
}
@@ -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<Option<CompositionBrush>>,
show: Cell<ShowState>,
deadline: Cell<Option<Instant>>,
attached: Cell<bool>,
native_ref: Cell<bool>,
}
/// Owned by one Backdrop, on its HWND's thread; no global registry or worker.
pub(super) struct Transitions {
hwnd: isize,
state: Rc<State>,
}
impl Transitions {
pub(super) fn new(hwnd: isize, target: ICompositionSupportsSystemBackdrop) -> Result<Self> {
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::<ANIMATIONINFO>() 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) }
}
+2
View File
@@ -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;
+15 -3
View File
@@ -185,9 +185,21 @@ impl StreamProcessor {
}
pub fn stop_sync<U: EventListener>(&mut self, terminal: &mut Term<U>) {
terminal.cancel_redraw_anchor();
self.parser.stop_sync(terminal);
}
fn advance<U: EventListener>(&mut self, terminal: &mut Term<U>, 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<U: EventListener>(
&mut self,
terminal: &mut Term<U>,
@@ -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..]);
}
}
+57
View File
@@ -0,0 +1,57 @@
//! 清屏与滚动历史清除的网格、选区及阅读位置合同。
use super::{Boundary, Column, Dimensions, EventListener, Line, Term, TermMode, ansi, cmp};
impl<T: EventListener> Term<T> {
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();
}
}
+13 -75
View File
@@ -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<usize>) -> Point {
}
pub struct Term<T> {
redraw_anchor: redraw_anchor::RedrawAnchor,
/// Terminal focus controlling the cursor shape.
pub is_focused: bool,
@@ -342,6 +345,7 @@ impl<T> Term<T> {
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<T> Term<T> {
let damage = TermDamageState::new(num_cols, num_lines);
Term {
redraw_anchor: Default::default(),
inactive_grid,
scroll_region,
event_proxy,
@@ -727,6 +732,7 @@ impl<T> Term<T> {
/// Resize terminal to new dimensions.
pub fn resize<S: Dimensions>(&mut self, size: S) {
self.cancel_redraw_anchor();
let old_cols = self.columns();
let old_lines = self.screen_lines();
@@ -804,6 +810,7 @@ impl<T> Term<T> {
/// 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<T: EventListener> Handler for Term<T> {
#[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<T: EventListener> Handler for Term<T> {
/// 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);
}
@@ -2094,14 +2036,10 @@ impl<T: EventListener> Handler for Term<T> {
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 {
@@ -2160,7 +2098,7 @@ impl<T: EventListener> Handler for Term<T> {
self.cursor_blinking_override = Some(true);
self.event_proxy.send_event(Event::CursorBlinkingChange);
},
NamedPrivateMode::SyncUpdate => (),
NamedPrivateMode::SyncUpdate => self.begin_redraw_anchor(),
}
}
@@ -2216,7 +2154,7 @@ impl<T: EventListener> Handler for Term<T> {
self.cursor_blinking_override = Some(false);
self.event_proxy.send_event(Event::CursorBlinkingChange);
},
NamedPrivateMode::SyncUpdate => (),
NamedPrivateMode::SyncUpdate => self.finish_redraw_anchor(),
}
}
+144
View File
@@ -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<Vec<String>>,
cleared_viewport: bool,
cleared_history: bool,
}
impl<T> Term<T> {
/// 由宿主根据已确认的应用生命周期选择;默认不改变终端滚动语义。
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<Vec<_>> = (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<T: EventListener> Term<T> {
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<Cell>, line: Line) -> Option<String> {
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)
}
+37 -8
View File
@@ -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);
+273
View File
@@ -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<VoidListener>;
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::<Vec<_>>().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::<String>()
.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::<Vec<_>>());
}
#[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::<Vec<_>>().join("\r\n\r\n");
feed(&mut stream, &mut term, frame(&paragraphs).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::<Vec<_>>()
.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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>()
.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 │");
}