Merge upstream main into fix/windows-ctrl-slash
This commit is contained in:
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.
|
||||
+70
@@ -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.
|
||||
@@ -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)。
|
||||
@@ -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
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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..]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(¶graphs).as_bytes());
|
||||
term.scroll_display(Scroll::Top);
|
||||
term.scroll_display(Scroll::Delta(if blank_top { -5 } else { -4 }));
|
||||
let before: Vec<_> =
|
||||
(0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect();
|
||||
feed(
|
||||
&mut stream,
|
||||
&mut term,
|
||||
frame(&format!("{paragraphs}\r\n\r\nNEW_PARAGRAPH")).as_bytes(),
|
||||
);
|
||||
let after: Vec<_> =
|
||||
(0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect();
|
||||
assert_eq!(after, before, "blank_top={blank_top}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rules_widen_without_losing_the_reading_row() {
|
||||
let table = |padding: usize| {
|
||||
(0..12)
|
||||
.map(|n| {
|
||||
format!(
|
||||
"│ ROW_{n:03}{}│ value {n} │\r\n├{}┼──────────┤",
|
||||
" ".repeat(padding),
|
||||
"─".repeat(9 + padding)
|
||||
)
|
||||
})
|
||||
.collect::<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 │");
|
||||
}
|
||||
Reference in new issue
Block a user