fix(gpui): restore CC Esc and default-shell plus button
Win32 input emits Esc down+up with uChar=27 so Claude Code sees 0x1b; kitty flags encode CSI 27u instead of a bare escape. The TABS plus button now creates the configured default shell; the dots keep the profile picker. Plus/dots use the old 20px chrome size. Checkpoint remaining GPUI parity work in the tree (settings rewrite, math overlay, SSH settings, SVN status).
This commit is contained in:
1 parent
4dee70beb2
commit
1ea860a2f7
39 files changed
+9984
-4769
No files matched your search
+104
-86
@@ -1,30 +1,35 @@
|
||||
target
|
||||
|
||||
# Local debug/release build bundles produced by packaging scripts
|
||||
dist/
|
||||
trellis/
|
||||
# `package-release.ps1 -TargetDirectory` builds into its own cargo target dir
|
||||
# so packaging never invalidates the incremental cache of the main one.
|
||||
target-pack/
|
||||
|
||||
# Local/generated visual assets and scratch files.
|
||||
docs/ui_mockups/
|
||||
tmp/
|
||||
# Window/UI probe captures (probe_*.png) and prototype pages (.tmp-*.html):
|
||||
# scratch evidence for a single design round, never part of the product.
|
||||
probe_*.png
|
||||
.tmp-*.html
|
||||
|
||||
# Optional local ConPTY runtime bundle; the app falls back when absent.
|
||||
assets/windows/conhost/
|
||||
|
||||
# Internal agent/session artifacts (review drafts, scratch notes — never publish)
|
||||
.claude/
|
||||
.tocodex/
|
||||
tocodex-docs/
|
||||
.codex/
|
||||
*.internal.md
|
||||
|
||||
target
|
||||
|
||||
# Local debug/release build bundles produced by packaging scripts
|
||||
dist/
|
||||
trellis/
|
||||
# `package-release.ps1 -TargetDirectory` builds into its own cargo target dir
|
||||
# so packaging never invalidates the incremental cache of the main one.
|
||||
target-pack/
|
||||
|
||||
# Local/generated visual assets and scratch files.
|
||||
docs/ui_mockups/
|
||||
tmp/
|
||||
# Window/UI probe captures (probe_*.png) and prototype pages (.tmp-*.html):
|
||||
# scratch evidence for a single design round, never part of the product.
|
||||
probe_*.png
|
||||
.tmp-*.html
|
||||
# 所有 .tmp-/-tmp_ 前缀的草稿(探针 cfg 目录、截图、stdout 抓取、临时
|
||||
# 笔记)与备用 cargo target 目录:一次性调试产物,永不入库。
|
||||
.tmp-*
|
||||
.tmp_*
|
||||
.target-*/
|
||||
|
||||
# Optional local ConPTY runtime bundle; the app falls back when absent.
|
||||
assets/windows/conhost/
|
||||
|
||||
# Internal agent/session artifacts (review drafts, scratch notes — never publish)
|
||||
.claude/
|
||||
.tocodex/
|
||||
tocodex-docs/
|
||||
.codex/
|
||||
*.internal.md
|
||||
|
||||
# `docs/` is private by default. Public documentation must be reviewed and
|
||||
# explicitly added to this file one path at a time; never allowlist another subtree.
|
||||
/docs/**
|
||||
@@ -38,63 +43,76 @@ tocodex-docs/
|
||||
# Local interactive demos belong under the private docs tree.
|
||||
/*.html
|
||||
/TASKS.md
|
||||
|
||||
# Secrets / credentials / environment — must never be committed
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.pfx
|
||||
*.p12
|
||||
*.keystore
|
||||
id_rsa*
|
||||
*.credentials
|
||||
secrets.*
|
||||
*_secret*
|
||||
*.token
|
||||
|
||||
# Editor / IDE local settings
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS-generated junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Build/debug scratch: parked-exe copies (Windows locked-file workaround),
|
||||
# logs, crash dumps, profiling output
|
||||
*.locked
|
||||
*.log
|
||||
*.tmp
|
||||
*.orig
|
||||
*.rej
|
||||
*.stackdump
|
||||
*.pdb.old
|
||||
|
||||
# Scratch notes and locally downloaded font archives
|
||||
body.txt
|
||||
JetBrainsMono-*.zip
|
||||
|
||||
# Local scratch checkouts, prototypes, and one-off repro scripts
|
||||
.build-*/
|
||||
target-aligned/
|
||||
target-fix/
|
||||
_nebula-front-stage/
|
||||
message-queue-prototype.html
|
||||
package.json
|
||||
package-lock.json
|
||||
node_modules/
|
||||
scripts/drawer_repro.ps1
|
||||
scripts/ghost_repro/
|
||||
scripts/icon-build/
|
||||
|
||||
# Secrets / credentials / environment — must never be committed
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.pfx
|
||||
*.p12
|
||||
*.keystore
|
||||
id_rsa*
|
||||
*.credentials
|
||||
secrets.*
|
||||
*_secret*
|
||||
*.token
|
||||
|
||||
# Editor / IDE local settings
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS-generated junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Build/debug scratch: parked-exe copies (Windows locked-file workaround),
|
||||
# logs, crash dumps, profiling output
|
||||
*.locked
|
||||
*.log
|
||||
*.tmp
|
||||
*.orig
|
||||
*.rej
|
||||
*.stackdump
|
||||
*.pdb.old
|
||||
|
||||
# Scratch notes and locally downloaded font archives
|
||||
body.txt
|
||||
JetBrainsMono-*.zip
|
||||
|
||||
# Local scratch checkouts, prototypes, and one-off repro scripts
|
||||
.build-*/
|
||||
target-aligned/
|
||||
target-fix/
|
||||
_nebula-front-stage/
|
||||
message-queue-prototype.html
|
||||
package.json
|
||||
package-lock.json
|
||||
node_modules/
|
||||
scripts/drawer_repro.ps1
|
||||
scripts/ghost_repro/
|
||||
scripts/icon-build/
|
||||
scripts/render_shell_icon.html
|
||||
|
||||
# 探针实例跑的是 debug 产物的副本(见 scripts/launch_probe_instance.ps1):
|
||||
# 直接运行构建产物会让下一次 cargo build 以一个含义不明的 mingw 链接错误告终。
|
||||
.probe/
|
||||
.run/
|
||||
|
||||
|
||||
# 探针实例跑的是 debug 产物的副本(见 scripts/launch_probe_instance.ps1):
|
||||
# 直接运行构建产物会让下一次 cargo build 以一个含义不明的 mingw 链接错误告终。
|
||||
.probe/
|
||||
.run/
|
||||
|
||||
# 调试取证与旁路产物:PTY 录制(NEBULA_PTY_RECORD)、工具链输出目录、
|
||||
# 参考用的外部项目工作拷贝。都不是 Nebula 源码,永不入库。
|
||||
*.recording
|
||||
output/
|
||||
impeccable/
|
||||
|
||||
# Windows 下 shell 重定向事故的常见落地物:bash 里写 `2>nul` 会真的建出
|
||||
# 名为 nul 的文件,`>false` 同理;PowerShell 在 HOME 未展开时把模块缓存
|
||||
# 写进 cwd。见到就删,这里兜底防止误提交。
|
||||
/nul
|
||||
/false
|
||||
/Microsoft/
|
||||
|
||||
Generated
+407
-7
@@ -2319,6 +2319,18 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.9.0"
|
||||
@@ -2453,6 +2465,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.2"
|
||||
@@ -3090,7 +3108,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -3120,7 +3137,35 @@ dependencies = [
|
||||
"smol",
|
||||
"tracing",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-c",
|
||||
"tree-sitter-c-sharp",
|
||||
"tree-sitter-cmake",
|
||||
"tree-sitter-cpp",
|
||||
"tree-sitter-css",
|
||||
"tree-sitter-diff",
|
||||
"tree-sitter-elixir",
|
||||
"tree-sitter-embedded-template",
|
||||
"tree-sitter-go",
|
||||
"tree-sitter-graphql",
|
||||
"tree-sitter-html",
|
||||
"tree-sitter-java",
|
||||
"tree-sitter-javascript",
|
||||
"tree-sitter-jsdoc",
|
||||
"tree-sitter-json",
|
||||
"tree-sitter-make",
|
||||
"tree-sitter-md",
|
||||
"tree-sitter-proto",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
"tree-sitter-rust",
|
||||
"tree-sitter-scala",
|
||||
"tree-sitter-sequel",
|
||||
"tree-sitter-swift",
|
||||
"tree-sitter-toml-ng",
|
||||
"tree-sitter-typescript",
|
||||
"tree-sitter-yaml",
|
||||
"tree-sitter-zig",
|
||||
"unicode-segmentation",
|
||||
"uuid",
|
||||
"zed-sum-tree",
|
||||
@@ -3129,7 +3174,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-assets"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gpui",
|
||||
@@ -3139,7 +3183,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui-component-macros"
|
||||
version = "0.5.1"
|
||||
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3229,14 +3272,35 @@ version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -3678,7 +3742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.0",
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -4055,6 +4119,17 @@ dependencies = [
|
||||
"redox_syscall 0.5.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -4603,12 +4678,14 @@ dependencies = [
|
||||
"pulldown-latex",
|
||||
"regex",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"sha2 0.11.0",
|
||||
"signal-hook",
|
||||
"tauri-winrt-notification",
|
||||
@@ -6595,6 +6672,31 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh"
|
||||
version = "0.62.2"
|
||||
@@ -7567,6 +7669,18 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh-cipher"
|
||||
version = "0.3.0"
|
||||
@@ -8444,6 +8558,156 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-bash"
|
||||
version = "0.23.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-c"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-c-sharp"
|
||||
version = "0.23.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-cmake"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-cpp"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-css"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-diff"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-elixir"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-embedded-template"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-go"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-graphql"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-html"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-java"
|
||||
version = "0.23.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-javascript"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-jsdoc"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-json"
|
||||
version = "0.24.8"
|
||||
@@ -8460,6 +8724,136 @@ version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-make"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-md"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-proto"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-python"
|
||||
version = "0.23.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-ruby"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-scala"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-sequel"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-swift"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-toml-ng"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-typescript"
|
||||
version = "0.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-yaml"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-zig"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "triomphe"
|
||||
version = "0.1.16"
|
||||
@@ -8828,6 +9222,12 @@ dependencies = [
|
||||
"sval_serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
||||
+26
-2
@@ -91,6 +91,30 @@ winit = { path = "third_party/winit-0.30.13" }
|
||||
# 3. 新增的 text/inline_flow.rs:shape_line 前把 \n\r 换成空格(gpui
|
||||
# shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效)
|
||||
# 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。
|
||||
#
|
||||
# 上游行为备忘(不是补丁,是调用侧要知道的事):`Scrollable`
|
||||
# (scroll/scrollable.rs 的 overflow_*_scrollbar)不是就地加滚动条,而是把
|
||||
# 被包装元素的 StyleRefinement 整个 clone 到它新建的外层 div,再把原元素
|
||||
# 样式清空、只补 flex_1,然后塞进一层 `overflow_y_scroll` 的 scroll-area。
|
||||
# 后果:真正承载 children 的那层退回 gpui 默认的 Display::Block 且宽度不
|
||||
# 确定,children 上的 `w_full`(width:100%)无从解析、回落到 max-content,
|
||||
# 于是每个子块按自己的内容宽度收缩,看起来就是"分组宽度参差、控件既不贴
|
||||
# 左也不贴右"(2026-08-17 设置页实测 A/B 确认)。**调用侧约定:滚动区内
|
||||
# 先放一层 `v_flex().w_full()`,内容写在它里面**——竖向 flex 的子项走交叉
|
||||
# 轴 stretch 拿宽度,绕开百分比解析。settings_pane.rs 的设置正文即按此写。
|
||||
#
|
||||
# 补丁清单(nebula 分支,rebase 新基点时逐条核对):
|
||||
# 1. popover 单层紧凑阴影(a59f704c,见上)。
|
||||
# 2. TextView 数学公式钩子(本地 nebula-math 分支,待 push 后恢复 rev pin):
|
||||
# markdown 解析启用 math 构造;新增 text/math.rs 的 set_math_renderer
|
||||
# 全局注册点;InlineNode/Node 增加数学节点并在渲染时调钩子,未注册时
|
||||
# 回退现状(inline=code 文本、block=CodeBlock)。TeX 引擎/栅格化/绘制
|
||||
# 全部住在 nebula_app::gpui_shell::math_view,fork 只有接线层。上游
|
||||
# 0.6 线出现 MarkdownPlugin/Custom block 后应迁过去并撤掉本补丁。
|
||||
#
|
||||
# ⚠ 临时状态:gpui-component 走本地 path patch(nebula-math 分支工作树),
|
||||
# 便于数学补丁的本地迭代;验收后 push 到 Kuddev/gpui-component 并恢复
|
||||
# `git + rev` 精确 pin(gpui 不受影响,仍是 rev pin)。
|
||||
gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" }
|
||||
gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" }
|
||||
gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" }
|
||||
gpui-component = { path = "../gpui-component-fork/crates/ui" }
|
||||
gpui-component-assets = { path = "../gpui-component-fork/crates/assets" }
|
||||
@@ -35,15 +35,18 @@ optional = true
|
||||
[dependencies.gpui-component]
|
||||
version = "=0.5.1"
|
||||
optional = true
|
||||
# 代码查看 tab 的 tree-sitter 语法高亮语言包(不开只有 JSON)。
|
||||
features = ["tree-sitter-languages"]
|
||||
|
||||
[dependencies.gpui-component-assets]
|
||||
version = "=0.5.1"
|
||||
optional = true
|
||||
|
||||
# 运行时用户设置(nebula_settings.txt + 主题终端色表)的共享权威实现。
|
||||
# 非可选:ai_hook 的 `ai_hooks` 持久开关(setup-ai --remove)在所有壳形态
|
||||
# 下都要读写这份设置。
|
||||
[dependencies.nebula-settings]
|
||||
path = "../nebula_settings"
|
||||
optional = true
|
||||
|
||||
# 分屏规则的共享权威实现(布局树/拖拽比例/方向导航);GPUI 壳消费。
|
||||
[dependencies.nebula-split]
|
||||
@@ -118,6 +121,8 @@ getrandom = "0.3.3"
|
||||
pollster = { version = "0.4", optional = true }
|
||||
wgpu = { version = "25.0.2", optional = true }
|
||||
regex = "1.13.1"
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
sha1 = "0.11.0"
|
||||
|
||||
[build-dependencies]
|
||||
gl_generator = "0.14.0"
|
||||
@@ -233,7 +238,6 @@ gpui-shell = [
|
||||
"dep:gpui",
|
||||
"dep:gpui-component",
|
||||
"dep:gpui-component-assets",
|
||||
"dep:nebula-settings",
|
||||
"dep:nebula-split",
|
||||
"dep:futures",
|
||||
]
|
||||
+165
-29
@@ -482,6 +482,13 @@ mod win {
|
||||
|
||||
/// Boot entrypoint: install now, then keep installed (see module docs).
|
||||
pub fn spawn_config_guard() {
|
||||
// `setup-ai --remove` 落下的持久开关:用户明确断开过就不再自动
|
||||
// 装回(#38 的自愈复发面 / #8 卸载后仍在 hook)。重新启用走
|
||||
// `nebula setup-ai`。
|
||||
if hooks_disabled() {
|
||||
log::info!("ai_hook: ai_hooks=0 (setup-ai --remove); auto-install disabled");
|
||||
return;
|
||||
}
|
||||
if let Err(err) =
|
||||
std::thread::Builder::new().name("nebula-ai-setup".into()).spawn(config_guard)
|
||||
{
|
||||
@@ -489,6 +496,24 @@ mod win {
|
||||
}
|
||||
}
|
||||
|
||||
/// `nebula_settings.txt` 里 `ai_hooks=0`(由 `setup-ai --remove` 写入)。
|
||||
fn hooks_disabled() -> bool {
|
||||
nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false)
|
||||
}
|
||||
|
||||
/// 一轮完整自愈。每轮都重读开关:`setup-ai --remove` 可能发生在本进程
|
||||
/// 存活期间,它触发的 config 变更事件会立刻打回这里——不重读就会在
|
||||
/// 400ms 内把刚移除的接线原样装回(#38 实测的自愈复发路径)。
|
||||
fn heal_all() {
|
||||
if hooks_disabled() {
|
||||
return;
|
||||
}
|
||||
ensure_claude_hooks();
|
||||
ensure_codex_notify();
|
||||
ensure_opencode_plugin();
|
||||
ensure_pi_extension();
|
||||
}
|
||||
|
||||
fn config_guard() {
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
|
||||
@@ -507,10 +532,7 @@ mod win {
|
||||
std::thread::sleep(Duration::from_secs(300));
|
||||
};
|
||||
|
||||
ensure_claude_hooks();
|
||||
ensure_codex_notify();
|
||||
ensure_opencode_plugin();
|
||||
ensure_pi_extension();
|
||||
heal_all();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut watcher = match notify::recommended_watcher(move |res| {
|
||||
@@ -551,10 +573,7 @@ mod win {
|
||||
// Debounce the writer's burst, then heal. Our own atomic
|
||||
// rename lands here once and heals to a no-op.
|
||||
while rx.recv_timeout(Duration::from_millis(400)).is_ok() {}
|
||||
ensure_claude_hooks();
|
||||
ensure_codex_notify();
|
||||
ensure_opencode_plugin();
|
||||
ensure_pi_extension();
|
||||
heal_all();
|
||||
},
|
||||
Err(_) => return, // channel closed: shutting down
|
||||
}
|
||||
@@ -565,10 +584,7 @@ mod win {
|
||||
fn poll_guard() -> ! {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(300));
|
||||
ensure_claude_hooks();
|
||||
ensure_codex_notify();
|
||||
ensure_opencode_plugin();
|
||||
ensure_pi_extension();
|
||||
heal_all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +611,53 @@ mod win {
|
||||
Some(PathBuf::from(std::env::var_os("USERPROFILE")?).join(".codex"))
|
||||
}
|
||||
|
||||
/// notify 序列化后的字节预算。正常接线只有几百字节;超过它的唯一已知
|
||||
/// 途径是与其他 notify 包装器互相包装的指数膨胀(#38,最终 130 MB 撑爆
|
||||
/// config.toml 让 codex 起不来)。宁可这一轮不接线,也不把病态值落盘。
|
||||
const NOTIFY_BYTE_BUDGET: usize = 8 * 1024;
|
||||
|
||||
/// 由现有 notify argv 算出应写入的新 argv;`None` = 不动文件。
|
||||
///
|
||||
/// 与 codex-computer-use 的共存规则(#38):它重新注册时若认不出
|
||||
/// notify\[0\] 是自己,会把整个旧数组 JSON 序列化进自己的
|
||||
/// `--previous-notify` 参数。此时 nebula-hook 不在最外层,但仍在链中
|
||||
/// ——事件会沿链回流。若这时再包一层,两个包装器互相包装、转义反斜杠
|
||||
/// 每轮翻倍。所以:helper 标记出现在**任何位置**(含 JSON 字符串内部)
|
||||
/// 都算已接线,只有最外层是自己时才做路径自愈。
|
||||
fn desired_codex_notify(current: &[String], helper: &str) -> Option<Vec<String>> {
|
||||
let desired: Vec<String> = match current.first() {
|
||||
// Already ours: heal the helper path, keep any chain tail as-is.
|
||||
Some(first) if first.contains(HELPER_MARK) => {
|
||||
let mut argv = current.to_vec();
|
||||
argv[0] = helper.to_owned();
|
||||
argv
|
||||
},
|
||||
// 已在链中但不在最外层:保持现状,绝不再包(见上)。
|
||||
Some(_) if current.iter().any(|arg| arg.contains(HELPER_MARK)) => return None,
|
||||
// Occupied: wrap the existing notifier behind --chain.
|
||||
Some(_) => {
|
||||
let mut argv = vec![helper.to_owned(), "codex".to_owned(), "--chain".to_owned()];
|
||||
argv.extend(current.iter().cloned());
|
||||
argv
|
||||
},
|
||||
None => vec![helper.to_owned(), "codex".to_owned()],
|
||||
};
|
||||
if current == desired {
|
||||
return None;
|
||||
}
|
||||
// 长度兜底:对任何形态的膨胀(不止 #38 这一种循环)一律拒写。
|
||||
// +4 ≈ 每个元素的引号、逗号与空格开销。
|
||||
let bytes: usize = desired.iter().map(|arg| arg.len() + 4).sum();
|
||||
if bytes > NOTIFY_BYTE_BUDGET {
|
||||
log::warn!(
|
||||
"ai_hook: codex notify would serialize to {bytes} bytes (> {NOTIFY_BYTE_BUDGET}); \
|
||||
refusing to write (wrapper loop guard, #38)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(desired)
|
||||
}
|
||||
|
||||
/// Wire codex's `notify` to nebula-hook. Codex has a SINGLE notify slot
|
||||
/// which may already be taken (e.g. OpenAI's own computer-use notifier),
|
||||
/// so an occupied slot is wrapped, not evicted: nebula-hook forwards to
|
||||
@@ -618,24 +681,9 @@ mod win {
|
||||
.map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_owned)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let desired: Vec<String> = match current.first() {
|
||||
// Already ours: heal the helper path, keep any chain tail as-is.
|
||||
Some(first) if first.contains(HELPER_MARK) => {
|
||||
let mut argv = current.clone();
|
||||
argv[0] = helper;
|
||||
argv
|
||||
},
|
||||
// Occupied: wrap the existing notifier behind --chain.
|
||||
Some(_) => {
|
||||
let mut argv = vec![helper, "codex".to_owned(), "--chain".to_owned()];
|
||||
argv.extend(current.iter().cloned());
|
||||
argv
|
||||
},
|
||||
None => vec![helper, "codex".to_owned()],
|
||||
};
|
||||
if current == desired {
|
||||
let Some(desired) = desired_codex_notify(¤t, &helper) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let mut array = toml_edit::Array::new();
|
||||
for arg in &desired {
|
||||
@@ -980,6 +1028,17 @@ export default function (pi: ExtensionAPI) {
|
||||
failed = true;
|
||||
},
|
||||
}
|
||||
// 持久开关:不写它,下次 Nebula 启动(含开机自启)会把上面
|
||||
// 刚清掉的四处原样装回——移除必须比自愈活得久(#8、#38)。
|
||||
match nebula_settings::persist_keys(&[("ai_hooks", "0".to_owned())]) {
|
||||
Ok(()) => println!(
|
||||
"已写入 ai_hooks=0:Nebula 启动时不再自动接线(重新启用:nebula setup-ai)。"
|
||||
),
|
||||
Err(err) => {
|
||||
eprintln!("警告:无法写入 ai_hooks=0({err}),下次启动仍会自动装回。");
|
||||
failed = true;
|
||||
},
|
||||
}
|
||||
// 卸载器必须尽最大努力清理所有集成,不能因一个损坏的用户配置
|
||||
// 提前返回而让其他 Hook 永久指向即将被删除的程序目录。
|
||||
return i32::from(failed);
|
||||
@@ -991,6 +1050,11 @@ export default function (pi: ExtensionAPI) {
|
||||
return 1;
|
||||
},
|
||||
}
|
||||
// 显式安装即重新授权:清掉 --remove 落下的持久开关,守护线程下次
|
||||
// 启动恢复自愈。
|
||||
if let Err(err) = nebula_settings::persist_keys(&[("ai_hooks", "1".to_owned())]) {
|
||||
eprintln!("警告:无法写入 ai_hooks=1({err});若之前执行过 --remove,自动接线仍是关闭状态。");
|
||||
}
|
||||
if dir.exists() {
|
||||
if ensure_claude_hooks() {
|
||||
println!("claude: 已写入 {}(首次改动备份 *.nebula-bak)。", path.display());
|
||||
@@ -1213,6 +1277,78 @@ export default function (pi: ExtensionAPI) {
|
||||
std::fs::rename(&tmp, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod codex_notify_tests {
|
||||
use super::desired_codex_notify;
|
||||
|
||||
const HELPER: &str = "C:/Program Files/Nebula/runtime/nebula-hook.exe";
|
||||
|
||||
fn argv(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_slot_is_claimed_outright() {
|
||||
assert_eq!(
|
||||
desired_codex_notify(&[], HELPER),
|
||||
Some(argv(&[HELPER, "codex"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_notifier_is_wrapped_behind_chain() {
|
||||
let current = argv(&["C:/cua/codex-computer-use.exe", "turn-ended"]);
|
||||
assert_eq!(
|
||||
desired_codex_notify(¤t, HELPER),
|
||||
Some(argv(&[
|
||||
HELPER,
|
||||
"codex",
|
||||
"--chain",
|
||||
"C:/cua/codex-computer-use.exe",
|
||||
"turn-ended",
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_stale_helper_path_heals_and_keeps_the_chain_tail() {
|
||||
let current = argv(&["D:/old/nebula-hook.exe", "codex", "--chain", "C:/cua/cua.exe"]);
|
||||
assert_eq!(
|
||||
desired_codex_notify(¤t, HELPER),
|
||||
Some(argv(&[HELPER, "codex", "--chain", "C:/cua/cua.exe"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_up_to_date_wiring_is_left_alone() {
|
||||
let current = argv(&[HELPER, "codex"]);
|
||||
assert_eq!(desired_codex_notify(¤t, HELPER), None);
|
||||
}
|
||||
|
||||
// #38 的核心形态:codex-computer-use 重新注册时把我们的 chain JSON
|
||||
// 编码进 --previous-notify。我们不在最外层,但已在链中——再包一层
|
||||
// 就进入互相包装、反斜杠每轮翻倍的指数爆炸。
|
||||
#[test]
|
||||
fn a_notifier_that_swallowed_us_into_previous_notify_is_not_wrapped_again() {
|
||||
let current = argv(&[
|
||||
"C:/cua/codex-computer-use.exe",
|
||||
"--previous-notify",
|
||||
r#"["C:\\Program Files\\Nebula\\runtime\\nebula-hook.exe", "codex", "--chain", "C:\\cua\\cua.exe", "turn-ended"]"#,
|
||||
"turn-ended",
|
||||
]);
|
||||
assert_eq!(desired_codex_notify(¤t, HELPER), None);
|
||||
}
|
||||
|
||||
// 兜底:即使标记检测失手(比如未来某个包装器改了我们的文件名),
|
||||
// 病态膨胀也会被字节预算拦住,config.toml 不会被写到 codex 起不来。
|
||||
#[test]
|
||||
fn an_oversized_result_is_refused() {
|
||||
let ballooned = "\\".repeat(64 * 1024);
|
||||
let current = argv(&["C:/cua/codex-computer-use.exe", &ballooned]);
|
||||
assert_eq!(desired_codex_notify(¤t, HELPER), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod runtime_asset_tests {
|
||||
use super::helper_path_from_exe;
|
||||
|
||||
@@ -112,7 +112,7 @@ impl LauncherGroup {
|
||||
}
|
||||
|
||||
impl LauncherFilter {
|
||||
fn label(self, language: super::UiLanguage) -> &'static str {
|
||||
pub(crate) fn label(self, language: super::UiLanguage) -> &'static str {
|
||||
match self {
|
||||
Self::All => language.pick("全部", "All"),
|
||||
Self::Ssh => "SSH",
|
||||
|
||||
@@ -205,7 +205,7 @@ fn cursor_follows_theme(color: InvertedCellColors) -> bool {
|
||||
color == NEBULA_DEFAULT_CURSOR
|
||||
}
|
||||
|
||||
fn themed_cursor_style(
|
||||
pub(crate) fn themed_cursor_style(
|
||||
shape: CursorShape,
|
||||
theme_is_light: bool,
|
||||
theme_anchor: Rgb,
|
||||
@@ -553,7 +553,16 @@ fn is_terminal_graphic(character: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_application_cursor_cell(
|
||||
pub(crate) fn is_application_cursor_glyph(
|
||||
character: char,
|
||||
flags: Flags,
|
||||
fixed_background: bool,
|
||||
) -> bool {
|
||||
matches!(character, '\u{2580}'..='\u{259f}')
|
||||
|| (character == ' ' && (flags.contains(Flags::INVERSE) || fixed_background))
|
||||
}
|
||||
|
||||
pub(crate) fn is_application_cursor_cell(
|
||||
terminal_cursor_shape: CursorShape,
|
||||
terminal_cursor_point: Point,
|
||||
cell_point: Point,
|
||||
@@ -563,14 +572,20 @@ fn is_application_cursor_cell(
|
||||
) -> bool {
|
||||
terminal_cursor_shape == CursorShape::Hidden
|
||||
&& terminal_cursor_point == cell_point
|
||||
&& (matches!(character, '\u{2580}'..='\u{259f}')
|
||||
|| (character == ' ' && (flags.contains(Flags::INVERSE) || fixed_background)))
|
||||
&& is_application_cursor_glyph(character, flags, fixed_background)
|
||||
}
|
||||
|
||||
pub(crate) fn cell_background_is_fixed(
|
||||
color: Color,
|
||||
overrides: &nebula_terminal::term::color::Colors,
|
||||
) -> bool {
|
||||
is_fixed_color(color, overrides)
|
||||
}
|
||||
|
||||
/// Alpha-compose a theme overlay over the cell's existing background. Keeping
|
||||
/// this in the render model preserves explicit ANSI cell backgrounds while
|
||||
/// still allowing the default transparent terminal surface/image to show.
|
||||
fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) {
|
||||
pub(crate) fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) {
|
||||
let oa = overlay_alpha.clamp(0.0, 1.0);
|
||||
let ba = base_alpha.clamp(0.0, 1.0);
|
||||
let out_a = oa + ba * (1.0 - oa);
|
||||
@@ -589,7 +604,7 @@ fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f3
|
||||
)
|
||||
}
|
||||
|
||||
fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb {
|
||||
pub(crate) fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb {
|
||||
let t = neutral_amount.clamp(0.0, 1.0);
|
||||
let channel = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
|
||||
Rgb::new(channel(color.r, neutral.r), channel(color.g, neutral.g), channel(color.b, neutral.b))
|
||||
@@ -653,8 +668,8 @@ mod tests {
|
||||
use nebula_terminal::vte::ansi::CursorShape;
|
||||
|
||||
use super::{
|
||||
composite_overlay, cursor_follows_theme, is_application_cursor_cell, is_terminal_graphic,
|
||||
mix_rgb, themed_cursor_style,
|
||||
composite_overlay, cursor_follows_theme, is_application_cursor_cell,
|
||||
is_application_cursor_glyph, is_terminal_graphic, mix_rgb, themed_cursor_style,
|
||||
};
|
||||
use crate::config::color::NEBULA_DEFAULT_CURSOR;
|
||||
use crate::display::color::Rgb;
|
||||
@@ -742,6 +757,9 @@ mod tests {
|
||||
Flags::empty(),
|
||||
false,
|
||||
));
|
||||
assert!(is_application_cursor_glyph('█', Flags::empty(), false));
|
||||
assert!(is_application_cursor_glyph(' ', Flags::INVERSE, false));
|
||||
assert!(!is_application_cursor_glyph(' ', Flags::empty(), false));
|
||||
|
||||
let (light_cursor, _) = composite_overlay(
|
||||
Rgb::new(124, 90, 196),
|
||||
|
||||
@@ -457,6 +457,7 @@ pub(crate) fn capture_combo(key: &KeyEvent, mods: ModifiersState) -> CaptureOutc
|
||||
/// - Shift+符号(gpui 直接给出变换后的字符并清掉 shift 位)只能按字符
|
||||
/// 存储,旧壳 winit 层不会命中这类行——与旧壳编辑器「无法表示 → 等待」
|
||||
/// 相比是多存了一条死绑定,可接受。
|
||||
#[cfg(feature = "gpui-shell")]
|
||||
pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome {
|
||||
let mods = gpui_modifiers(&keystroke.modifiers);
|
||||
let key = keystroke.key.trim().to_lowercase();
|
||||
@@ -494,6 +495,7 @@ pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome {
|
||||
}
|
||||
|
||||
/// gpui `Modifiers` → winit `ModifiersState`(位语义一致,直接搬运)。
|
||||
#[cfg(feature = "gpui-shell")]
|
||||
pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState {
|
||||
let mut mods = ModifiersState::empty();
|
||||
if modifiers.control {
|
||||
@@ -512,6 +514,7 @@ pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState {
|
||||
}
|
||||
|
||||
/// gpui `Modifiers` 的修饰键前缀回显(捕获态实时显示 "Ctrl+…")。
|
||||
#[cfg(feature = "gpui-shell")]
|
||||
pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String {
|
||||
mods_prefix(gpui_modifiers(modifiers))
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ mod state;
|
||||
pub(crate) mod suggest_engine;
|
||||
mod surface_opacity;
|
||||
mod terminal_color;
|
||||
mod terminal_math;
|
||||
pub(crate) mod terminal_math;
|
||||
mod toast;
|
||||
|
||||
/// Processor uses the same persisted value before the first window exists so
|
||||
|
||||
@@ -69,6 +69,10 @@ pub struct GitInfo {
|
||||
pub unstaged: Vec<(char, String)>,
|
||||
/// Index changes ready to commit.
|
||||
pub staged: Vec<(char, String)>,
|
||||
/// 合并冲突(porcelain 的 U*/AA/DD;SVN 的 `C`)。冲突路径**同时**保留
|
||||
/// 在 `staged`/`unstaged` 里:旧壳视图零改动照常显示,GPUI 壳按本列表
|
||||
/// 单独分组并从另两组过滤(VS Code 的 Merge Changes 合同)。
|
||||
pub conflicts: Vec<(char, String)>,
|
||||
}
|
||||
|
||||
/// Result of hit-testing a pixel against the open drawer.
|
||||
@@ -502,7 +506,22 @@ impl SidePanel {
|
||||
let slot = std::sync::Arc::clone(&self.snapshot_slot);
|
||||
std::thread::spawn(move || {
|
||||
let rows = SidePanel::tree_rows(&root, &expanded);
|
||||
let git = read_git(&git_root).or_else(|| read_svn(&git_root));
|
||||
// 设置可强制只认 Git / SVN(混合仓库场景);Auto 保持既有探测:
|
||||
// a checkout nested inside a Git tree must remain visible as SVN.
|
||||
// Prefer SVN only when its metadata is in the current path's
|
||||
// ancestor chain; ordinary Git directories keep the cheaper Git
|
||||
// first path and only probe SVN as a fallback.
|
||||
let git = match nebula_settings::RuntimeSettings::load().vcs_display {
|
||||
nebula_settings::VcsDisplayName::Git => read_git(&git_root),
|
||||
nebula_settings::VcsDisplayName::Svn => read_svn(&git_root),
|
||||
nebula_settings::VcsDisplayName::Auto => {
|
||||
if svn_working_copy_hint(&git_root) {
|
||||
read_svn(&git_root).or_else(|| read_git(&git_root))
|
||||
} else {
|
||||
read_git(&git_root).or_else(|| read_svn(&git_root))
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Ok(mut slot) = slot.lock() {
|
||||
*slot = Some(PanelSnapshot { root, rows, git });
|
||||
}
|
||||
@@ -653,6 +672,33 @@ impl SidePanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// `git add -- <path>`:单文件暂存(VS Code 行内 + 的合同)。
|
||||
pub fn git_stage_path(&mut self, path: &str) {
|
||||
if self.vcs() == Some(VcsKind::Git) && !self.op_running() {
|
||||
self.spawn_git(vec!["add".into(), "--".into(), path.to_owned()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `git restore --staged -- <path>`:单文件取消暂存(VS Code 行内 −)。
|
||||
pub fn git_unstage_path(&mut self, path: &str) {
|
||||
if self.vcs() == Some(VcsKind::Git) && !self.op_running() {
|
||||
self.spawn_git(vec![
|
||||
"restore".into(),
|
||||
"--staged".into(),
|
||||
"--".into(),
|
||||
path.to_owned(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// `git restore -- <path>`:丢弃工作区改动(调用方负责确认交互;
|
||||
/// untracked 文件不适用——restore 不删新文件,按钮层不对 `?` 提供)。
|
||||
pub fn git_discard_path(&mut self, path: &str) {
|
||||
if self.vcs() == Some(VcsKind::Git) && !self.op_running() {
|
||||
self.spawn_git(vec!["restore".into(), "--".into(), path.to_owned()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit button: with staged changes, open the message input (Enter then
|
||||
/// commits via [`Self::git_commit_submit`]).
|
||||
pub fn git_begin_commit(&mut self) {
|
||||
@@ -1222,6 +1268,12 @@ fn read_git(root: &Path) -> Option<GitInfo> {
|
||||
info.unstaged.push(('?', path));
|
||||
continue;
|
||||
}
|
||||
// Merge conflicts (VS Code's "Merge Changes" group). The path
|
||||
// also stays in staged/unstaged below so the legacy view keeps
|
||||
// rendering it untouched.
|
||||
if x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D') {
|
||||
info.conflicts.push(('U', path.clone()));
|
||||
}
|
||||
// One file can be in BOTH lists (partially staged).
|
||||
if x != ' ' {
|
||||
info.staged.push((x, path.clone()));
|
||||
@@ -1246,10 +1298,10 @@ fn read_git(root: &Path) -> Option<GitInfo> {
|
||||
Some(info)
|
||||
}
|
||||
|
||||
/// SVN fallback(`read_git` 判空后才尝试):工作副本检测走祖先链找 `.svn`
|
||||
/// ——零子进程的廉价预检,svn 未安装或普通目录都在这里就地退出,不为
|
||||
/// 每次快照付一次 CLI 启动开销。
|
||||
fn svn_working_copy(root: &Path) -> bool {
|
||||
/// Cheap preference hint for nested checkouts. `read_svn` still runs the
|
||||
/// authoritative `svn info` command, so this hint cannot make detection
|
||||
/// incorrect when metadata is represented differently by an SVN client.
|
||||
fn svn_working_copy_hint(root: &Path) -> bool {
|
||||
root.ancestors().any(|dir| dir.join(".svn").is_dir())
|
||||
}
|
||||
|
||||
@@ -1270,15 +1322,71 @@ fn parse_svn_status(status: &str) -> Vec<(char, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Snapshot SVN state for `root`: revision + working-copy changes. `None`
|
||||
/// when `root` isn't inside a working copy or the `svn` CLI is missing.
|
||||
/// 认证失败等错误同样落 `None` + debug 日志——面板留白,操作路径
|
||||
/// (commit/update)的错误才经 `op_error` 呈现给用户。
|
||||
/// Snapshot SVN state for `root`. The CLI(`svn info`/`svn status`)is tried
|
||||
/// first for maximum fidelity; machines without a command-line client
|
||||
/// (TortoiseSVN installs GUI only) fall back to reading `.svn/wc.db`
|
||||
/// directly(`svn_status` 模块,TSVNCache 同款路线)。`None` means the path
|
||||
/// is not a working copy at all.
|
||||
fn read_svn(root: &Path) -> Option<GitInfo> {
|
||||
use std::process::Command;
|
||||
if !svn_working_copy(root) {
|
||||
read_svn_cli(root).map(fill_svn_conflicts).or_else(|| read_svn_wc_db(root))
|
||||
}
|
||||
|
||||
/// CLI 输出没有单独的冲突列表:从 `unstaged` 里把 `C` 行登记到
|
||||
/// [`GitInfo::conflicts`](路径保留原位,合同见字段注释)。
|
||||
fn fill_svn_conflicts(mut info: GitInfo) -> GitInfo {
|
||||
info.conflicts = info
|
||||
.unstaged
|
||||
.iter()
|
||||
.filter(|(state, _)| *state == 'C')
|
||||
.map(|(_, path)| ('C', path.clone()))
|
||||
.collect();
|
||||
info
|
||||
}
|
||||
|
||||
/// 零外部依赖的 SVN 快照:`svn_status` 读 `.svn/wc.db` 推导状态字母表,
|
||||
/// 修订号取 NODES 根行。路径统一转成相对 `root` 的正斜杠形式,与 CLI
|
||||
/// `svn status` 的展示合同一致(只显示 `root` 之下的条目)。
|
||||
fn read_svn_wc_db(root: &Path) -> Option<GitInfo> {
|
||||
let crate::svn_status::SvnDirKind::WorkingCopy(wc_root) = crate::svn_status::classify_dir(root)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let changes = crate::svn_status::working_copy_status(&wc_root).ok()?;
|
||||
let revision = crate::svn_status::working_copy_revision(&wc_root);
|
||||
let mut info = GitInfo {
|
||||
vcs: VcsKind::Svn,
|
||||
branch: revision.map(|value| format!("r{value}")).unwrap_or_else(|| "svn".to_owned()),
|
||||
..GitInfo::default()
|
||||
};
|
||||
let prefix = root.strip_prefix(&wc_root).ok().map(|relative| {
|
||||
let mut text = relative.to_string_lossy().replace('\\', "/");
|
||||
if !text.is_empty() && !text.ends_with('/') {
|
||||
text.push('/');
|
||||
}
|
||||
text
|
||||
});
|
||||
for change in changes {
|
||||
let shown = match prefix.as_deref() {
|
||||
None | Some("") => change.rel_path.clone(),
|
||||
Some(prefix) => match change.rel_path.strip_prefix(prefix) {
|
||||
Some(inside) => inside.to_owned(),
|
||||
// `root` 子目录之外的变更不在本视图的展示合同内。
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let state = change.state.letter().chars().next().unwrap_or('M');
|
||||
if change.state == crate::svn_status::SvnState::Conflicted {
|
||||
info.conflicts.push(('C', shown.clone()));
|
||||
}
|
||||
// SVN 无暂存区:全部归 unstaged(GPUI 会把冲突条目过滤出去单独分组)。
|
||||
info.unstaged.push((if state == 'U' { '?' } else { state }, shown));
|
||||
}
|
||||
Some(info)
|
||||
}
|
||||
|
||||
/// CLI 路线(现代客户端的权威路径)。
|
||||
fn read_svn_cli(root: &Path) -> Option<GitInfo> {
|
||||
use std::process::Command;
|
||||
let run = |args: &[&str]| -> Option<String> {
|
||||
let mut cmd = Command::new("svn");
|
||||
// 交互式认证提示会把无头子进程挂死;快照必须是非交互的。
|
||||
@@ -1298,7 +1406,11 @@ fn read_svn(root: &Path) -> Option<GitInfo> {
|
||||
Some(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
};
|
||||
|
||||
let revision = run(&["info", "--show-item", "revision"])?;
|
||||
// `--show-item` is available in modern SVN. Fall back to regular
|
||||
// `svn info` so older clients still produce a revision number.
|
||||
let revision = run(&["info", "--show-item", "revision"])
|
||||
.and_then(|value| (!value.trim().is_empty()).then_some(value))
|
||||
.or_else(|| run(&["info"]).and_then(|value| parse_svn_revision(&value)))?;
|
||||
let status = run(&["status"])?;
|
||||
Some(GitInfo {
|
||||
vcs: VcsKind::Svn,
|
||||
@@ -1308,6 +1420,13 @@ fn read_svn(root: &Path) -> Option<GitInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_svn_revision(info: &str) -> Option<String> {
|
||||
info.lines().find_map(|line| {
|
||||
let value = line.strip_prefix("Revision:")?.trim();
|
||||
(!value.is_empty()).then_some(value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
// ---- rendering (mirrors the `settings.rs` split: the parent `display::mod`
|
||||
// hands in a snapshot + renderer; this module owns the drawer's pixels) ----
|
||||
|
||||
@@ -2443,6 +2562,7 @@ mod tests {
|
||||
ahead: 0,
|
||||
unstaged: vec![('?', "one.txt".into()), ('M', "two.txt".into())],
|
||||
staged: vec![('A', "three.txt".into())],
|
||||
conflicts: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(!panel.git_row_is_file(0), "未暂存标题");
|
||||
@@ -2565,6 +2685,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svn_info_revision_parser_accepts_standard_output() {
|
||||
let info = "Path: .\r\nWorking Copy Root Path: D:\\checkout\r\nRevision: 42\r\nNode Kind: directory\r\n";
|
||||
assert_eq!(parse_svn_revision(info).as_deref(), Some("42"));
|
||||
assert_eq!(parse_svn_revision("Path: .\nNode Kind: directory\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svn_snapshot_disables_stage_and_push_semantics() {
|
||||
// SVN 没有暂存区、没有 push:快照层用 staged 恒空 + ahead 恒 0 编码,
|
||||
|
||||
@@ -233,6 +233,9 @@ pub struct NebulaPaneState {
|
||||
/// 本次前台 agent 命令是否收到过 hook。屏幕规则的 idle 不得覆盖精确的
|
||||
/// hook done;可见 blocker/working 仍可纠正漏报或过时事件。
|
||||
pub agent_hook_seen: bool,
|
||||
/// 屏幕检测连续看到空闲提示符的拍数。Working 要连续两拍空闲才降级
|
||||
/// (单拍可能是重绘间隙);任何非 idle 检测都会清零。
|
||||
pub idle_screen_streak: u8,
|
||||
pub last_committed: String,
|
||||
pub awaiting_input: bool,
|
||||
pub finished_unseen: bool,
|
||||
|
||||
@@ -61,7 +61,7 @@ const HEIGHT_OVERRUN_TOLERANCE: f32 = 0.12;
|
||||
/// Per-pane state. Layout data is intentionally discarded when a pane state is
|
||||
/// cloned: cloned UI metadata can outlive a renderer/font scale, while layouts
|
||||
/// are cheap to rebuild and remain bounded by `MathLayoutCache` afterwards.
|
||||
pub(super) struct TerminalMathState {
|
||||
pub(crate) struct TerminalMathState {
|
||||
cache: MathLayoutCache,
|
||||
projection: LineProjection,
|
||||
ai_cli_seen: bool,
|
||||
@@ -110,15 +110,15 @@ impl fmt::Debug for TerminalMathState {
|
||||
}
|
||||
|
||||
impl TerminalMathState {
|
||||
pub(super) fn observe_program(&mut self, program: Option<&str>) {
|
||||
pub(crate) fn observe_program(&mut self, program: Option<&str>) {
|
||||
self.ai_cli_seen |= program.is_some_and(is_ai_cli);
|
||||
}
|
||||
|
||||
pub(super) fn inline_dollar_enabled(&self) -> bool {
|
||||
pub(crate) fn inline_dollar_enabled(&self) -> bool {
|
||||
self.ai_cli_seen
|
||||
}
|
||||
|
||||
pub(super) fn update_projection(
|
||||
pub(crate) fn update_projection(
|
||||
&mut self,
|
||||
overlays: &[FormulaOverlay],
|
||||
prepared: &[Option<PreparedFormula>],
|
||||
@@ -126,7 +126,7 @@ impl TerminalMathState {
|
||||
self.projection.rebuild(overlays, prepared);
|
||||
}
|
||||
|
||||
pub(super) fn project_cell(&self, point: Point<usize>, columns: usize) -> Option<Point<usize>> {
|
||||
pub(crate) fn project_cell(&self, point: Point<usize>, columns: usize) -> Option<Point<usize>> {
|
||||
self.projection.project_cell(point, columns)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ impl TerminalMathState {
|
||||
/// `viewport_origin` is the renderer's viewport top row: projection spans
|
||||
/// live in rendered-viewport coordinates, which can be cropped relative to
|
||||
/// the grid while a resize commit is pending.
|
||||
pub(super) fn source_point(
|
||||
pub(crate) fn source_point(
|
||||
&self,
|
||||
point: Point,
|
||||
side: Side,
|
||||
@@ -580,14 +580,14 @@ struct GridPosition {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct RowSpan {
|
||||
pub(crate) struct RowSpan {
|
||||
row: i32,
|
||||
start: usize,
|
||||
end: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct FormulaOverlay {
|
||||
pub(crate) struct FormulaOverlay {
|
||||
source: Arc<str>,
|
||||
display: bool,
|
||||
formula_id: u64,
|
||||
@@ -615,6 +615,21 @@ pub(super) struct FormulaOverlay {
|
||||
}
|
||||
|
||||
impl FormulaOverlay {
|
||||
/// GPUI 壳的只读视图:该公式覆盖的 (行, 起列, 止列) 序列。行可为负
|
||||
/// (部分滚出视口的持久公式)。用于围栏背景过滤与源格跳过。
|
||||
pub(crate) fn covered_ranges(&self) -> impl Iterator<Item = (i32, usize, usize)> + '_ {
|
||||
self.spans.iter().map(|span| (span.row, span.start, span.end))
|
||||
}
|
||||
|
||||
/// 归一化后的 TeX 源(GPUI 壳绘制/缓存键用)。
|
||||
pub(crate) fn source_arc(&self) -> Arc<str> {
|
||||
Arc::clone(&self.source)
|
||||
}
|
||||
|
||||
pub(crate) fn formula_id(&self) -> u64 {
|
||||
self.formula_id
|
||||
}
|
||||
|
||||
fn contains(&self, point: Point<usize>) -> bool {
|
||||
self.spans.iter().any(|span| {
|
||||
usize::try_from(span.row) == Ok(point.line)
|
||||
@@ -981,7 +996,7 @@ impl Ord for GridPosition {
|
||||
|
||||
/// Scan the visible grid and attach renderer-resolved colors/fallback cells.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn scan_visible<T>(
|
||||
pub(crate) fn scan_visible<T>(
|
||||
state: &mut TerminalMathState,
|
||||
terminal: &Term<T>,
|
||||
size: &SizeInfo,
|
||||
@@ -1452,7 +1467,7 @@ fn make_overlay(
|
||||
/// Every geometric decision is taken here, so the fit and the clip can never
|
||||
/// disagree about how much room the formula was given.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct PreparedFormula {
|
||||
pub(crate) struct PreparedFormula {
|
||||
fitted_pixel_size: f32,
|
||||
/// Typesetting style actually used. A block formula that does not fit its
|
||||
/// row falls back to text style before it gives up any size: readers judge
|
||||
@@ -1656,7 +1671,7 @@ fn ink_columns(
|
||||
/// Pre-compile every overlay before the cell pass so the renderer knows which
|
||||
/// source cells to skip. Returns one entry per overlay; `None` keeps the raw
|
||||
/// source visible (layout failure, or the fitted size would be unreadable).
|
||||
pub(super) fn prepare_overlays(
|
||||
pub(crate) fn prepare_overlays(
|
||||
state: &mut TerminalMathState,
|
||||
overlays: &[FormulaOverlay],
|
||||
size: &SizeInfo,
|
||||
@@ -1803,15 +1818,19 @@ pub(super) fn prepare_overlays(
|
||||
/// pass can skip those glyphs. Painting them and covering the area with an
|
||||
/// opaque patch afterwards is what produced the black/white formula slabs on
|
||||
/// transparent and background-image windows.
|
||||
pub(super) struct CoverageMask {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct CoverageMask {
|
||||
rows: BTreeMap<usize, Vec<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl CoverageMask {
|
||||
pub(super) fn build(overlays: &[FormulaOverlay], prepared: &[Option<PreparedFormula>]) -> Self {
|
||||
/// `gates` 与 overlays 一一对应;`None` 的公式保留原文(其源格不进
|
||||
/// 掩码)。旧壳传 `Option<PreparedFormula>`,GPUI 壳传
|
||||
/// `Option<OverlayDrawPlan>`——覆盖判定与各自的回退路径保持一致。
|
||||
pub(crate) fn build<T>(overlays: &[FormulaOverlay], gates: &[Option<T>]) -> Self {
|
||||
let mut rows: BTreeMap<usize, Vec<(usize, usize)>> = BTreeMap::new();
|
||||
for (overlay, prepared) in overlays.iter().zip(prepared) {
|
||||
if prepared.is_none() {
|
||||
for (overlay, gate) in overlays.iter().zip(gates) {
|
||||
if gate.is_none() {
|
||||
continue;
|
||||
}
|
||||
for span in &overlay.spans {
|
||||
@@ -1822,22 +1841,123 @@ impl CoverageMask {
|
||||
Self { rows }
|
||||
}
|
||||
|
||||
pub(super) fn covers(&self, point: Point<usize>) -> bool {
|
||||
pub(crate) fn covers(&self, point: Point<usize>) -> bool {
|
||||
self.rows.get(&point.line).is_some_and(|spans| {
|
||||
spans.iter().any(|&(start, end)| (start..end).contains(&point.column.0))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 overlay 的后端无关绘制计划:几何决策(fit/居中/bleed/裁剪)的
|
||||
/// 单一出口,OpenGL(旧壳 [`draw_overlays`])与 GPUI 壳共同消费,保证两壳
|
||||
/// 的公式落点与裁剪像素级同源。坐标系与 [`SizeInfo`] 一致。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct OverlayDrawPlan {
|
||||
pub(crate) fitted_pixel_size: f32,
|
||||
pub(crate) display_style: bool,
|
||||
pub(crate) origin_x: f32,
|
||||
pub(crate) baseline_y: f32,
|
||||
pub(crate) clip_left: f32,
|
||||
pub(crate) clip_top: f32,
|
||||
pub(crate) clip_right: f32,
|
||||
pub(crate) clip_bottom: f32,
|
||||
pub(crate) foreground: Rgb,
|
||||
}
|
||||
|
||||
/// 计算一个已通过预检的 overlay 的绘制几何。`None` = 本帧回退原文
|
||||
/// (布局失败或裁剪退化),调用方自行决定回退方式(旧壳补画源格,GPUI
|
||||
/// 壳保留源格不跳过)。
|
||||
pub(crate) fn plan_overlay_draw(
|
||||
state: &mut TerminalMathState,
|
||||
overlay: &FormulaOverlay,
|
||||
prepared: &PreparedFormula,
|
||||
size: &SizeInfo,
|
||||
pixels_per_point: f32,
|
||||
) -> Option<OverlayDrawPlan> {
|
||||
let mut bounds = overlay.bounds(size)?;
|
||||
let shift_columns = match overlay.spans.as_slice() {
|
||||
[span] => usize::try_from(span.row)
|
||||
.ok()
|
||||
.map_or(0, |row| state.projection.shift_before(row, span.start)),
|
||||
_ => 0,
|
||||
};
|
||||
let shift_pixels = shift_columns as f32 * size.cell_width();
|
||||
bounds.left += shift_pixels;
|
||||
bounds.right += shift_pixels;
|
||||
let projected_box_right = prepared.box_right + shift_pixels;
|
||||
|
||||
// Pre-flight succeeded, so failure here is unreachable in practice.
|
||||
let metrics = state
|
||||
.layout(
|
||||
overlay.formula_id,
|
||||
&overlay.source,
|
||||
prepared.fitted_pixel_size,
|
||||
pixels_per_point,
|
||||
prepared.display_style,
|
||||
)
|
||||
.ok()?
|
||||
.metrics;
|
||||
let total_height = metrics.height + metrics.depth;
|
||||
let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width();
|
||||
let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height();
|
||||
let box_right = projected_box_right;
|
||||
let box_width = box_right - bounds.left;
|
||||
let origin_x = if prepared.centered {
|
||||
bounds.left + (box_width - metrics.width) / 2.0
|
||||
} else {
|
||||
bounds.left + FORMULA_INSET
|
||||
};
|
||||
// Centre inside the source rows while the ink fits them. What does not
|
||||
// fit is split between the sides in proportion to what each side can
|
||||
// actually lend: with a blank row above and prose below, the overflow
|
||||
// goes up, and the formula keeps the full line gap between itself and
|
||||
// the text underneath instead of splitting the crowding evenly.
|
||||
let slack = bounds.height() - total_height;
|
||||
let room = prepared.bleed_top + prepared.bleed_bottom;
|
||||
let top = if slack >= 0.0 {
|
||||
bounds.top + slack / 2.0
|
||||
} else if room > 0.0 {
|
||||
bounds.top + slack * (prepared.bleed_top / room)
|
||||
} else {
|
||||
bounds.top + slack / 2.0
|
||||
};
|
||||
let baseline_y = (top + metrics.height)
|
||||
.max(bounds.top - prepared.bleed_top + metrics.height)
|
||||
.min(bounds.bottom + prepared.bleed_bottom - metrics.depth);
|
||||
|
||||
// The clip follows the bleed budget, never the ink: whatever the fit
|
||||
// could not absorb gets cropped instead of landing on neighbouring
|
||||
// prose. (Following the ink is what painted formulas over adjacent
|
||||
// rows.)
|
||||
let clip_left = bounds.left.max(size.padding_x());
|
||||
let clip_top = (bounds.top - prepared.bleed_top).max(size.padding_y());
|
||||
let clip_right = box_right.min(viewport_right);
|
||||
let clip_bottom = (bounds.bottom + prepared.bleed_bottom).min(viewport_bottom);
|
||||
if clip_right <= clip_left || clip_bottom <= clip_top {
|
||||
return None;
|
||||
}
|
||||
Some(OverlayDrawPlan {
|
||||
fitted_pixel_size: prepared.fitted_pixel_size,
|
||||
display_style: prepared.display_style,
|
||||
origin_x,
|
||||
baseline_y,
|
||||
clip_left,
|
||||
clip_top,
|
||||
clip_right,
|
||||
clip_bottom,
|
||||
foreground: overlay.foreground,
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw prepared overlays after terminal rectangles. The source cells were
|
||||
/// already skipped during the grid pass, so the formula renders directly on
|
||||
/// the window background — no cover quad, transparency and wallpaper intact.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn draw_overlays(
|
||||
pub(crate) fn draw_overlays(
|
||||
renderer: &mut Renderer,
|
||||
glyph_cache: &mut GlyphCache,
|
||||
state: &mut TerminalMathState,
|
||||
@@ -1850,78 +1970,34 @@ pub(super) fn draw_overlays(
|
||||
let Some(prepared) = prepared else {
|
||||
continue;
|
||||
};
|
||||
let Some(mut bounds) = overlay.bounds(size) else {
|
||||
let Some(plan) = plan_overlay_draw(state, overlay, prepared, size, pixels_per_point)
|
||||
else {
|
||||
// Repaint the skipped source cells rather than leave a hole.
|
||||
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
|
||||
continue;
|
||||
};
|
||||
let shift_columns = match overlay.spans.as_slice() {
|
||||
[span] => usize::try_from(span.row)
|
||||
.ok()
|
||||
.map_or(0, |row| state.projection.shift_before(row, span.start)),
|
||||
_ => 0,
|
||||
};
|
||||
let shift_pixels = shift_columns as f32 * size.cell_width();
|
||||
bounds.left += shift_pixels;
|
||||
bounds.right += shift_pixels;
|
||||
let projected_box_right = prepared.box_right + shift_pixels;
|
||||
|
||||
let layout = match state.layout(
|
||||
overlay.formula_id,
|
||||
&overlay.source,
|
||||
prepared.fitted_pixel_size,
|
||||
plan.fitted_pixel_size,
|
||||
pixels_per_point,
|
||||
prepared.display_style,
|
||||
plan.display_style,
|
||||
) {
|
||||
Ok(layout) => layout,
|
||||
Err(_) => {
|
||||
// Pre-flight succeeded, so this is unreachable in practice;
|
||||
// repaint the skipped source cells rather than leave a hole.
|
||||
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
|
||||
continue;
|
||||
},
|
||||
};
|
||||
let total_height = layout.metrics.height + layout.metrics.depth;
|
||||
let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width();
|
||||
let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height();
|
||||
let box_right = projected_box_right;
|
||||
let box_width = box_right - bounds.left;
|
||||
let origin_x = if prepared.centered {
|
||||
bounds.left + (box_width - layout.metrics.width) / 2.0
|
||||
} else {
|
||||
bounds.left + FORMULA_INSET
|
||||
};
|
||||
// Centre inside the source rows while the ink fits them. What does not
|
||||
// fit is split between the sides in proportion to what each side can
|
||||
// actually lend: with a blank row above and prose below, the overflow
|
||||
// goes up, and the formula keeps the full line gap between itself and
|
||||
// the text underneath instead of splitting the crowding evenly.
|
||||
let slack = bounds.height() - total_height;
|
||||
let room = prepared.bleed_top + prepared.bleed_bottom;
|
||||
let top = if slack >= 0.0 {
|
||||
bounds.top + slack / 2.0
|
||||
} else if room > 0.0 {
|
||||
bounds.top + slack * (prepared.bleed_top / room)
|
||||
} else {
|
||||
bounds.top + slack / 2.0
|
||||
};
|
||||
let baseline_y = (top + layout.metrics.height)
|
||||
.max(bounds.top - prepared.bleed_top + layout.metrics.height)
|
||||
.min(bounds.bottom + prepared.bleed_bottom - layout.metrics.depth);
|
||||
|
||||
// The clip follows the bleed budget, never the ink: whatever the fit
|
||||
// could not absorb gets cropped instead of landing on neighbouring
|
||||
// prose. (Following the ink is what painted formulas over adjacent
|
||||
// rows.)
|
||||
let clip = MathClip {
|
||||
left: bounds.left.max(size.padding_x()),
|
||||
top: (bounds.top - prepared.bleed_top).max(size.padding_y()),
|
||||
right: box_right.min(viewport_right),
|
||||
bottom: (bounds.bottom + prepared.bleed_bottom).min(viewport_bottom),
|
||||
left: plan.clip_left,
|
||||
top: plan.clip_top,
|
||||
right: plan.clip_right,
|
||||
bottom: plan.clip_bottom,
|
||||
};
|
||||
if clip.right <= clip.left || clip.bottom <= clip.top {
|
||||
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
|
||||
continue;
|
||||
}
|
||||
if renderer.draw_math(size, layout, origin_x, baseline_y, overlay.foreground, clip).is_err()
|
||||
if renderer
|
||||
.draw_math(size, layout, plan.origin_x, plan.baseline_y, plan.foreground, clip)
|
||||
.is_err()
|
||||
{
|
||||
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
|
||||
continue;
|
||||
@@ -1929,12 +2005,16 @@ pub(super) fn draw_overlays(
|
||||
|
||||
let base_ascent = (size.cell_height() + glyph_cache.font_metrics().descent).max(1.0);
|
||||
for operation in &layout.text {
|
||||
let scale = operation.pixel_size / prepared.fitted_pixel_size;
|
||||
let x = origin_x + operation.x;
|
||||
let y = baseline_y + operation.baseline_y - base_ascent * scale;
|
||||
let scale = operation.pixel_size / plan.fitted_pixel_size;
|
||||
let x = plan.origin_x + operation.x;
|
||||
let y = plan.baseline_y + operation.baseline_y - base_ascent * scale;
|
||||
let width = size.cell_width() * scale;
|
||||
let height = size.cell_height() * scale;
|
||||
if x < clip.left || x + width > clip.right || y < clip.top || y + height > clip.bottom {
|
||||
if x < plan.clip_left
|
||||
|| x + width > plan.clip_right
|
||||
|| y < plan.clip_top
|
||||
|| y + height > plan.clip_bottom
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut text = [0u8; 4];
|
||||
@@ -1943,7 +2023,7 @@ pub(super) fn draw_overlays(
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
overlay.foreground,
|
||||
plan.foreground,
|
||||
Flags::empty(),
|
||||
operation.character.encode_utf8(&mut text),
|
||||
glyph_cache,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 代码查看 Tab:tree-sitter 语法高亮 + 行级虚拟化的只读查看器。
|
||||
//!
|
||||
//! 渲染走组件库 `InputState::code_editor`——它按可视行布局/绘制(约 5 万行
|
||||
//! 量级依旧流畅),正好满足"超大代码只渲染可视范围"的性能合同;Markdown
|
||||
//! 文档继续走 `TextView`(根级块虚拟化),两条管线各管各的文件类型。
|
||||
//!
|
||||
//! 只读策略:这是查看器不是编辑器——文件树双击的诉求是"看一眼源码",
|
||||
//! 写盘语义(保存/冲突/编码回写)超出本 tab 的合同。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
|
||||
Styled as _, Window, div, px,
|
||||
};
|
||||
|
||||
use crate::gpui_shell::prelude::*;
|
||||
|
||||
/// 一次性读入的上限。行级虚拟化解决的是渲染成本,解析/塑形仍随内容量
|
||||
/// 增长;8MB 已覆盖常见源码文件,超限截断并在文首说明。
|
||||
const MAX_CODE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// 扩展名 → tree-sitter 语言 id(组件库 highlighter 的注册名)。
|
||||
/// 未命中的扩展仍可打开(纯文本、无高亮)。
|
||||
fn language_for_extension(extension: &str) -> Option<&'static str> {
|
||||
Some(match extension.to_ascii_lowercase().as_str() {
|
||||
"rs" => "rust",
|
||||
"py" | "pyi" => "python",
|
||||
"js" | "mjs" | "cjs" => "javascript",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" => "tsx",
|
||||
"jsx" => "jsx",
|
||||
"c" | "h" => "c",
|
||||
"cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp",
|
||||
"cs" => "csharp",
|
||||
"go" => "go",
|
||||
"java" => "java",
|
||||
"rb" => "ruby",
|
||||
"php" => "php",
|
||||
"swift" => "swift",
|
||||
"kt" | "kts" => "kotlin",
|
||||
"lua" => "lua",
|
||||
"sh" | "bash" | "zsh" => "bash",
|
||||
"ps1" | "psm1" | "psd1" => "powershell",
|
||||
"toml" => "toml",
|
||||
"yaml" | "yml" => "yaml",
|
||||
"html" | "htm" => "html",
|
||||
"css" => "css",
|
||||
"scss" => "scss",
|
||||
"sql" => "sql",
|
||||
"zig" => "zig",
|
||||
"cmake" => "cmake",
|
||||
"dockerfile" => "dockerfile",
|
||||
"ex" | "exs" => "elixir",
|
||||
"erl" => "erlang",
|
||||
"hs" => "haskell",
|
||||
"ini" | "cfg" | "conf" => "ini",
|
||||
"vim" => "vim",
|
||||
"xml" => "xml",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 文件树双击是否进代码查看 tab(与 markdown/图片路由并列;三者都不认的
|
||||
/// 交系统处理器)。
|
||||
pub fn viewable_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| language_for_extension(extension).is_some())
|
||||
}
|
||||
|
||||
pub struct CodeTabView {
|
||||
pub path: PathBuf,
|
||||
pub title: String,
|
||||
input: Entity<InputState>,
|
||||
notice: Option<String>,
|
||||
lines: usize,
|
||||
}
|
||||
|
||||
impl CodeTabView {
|
||||
pub fn new(path: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let title = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
let language = path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.and_then(language_for_extension)
|
||||
.unwrap_or("text");
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.code_editor(language)
|
||||
.line_number(true)
|
||||
.indent_guides(true)
|
||||
.soft_wrap(false)
|
||||
});
|
||||
let mut this =
|
||||
Self { path, title, input, notice: None, lines: 0 };
|
||||
this.reload(window, cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// 重新读盘(文件树再次双击同一路径时宿主调用,与 doc tab 同义)。
|
||||
pub fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let (text, notice) = match std::fs::read(&self.path) {
|
||||
Ok(bytes) => {
|
||||
let truncated = bytes.len() > MAX_CODE_BYTES;
|
||||
let slice = if truncated { &bytes[..MAX_CODE_BYTES] } else { &bytes[..] };
|
||||
let text = String::from_utf8_lossy(slice).into_owned();
|
||||
let notice = truncated
|
||||
.then(|| format!("文件超过 {} MB,仅显示开头部分", MAX_CODE_BYTES / 1024 / 1024));
|
||||
(text, notice)
|
||||
},
|
||||
Err(error) => {
|
||||
(String::new(), Some(format!("无法读取 {}: {error}", self.path.display())))
|
||||
},
|
||||
};
|
||||
self.lines = text.lines().count();
|
||||
self.notice = notice;
|
||||
self.input.update(cx, |input, cx| input.set_value(text, window, cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CodeTabView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let muted = theme.muted_foreground;
|
||||
let path_label: SharedString = self.path.display().to_string().into();
|
||||
let meta: SharedString = format!("{} 行", self.lines).into();
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.p_3()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.h(px(28.0))
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_xs()
|
||||
.text_color(muted)
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.child(path_label),
|
||||
)
|
||||
.child(div().text_xs().text_color(muted).child(meta))
|
||||
.child(
|
||||
Button::new("code-reload")
|
||||
.icon(IconName::Redo2)
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.tooltip("重新读取文件")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.reload(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when_some(self.notice.clone(), |root, notice| {
|
||||
root.child(div().text_xs().text_color(theme.warning).child(notice))
|
||||
})
|
||||
.child(div().flex_1().min_h_0().child(Input::new(&self.input).h_full()))
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ pub struct Settings {
|
||||
pub completion_style: crate::display::CompletionStyle,
|
||||
/// 单元格宽度取整方式;同一窗口宽度下必须与旧壳得到相同列数。
|
||||
pub cell_width_mode: nebula_settings::CellWidthModeName,
|
||||
/// 全宽字形(CJK 等)的 bold run 用 Regular 字形栅格(粗体只提亮不加粗,
|
||||
/// 旧壳 `glyph_cache.wide_bold_use_regular` 同义)。
|
||||
pub cjk_bold_regular: bool,
|
||||
/// 默认 shell 的稳定 id(`nebula_settings.txt` 的 `shell=`,如
|
||||
/// "pwsh" / "cmd" / "wsl:Ubuntu")。None = 引擎默认。
|
||||
pub shell_id: Option<String>,
|
||||
@@ -138,6 +141,7 @@ impl Settings {
|
||||
_ => crate::display::CompletionStyle::Inline,
|
||||
},
|
||||
cell_width_mode: runtime.cell_width_mode,
|
||||
cjk_bold_regular: runtime.cjk_bold_regular,
|
||||
shell_id: runtime.shell.clone(),
|
||||
font_family: normal_family,
|
||||
source_path: path,
|
||||
|
||||
@@ -26,11 +26,12 @@ use crate::display::image_viewer::ImageView;
|
||||
use crate::gpui_shell::prelude::*;
|
||||
use gpui_component::text::TextView;
|
||||
|
||||
/// 双击路由与旧壳 `input/chrome.rs` 同合同:应用内能读的开 tab,其余交
|
||||
/// 系统处理器。
|
||||
/// 双击路由:应用内能读的开 tab(图片/文档/源码),其余交系统处理器。
|
||||
/// 源码查看是 GPUI 壳新增能力,旧壳合同(`input/chrome.rs`)之上的超集。
|
||||
pub fn openable_in_app(path: &Path) -> bool {
|
||||
crate::display::image_viewer::viewable_file(path)
|
||||
|| crate::display::markdown_view::viewable_file(path)
|
||||
|| crate::gpui_shell::code_tab::viewable_file(path)
|
||||
}
|
||||
|
||||
/// 文档一次性读入的上限。组件 TextView 是全量解析(没有旧壳 DocView 的
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
//! GPUI 壳的公式渲染:旧壳数学管线接入组件库 TextView。
|
||||
//!
|
||||
//! 管线与旧壳三层同源:`compile_formula` 产出后端无关绘制指令 →
|
||||
//! `MathGlyphRasterizer`(与旧壳 GL 图集同一支栅格化器)按物理像素把整条
|
||||
//! 公式合成一张 BGRA 位图 → `window.paint_image` 上屏;数学字体缺字的
|
||||
//! 字符(旧笔记里的中文等)用 gpui 文本系统按基线叠加补画,对应旧壳
|
||||
//! `draw_math` 之后的 `draw_doc_text` 回补。
|
||||
//!
|
||||
//! 为什么走位图而不是 gpui 字形管线:数学排版要按字形 ID 绘制(拉伸变体、
|
||||
//! 装配件都不经 cmap),而 gpui 的 `GlyphId` 构造器是 crate 私有的;位图
|
||||
//! 路线反而让两条壳共享唯一一支栅格化器,公式像素级同源。
|
||||
//!
|
||||
//! 失败合同与旧壳一致:编译失败/超预算/缩到 [`MIN_READABLE_MATH_PX`] 之下
|
||||
//! 的公式回退为源码文本(组件库侧代码样式),不撑破阅读列。
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{
|
||||
App, AvailableSpace, Bounds, Corners, Element, ElementId, GlobalElementId, InspectorElementId,
|
||||
IntoElement, LayoutId, Pixels, Rgba, ShapedLine, SharedString, Size, Style, Window, point, px,
|
||||
size,
|
||||
};
|
||||
use image::Frame;
|
||||
|
||||
use crate::math::layout::MathLayout;
|
||||
use crate::math::rasterizer::MathGlyphRasterizer;
|
||||
use crate::math::{DEFAULT_LIMITS, MIN_READABLE_MATH_PX, compile_formula};
|
||||
|
||||
/// 探针编译字号:注册的渲染闭包用它判定"这条公式能否编译",失败即让
|
||||
/// 组件库走源码文本回退。解析/预算类失败与字号无关,任意正值等价。
|
||||
const PROBE_PX: f32 = 16.0;
|
||||
|
||||
/// 布局缓存条数上限。键含字号与 DPI,同一公式 fit 前后各占一条;超限
|
||||
/// 整体清空,单条重建是亚毫秒级 compile,不值得上 LRU。
|
||||
const MAX_LAYOUTS: usize = 1024;
|
||||
|
||||
/// 公式位图缓存的字节预算;超限整体清空,可见公式下一帧按需重建。
|
||||
const IMAGE_BUDGET_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// 单条公式位图的物理边长/字节上限;超限回退源码文本,与旧壳"fit 不进
|
||||
/// 列宽即回退"同一精神,防止病态长公式吃掉显存。
|
||||
const MAX_IMAGE_EDGE_PX: u32 = 8192;
|
||||
const MAX_IMAGE_BYTES: usize = 24 * 1024 * 1024;
|
||||
|
||||
/// 位图四周的出血余量(物理 px):字形位图的 bearing 可以越出公式度量
|
||||
/// 包围盒 1–2px(`GLYPH_PADDING` + 抗锯齿),出血兜住后仍越界的按行裁剪。
|
||||
const CANVAS_PAD: u32 = 2;
|
||||
|
||||
/// 与旧壳 `fit_math_run` 相同的收缩余量:布局对字号线性,留 2% 吸收
|
||||
/// 取整误差,保证最右侧抗锯齿像素不越出阅读列。
|
||||
const FIT_MARGIN: f32 = 0.98;
|
||||
|
||||
/// 注册 TextView 的公式渲染器;`gpui_shell::init` 调用一次。
|
||||
pub fn register(cx: &mut App) {
|
||||
cx.set_global(MathAssets::new());
|
||||
gpui_component::text::set_math_renderer(cx, |spec, _, cx| {
|
||||
let assets = cx.global_mut::<MathAssets>();
|
||||
if assets.rasterizer.is_none() {
|
||||
return None;
|
||||
}
|
||||
// 探针编译:失败的公式仍是文档文本,交回组件库按代码样式排版。
|
||||
assets.layout(&spec.source, spec.display, PROBE_PX, 1.0)?;
|
||||
Some(
|
||||
MathView { source: spec.source.clone(), display: spec.display }.into_any_element(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 全局缓存 ----
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct LayoutKey {
|
||||
source: SharedString,
|
||||
display: bool,
|
||||
pixel_size_bits: u32,
|
||||
pixels_per_point_bits: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct ImageKey {
|
||||
source: SharedString,
|
||||
display: bool,
|
||||
pixel_size_bits: u32,
|
||||
raster_scale_bits: u32,
|
||||
color: u32,
|
||||
}
|
||||
|
||||
/// 位图内的定位信息(物理 px):paint 时把位图基线吸附到元素基线。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ImageGeometry {
|
||||
/// 位图顶边到公式基线的距离。
|
||||
baseline: u32,
|
||||
/// 位图左边相对公式盒左缘的外扩(出血)。
|
||||
pad: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct MathAssets {
|
||||
/// 字体解析失败时为 None:探针直接失败,所有公式回退源码文本。
|
||||
rasterizer: Option<MathGlyphRasterizer>,
|
||||
layouts: HashMap<LayoutKey, Option<Arc<MathLayout>>>,
|
||||
images: HashMap<ImageKey, Option<(Arc<gpui::RenderImage>, ImageGeometry)>>,
|
||||
image_bytes: usize,
|
||||
}
|
||||
|
||||
impl gpui::Global for MathAssets {}
|
||||
|
||||
impl MathAssets {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rasterizer: MathGlyphRasterizer::new().ok(),
|
||||
layouts: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
image_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 编排(含负缓存:失败公式不逐帧重试)。
|
||||
pub(crate) fn layout(
|
||||
&mut self,
|
||||
source: &SharedString,
|
||||
display: bool,
|
||||
pixel_size: f32,
|
||||
pixels_per_point: f32,
|
||||
) -> Option<Arc<MathLayout>> {
|
||||
let key = LayoutKey {
|
||||
source: source.clone(),
|
||||
display,
|
||||
pixel_size_bits: pixel_size.to_bits(),
|
||||
pixels_per_point_bits: pixels_per_point.to_bits(),
|
||||
};
|
||||
if !self.layouts.contains_key(&key) {
|
||||
if self.layouts.len() >= MAX_LAYOUTS {
|
||||
self.layouts.clear();
|
||||
}
|
||||
let compiled =
|
||||
compile_formula(source.as_ref(), display, pixel_size, pixels_per_point, DEFAULT_LIMITS)
|
||||
.ok()
|
||||
.map(Arc::new);
|
||||
self.layouts.insert(key.clone(), compiled);
|
||||
}
|
||||
self.layouts.get(&key).and_then(Clone::clone)
|
||||
}
|
||||
|
||||
/// 旧壳 `fit_math_run` 的等价物:超宽公式按线性比例缩字号,缩到
|
||||
/// [`MIN_READABLE_MATH_PX`] 之下就放弃(调用方回退源码文本)。
|
||||
fn fit(
|
||||
&mut self,
|
||||
source: &SharedString,
|
||||
display: bool,
|
||||
pixel_size: f32,
|
||||
pixels_per_point: f32,
|
||||
max_width: f32,
|
||||
) -> Option<(Arc<MathLayout>, f32)> {
|
||||
let base = self.layout(source, display, pixel_size, pixels_per_point)?;
|
||||
if base.metrics.width <= max_width {
|
||||
return Some((base, pixel_size));
|
||||
}
|
||||
let fitted_size = pixel_size * (max_width / base.metrics.width) * FIT_MARGIN;
|
||||
if fitted_size < MIN_READABLE_MATH_PX {
|
||||
return None;
|
||||
}
|
||||
let fitted = self.layout(source, display, fitted_size, pixels_per_point)?;
|
||||
(fitted.metrics.width <= max_width).then_some((fitted, fitted_size))
|
||||
}
|
||||
|
||||
/// 公式位图(含负缓存:超限公式不逐帧重试合成)。
|
||||
pub(crate) fn image(
|
||||
&mut self,
|
||||
source: &SharedString,
|
||||
display: bool,
|
||||
pixel_size: f32,
|
||||
pixels_per_point: f32,
|
||||
raster_scale: f32,
|
||||
color: Rgba,
|
||||
) -> Option<(Arc<gpui::RenderImage>, ImageGeometry)> {
|
||||
let color_bits = ((color.r * 255.0) as u32) << 16
|
||||
| ((color.g * 255.0) as u32) << 8
|
||||
| (color.b * 255.0) as u32;
|
||||
let key = ImageKey {
|
||||
source: source.clone(),
|
||||
display,
|
||||
pixel_size_bits: pixel_size.to_bits(),
|
||||
raster_scale_bits: raster_scale.to_bits(),
|
||||
color: color_bits,
|
||||
};
|
||||
if let Some(cached) = self.images.get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let layout = self.layout(source, display, pixel_size, pixels_per_point)?;
|
||||
let composed = self
|
||||
.rasterizer
|
||||
.as_ref()
|
||||
.and_then(|rasterizer| compose_image(rasterizer, &layout, raster_scale, color));
|
||||
if let Some((_, geometry)) = &composed {
|
||||
let bytes = geometry.width as usize * geometry.height as usize * 4;
|
||||
if self.image_bytes.saturating_add(bytes) > IMAGE_BUDGET_BYTES {
|
||||
self.images.clear();
|
||||
self.image_bytes = 0;
|
||||
}
|
||||
self.image_bytes += bytes;
|
||||
}
|
||||
self.images.insert(key, composed.clone());
|
||||
composed
|
||||
}
|
||||
}
|
||||
|
||||
/// 把整条公式(字形 + 分数线等矩形)按物理像素合成为一张直通 alpha 的
|
||||
/// BGRA 位图。字形位图原点吸附整数物理像素(旧壳同款),advance 保持
|
||||
/// 全精度。
|
||||
fn compose_image(
|
||||
rasterizer: &MathGlyphRasterizer,
|
||||
layout: &MathLayout,
|
||||
raster_scale: f32,
|
||||
color: Rgba,
|
||||
) -> Option<(Arc<gpui::RenderImage>, ImageGeometry)> {
|
||||
let content_width = (layout.metrics.width * raster_scale).ceil().max(1.0) as u32;
|
||||
let ascent = (layout.metrics.height * raster_scale).ceil().max(0.0) as u32;
|
||||
let descent = (layout.metrics.depth * raster_scale).ceil().max(0.0) as u32;
|
||||
let width = content_width.checked_add(CANVAS_PAD * 2)?;
|
||||
let height = ascent.checked_add(descent)?.checked_add(CANVAS_PAD * 2)?.max(1);
|
||||
if width > MAX_IMAGE_EDGE_PX || height > MAX_IMAGE_EDGE_PX {
|
||||
return None;
|
||||
}
|
||||
let bytes = width as usize * height as usize * 4;
|
||||
if bytes > MAX_IMAGE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let baseline = (ascent + CANVAS_PAD) as i64;
|
||||
|
||||
// 先在 alpha 平面上合成(Porter-Duff over),最后一次性染色。
|
||||
let mut coverage = vec![0u8; width as usize * height as usize];
|
||||
for op in &layout.glyphs {
|
||||
let glyph = rasterizer.rasterize(op.glyph_id, op.pixel_size * raster_scale).ok()?;
|
||||
if glyph.width == 0 || glyph.height == 0 {
|
||||
continue;
|
||||
}
|
||||
let origin_x = (op.x * raster_scale).round() as i64 + glyph.left as i64 + CANVAS_PAD as i64;
|
||||
let origin_y = baseline + (op.baseline_y * raster_scale).round() as i64 - glyph.top as i64;
|
||||
blit_over(
|
||||
&mut coverage,
|
||||
width,
|
||||
height,
|
||||
origin_x,
|
||||
origin_y,
|
||||
glyph.width as u32,
|
||||
glyph.height as u32,
|
||||
|x, y| glyph.rgba[(y * glyph.width as usize + x) * 4 + 3],
|
||||
);
|
||||
}
|
||||
for rule in &layout.rules {
|
||||
let x0 = (rule.x * raster_scale).round() as i64 + CANVAS_PAD as i64;
|
||||
let y0 = baseline + (rule.y * raster_scale).round() as i64;
|
||||
let w = (rule.width * raster_scale).round().max(1.0) as u32;
|
||||
let h = (rule.height * raster_scale).round().max(1.0) as u32;
|
||||
blit_over(&mut coverage, width, height, x0, y0, w, h, |_, _| u8::MAX);
|
||||
}
|
||||
|
||||
// gpui 图像管线吃直通 alpha 的 BGRA(与 DirectWrite 字形同一约定)。
|
||||
let mut bgra = vec![0u8; bytes];
|
||||
let (r, g, b) = (
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
);
|
||||
for (pixel, alpha) in bgra.chunks_exact_mut(4).zip(&coverage) {
|
||||
pixel[0] = b;
|
||||
pixel[1] = g;
|
||||
pixel[2] = r;
|
||||
pixel[3] = *alpha;
|
||||
}
|
||||
let buffer = image::RgbaImage::from_raw(width, height, bgra)?;
|
||||
let geometry =
|
||||
ImageGeometry { baseline: baseline as u32, pad: CANVAS_PAD, width, height };
|
||||
Some((Arc::new(gpui::RenderImage::new([Frame::new(buffer)])), geometry))
|
||||
}
|
||||
|
||||
/// alpha 平面上的 Porter-Duff over 合成;目标越界的像素按行列裁剪。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blit_over(
|
||||
canvas: &mut [u8],
|
||||
canvas_width: u32,
|
||||
canvas_height: u32,
|
||||
origin_x: i64,
|
||||
origin_y: i64,
|
||||
width: u32,
|
||||
height: u32,
|
||||
sample: impl Fn(usize, usize) -> u8,
|
||||
) {
|
||||
for row in 0..height as i64 {
|
||||
let y = origin_y + row;
|
||||
if y < 0 || y >= canvas_height as i64 {
|
||||
continue;
|
||||
}
|
||||
for column in 0..width as i64 {
|
||||
let x = origin_x + column;
|
||||
if x < 0 || x >= canvas_width as i64 {
|
||||
continue;
|
||||
}
|
||||
let source = sample(column as usize, row as usize) as u32;
|
||||
if source == 0 {
|
||||
continue;
|
||||
}
|
||||
let target = &mut canvas[y as usize * canvas_width as usize + x as usize];
|
||||
let existing = *target as u32;
|
||||
*target = (existing + source * (255 - existing) / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 公式元素 ----
|
||||
|
||||
/// 一条公式的 GPUI 元素。行内公式由组件库放进换行 flex 行(底边对齐),
|
||||
/// 自身用底部留白把公式基线补齐到文本基线;块级公式由组件库水平居中。
|
||||
struct MathView {
|
||||
source: SharedString,
|
||||
display: bool,
|
||||
}
|
||||
|
||||
/// measure 闭包与 paint 之间的当帧交接。taffy 可能以不同可用宽度多次
|
||||
/// 调用 measure,槽里保存最后一次结果;paint 用实际 bounds 核对,不符
|
||||
/// (罕见)就按 bounds 宽度重新 fit,宁可略小也不越界。
|
||||
#[derive(Default)]
|
||||
enum FitSlot {
|
||||
#[default]
|
||||
Pending,
|
||||
Math {
|
||||
layout: Arc<MathLayout>,
|
||||
pixel_size: f32,
|
||||
/// 元素底边到公式基线的距离(行内基线补偿;块级为 depth)。
|
||||
baseline_from_bottom: f32,
|
||||
},
|
||||
/// fit 放弃后的退化形态:单行源码文本(探针已保证可编译,只有
|
||||
/// 极窄列会走到这里)。
|
||||
Text(ShapedLine),
|
||||
}
|
||||
|
||||
impl IntoElement for MathView {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for MathView {
|
||||
type RequestLayoutState = Rc<RefCell<FitSlot>>;
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
_: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let slot: Rc<RefCell<FitSlot>> = Rc::default();
|
||||
let text_style = window.text_style();
|
||||
let rem_size = window.rem_size();
|
||||
let font_size = text_style.font_size.to_pixels(rem_size);
|
||||
let line_height = f32::from(text_style.line_height_in_pixels(rem_size));
|
||||
let run = text_style.to_run(self.source.len());
|
||||
// 文本行盒里基线到底边的距离 = descent + 半行距;行内公式用它把
|
||||
// 自己的基线抬到与同行文本一致(旧壳 push_with_math 的基线合同)。
|
||||
let probe = window.text_system().shape_line("M".into(), font_size, &[text_style.to_run(1)], None);
|
||||
let text_baseline_from_bottom = f32::from(probe.descent)
|
||||
+ (line_height - f32::from(probe.ascent) - f32::from(probe.descent)).max(0.0) / 2.0;
|
||||
|
||||
let source = self.source.clone();
|
||||
let display = self.display;
|
||||
let nominal_px = f32::from(font_size);
|
||||
let pixels_per_point = window.scale_factor();
|
||||
let measure_slot = slot.clone();
|
||||
let style = Style { flex_shrink: 0.0, ..Style::default() };
|
||||
let layout_id = window.request_measured_layout(style, move |_, available, window, cx| {
|
||||
let max_width = match available.width {
|
||||
AvailableSpace::Definite(width) => f32::from(width).max(8.0),
|
||||
AvailableSpace::MinContent | AvailableSpace::MaxContent => f32::INFINITY,
|
||||
};
|
||||
let assets = cx.global_mut::<MathAssets>();
|
||||
match assets.fit(&source, display, nominal_px, pixels_per_point, max_width) {
|
||||
Some((layout, pixel_size)) => {
|
||||
let bottom_pad = if display {
|
||||
0.0
|
||||
} else {
|
||||
(text_baseline_from_bottom - layout.metrics.depth).max(0.0)
|
||||
};
|
||||
let width = layout.metrics.width;
|
||||
let height = layout.metrics.height + layout.metrics.depth + bottom_pad;
|
||||
let baseline_from_bottom = layout.metrics.depth + bottom_pad;
|
||||
*measure_slot.borrow_mut() =
|
||||
FitSlot::Math { layout, pixel_size, baseline_from_bottom };
|
||||
size(px(width), px(height))
|
||||
},
|
||||
None => {
|
||||
let line = window.text_system().shape_line(
|
||||
source.clone(),
|
||||
px(nominal_px),
|
||||
std::slice::from_ref(&run),
|
||||
None,
|
||||
);
|
||||
let width = f32::from(line.width).min(max_width.max(8.0));
|
||||
*measure_slot.borrow_mut() = FitSlot::Text(line);
|
||||
size(px(width), px(line_height))
|
||||
},
|
||||
}
|
||||
});
|
||||
(layout_id, slot)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Window,
|
||||
_: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
slot: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let text_style = window.text_style();
|
||||
let line_height = text_style.line_height_in_pixels(window.rem_size());
|
||||
match &*slot.borrow() {
|
||||
FitSlot::Pending => {},
|
||||
FitSlot::Text(line) => {
|
||||
let _ = line.paint(bounds.origin, line_height, window, cx);
|
||||
},
|
||||
FitSlot::Math { layout, pixel_size, baseline_from_bottom } => {
|
||||
let bounds_width = f32::from(bounds.size.width);
|
||||
// taffy 最终宽度与最后一次 measure 不一致(罕见):按实际
|
||||
// bounds 重新 fit,保证不越界绘制。
|
||||
if layout.metrics.width > bounds_width + 0.5 {
|
||||
let nominal = f32::from(text_style.font_size.to_pixels(window.rem_size()));
|
||||
let scale_factor = window.scale_factor();
|
||||
let assets = cx.global_mut::<MathAssets>();
|
||||
let Some((layout, pixel_size)) = assets.fit(
|
||||
&self.source,
|
||||
self.display,
|
||||
nominal,
|
||||
scale_factor,
|
||||
bounds_width,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let baseline = bounds.bottom() - px(layout.metrics.depth);
|
||||
self.paint_math(layout.as_ref(), pixel_size, baseline, bounds, window, cx);
|
||||
return;
|
||||
}
|
||||
let baseline = bounds.bottom() - px(*baseline_from_bottom);
|
||||
let layout = layout.clone();
|
||||
self.paint_math(layout.as_ref(), *pixel_size, baseline, bounds, window, cx);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MathView {
|
||||
/// 位图贴到基线上(物理像素吸附),数学字体缺字的字符用 gpui 文本
|
||||
/// 按各自字号补画在同一条基线上。
|
||||
fn paint_math(
|
||||
&self,
|
||||
layout: &MathLayout,
|
||||
pixel_size: f32,
|
||||
baseline: Pixels,
|
||||
bounds: Bounds<Pixels>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let text_style = window.text_style();
|
||||
let color = Rgba::from(text_style.color);
|
||||
paint_formula_image(
|
||||
&self.source,
|
||||
self.display,
|
||||
pixel_size,
|
||||
window.scale_factor(),
|
||||
bounds.left(),
|
||||
baseline,
|
||||
color,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
for op in &layout.text {
|
||||
let mut buffer = [0u8; 4];
|
||||
let text: SharedString = op.character.encode_utf8(&mut buffer).to_string().into();
|
||||
let run = text_style.to_run(text.len());
|
||||
let line =
|
||||
window.text_system().shape_line(text, px(op.pixel_size), std::slice::from_ref(&run), None);
|
||||
let origin = point(
|
||||
bounds.left() + px(op.x),
|
||||
baseline + px(op.baseline_y) - line.ascent,
|
||||
);
|
||||
let _ = line.paint(origin, line.ascent + line.descent, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把一条公式的位图贴到指定基线(物理像素吸附),文档元素与终端覆盖层
|
||||
/// 共用。位图缓存/合成走 [`MathAssets`];数学字体缺字的 text ops 由调用
|
||||
/// 方按各自的裁剪合同补画。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn paint_formula_image(
|
||||
source: &SharedString,
|
||||
display: bool,
|
||||
pixel_size: f32,
|
||||
pixels_per_point: f32,
|
||||
left: Pixels,
|
||||
baseline: Pixels,
|
||||
color: Rgba,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let raster_scale = window.scale_factor();
|
||||
let assets = cx.global_mut::<MathAssets>();
|
||||
let Some((render_image, geometry)) =
|
||||
assets.image(source, display, pixel_size, pixels_per_point, raster_scale, color)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// 位图物理像素与屏幕 1:1:原点在物理网格上取整后再折回逻辑坐标。
|
||||
let left_physical = (f32::from(left) * raster_scale).round();
|
||||
let baseline_physical = (f32::from(baseline) * raster_scale).round();
|
||||
let origin = point(
|
||||
px((left_physical - geometry.pad as f32) / raster_scale),
|
||||
px((baseline_physical - geometry.baseline as f32) / raster_scale),
|
||||
);
|
||||
let image_size = size(
|
||||
px(geometry.width as f32 / raster_scale),
|
||||
px(geometry.height as f32 / raster_scale),
|
||||
);
|
||||
let _ = window.paint_image(
|
||||
Bounds::new(origin, image_size),
|
||||
Corners::default(),
|
||||
render_image,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -10,16 +10,20 @@
|
||||
//! - `NEBULA_GPUI_SHELL=1`(需 gpui-shell 构建):spike 形态——GPUI 跑在
|
||||
//! 专用线程,与 winit 旧壳同进程并存,仅用于双运行时验证,P3 完成后移除。
|
||||
|
||||
pub mod code_tab;
|
||||
pub mod config;
|
||||
pub mod doc_tabs;
|
||||
pub mod math_view;
|
||||
pub mod prelude;
|
||||
pub mod session_restore;
|
||||
pub mod settings_pane;
|
||||
pub mod ssh_hosts;
|
||||
pub mod ssh_settings;
|
||||
pub mod terminal;
|
||||
pub mod theme;
|
||||
pub mod toast;
|
||||
pub mod wallpaper;
|
||||
pub mod widgets;
|
||||
pub mod workspace;
|
||||
|
||||
use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size};
|
||||
@@ -55,6 +59,9 @@ fn init(cx: &mut App) {
|
||||
|
||||
// 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。
|
||||
gpui_component::init(cx);
|
||||
// TextView 的公式渲染钩子:旧壳数学管线(compile → 栅格化)接入组件库
|
||||
// 的 markdown 渲染;不注册时公式回退为源码文本。
|
||||
math_view::register(cx);
|
||||
theme::apply_chrome_theme(cx);
|
||||
terminal::init(cx);
|
||||
workspace::init(cx);
|
||||
@@ -124,6 +131,14 @@ fn open_main_window(
|
||||
|
||||
cx.open_window(options, move |window, cx| {
|
||||
let workspace = cx.new(|cx| NebulaWorkspace::new(window, ai_events, cx));
|
||||
// 调试/验收后门:启动即打开指定文档(见 open_document_at_startup)。
|
||||
if let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC")
|
||||
&& !path.is_empty()
|
||||
{
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
workspace.open_document_at_startup(path.into(), window, cx);
|
||||
});
|
||||
}
|
||||
cx.new(|cx| Root::new(workspace, window, cx))
|
||||
})
|
||||
.expect("failed to open Nebula GPUI window");
|
||||
|
||||
@@ -30,3 +30,18 @@ pub use gpui_component::{
|
||||
|
||||
pub use gpui_component::IndexPath;
|
||||
pub use gpui_component::WindowExt as _;
|
||||
|
||||
use gpui::{Window, px};
|
||||
use gpui_component::PixelsExt as _;
|
||||
|
||||
/// 旧壳 `draw_confirm_modal`:`(win_w - box_w)/2`、`(win_h - box_h)/2`。
|
||||
/// 组件库 Dialog 水平已经居中,垂直默认贴在 `height/10`。不改 gpui-component,
|
||||
/// 只把 `margin_top` 调到窗口中线。关闭/粘贴确认约 200px 高;入场动画结束
|
||||
/// 时还会把 top 再加 30px,所以这里先减去这段位移,落点才是正中。
|
||||
pub fn center_confirm_dialog(dialog: Dialog, window: &Window) -> Dialog {
|
||||
let height = window.viewport_size().height.as_f32();
|
||||
const DIALOG_H: f32 = 200.0;
|
||||
const SLIDE: f32 = 30.0;
|
||||
let top = ((height - DIALOG_H) / 2.0 - SLIDE).max(16.0);
|
||||
dialog.margin_top(px(top))
|
||||
}
|
||||
+3500
-4237
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@ use nebula_terminal::grid::Dimensions as _;
|
||||
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig, boxdraw};
|
||||
#[cfg(windows)]
|
||||
use nebula_terminal::term::TermMode;
|
||||
use nebula_terminal::vte::ansi::CursorShape;
|
||||
use nebula_terminal::vte::ansi::{Color, CursorShape};
|
||||
|
||||
use super::view::TerminalView;
|
||||
|
||||
@@ -187,6 +187,71 @@ impl Element for TerminalElement {
|
||||
view.refresh_suggestion_from_snapshot(prompt_line, suggest_anchor);
|
||||
});
|
||||
let overrides = snap.color_overrides;
|
||||
let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx);
|
||||
let host_cursor_follows_theme = is_default_host_cursor(&theme);
|
||||
let app_cursor = snap.cursor.as_ref().filter(|cursor| {
|
||||
cursor.shape == CursorShape::Hidden
|
||||
&& crate::display::content::is_application_cursor_glyph(
|
||||
cursor.cell_ch,
|
||||
cursor.cell_flags,
|
||||
crate::display::content::cell_background_is_fixed(cursor.cell_bg, &overrides),
|
||||
)
|
||||
});
|
||||
let themed_block = host_cursor_follows_theme
|
||||
&& snap
|
||||
.cursor
|
||||
.as_ref()
|
||||
.is_some_and(|cursor| matches!(cursor.shape, CursorShape::Block));
|
||||
|
||||
// 公式覆盖层:与本帧快照同一份网格状态(term 锁内扫描 + 计划),
|
||||
// 探测/fit/几何合同全部由共享的 display::terminal_math 裁定。
|
||||
let scale_factor = window.scale_factor();
|
||||
let math_frame = {
|
||||
let cell_w = layout.cell_width.as_f32();
|
||||
let line_h = layout.line_height.as_f32();
|
||||
let font_px = f32::from(self.view.read(cx).font_size);
|
||||
let fg = self.view.read(cx).palette.foreground;
|
||||
let foreground = crate::display::color::Rgb::new(
|
||||
(fg.r * 255.0).round() as u8,
|
||||
(fg.g * 255.0).round() as u8,
|
||||
(fg.b * 255.0).round() as u8,
|
||||
);
|
||||
let cursor = snap.cursor.as_ref().map(|c| (c.row as usize, c.col as usize));
|
||||
let has_selection = !snap.selection_runs.is_empty();
|
||||
let bg_runs = &snap.bg_runs;
|
||||
self.view.update(cx, |view, _| {
|
||||
let Some(term) = view.session.as_ref().map(|session| session.term.clone()) else {
|
||||
return super::math_overlay::MathFrame::default();
|
||||
};
|
||||
view.math.observe_program(view.running_program.as_deref());
|
||||
let size_info = crate::display::SizeInfo::new(
|
||||
cell_w * layout.cols as f32,
|
||||
line_h * layout.rows as f32,
|
||||
cell_w,
|
||||
line_h,
|
||||
0.0,
|
||||
0.0,
|
||||
false,
|
||||
);
|
||||
let term = term.lock();
|
||||
view.math.plan_frame(
|
||||
&term,
|
||||
&size_info,
|
||||
has_selection,
|
||||
cursor,
|
||||
foreground,
|
||||
font_px,
|
||||
scale_factor,
|
||||
|row, start, end| {
|
||||
bg_runs.iter().any(|run| {
|
||||
run.row as usize == row
|
||||
&& (run.start as usize) < end
|
||||
&& start < (run.end as usize)
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
let cell_rect = |row: usize, start: usize, count: usize| -> Bounds<Pixels> {
|
||||
Bounds::new(
|
||||
@@ -199,29 +264,94 @@ impl Element for TerminalElement {
|
||||
};
|
||||
|
||||
for run in &snap.bg_runs {
|
||||
let color = theme.resolve(run.color, &overrides, false);
|
||||
window.paint_quad(fill(
|
||||
cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize),
|
||||
color,
|
||||
));
|
||||
let mut paint = |start: u16, end: u16, color: Color| {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let color = theme.resolve(color, &overrides, false);
|
||||
window.paint_quad(fill(
|
||||
cell_rect(run.row as usize, start as usize, (end - start) as usize),
|
||||
color,
|
||||
));
|
||||
};
|
||||
if let Some(cursor) = app_cursor {
|
||||
if run.row == cursor.row {
|
||||
let skip_end = cursor.col.saturating_add(if cursor.wide { 2 } else { 1 });
|
||||
if run.start < skip_end && cursor.col < run.end {
|
||||
paint(run.start, cursor.col, run.color);
|
||||
paint(skip_end, run.end, run.color);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
paint(run.start, run.end, run.color);
|
||||
}
|
||||
let selection_fill = if is_default_selection(&theme) {
|
||||
let alpha = if theme_is_light {
|
||||
crate::display::ui::tokens::terminal_feedback::SELECTION_ALPHA_LIGHT
|
||||
} else {
|
||||
crate::display::ui::tokens::terminal_feedback::SELECTION_ALPHA_DARK
|
||||
};
|
||||
rgba_rgb(theme_anchor, alpha)
|
||||
} else {
|
||||
theme.selection
|
||||
};
|
||||
for run in &snap.selection_runs {
|
||||
window.paint_quad(fill(
|
||||
cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize),
|
||||
theme.selection,
|
||||
selection_fill,
|
||||
));
|
||||
}
|
||||
|
||||
// 聚焦时的块状光标垫在文字下面,让字形以反色盖在上面。
|
||||
// 聚焦时的块状光标:旧壳默认主题走半透明 theme_anchor(浅色 0.20),
|
||||
// 叠在格子/壁纸上,不反色文字;用户显式配置光标才用实心色。
|
||||
if let Some(cursor) = &snap.cursor {
|
||||
if focused && cursor_visible && matches!(cursor.shape, CursorShape::Block) {
|
||||
let width = if cursor.wide { 2 } else { 1 };
|
||||
let fill_color = if host_cursor_follows_theme {
|
||||
let alpha = if theme_is_light {
|
||||
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT
|
||||
} else {
|
||||
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_DARK
|
||||
};
|
||||
rgba_rgb(theme_anchor, alpha)
|
||||
} else {
|
||||
theme.cursor
|
||||
};
|
||||
window.paint_quad(fill(
|
||||
cell_rect(cursor.row as usize, cursor.col as usize, width),
|
||||
theme.cursor,
|
||||
fill_color,
|
||||
));
|
||||
}
|
||||
}
|
||||
// CC/Codex:DECSCUSR Hidden 后自己画反色空格。旧壳把那格从反色黑
|
||||
// 改成叠在主题底上的 theme_anchor;块元素则只换前景,保留字形。
|
||||
let app_cursor_color = app_cursor.map(|cursor| {
|
||||
let alpha = if theme_is_light {
|
||||
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT
|
||||
} else {
|
||||
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_DARK
|
||||
};
|
||||
let base = if cursor.cell_ch == ' ' {
|
||||
rgb_from_rgba(theme.background)
|
||||
} else {
|
||||
rgb_from_rgba(theme.resolve(cursor.cell_bg, &overrides, false))
|
||||
};
|
||||
let (color, _) =
|
||||
crate::display::content::composite_overlay(theme_anchor, alpha, base, 1.0);
|
||||
rgba_rgb(color, 1.0)
|
||||
});
|
||||
if let Some(cursor) = app_cursor {
|
||||
if cursor.cell_ch == ' ' {
|
||||
if let Some(color) = app_cursor_color {
|
||||
let width = if cursor.wide { 2 } else { 1 };
|
||||
window.paint_quad(fill(
|
||||
cell_rect(cursor.row as usize, cursor.col as usize, width),
|
||||
color,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (font, bold_font, italic_font, bold_italic_font, font_size) = {
|
||||
let view = self.view.read(cx);
|
||||
@@ -233,16 +363,28 @@ impl Element for TerminalElement {
|
||||
view.font_size,
|
||||
)
|
||||
};
|
||||
let pick_font = |bold: bool, italic: bool| match (bold, italic) {
|
||||
(false, false) => font.clone(),
|
||||
(true, false) => bold_font.clone(),
|
||||
(false, true) => italic_font.clone(),
|
||||
(true, true) => bold_italic_font.clone(),
|
||||
// 全宽(CJK 等)bold run 的字形策略(设置 `cjk_bold_regular`,默认
|
||||
// 开):bold 只提亮颜色(resolve 已做),字形保持 Regular——旧壳
|
||||
// `wide_bold_use_regular` 的同义移植。CJK 假粗体在小字号糊成一团,
|
||||
// 这是旧壳早已裁定过的取舍。
|
||||
let cjk_bold_regular = cx
|
||||
.try_global::<crate::gpui_shell::config::Settings>()
|
||||
.map(|settings| settings.cjk_bold_regular)
|
||||
.unwrap_or(true);
|
||||
let pick_font = |bold: bool, italic: bool, wide: bool| {
|
||||
let bold = bold && !(wide && cjk_bold_regular);
|
||||
match (bold, italic) {
|
||||
(false, false) => font.clone(),
|
||||
(true, false) => bold_font.clone(),
|
||||
(false, true) => italic_font.clone(),
|
||||
(true, true) => bold_italic_font.clone(),
|
||||
}
|
||||
};
|
||||
// 聚焦块光标下的字形反色(合同保证该格是独立段)。
|
||||
let cursor_inverts = |row: u16, col: u16| {
|
||||
focused
|
||||
&& cursor_visible
|
||||
&& !themed_block
|
||||
&& snap.cursor.as_ref().is_some_and(|c| {
|
||||
matches!(c.shape, CursorShape::Block) && c.row == row && c.col == col
|
||||
})
|
||||
@@ -253,7 +395,11 @@ impl Element for TerminalElement {
|
||||
// —— CJK 字体下的框线错位就此根治。
|
||||
let scale = window.scale_factor();
|
||||
for glyph in &snap.box_glyphs {
|
||||
let fg = if cursor_inverts(glyph.row, glyph.col) {
|
||||
let fg = if app_cursor
|
||||
.is_some_and(|cursor| cursor.row == glyph.row && cursor.col == glyph.col)
|
||||
{
|
||||
app_cursor_color.unwrap_or_else(|| theme.resolve(glyph.fg, &overrides, glyph.bold))
|
||||
} else if cursor_inverts(glyph.row, glyph.col) {
|
||||
theme.background
|
||||
} else {
|
||||
theme.resolve(glyph.fg, &overrides, glyph.bold)
|
||||
@@ -302,6 +448,11 @@ impl Element for TerminalElement {
|
||||
// 光标反色在这里只是换色,不影响任何字形位置。
|
||||
for seg in &snap.segments {
|
||||
for cell in &seg.cells {
|
||||
// 被公式覆盖的源格不画原文(公式直接落在卡底上,与旧壳
|
||||
// CoverageMask 合同一致;计划失败的公式不进掩码、原文保留)。
|
||||
if math_frame.covers(seg.row as usize, cell.col as usize) {
|
||||
continue;
|
||||
}
|
||||
let fg: Hsla = if cursor_inverts(seg.row, cell.col) {
|
||||
theme.background.into()
|
||||
} else {
|
||||
@@ -317,7 +468,7 @@ impl Element for TerminalElement {
|
||||
.then(|| gpui::StrikethroughStyle { thickness: px(1.0), color: Some(fg) });
|
||||
let run = TextRun {
|
||||
len: cell.text.len(),
|
||||
font: pick_font(cell.bold, cell.italic),
|
||||
font: pick_font(cell.bold, cell.italic, seg.wide),
|
||||
color: fg,
|
||||
background_color: None,
|
||||
underline,
|
||||
@@ -339,6 +490,19 @@ impl Element for TerminalElement {
|
||||
}
|
||||
}
|
||||
|
||||
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
|
||||
// 与旧壳 draw_rects → draw_overlays 的次序一致。
|
||||
if !math_frame.is_empty() {
|
||||
super::math_overlay::paint_frame(
|
||||
&math_frame,
|
||||
bounds.origin,
|
||||
(layout.cell_width.as_f32(), layout.line_height.as_f32()),
|
||||
scale_factor,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(cursor) = &snap.cursor {
|
||||
// 内联 ghost 余量与弹窗补全:结果由 view 在本帧 render 时算好
|
||||
// (引擎与旧壳共享),元素只负责画。IME 组合中让位给 preedit,
|
||||
@@ -401,6 +565,16 @@ impl Element for TerminalElement {
|
||||
// ghost 从光标单元格起笔;GPUI 的字形抗锯齿可能盖住同一位置的
|
||||
// beam/underline 边缘,所以非块状光标的前景必须在 ghost 后补画。
|
||||
// 聚焦块状光标仍在文字层下方填充,保持旧壳的反色合同。
|
||||
let stroke = if host_cursor_follows_theme {
|
||||
let alpha = if theme_is_light {
|
||||
crate::display::ui::tokens::terminal_feedback::STROKE_CURSOR_ALPHA_LIGHT
|
||||
} else {
|
||||
crate::display::ui::tokens::terminal_feedback::STROKE_CURSOR_ALPHA_DARK
|
||||
};
|
||||
rgba_rgb(theme_anchor, alpha)
|
||||
} else {
|
||||
theme.cursor
|
||||
};
|
||||
let width = if cursor.wide { 2 } else { 1 };
|
||||
let rect = cell_rect(cursor.row as usize, cursor.col as usize, width);
|
||||
if !focused || cursor_visible {
|
||||
@@ -408,12 +582,12 @@ impl Element for TerminalElement {
|
||||
(true, CursorShape::Block) => {}, // 已在文字层下方填充
|
||||
(false, CursorShape::Block | CursorShape::HollowBlock)
|
||||
| (true, CursorShape::HollowBlock) => {
|
||||
window.paint_quad(outline(rect, theme.cursor, gpui::BorderStyle::Solid));
|
||||
window.paint_quad(outline(rect, stroke, gpui::BorderStyle::Solid));
|
||||
},
|
||||
(_, CursorShape::Beam) => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(rect.origin, size(px(2.0), layout.line_height)),
|
||||
theme.cursor,
|
||||
stroke,
|
||||
));
|
||||
},
|
||||
(_, CursorShape::Underline) => {
|
||||
@@ -422,7 +596,7 @@ impl Element for TerminalElement {
|
||||
point(rect.origin.x, rect.origin.y + layout.line_height - px(2.0)),
|
||||
size(rect.size.width, px(2.0)),
|
||||
),
|
||||
theme.cursor,
|
||||
stroke,
|
||||
));
|
||||
},
|
||||
(_, CursorShape::Hidden) => {},
|
||||
@@ -761,6 +935,56 @@ fn paint_completion_popup(
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba_channels(color: Rgba) -> (u8, u8, u8) {
|
||||
(
|
||||
(color.r * 255.0).round() as u8,
|
||||
(color.g * 255.0).round() as u8,
|
||||
(color.b * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_default_host_cursor(palette: &super::colors::Palette) -> bool {
|
||||
rgba_channels(palette.cursor) == default_cursor_rgb()
|
||||
}
|
||||
|
||||
fn is_default_selection(palette: &super::colors::Palette) -> bool {
|
||||
rgba_channels(palette.selection) == default_cursor_rgb()
|
||||
&& (palette.selection.a - 0.60).abs() < 0.05
|
||||
}
|
||||
|
||||
fn default_cursor_rgb() -> (u8, u8, u8) {
|
||||
match crate::config::color::NEBULA_DEFAULT_CURSOR.background {
|
||||
crate::display::color::CellRgb::Rgb(rgb) => (rgb.r, rgb.g, rgb.b),
|
||||
_ => (0x49, 0x4d, 0x72),
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_from_rgba(color: Rgba) -> crate::display::color::Rgb {
|
||||
let (r, g, b) = rgba_channels(color);
|
||||
crate::display::color::Rgb::new(r, g, b)
|
||||
}
|
||||
|
||||
fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
|
||||
Rgba {
|
||||
r: f32::from(color.r) / 255.0,
|
||||
g: f32::from(color.g) / 255.0,
|
||||
b: f32::from(color.b) / 255.0,
|
||||
a: alpha,
|
||||
}
|
||||
}
|
||||
|
||||
fn themed_anchor(palette: &super::colors::Palette, cx: &App) -> (crate::display::color::Rgb, bool) {
|
||||
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
|
||||
// ANSI magenta = index 5;旧壳 `display.colors[NamedColor::Magenta]`。
|
||||
let magenta = rgb_from_rgba(palette.ansi[5]);
|
||||
let mix = if sk.is_light {
|
||||
crate::display::ui::tokens::terminal_feedback::ANCHOR_NEUTRAL_MIX_LIGHT
|
||||
} else {
|
||||
crate::display::ui::tokens::terminal_feedback::ANCHOR_NEUTRAL_MIX_DARK
|
||||
};
|
||||
(crate::display::content::mix_rgb(magenta, sk.ink_dim, mix), sk.is_light)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::completion_popup_layout;
|
||||
|
||||
@@ -81,10 +81,179 @@ fn ctrl_char(c: char) -> Option<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// ConPTY Win32 input mode 是否当家。
|
||||
///
|
||||
/// 子进程请求的 Win32 记录是**回落**协议,不是与 kitty 并列的第二编码器:
|
||||
/// 一旦子进程要过 kitty 键盘标志,那份标志就是线上合同,压过 DECSET 9001
|
||||
/// (口径逐字同旧壳 `input::terminal_input::use_win32_input_mode`)。
|
||||
fn use_win32_input_mode(mode: &TermMode) -> bool {
|
||||
mode.contains(TermMode::WIN32_INPUT_MODE)
|
||||
&& !mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL)
|
||||
}
|
||||
|
||||
/// GPUI 键名 → Win32 虚拟键码。
|
||||
///
|
||||
/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的
|
||||
/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键**
|
||||
/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。
|
||||
#[cfg(windows)]
|
||||
fn virtual_key_of(key: &str) -> Option<u16> {
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
|
||||
VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT,
|
||||
VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP,
|
||||
};
|
||||
|
||||
let vk = match key {
|
||||
"escape" => VK_ESCAPE,
|
||||
"enter" => VK_RETURN,
|
||||
"tab" => VK_TAB,
|
||||
"backspace" => VK_BACK,
|
||||
"space" => VK_SPACE,
|
||||
"up" => VK_UP,
|
||||
"down" => VK_DOWN,
|
||||
"left" => VK_LEFT,
|
||||
"right" => VK_RIGHT,
|
||||
"home" => VK_HOME,
|
||||
"end" => VK_END,
|
||||
"insert" => VK_INSERT,
|
||||
"delete" => VK_DELETE,
|
||||
"pageup" => VK_PRIOR,
|
||||
"pagedown" => VK_NEXT,
|
||||
// F1..F24 在 VK 表里连号。
|
||||
key if key.starts_with('f') => {
|
||||
let index: u16 = key[1..].parse().ok()?;
|
||||
if !(1..=24).contains(&index) {
|
||||
return None;
|
||||
}
|
||||
VK_F1 + index - 1
|
||||
},
|
||||
// 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。
|
||||
key => {
|
||||
let mut chars = key.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else { return None };
|
||||
if !c.is_ascii_alphanumeric() {
|
||||
return None;
|
||||
}
|
||||
c.to_ascii_uppercase() as u16
|
||||
},
|
||||
};
|
||||
Some(vk)
|
||||
}
|
||||
|
||||
/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、
|
||||
/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0
|
||||
/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与
|
||||
/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。
|
||||
#[cfg(windows)]
|
||||
fn unicode_char_of(ks: &Keystroke) -> u16 {
|
||||
// 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。
|
||||
// `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。
|
||||
if let Some(text) = ks.key_char.as_deref() {
|
||||
let mut units = text.encode_utf16();
|
||||
if let (Some(first), None) = (units.next(), units.next()) {
|
||||
if first != 0 {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
}
|
||||
match ks.key.as_str() {
|
||||
"escape" => 0x1b,
|
||||
"enter" => b'\r' as u16,
|
||||
"tab" => b'\t' as u16,
|
||||
"backspace" => 0x08,
|
||||
"space" => b' ' as u16,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。
|
||||
///
|
||||
/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录,
|
||||
/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。
|
||||
#[cfg(windows)]
|
||||
fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option<Vec<u8>> {
|
||||
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW};
|
||||
|
||||
const SHIFT_PRESSED: u32 = 0x0010;
|
||||
const LEFT_ALT_PRESSED: u32 = 0x0002;
|
||||
const LEFT_CTRL_PRESSED: u32 = 0x0008;
|
||||
|
||||
let vk = virtual_key_of(ks.key.as_str())?;
|
||||
// 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。
|
||||
let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) };
|
||||
let mut control_key_state = 0u32;
|
||||
if ks.modifiers.shift {
|
||||
control_key_state |= SHIFT_PRESSED;
|
||||
}
|
||||
if ks.modifiers.alt {
|
||||
control_key_state |= LEFT_ALT_PRESSED;
|
||||
}
|
||||
if ks.modifiers.control {
|
||||
control_key_state |= LEFT_CTRL_PRESSED;
|
||||
}
|
||||
let key_down = u8::from(key_down);
|
||||
Some(
|
||||
format!(
|
||||
"\x1b[{};{};{};{key_down};{};1_",
|
||||
vk,
|
||||
scan_code,
|
||||
unicode_char_of(ks),
|
||||
control_key_state
|
||||
)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起
|
||||
/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。
|
||||
/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是
|
||||
/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。
|
||||
#[cfg(windows)]
|
||||
fn win32_press_and_release(ks: &Keystroke) -> Option<Vec<u8>> {
|
||||
let mut sequence = win32_input_record(ks, true)?;
|
||||
sequence.extend(win32_input_record(ks, false)?);
|
||||
Some(sequence)
|
||||
}
|
||||
|
||||
/// 子进程要过 kitty 键盘标志时,Esc 必须是 `CSI 27u`,不是裸 `\x1b`。
|
||||
/// 口径同旧壳 `kitty_disambiguate_escape_is_csi_27_u`。
|
||||
fn kitty_escape(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
|
||||
let kitty = mode.intersects(
|
||||
TermMode::REPORT_ALL_KEYS_AS_ESC
|
||||
| TermMode::DISAMBIGUATE_ESC_CODES
|
||||
| TermMode::REPORT_EVENT_TYPES,
|
||||
);
|
||||
if !kitty || ks.key.as_str() != "escape" {
|
||||
return None;
|
||||
}
|
||||
let param = modifier_param(ks);
|
||||
Some(if param == 1 {
|
||||
b"\x1b[27u".to_vec()
|
||||
} else {
|
||||
format!("\x1b[27;{param}u").into_bytes()
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。
|
||||
pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
|
||||
let mods = &ks.modifiers;
|
||||
|
||||
// ConPTY 是带 Win32 input mode 标志创建的(见 `tty::windows::conpty`),
|
||||
// 子进程一发 DECSET 9001 就切到这份记录格式。旧壳在 `build_sequence`
|
||||
// 开头做同样的分流;GPUI 此前一直只发传统 VT,于是裸 `\x1b` 要由
|
||||
// OpenConsole 的翻译层反译成 KEY_EVENT_RECORD,而它会丢掉 uChar=0 的
|
||||
// VK_ESCAPE——读字节流的应用(Claude Code)因此收不到 Esc。
|
||||
#[cfg(windows)]
|
||||
if use_win32_input_mode(mode) {
|
||||
if let Some(record) = win32_press_and_release(ks) {
|
||||
return Some(record);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bytes) = kitty_escape(ks, mode) {
|
||||
return Some(bytes);
|
||||
}
|
||||
|
||||
match ks.key.as_str() {
|
||||
"enter" => {
|
||||
return Some(if mods.alt { b"\x1b\r".to_vec() } else { b"\r".to_vec() });
|
||||
@@ -159,3 +328,74 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keystroke(key: &str) -> Keystroke {
|
||||
Keystroke { modifiers: gpui::Modifiers::default(), key: key.to_owned(), key_char: None }
|
||||
}
|
||||
|
||||
/// 传统 VT 路径(子进程没要过 DECSET 9001):Esc 就是裸 `\x1b`。
|
||||
#[test]
|
||||
fn escape_stays_a_bare_byte_on_the_legacy_vt_path() {
|
||||
let mode = TermMode::default();
|
||||
assert_eq!(encode(&keystroke("escape"), &mode), Some(b"\x1b".to_vec()));
|
||||
}
|
||||
|
||||
/// Win32 input mode 下 Esc 必须走 `CSI Vk;Sc;Uc;Kd;Cs;Rc_`,且 Uc 是
|
||||
/// 真实的 0x1B——填 0 会被 OpenConsole 1.22 的翻译层丢掉,读字节流的
|
||||
/// 应用(Claude Code)就永远收不到 Esc。GPUI 没有 key-up 通道,所以
|
||||
/// 一次按键要成对写出 down+up,形状对齐旧壳真实键盘。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn escape_becomes_a_win32_record_carrying_its_real_char() {
|
||||
let bytes = encode(&keystroke("escape"), &TermMode::WIN32_INPUT_MODE)
|
||||
.expect("win32 模式下 Esc 必须编码");
|
||||
let text = String::from_utf8(bytes).expect("记录是 ASCII");
|
||||
let records: Vec<&str> = text.split_inclusive('_').filter(|s| !s.is_empty()).collect();
|
||||
assert_eq!(records.len(), 2, "一次 Esc 必须是 down+up 两条记录:{text:?}");
|
||||
|
||||
let parse = |record: &str| -> Vec<String> {
|
||||
let body = record
|
||||
.strip_prefix("\x1b[")
|
||||
.and_then(|s| s.strip_suffix('_'))
|
||||
.expect("CSI … _");
|
||||
body.split(';').map(str::to_owned).collect()
|
||||
};
|
||||
let down = parse(records[0]);
|
||||
let up = parse(records[1]);
|
||||
assert_eq!(down.len(), 6, "字段数固定为 Vk;Sc;Uc;Kd;Cs;Rc:{text:?}");
|
||||
assert_eq!(down[0], "27", "VK_ESCAPE");
|
||||
// 扫描码问系统要(布局相关),只断言它不是 0——0 意味着查询失败。
|
||||
assert_ne!(down[1], "0", "扫描码不能为 0:{text:?}");
|
||||
assert_eq!(down[2], "27", "uChar 必须是真实的 0x1B,不是 0");
|
||||
assert_eq!(down[3], "1", "按下");
|
||||
assert_eq!(down[4], "0", "无修饰");
|
||||
assert_eq!(down[5], "1", "重复次数");
|
||||
assert_eq!(&up[..3], &down[..3], "抬起与按下的 Vk/Sc/Uc 必须一致");
|
||||
assert_eq!(up[3], "0", "抬起");
|
||||
assert_eq!(up[4], down[4]);
|
||||
assert_eq!(up[5], "1");
|
||||
}
|
||||
|
||||
/// kitty 键盘标志是子进程明确要过的线上合同,压过 DECSET 9001
|
||||
/// (口径同旧壳 `use_win32_input_mode` / `kitty_disambiguate_escape_is_csi_27_u`)。
|
||||
#[test]
|
||||
fn kitty_flags_take_precedence_over_win32_records() {
|
||||
let mode = TermMode::WIN32_INPUT_MODE | TermMode::DISAMBIGUATE_ESC_CODES;
|
||||
assert!(!use_win32_input_mode(&mode));
|
||||
assert_eq!(encode(&keystroke("escape"), &mode), Some(b"\x1b[27u".to_vec()));
|
||||
}
|
||||
|
||||
/// 认不出 VK 的键回落传统 VT,而不是编一条 Vk=0 的假记录。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn unknown_keys_fall_back_instead_of_forging_a_record() {
|
||||
assert_eq!(virtual_key_of("f99"), None);
|
||||
assert_eq!(virtual_key_of("capslock"), None);
|
||||
// 方向键在 win32 模式下仍要出记录(它们有确定的 VK)。
|
||||
assert!(win32_input_record(&keystroke("up"), true).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! GPUI 壳终端的公式覆盖层。
|
||||
//!
|
||||
//! 探测/持久化/fit/几何全部复用旧壳 `display::terminal_math`(单一权威,
|
||||
//! 两壳像素级同源);本文件只做三件事:在 term 锁内组织一帧的扫描与
|
||||
//! 绘制计划、把覆盖掩码交给格子绘制循环跳过源格、按计划用
|
||||
//! `math_view` 的位图管线上屏。
|
||||
//!
|
||||
//! 与旧壳 `draw_pane` 相同的门控:alt screen / vi 模式 / 存在选区时整帧
|
||||
//! 不覆盖(源码可正常选择复制);光标所在逻辑行的排除在 `scan_visible`
|
||||
//! 内部完成。旧壳按 `RenderableCell` 背景 alpha 过滤围栏代码块,本壳的
|
||||
//! 等价数据源是快照的 `bg_runs`(默认背景不产生 run)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{App, Bounds, Pixels, Rgba, SharedString, Window, point, px, size};
|
||||
use nebula_terminal::Term;
|
||||
use nebula_terminal::event::EventListener;
|
||||
use nebula_terminal::index::{Column, Point};
|
||||
use nebula_terminal::term::TermMode;
|
||||
|
||||
use crate::display::SizeInfo;
|
||||
use crate::display::color::Rgb;
|
||||
use crate::display::terminal_math::{
|
||||
self, CoverageMask, OverlayDrawPlan, TerminalMathState,
|
||||
};
|
||||
use crate::gpui_shell::math_view;
|
||||
|
||||
/// 每 pane 一份的覆盖层状态(`TerminalView` 持有)。内部的持久锚点/缓存
|
||||
/// 语义(滚动、reflow、清屏失效)全部由共享的 [`TerminalMathState`] 决定。
|
||||
#[derive(Default)]
|
||||
pub struct MathOverlay {
|
||||
state: TerminalMathState,
|
||||
}
|
||||
|
||||
/// 一条通过几何计划的公式:源码 + 绘制计划(坐标系 = 网格局部逻辑 px)。
|
||||
pub struct PlannedFormula {
|
||||
source: Arc<str>,
|
||||
plan: OverlayDrawPlan,
|
||||
}
|
||||
|
||||
/// 一帧的覆盖产物:绘制清单 + 源格跳过掩码。
|
||||
#[derive(Default)]
|
||||
pub struct MathFrame {
|
||||
formulas: Vec<PlannedFormula>,
|
||||
coverage: CoverageMask,
|
||||
}
|
||||
|
||||
impl MathFrame {
|
||||
/// 该格是否被某条公式覆盖(覆盖格不画原文)。
|
||||
pub fn covers(&self, row: usize, column: usize) -> bool {
|
||||
!self.coverage.is_empty() && self.coverage.covers(Point::new(row, Column(column)))
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.formulas.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MathOverlay {
|
||||
/// shell 集成上报的前台程序(AI CLI 名单解锁行内 `$…$`,与旧壳同门)。
|
||||
pub fn observe_program(&mut self, program: Option<&str>) {
|
||||
self.state.observe_program(program);
|
||||
}
|
||||
|
||||
/// 每帧的扫描 + 预编 + 几何计划。必须在 term 锁内调用。
|
||||
///
|
||||
/// `size` 以网格左上角为原点(padding = 0);`bg_covered` 回答"该行的
|
||||
/// 列区间内是否存在非默认背景"(围栏代码过滤)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn plan_frame<T: EventListener>(
|
||||
&mut self,
|
||||
term: &Term<T>,
|
||||
size: &SizeInfo,
|
||||
has_selection: bool,
|
||||
cursor: Option<(usize, usize)>,
|
||||
default_foreground: Rgb,
|
||||
font_pixel_size: f32,
|
||||
pixels_per_point: f32,
|
||||
bg_covered: impl Fn(usize, usize, usize) -> bool,
|
||||
) -> MathFrame {
|
||||
// 旧壳 draw_pane 的整帧门控(搜索态本壳尚不存在,故不在此列)。
|
||||
if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) || has_selection {
|
||||
return MathFrame::default();
|
||||
}
|
||||
|
||||
let allow_inline_dollar = self.state.inline_dollar_enabled();
|
||||
let cursor = cursor.map(|(row, column)| Point::new(row, Column(column)));
|
||||
let mut overlays = terminal_math::scan_visible(
|
||||
&mut self.state,
|
||||
term,
|
||||
size,
|
||||
// 本壳不走 RenderableCell 管线:fallback 重画与围栏过滤各有
|
||||
// 等价实现(覆盖失败不跳格 / bg_runs 过滤)。
|
||||
&[],
|
||||
allow_inline_dollar,
|
||||
cursor,
|
||||
default_foreground,
|
||||
);
|
||||
// 围栏代码里的公式源码保持原文(旧壳按单元格背景 alpha 判定)。
|
||||
overlays.retain(|overlay| {
|
||||
!overlay.covered_ranges().any(|(row, start, end)| {
|
||||
usize::try_from(row).is_ok_and(|row| bg_covered(row, start, end))
|
||||
})
|
||||
});
|
||||
|
||||
let prepared = terminal_math::prepare_overlays(
|
||||
&mut self.state,
|
||||
&overlays,
|
||||
size,
|
||||
font_pixel_size,
|
||||
pixels_per_point,
|
||||
);
|
||||
let mut plans: Vec<Option<OverlayDrawPlan>> = Vec::with_capacity(overlays.len());
|
||||
for (overlay, prepared) in overlays.iter().zip(&prepared) {
|
||||
plans.push(prepared.as_ref().and_then(|prepared| {
|
||||
terminal_math::plan_overlay_draw(
|
||||
&mut self.state,
|
||||
overlay,
|
||||
prepared,
|
||||
size,
|
||||
pixels_per_point,
|
||||
)
|
||||
}));
|
||||
}
|
||||
// 掩码按"真的会画"的公式构建:计划失败的公式保留原文,不留洞。
|
||||
let coverage = CoverageMask::build(&overlays, &plans);
|
||||
let formulas = overlays
|
||||
.iter()
|
||||
.zip(&plans)
|
||||
.filter_map(|(overlay, plan)| {
|
||||
plan.map(|plan| PlannedFormula { source: overlay.source_arc(), plan })
|
||||
})
|
||||
.collect();
|
||||
MathFrame { formulas, coverage }
|
||||
}
|
||||
}
|
||||
|
||||
/// 按计划绘制一帧的公式(在格子文本之后调用)。`origin` 是网格左上角的
|
||||
/// 窗口坐标;`cell` 是 (宽, 高),text ops 的"整字符可见才画"合同用它。
|
||||
pub fn paint_frame(
|
||||
frame: &MathFrame,
|
||||
origin: gpui::Point<Pixels>,
|
||||
cell: (f32, f32),
|
||||
pixels_per_point: f32,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
for formula in &frame.formulas {
|
||||
let plan = &formula.plan;
|
||||
let color = Rgba {
|
||||
r: plan.foreground.r as f32 / 255.0,
|
||||
g: plan.foreground.g as f32 / 255.0,
|
||||
b: plan.foreground.b as f32 / 255.0,
|
||||
a: 1.0,
|
||||
};
|
||||
let source: SharedString = formula.source.to_string().into();
|
||||
|
||||
// 裁剪跟随 bleed 预算(与旧壳 MathClip 同一矩形),交给 GPUI 的
|
||||
// content mask 执行像素级裁剪。
|
||||
let clip = Bounds::new(
|
||||
point(origin.x + px(plan.clip_left), origin.y + px(plan.clip_top)),
|
||||
size(
|
||||
px((plan.clip_right - plan.clip_left).max(0.0)),
|
||||
px((plan.clip_bottom - plan.clip_top).max(0.0)),
|
||||
),
|
||||
);
|
||||
let left = origin.x + px(plan.origin_x);
|
||||
let baseline = origin.y + px(plan.baseline_y);
|
||||
window.with_content_mask(Some(gpui::ContentMask { bounds: clip }), |window| {
|
||||
math_view::paint_formula_image(
|
||||
&source,
|
||||
plan.display_style,
|
||||
plan.fitted_pixel_size,
|
||||
pixels_per_point,
|
||||
left,
|
||||
baseline,
|
||||
color,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
// 数学字体缺字的字符(公式里的中文等):旧壳合同是整字符落在
|
||||
// 裁剪窗内才画,避免半个汉字。
|
||||
let layout = cx.global_mut::<math_view::MathAssets>().layout(
|
||||
&source,
|
||||
plan.display_style,
|
||||
plan.fitted_pixel_size,
|
||||
pixels_per_point,
|
||||
);
|
||||
let Some(layout) = layout else { return };
|
||||
let text_style = window.text_style();
|
||||
for op in &layout.text {
|
||||
let scale = op.pixel_size / plan.fitted_pixel_size.max(f32::EPSILON);
|
||||
let x = plan.origin_x + op.x;
|
||||
let width = cell.0 * scale;
|
||||
let height = cell.1 * scale;
|
||||
let top = plan.baseline_y + op.baseline_y - height * 0.8;
|
||||
if x < plan.clip_left
|
||||
|| x + width > plan.clip_right
|
||||
|| top < plan.clip_top
|
||||
|| top + height > plan.clip_bottom
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut buffer = [0u8; 4];
|
||||
let text: SharedString = op.character.encode_utf8(&mut buffer).to_string().into();
|
||||
let run = text_style.to_run(text.len());
|
||||
let line = window.text_system().shape_line(
|
||||
text,
|
||||
px(op.pixel_size),
|
||||
std::slice::from_ref(&run),
|
||||
None,
|
||||
);
|
||||
let target = point(
|
||||
origin.x + px(x),
|
||||
origin.y + px(plan.baseline_y + op.baseline_y) - line.ascent,
|
||||
);
|
||||
let _ = line.paint(target, line.ascent + line.descent, window, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod colors;
|
||||
pub mod element;
|
||||
pub mod keymap;
|
||||
pub mod math_overlay;
|
||||
pub mod mouse_protocol;
|
||||
pub mod session;
|
||||
pub mod suggest;
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
//! - 等宽 cell 度量(`SizeInfo`)→ UI 基准字号(同一设置源)
|
||||
|
||||
use gpui::{
|
||||
AnyElement, Bounds, Context, Hsla, InteractiveElement as _, StatefulInteractiveElement as _,
|
||||
IntoElement, MouseButton, ParentElement as _, SharedString,
|
||||
Styled as _, canvas, div, fill, hsla, point,
|
||||
px, size,
|
||||
canvas, div, fill, hsla, point, px, size, AnyElement, Bounds, Context, Hsla,
|
||||
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _,
|
||||
};
|
||||
|
||||
use crate::display::ssh_connect::{self, SshConnectState, stage_labels, stage_message};
|
||||
use crate::display::ssh_connect::{self, stage_labels, stage_message, SshConnectState};
|
||||
use crate::gpui_shell::prelude::*;
|
||||
|
||||
/// UiLanguage 由设置折算(两壳同源)。
|
||||
@@ -48,12 +47,7 @@ fn hsla_from_rgba(r: u8, g: u8, b: u8) -> Hsla {
|
||||
|
||||
fn lerp_hsla(a: Hsla, b: Hsla, k: f32) -> Hsla {
|
||||
let k = k.clamp(0.0, 1.0);
|
||||
hsla(
|
||||
a.h + (b.h - a.h) * k,
|
||||
a.s + (b.s - a.s) * k,
|
||||
a.l + (b.l - a.l) * k,
|
||||
1.0,
|
||||
)
|
||||
hsla(a.h + (b.h - a.h) * k, a.s + (b.s - a.s) * k, a.l + (b.l - a.l) * k, 1.0)
|
||||
}
|
||||
|
||||
/// 品牌紫→青(旧壳 palette.edge_l/edge_r;浅色主题压暗 0.28)。
|
||||
@@ -243,9 +237,10 @@ pub(super) fn overlay(
|
||||
|
||||
let theme = cx.theme();
|
||||
let lang = language();
|
||||
// chrome 文本锚定配置字号(旧壳 ui_font 合同),不跟终端缩放。
|
||||
let ui_px = cx
|
||||
.try_global::<crate::gpui_shell::config::Settings>()
|
||||
.map(|settings| settings.font_size_px)
|
||||
.map(|settings| settings.base_font_size_px)
|
||||
.unwrap_or(15.0);
|
||||
let family: SharedString = cx
|
||||
.try_global::<crate::gpui_shell::config::Settings>()
|
||||
@@ -261,12 +256,7 @@ pub(super) fn overlay(
|
||||
let card_bg = theme.group_box;
|
||||
let hairline = theme.border;
|
||||
let dark = theme.is_dark();
|
||||
let elevation = gpui::hsla(
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
if dark { 56.0 / 255.0 } else { 26.0 / 255.0 },
|
||||
);
|
||||
let elevation = gpui::hsla(0.0, 0.0, 0.0, if dark { 56.0 / 255.0 } else { 26.0 / 255.0 });
|
||||
// 遮罩沿用终端 pane 的真实底色;直接铺 popover 会把空网格区域
|
||||
// 换成另一层面板色,尤其在浅色主题下会显得整块发白。
|
||||
let backdrop = crate::gpui_shell::theme::card_content_bg(cx);
|
||||
@@ -304,14 +294,7 @@ pub(super) fn overlay(
|
||||
rack_panel,
|
||||
);
|
||||
let d = 2.4;
|
||||
quad(
|
||||
bx + stroke + 2.2,
|
||||
by + bh * 0.5 - d * 0.5,
|
||||
d,
|
||||
d,
|
||||
d * 0.5,
|
||||
rack_ink,
|
||||
);
|
||||
quad(bx + stroke + 2.2, by + bh * 0.5 - d * 0.5, d, d, d * 0.5, rack_ink);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -323,13 +306,10 @@ pub(super) fn overlay(
|
||||
let logs_target = cx.entity().downgrade();
|
||||
let logs_open = state.is_logs_open();
|
||||
let logs_hover = theme.list_hover;
|
||||
let logs_chevron = Icon::new(if logs_open {
|
||||
IconName::ChevronUp
|
||||
} else {
|
||||
IconName::ChevronDown
|
||||
})
|
||||
.xsmall()
|
||||
.text_color(ink_dim);
|
||||
let logs_chevron =
|
||||
Icon::new(if logs_open { IconName::ChevronUp } else { IconName::ChevronDown })
|
||||
.xsmall()
|
||||
.text_color(ink_dim);
|
||||
let logs_button = h_flex()
|
||||
.id("ssh-connect-logs")
|
||||
.h(px(30.0))
|
||||
@@ -410,12 +390,8 @@ pub(super) fn overlay(
|
||||
let labels = stage_labels(lang);
|
||||
let active = state.stage_index();
|
||||
let caption_h = ui_px * 0.75;
|
||||
let rail_block = v_flex()
|
||||
.w_full()
|
||||
.gap(px(8.0))
|
||||
.mt(px(24.0))
|
||||
.child(rail_canvas(state, cx, &theme))
|
||||
.child(
|
||||
let rail_block =
|
||||
v_flex().w_full().gap(px(8.0)).mt(px(24.0)).child(rail_canvas(state, cx, &theme)).child(
|
||||
div().relative().h(px(caption_h)).children(
|
||||
labels
|
||||
.iter()
|
||||
@@ -472,11 +448,7 @@ pub(super) fn overlay(
|
||||
.mt(px(24.0))
|
||||
.justify_between()
|
||||
.child(
|
||||
div()
|
||||
.font_family(family.clone())
|
||||
.text_size(px(ui_px))
|
||||
.text_color(msg_ink)
|
||||
.child(msg),
|
||||
div().font_family(family.clone()).text_size(px(ui_px)).text_color(msg_ink).child(msg),
|
||||
)
|
||||
.when(!state.failed(), |row| {
|
||||
row.child(
|
||||
@@ -491,22 +463,19 @@ pub(super) fn overlay(
|
||||
// ── 失败详情(两行,超出收省略号)──
|
||||
let per_line = 44usize;
|
||||
let detail = state.failure().map(|reason| {
|
||||
v_flex()
|
||||
.mt(px(8.0))
|
||||
.gap(px(ui_px * 0.28))
|
||||
.children(
|
||||
ssh_connect::wrap(reason, per_line)
|
||||
.into_iter()
|
||||
.take(2)
|
||||
.map(|line| {
|
||||
div()
|
||||
.font_family(family.clone())
|
||||
.text_size(px(ui_px * 0.80))
|
||||
.text_color(ink_dim)
|
||||
.child(ssh_connect::truncate_cols(&line, per_line))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
v_flex().mt(px(8.0)).gap(px(ui_px * 0.28)).children(
|
||||
ssh_connect::wrap(reason, per_line)
|
||||
.into_iter()
|
||||
.take(2)
|
||||
.map(|line| {
|
||||
div()
|
||||
.font_family(family.clone())
|
||||
.text_size(px(ui_px * 0.80))
|
||||
.text_color(ink_dim)
|
||||
.child(ssh_connect::truncate_cols(&line, per_line))
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
});
|
||||
|
||||
// ── Logs 区(末尾 6 行,最新在最下)──
|
||||
@@ -546,27 +515,22 @@ pub(super) fn overlay(
|
||||
};
|
||||
let failed = state.failed();
|
||||
let action_target = cx.entity().downgrade();
|
||||
let buttons = h_flex()
|
||||
.w_full()
|
||||
.mt(px(24.0))
|
||||
.justify_end()
|
||||
.child(
|
||||
Button::new("ssh-connect-action")
|
||||
.label(action_label)
|
||||
.small()
|
||||
.map(|button| if failed { button.primary() } else { button.outline() })
|
||||
.on_click(move |_, _, cx| {
|
||||
if let Some(view) = action_target.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
// 取消与关闭是同一个动作(旧裁定):这个 pane
|
||||
// 除了这条连接没有别的内容。
|
||||
this.ssh_connect = None;
|
||||
cx.emit(super::view::TerminalViewEvent::RequestClose);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
let buttons = h_flex().w_full().mt(px(24.0)).justify_end().child({
|
||||
let button =
|
||||
crate::gpui_shell::widgets::NebulaButton::new("ssh-connect-action").label(action_label);
|
||||
let button = if failed { button.primary() } else { button.outline() };
|
||||
button.on_click(move |_, _, cx| {
|
||||
if let Some(view) = action_target.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
// 取消与关闭是同一个动作(旧裁定):这个 pane
|
||||
// 除了这条连接没有别的内容。
|
||||
this.ssh_connect = None;
|
||||
cx.emit(super::view::TerminalViewEvent::RequestClose);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ── 组装 ──
|
||||
div()
|
||||
|
||||
@@ -27,7 +27,7 @@ use super::session::{self, TerminalSession};
|
||||
use super::suggest;
|
||||
use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab};
|
||||
use crate::gpui_shell::config::Settings;
|
||||
use crate::gpui_shell::prelude::DialogButtonProps;
|
||||
use crate::gpui_shell::prelude::{DialogButtonProps, center_confirm_dialog};
|
||||
|
||||
use futures::StreamExt as _;
|
||||
|
||||
@@ -111,11 +111,17 @@ pub enum TerminalLaunch {
|
||||
Ssh { destination: String },
|
||||
}
|
||||
|
||||
/// 侧边栏只消费稳定的三态,不直接窥探终端/SSH 的内部错误字段。
|
||||
/// 侧边栏只消费稳定的枚举态,不直接窥探终端/SSH 的内部错误字段。
|
||||
/// `Done`/`Attention` 是旧壳 `AgentStatus::Done`/`Blocked` 的呈现位:
|
||||
/// 回合结束≠会话结束,转圈必须停,但要留下「有结果没看」的痕迹。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SidebarActivity {
|
||||
Idle,
|
||||
Running,
|
||||
/// Agent 回合完成,等待下一条指令(旧壳蓝点语义)。
|
||||
Done,
|
||||
/// Agent 停在授权/提问上,需要用户表态(旧壳手掌语义)。
|
||||
Attention,
|
||||
Failed,
|
||||
}
|
||||
|
||||
@@ -123,6 +129,8 @@ pub struct TerminalView {
|
||||
pub pane_id: u64,
|
||||
pub session: Option<TerminalSession>,
|
||||
pub focus_handle: FocusHandle,
|
||||
/// 公式覆盖层(探测/持久化状态复用旧壳 `terminal_math`,每 pane 一份)。
|
||||
pub math: super::math_overlay::MathOverlay,
|
||||
pub font: Font,
|
||||
pub font_bold: Font,
|
||||
pub font_italic: Font,
|
||||
@@ -148,6 +156,16 @@ pub struct TerminalView {
|
||||
/// `NEBULA|` 标题报 `running_program`,装了 OSC133 集成的 bash/zsh/nu 只
|
||||
/// 发这一对语义标记——两条路都要能点亮侧栏的「运行中」。
|
||||
command_running: bool,
|
||||
/// Hook 事件驱动的 agent 回合状态(旧壳 `nebula_state.agent_status` 的
|
||||
/// 边沿触发版)。`Unknown` = 本 pane 没有 agent 参与,spinner 完全由
|
||||
/// `command_running`/`running_program` 决定。
|
||||
agent_status: crate::ai_agents::AgentStatus,
|
||||
/// 本次前台 agent 会话是否收到过 hook(旧壳同名字段同语义):屏幕
|
||||
/// 检测的空闲提示符不得降级 hook 报出的 Done/Blocked 精确终态。
|
||||
agent_hook_seen: bool,
|
||||
/// 屏幕检测连续看到空闲提示符的拍数;Working 连续两拍空闲才降级
|
||||
/// (单拍可能是重绘间隙),非 idle 检测与任何 hook 边沿都清零。
|
||||
idle_screen_streak: u8,
|
||||
/// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。
|
||||
pending_notify: Vec<String>,
|
||||
/// SSH 直连目的地(`user@host[:port]`);本地会话为 None。
|
||||
@@ -558,6 +576,7 @@ impl TerminalView {
|
||||
pane_id,
|
||||
session,
|
||||
focus_handle: cx.focus_handle(),
|
||||
math: super::math_overlay::MathOverlay::default(),
|
||||
font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal),
|
||||
font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal),
|
||||
font_italic: mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic),
|
||||
@@ -574,6 +593,9 @@ impl TerminalView {
|
||||
branch: String::new(),
|
||||
running_program: None,
|
||||
command_running: false,
|
||||
agent_status: crate::ai_agents::AgentStatus::Unknown,
|
||||
agent_hook_seen: false,
|
||||
idle_screen_streak: 0,
|
||||
pending_notify: Vec::new(),
|
||||
ssh_destination,
|
||||
ssh_stage: None,
|
||||
@@ -713,6 +735,9 @@ impl TerminalView {
|
||||
cx.emit(TerminalViewEvent::TitleChanged);
|
||||
}
|
||||
self.command_running = false;
|
||||
self.agent_status = crate::ai_agents::AgentStatus::Unknown;
|
||||
self.agent_hook_seen = false;
|
||||
self.idle_screen_streak = 0;
|
||||
cx.notify();
|
||||
},
|
||||
TermEvent::Notify(body) => {
|
||||
@@ -775,7 +800,15 @@ impl TerminalView {
|
||||
}
|
||||
|
||||
/// Apply one lifecycle event already routed to this pane by the workspace.
|
||||
///
|
||||
/// 旧壳 `WindowContext::handle_ai_hook` 状态机的忠实移植:此前这里把
|
||||
/// 所有非 SessionEnd 事件压平成「点亮 running_program」,于是回合结束
|
||||
/// (TurnDone)后 spinner 也永远转下去——「CLI 都答完了侧栏还在转圈」
|
||||
/// 的病根就是这次压平。
|
||||
pub fn handle_ai_hook(&mut self, event: &crate::ai_hook::AiHookEvent, cx: &mut Context<Self>) {
|
||||
use crate::ai_agents::AgentStatus;
|
||||
use crate::ai_hook::AiHookKind;
|
||||
|
||||
// SessionEnd 可能是第一次携带最终权威 id 的事件;必须先落盘再清理
|
||||
// pane 现场,否则 CLI 正常退出后反而无法冷恢复。
|
||||
if let Some(id) = event.session_id.as_deref() {
|
||||
@@ -785,28 +818,149 @@ impl TerminalView {
|
||||
log::warn!("agent session index: could not record {} {id}: {error}", event.source);
|
||||
}
|
||||
}
|
||||
if event.kind == crate::ai_hook::AiHookKind::SessionEnd {
|
||||
self.ai_session = None;
|
||||
self.running_program = None;
|
||||
} else {
|
||||
self.running_program = Some(event.source.clone());
|
||||
if let Some(id) = event.session_id.as_deref() {
|
||||
self.ai_session = Some(crate::display::AiSessionIdentity {
|
||||
source: event.source.clone(),
|
||||
session_id: id.to_owned(),
|
||||
});
|
||||
}
|
||||
match event.kind {
|
||||
AiHookKind::SessionEnd => {
|
||||
self.ai_session = None;
|
||||
self.running_program = None;
|
||||
self.agent_status = AgentStatus::Unknown;
|
||||
self.agent_hook_seen = false;
|
||||
self.idle_screen_streak = 0;
|
||||
},
|
||||
kind => {
|
||||
self.running_program = Some(event.source.clone());
|
||||
self.agent_hook_seen = true;
|
||||
// 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的
|
||||
// 拍数不能把新回合的第一个空闲闪现立即降级)。
|
||||
self.idle_screen_streak = 0;
|
||||
if let Some(id) = event.session_id.as_deref() {
|
||||
self.ai_session = Some(crate::display::AiSessionIdentity {
|
||||
source: event.source.clone(),
|
||||
session_id: id.to_owned(),
|
||||
});
|
||||
}
|
||||
match kind {
|
||||
// 会话起来了但还没干活:亮图标,不转圈。
|
||||
AiHookKind::SessionStart => self.agent_status = AgentStatus::Idle,
|
||||
AiHookKind::PromptSubmit => self.agent_status = AgentStatus::Working,
|
||||
// 一个工具刚跑完 = agent 正在干活,这是无条件事实(回合
|
||||
// 可能经不发 PromptSubmit 的路径继续——授权点头、队列
|
||||
// 消息——状态却还挂在 Done:「还在执行却显示完成」)。
|
||||
AiHookKind::ToolComplete => self.agent_status = AgentStatus::Working,
|
||||
AiHookKind::TurnDone | AiHookKind::NeedsAttention => {
|
||||
// codex 的 notify 只有「回合完成」一种事件:弹交互式
|
||||
// 提问时发的也是 turn-complete。回合结束的瞬间看一眼
|
||||
// 屏幕尾部——还挂着选择框/确认提示,就按「等你批准」
|
||||
// 处理(旧壳同款嗅探)。
|
||||
let screen_asks =
|
||||
event.kind == AiHookKind::TurnDone && self.screen_tail_asks();
|
||||
self.agent_status =
|
||||
if event.kind == AiHookKind::NeedsAttention || screen_asks {
|
||||
AgentStatus::Blocked
|
||||
} else {
|
||||
AgentStatus::Done
|
||||
};
|
||||
},
|
||||
AiHookKind::SessionEnd => unreachable!("handled above"),
|
||||
}
|
||||
},
|
||||
}
|
||||
cx.emit(TerminalViewEvent::TitleChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 旧壳 TurnDone 分支的屏尾提问嗅探:取可视区最后 ≤15 行喂
|
||||
/// `tail_looks_like_question`。
|
||||
fn screen_tail_asks(&self) -> bool {
|
||||
let Some(session) = &self.session else { return false };
|
||||
let term = session.term.lock();
|
||||
let lines = term.screen_lines();
|
||||
let take = lines.min(15);
|
||||
let start = TermPoint::new(Line((lines - take) as i32), Column(0));
|
||||
let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1)));
|
||||
crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end))
|
||||
}
|
||||
|
||||
/// 1 Hz 声明式屏幕看门狗(旧壳 `refresh_agent_screen_states` 的单 pane
|
||||
/// 版)。Hook 是精确边沿,但边会丢:打断的回合没有 Stop 事件、codex
|
||||
/// 只有 turn-complete 一种事件。屏幕负责两件事——给无 hook 客户端补位,
|
||||
/// 以及纠正与可见现场矛盾的僵尸状态:
|
||||
/// - 可见的 working/blocked chrome 永远可以纠正(现场比旧事件可信);
|
||||
/// - 空闲提示符降级分档:hook 报过的 Done/Blocked 精确终态不动;
|
||||
/// Working 连续两拍空闲才按「完成未读」收场(单拍可能是重绘间隙)。
|
||||
pub fn refresh_agent_screen_state(&mut self, cx: &mut Context<Self>) {
|
||||
use crate::ai_agents::AgentStatus;
|
||||
let Some(program) = self.running_program.clone() else {
|
||||
self.idle_screen_streak = 0;
|
||||
return;
|
||||
};
|
||||
if crate::ai_agents::AgentKind::parse(&program).is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(session) = &self.session else { return };
|
||||
let screen = {
|
||||
let term = session.term.lock();
|
||||
let lines = term.screen_lines();
|
||||
if lines == 0 || term.columns() == 0 {
|
||||
return;
|
||||
}
|
||||
let take = lines.min(24);
|
||||
let start = TermPoint::new(Line((lines - take) as i32), Column(0));
|
||||
let end =
|
||||
TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1)));
|
||||
term.bounds_to_string(start, end)
|
||||
};
|
||||
let Some(detection) = crate::ai_agents::detect(&program, &screen) else { return };
|
||||
|
||||
let next = match detection.status {
|
||||
AgentStatus::Idle => {
|
||||
if self.agent_hook_seen
|
||||
&& matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.agent_status == AgentStatus::Working {
|
||||
self.idle_screen_streak = self.idle_screen_streak.saturating_add(1);
|
||||
if self.idle_screen_streak < 2 {
|
||||
return;
|
||||
}
|
||||
// 连续空闲 = TurnDone 丢了:按「回合完成未读」收场
|
||||
// (蓝点),而不是无痕回 Idle——用户没盯着屏幕时这个
|
||||
// 结果仍然值得一个标记。
|
||||
AgentStatus::Done
|
||||
} else {
|
||||
AgentStatus::Idle
|
||||
}
|
||||
},
|
||||
status @ (AgentStatus::Working | AgentStatus::Blocked) => {
|
||||
self.idle_screen_streak = 0;
|
||||
status
|
||||
},
|
||||
AgentStatus::Done | AgentStatus::Unknown => {
|
||||
self.idle_screen_streak = 0;
|
||||
return;
|
||||
},
|
||||
};
|
||||
if next != self.agent_status {
|
||||
log::debug!(
|
||||
"agent screen state: pane={} program={} {:?}->{next:?} rule={}",
|
||||
self.pane_id,
|
||||
program,
|
||||
self.agent_status,
|
||||
detection.rule_id
|
||||
);
|
||||
self.agent_status = next;
|
||||
cx.emit(TerminalViewEvent::TitleChanged);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ai_fork_command(&self) -> Option<String> {
|
||||
let identity = self.ai_session.as_ref()?;
|
||||
crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id)
|
||||
}
|
||||
|
||||
pub fn sidebar_activity(&self) -> SidebarActivity {
|
||||
use crate::ai_agents::AgentStatus;
|
||||
if self.error.is_some()
|
||||
|| self.exited.is_some()
|
||||
|| matches!(
|
||||
@@ -814,8 +968,18 @@ impl TerminalView {
|
||||
Some(crate::ssh_session::SshStage::Failed(_))
|
||||
)
|
||||
{
|
||||
SidebarActivity::Failed
|
||||
} else if self.command_running
|
||||
return SidebarActivity::Failed;
|
||||
}
|
||||
// Agent 会话在场时回合状态是权威:TurnDone 之后 spinner 必须停,
|
||||
// 不能因为 `running_program` 还亮着(图标要继续显示)就一直转。
|
||||
match self.agent_status {
|
||||
AgentStatus::Working => return SidebarActivity::Running,
|
||||
AgentStatus::Blocked => return SidebarActivity::Attention,
|
||||
AgentStatus::Done => return SidebarActivity::Done,
|
||||
AgentStatus::Idle => return SidebarActivity::Idle,
|
||||
AgentStatus::Unknown => {},
|
||||
}
|
||||
if self.command_running
|
||||
|| self.running_program.is_some()
|
||||
|| self.ssh_stage.as_ref().is_some_and(|stage| {
|
||||
!matches!(stage, crate::ssh_session::SshStage::Ready)
|
||||
@@ -1209,10 +1373,10 @@ impl TerminalView {
|
||||
// pane」的 view 释放不掉。
|
||||
let text = Arc::new(text);
|
||||
let view = cx.entity().downgrade();
|
||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||
window.open_dialog(cx, move |dialog, window, _cx| {
|
||||
let text = text.clone();
|
||||
let view = view.clone();
|
||||
dialog
|
||||
center_confirm_dialog(dialog, window)
|
||||
.title(title.clone())
|
||||
.confirm()
|
||||
.button_props(
|
||||
|
||||
@@ -41,19 +41,50 @@ pub(crate) fn chrome_theme_resolved(cx: &App) -> NebulaTheme {
|
||||
chrome_theme(effective_theme_name(cx))
|
||||
}
|
||||
|
||||
/// 当前 OS 外观是否为浅色。跟随系统时与旧壳 `WinitTheme::Light` 同一判定。
|
||||
pub(crate) fn system_is_light(cx: &App) -> bool {
|
||||
matches!(
|
||||
cx.window_appearance(),
|
||||
gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight
|
||||
)
|
||||
}
|
||||
|
||||
/// 把用户点选的主题家族折算成此刻该显示的成员。
|
||||
/// `follow_system` 关闭时原样返回 preference;开启时走
|
||||
/// [`NebulaTheme::for_system_appearance`]。
|
||||
pub(crate) fn resolve_theme_name(
|
||||
preference: ThemeName,
|
||||
follow_system: bool,
|
||||
system_is_light: bool,
|
||||
) -> ThemeName {
|
||||
if !follow_system {
|
||||
return preference;
|
||||
}
|
||||
settings_theme_name(chrome_theme(preference).for_system_appearance(system_is_light))
|
||||
}
|
||||
|
||||
/// 生效主题:`follow_system_theme` 开启时按系统外观折算到用户主题家族的
|
||||
/// 亮/暗成员(规则与旧壳 `NebulaTheme::for_system_appearance` 同一来源)。
|
||||
/// chrome 令牌与终端 palette 都必须走这里,两层才不会分家。
|
||||
pub fn effective_theme_name(cx: &App) -> ThemeName {
|
||||
let rt = nebula_settings::RuntimeSettings::load();
|
||||
if !rt.follow_system_theme {
|
||||
return rt.theme;
|
||||
}
|
||||
let is_light = matches!(
|
||||
cx.window_appearance(),
|
||||
gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight
|
||||
);
|
||||
settings_theme_name(chrome_theme(rt.theme).for_system_appearance(is_light))
|
||||
resolve_theme_name(rt.theme, rt.follow_system_theme, system_is_light(cx))
|
||||
}
|
||||
|
||||
/// 点选一张主题卡时要写盘的键:对齐旧壳 `select_nebula_theme`
|
||||
/// (关掉跟随系统)+ `apply_nebula_theme`(底色换成该主题 `term_bg`)。
|
||||
pub(crate) fn theme_card_persist_updates(name: ThemeName) -> [(&'static str, String); 3] {
|
||||
[
|
||||
("theme", name.prompt_name().to_owned()),
|
||||
("follow_system_theme", "0".to_owned()),
|
||||
("background", nebula_settings::format_hex_rgb(name.term_theme().background)),
|
||||
]
|
||||
}
|
||||
|
||||
/// 当前生效主题的终端底色(给「跟随主题」取色器同步用,不是壳色)。
|
||||
pub(crate) fn theme_term_background(cx: &App) -> Hsla {
|
||||
let c = chrome_theme(effective_theme_name(cx)).palette().term_bg;
|
||||
to_hsla(c.r, c.g, c.b)
|
||||
}
|
||||
|
||||
fn to_hsla(r: u8, g: u8, b: u8) -> Hsla {
|
||||
@@ -425,3 +456,36 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) {
|
||||
theme.radius = px(crate::display::UI_CORNER_RADIUS_LOGICAL);
|
||||
theme.radius_lg = px(12.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{resolve_theme_name, theme_card_persist_updates};
|
||||
use nebula_settings::ThemeName;
|
||||
|
||||
#[test]
|
||||
fn follow_system_remaps_theme_family_and_manual_mode_keeps_preference() {
|
||||
assert_eq!(
|
||||
resolve_theme_name(ThemeName::SilverLight, true, false),
|
||||
ThemeName::SteelDark
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_theme_name(ThemeName::Nebula, true, true),
|
||||
ThemeName::SilverLight
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_theme_name(ThemeName::SilverLight, false, false),
|
||||
ThemeName::SilverLight
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_a_theme_card_disables_follow_system_and_writes_that_theme_background() {
|
||||
let updates = theme_card_persist_updates(ThemeName::LinenLight);
|
||||
assert_eq!(updates[0], ("theme", "LinenLight".to_owned()));
|
||||
assert_eq!(updates[1], ("follow_system_theme", "0".to_owned()));
|
||||
assert_eq!(
|
||||
updates[2],
|
||||
("background", nebula_settings::format_hex_rgb(ThemeName::LinenLight.term_theme().background))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! 旧壳设置控件的 GPUI 形态。
|
||||
//!
|
||||
//! - [`NebulaSwitch`]:液态胶囊开关。几何、配色与动画通道逐项对照旧壳
|
||||
//! `display/ui/widgets.rs::push_toggle`(HTML 参考的
|
||||
//! `transition: all 0.3s ease`)。组件库 `Switch` 是 0.15s 线性、蓝底白点
|
||||
//! 的另一套语言,与旧壳中性胶囊在同一页面上会打架。
|
||||
//! - [`NebulaButton`]:设置行动作按钮。几何对照 `row_action_rect` / HTML
|
||||
//! `.bt`,hover 色过渡对照 `.bt { transition: all .13s }`。组件库 `Button`
|
||||
//! 的 `.hover()` / `.active()` 是瞬时换色,没有这段动效。
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, App, ClickEvent, ElementId, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, RenderImage, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement as _, Styled as _, Window, div, ease_in_out, px,
|
||||
};
|
||||
use image::Frame;
|
||||
|
||||
use crate::renderer::ui::Rgba;
|
||||
|
||||
/// Shell 的彩色品牌图标(`extra/shell-icons` 的 PNG)预缩放成与物理像素
|
||||
/// 一一对应的纹理。设置页的默认 Shell 下拉与新建终端的 Shell 选择弹窗
|
||||
/// 共用同一份资产和同一条缩放路径——把 128px 原图交给 GPUI 每帧缩小会糊
|
||||
/// 边,两处各写一遍缩放又迟早缩出两种观感。
|
||||
///
|
||||
/// `logical_size` 是控件里图标的逻辑边长,`scale_factor` 是窗口 DPI 缩放。
|
||||
/// 返回 `None` 表示该 id 没有品牌资产,调用方保留自己的字形回落。
|
||||
pub fn shell_brand_image(
|
||||
id: &str,
|
||||
logical_size: f32,
|
||||
scale_factor: f32,
|
||||
) -> Option<Arc<RenderImage>> {
|
||||
let bytes = crate::shell_detect::color_icon_png(id)?;
|
||||
let source = image::load_from_memory(bytes).ok()?.into_rgba8();
|
||||
let (width, height) = source.dimensions();
|
||||
let target_size = (logical_size * scale_factor).round().max(1.0) as u32;
|
||||
let (mut rgba, width, height) =
|
||||
crate::display::prepare_ai_logo_texture(source.as_raw(), width, height, target_size);
|
||||
// GPUI RenderImage 使用 BGRA;预缩放必须在 RGBA 上完成,换序放在最后,
|
||||
// 否则 Lanczos 会把红蓝通道按错误语义混合。
|
||||
for pixel in rgba.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
let rgba = image::RgbaImage::from_raw(width, height, rgba)?;
|
||||
Some(Arc::new(RenderImage::new([Frame::new(rgba)])))
|
||||
}
|
||||
|
||||
const TRACK_W: f32 = 48.0;
|
||||
const TRACK_H: f32 = 26.0;
|
||||
const KNOB: f32 = 20.0;
|
||||
const INSET: f32 = 2.0;
|
||||
const TRAVEL: f32 = 24.0;
|
||||
const STRETCH: f32 = 8.0;
|
||||
const DURATION: Duration = Duration::from_millis(300);
|
||||
|
||||
/// 旧壳设置行的动画开关(液态胶囊)。
|
||||
///
|
||||
/// - 胶囊 48×26(圆角 13),knob 20×20、inset 2,行程 24px;
|
||||
/// - 按住时 knob 拉伸 +8px(开态从右缘回弹,旧壳「liquid recoil」);
|
||||
/// - 状态切换 = 0.3s ease 同步过渡位置与颜色(border/track/knob 三通道)。
|
||||
#[derive(IntoElement)]
|
||||
pub struct NebulaSwitch {
|
||||
key: SharedString,
|
||||
checked: bool,
|
||||
disabled: bool,
|
||||
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl NebulaSwitch {
|
||||
pub fn new(key: impl Into<SharedString>) -> Self {
|
||||
Self { key: key.into(), checked: false, disabled: false, on_click: None }
|
||||
}
|
||||
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&bool, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// u8 通道混色(旧壳 `widgets::mix_color` 的语义),返回 gpui 颜色。
|
||||
fn mix(start: Rgba, end: Rgba, t: f32) -> gpui::Rgba {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let channel = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t) / 255.0;
|
||||
gpui::Rgba {
|
||||
r: channel(start.r, end.r),
|
||||
g: channel(start.g, end.g),
|
||||
b: channel(start.b, end.b),
|
||||
a: channel(start.a, end.a),
|
||||
}
|
||||
}
|
||||
|
||||
/// `overlay`(带 alpha)盖到 `base`(不透明)上(旧壳 `surface::over` 语义)。
|
||||
fn over(overlay: Rgba, base: gpui::Rgba) -> gpui::Rgba {
|
||||
let alpha = f32::from(overlay.a) / 255.0;
|
||||
let channel = |o: u8, b: f32| b + (f32::from(o) / 255.0 - b) * alpha;
|
||||
gpui::Rgba {
|
||||
r: channel(overlay.r, base.r),
|
||||
g: channel(overlay.g, base.g),
|
||||
b: channel(overlay.b, base.b),
|
||||
a: base.a,
|
||||
}
|
||||
}
|
||||
|
||||
fn over_rgba(overlay: Rgba, base: Rgba) -> Rgba {
|
||||
let alpha = f32::from(overlay.a) / 255.0;
|
||||
let channel = |o: u8, b: u8| {
|
||||
(f32::from(b) + (f32::from(o) - f32::from(b)) * alpha).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
Rgba::new(channel(overlay.r, base.r), channel(overlay.g, base.g), channel(overlay.b, base.b), base.a)
|
||||
}
|
||||
|
||||
impl RenderOnce for NebulaSwitch {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
|
||||
let checked = self.checked;
|
||||
let disabled = self.disabled;
|
||||
|
||||
// 动画驱动(组件库 Switch 同款结构):keyed state 记录「上一次画的
|
||||
// 状态」,与本次 checked 不同才播 0.3s 过渡,结束后把状态落下来。
|
||||
let settled_id = ElementId::Name(format!("nebula-switch-settled-{}", self.key).into());
|
||||
let settled = window.use_keyed_state(settled_id, cx, |_, _| checked);
|
||||
let animate = !disabled && *settled.read(cx) != checked;
|
||||
if animate {
|
||||
let settled = settled.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(DURATION).await;
|
||||
let _ = settled.update(cx, |state, _| *state = checked);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
// 按住拉伸:knob +8px;开态按住时从右缘回弹(x 左移补偿)。
|
||||
let pressed_id = ElementId::Name(format!("nebula-switch-pressed-{}", self.key).into());
|
||||
let pressed = window.use_keyed_state(pressed_id, cx, |_, _| false);
|
||||
let is_pressed = !disabled && *pressed.read(cx);
|
||||
|
||||
let hover_ink = Rgba::new(sk.icon_hover.r, sk.icon_hover.g, sk.icon_hover.b, 12);
|
||||
let track_of = move |t: f32| mix(sk.toggle_track_off, sk.toggle_track_on, t);
|
||||
let border_of = move |t: f32| mix(sk.toggle_border_off, sk.toggle_border_on, t);
|
||||
let knob_color_of = move |t: f32| mix(sk.knob_off, sk.knob_on, t);
|
||||
let knob_w = if is_pressed { KNOB + STRETCH } else { KNOB };
|
||||
let knob_x_of = move |t: f32| {
|
||||
let x = INSET + TRAVEL * t;
|
||||
// 开态按住:宽度向左生长,右缘保持贴住(旧壳 recoil 语义)。
|
||||
if is_pressed && t > 0.5 { x - STRETCH } else { x }
|
||||
};
|
||||
|
||||
let knob = div()
|
||||
.absolute()
|
||||
.top(px((TRACK_H - 2.0 * INSET - KNOB) / 2.0))
|
||||
.w(px(knob_w))
|
||||
.h(px(KNOB))
|
||||
.rounded(px(KNOB / 2.0));
|
||||
let knob = if animate {
|
||||
knob.with_animation(
|
||||
ElementId::NamedInteger(
|
||||
format!("nebula-switch-knob-{}", self.key).into(),
|
||||
checked as u64,
|
||||
),
|
||||
Animation::new(DURATION).with_easing(ease_in_out),
|
||||
move |knob, delta| {
|
||||
let t = if checked { delta } else { 1.0 - delta };
|
||||
knob.left(px(knob_x_of(t))).bg(knob_color_of(t))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let t = if checked { 1.0 } else { 0.0 };
|
||||
knob.left(px(knob_x_of(t))).bg(knob_color_of(t)).into_any_element()
|
||||
};
|
||||
|
||||
// 胶囊本体:动画帧由 with_animation 重染 bg/border;静止帧直接给
|
||||
// settled 色。命中与手势挂在外层 stateful div 上,动画期间照常可点。
|
||||
let track = div()
|
||||
.relative()
|
||||
.w(px(TRACK_W))
|
||||
.h(px(TRACK_H))
|
||||
.rounded(px(TRACK_H / 2.0))
|
||||
.border_1()
|
||||
.child(knob);
|
||||
let track = if animate {
|
||||
track
|
||||
.with_animation(
|
||||
ElementId::NamedInteger(
|
||||
format!("nebula-switch-track-{}", self.key).into(),
|
||||
checked as u64,
|
||||
),
|
||||
Animation::new(DURATION).with_easing(ease_in_out),
|
||||
move |track, delta| {
|
||||
let t = if checked { delta } else { 1.0 - delta };
|
||||
track.bg(track_of(t)).border_color(border_of(t))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let t = if checked { 1.0 } else { 0.0 };
|
||||
track
|
||||
.bg(track_of(t))
|
||||
.border_color(border_of(t))
|
||||
// hover 水洗(旧壳 border 提亮 + track 12/255 水洗的简化)。
|
||||
.hover(move |style| style.bg(over(hover_ink, track_of(t))))
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
div()
|
||||
.id(ElementId::Name(format!("nebula-switch-{}", self.key).into()))
|
||||
.flex_shrink_0()
|
||||
.rounded(px(TRACK_H / 2.0))
|
||||
.cursor_pointer()
|
||||
.child(track)
|
||||
.when(!disabled, |wrapper| {
|
||||
let pressed_down = pressed.clone();
|
||||
let pressed_up = pressed;
|
||||
wrapper
|
||||
.on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
let _ = pressed_down.update(cx, |state, _| *state = true);
|
||||
})
|
||||
.on_mouse_up(gpui::MouseButton::Left, move |_, _, cx| {
|
||||
let _ = pressed_up.update(cx, |state, _| *state = false);
|
||||
})
|
||||
.when_some(self.on_click, |wrapper, on_click| {
|
||||
wrapper.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
on_click(&!checked, window, cx);
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const BUTTON_H: f32 = 30.0;
|
||||
const BUTTON_PX: f32 = 13.0;
|
||||
const BUTTON_DURATION: Duration = Duration::from_millis(130);
|
||||
|
||||
/// 旧壳设置行的文字按钮。
|
||||
///
|
||||
/// 对照两处权威,不对照组件库 `Button`:
|
||||
/// - 几何:旧壳 `row_action_rect`(高 30)+ HTML `.bt`(`padding: 0 13px`,
|
||||
/// 圆角走 chrome `UI_CORNER_RADIUS_LOGICAL`);
|
||||
/// - 动效:HTML `.bt { transition: all .13s }`,hover 只插值底/边/字色,
|
||||
/// 不改尺寸(旧壳 `push_button_frame`:「hover 只改亮度,不改尺寸」);
|
||||
/// - 配色:默认态 = `action_button` 的 `sk.surface` → `sk.hover`;描边态 =
|
||||
/// `push_outline_button` 的发丝 + panel;主按钮 = accent 实心再按亮度抬。
|
||||
#[derive(IntoElement)]
|
||||
pub struct NebulaButton {
|
||||
key: SharedString,
|
||||
label: SharedString,
|
||||
kind: NebulaButtonKind,
|
||||
disabled: bool,
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum NebulaButtonKind {
|
||||
Default,
|
||||
Primary,
|
||||
Outline,
|
||||
Danger,
|
||||
}
|
||||
|
||||
impl NebulaButton {
|
||||
pub fn new(key: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
label: SharedString::default(),
|
||||
kind: NebulaButtonKind::Default,
|
||||
disabled: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
|
||||
self.label = label.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn primary(mut self) -> Self {
|
||||
self.kind = NebulaButtonKind::Primary;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn outline(mut self) -> Self {
|
||||
self.kind = NebulaButtonKind::Outline;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn danger(mut self) -> Self {
|
||||
self.kind = NebulaButtonKind::Danger;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||
self.disabled = disabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click<F>(mut self, handler: F) -> Self
|
||||
where
|
||||
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
{
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_rgba(c: crate::display::color::Rgb) -> Rgba {
|
||||
Rgba::new(c.r, c.g, c.b, 255)
|
||||
}
|
||||
|
||||
fn mix_rgba(start: Rgba, end: Rgba, t: f32) -> Rgba {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let channel = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8;
|
||||
Rgba::new(
|
||||
channel(start.r, end.r),
|
||||
channel(start.g, end.g),
|
||||
channel(start.b, end.b),
|
||||
channel(start.a, end.a),
|
||||
)
|
||||
}
|
||||
|
||||
fn button_palette(
|
||||
kind: NebulaButtonKind,
|
||||
sk: &crate::display::ui::theme::Skin,
|
||||
) -> (Rgba, Rgba, Rgba, Rgba, Rgba, Rgba) {
|
||||
// (bg0, bg1, border0, border1, fg0, fg1)
|
||||
let ink = rgb_rgba(sk.ink);
|
||||
let ink_dim = rgb_rgba(sk.ink_dim);
|
||||
let transparent = Rgba::new(0, 0, 0, 0);
|
||||
let hover_line = mix_rgba(sk.hairline, rgb_rgba(sk.icon_hover), 0.14);
|
||||
match kind {
|
||||
NebulaButtonKind::Default => (sk.surface, sk.hover, sk.hairline, hover_line, ink_dim, ink),
|
||||
NebulaButtonKind::Outline => (sk.panel, sk.hover, sk.hairline, hover_line, ink_dim, ink),
|
||||
NebulaButtonKind::Primary => {
|
||||
let rest = Rgba::opaque(sk.accent);
|
||||
let wash =
|
||||
if sk.is_light { Rgba::new(0, 0, 0, 20) } else { Rgba::new(255, 255, 255, 26) };
|
||||
let hover = over_rgba(wash, rest);
|
||||
let on_accent = rgb_rgba(sk.ink_on_accent);
|
||||
(rest, hover, transparent, transparent, on_accent, on_accent)
|
||||
}
|
||||
NebulaButtonKind::Danger => {
|
||||
let wash = Rgba::new(sk.danger.r, sk.danger.g, sk.danger.b, 28);
|
||||
let line = Rgba::new(sk.danger.r, sk.danger.g, sk.danger.b, 90);
|
||||
(transparent, wash, transparent, line, sk.danger, sk.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NebulaButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
|
||||
let disabled = self.disabled;
|
||||
let kind = self.kind;
|
||||
let (bg0, bg1, border0, border1, fg0, fg1) = button_palette(kind, &sk);
|
||||
let stroked = !matches!(kind, NebulaButtonKind::Primary);
|
||||
|
||||
let hover_id = ElementId::Name(format!("nebula-btn-hover-{}", self.key).into());
|
||||
let hover = window.use_keyed_state(hover_id, cx, |_, _| false);
|
||||
let hovered = !disabled && *hover.read(cx);
|
||||
|
||||
let settled_id = ElementId::Name(format!("nebula-btn-settled-{}", self.key).into());
|
||||
let settled = window.use_keyed_state(settled_id, cx, |_, _| false);
|
||||
let animate = !disabled && *settled.read(cx) != hovered;
|
||||
if animate {
|
||||
let settled = settled.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(BUTTON_DURATION).await;
|
||||
let _ = settled.update(cx, |state, _| *state = hovered);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
let bg_of = move |t: f32| mix(bg0, bg1, t);
|
||||
let border_of = move |t: f32| mix(border0, border1, t);
|
||||
let fg_of = move |t: f32| mix(fg0, fg1, t);
|
||||
|
||||
let face = div()
|
||||
.h(px(BUTTON_H))
|
||||
.px(px(BUTTON_PX))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
|
||||
.when(stroked, |face| face.border_1())
|
||||
.child(self.label);
|
||||
let face = if animate {
|
||||
face.with_animation(
|
||||
ElementId::NamedInteger(
|
||||
format!("nebula-btn-face-{}", self.key).into(),
|
||||
hovered as u64,
|
||||
),
|
||||
Animation::new(BUTTON_DURATION).with_easing(ease_in_out),
|
||||
move |face, delta| {
|
||||
let t = if hovered { delta } else { 1.0 - delta };
|
||||
face.bg(bg_of(t)).border_color(border_of(t)).text_color(fg_of(t))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let t = if hovered { 1.0 } else { 0.0 };
|
||||
face.bg(bg_of(t))
|
||||
.border_color(border_of(t))
|
||||
.text_color(fg_of(t))
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
div()
|
||||
.id(ElementId::Name(format!("nebula-btn-{}", self.key).into()))
|
||||
.flex_shrink_0()
|
||||
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
|
||||
.when(disabled, |wrapper| wrapper.opacity(0.45).cursor_default())
|
||||
.when(!disabled, |wrapper| wrapper.cursor_pointer())
|
||||
.child(face)
|
||||
.when(!disabled, |wrapper| {
|
||||
let hover_enter = hover.clone();
|
||||
wrapper
|
||||
.on_hover(move |hovered, _, cx| {
|
||||
let _ = hover_enter.update(cx, |state, _| *state = *hovered);
|
||||
})
|
||||
.when_some(self.on_click, |wrapper, on_click| {
|
||||
wrapper.on_click(move |event, window, cx| {
|
||||
cx.stop_propagation();
|
||||
on_click(event, window, cx);
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -74,6 +74,7 @@ mod session;
|
||||
mod shell_detect;
|
||||
#[cfg(windows)]
|
||||
mod ssh;
|
||||
mod svn_status;
|
||||
#[cfg(windows)]
|
||||
mod ssh_credentials;
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -34,12 +34,6 @@ pub(crate) const OPTICAL_SCALE: f32 = 1.21;
|
||||
/// 同类公式的字号不会因此分裂。
|
||||
///
|
||||
/// 这是刻意偏离 LaTeX/KaTeX 的一处:那两者排的是版面充裕的文档,我们排的
|
||||
/// 是终端里 AI 输出的一行文字。
|
||||
/// 上下标 / 分子分母的最小相对字号。Latin Modern 的 MATH 表给 70%,是为
|
||||
/// 纸面阅读字号调的;终端正文本来就小,0.7 em 的分子在 20px 字号下只有
|
||||
/// 14px,用户看到的就是"公式比正文小一圈"。
|
||||
///
|
||||
/// 这是刻意偏离 LaTeX/KaTeX 的一处:那两者排的是版面充裕的文档,我们排的
|
||||
/// 是终端里 AI 输出的一行文字。上限卡在 0.8:再往上(实测 0.85)根号里的
|
||||
/// 上标会把 `\sqrt` 顶过字形变体的阈值,高度从 30px 跳到 45px,那条公式
|
||||
/// 就得缩——同类公式一样大比多两个百分点重要。
|
||||
|
||||
@@ -405,9 +405,19 @@ pub fn shell_short_tag(name_or_id: &str) -> String {
|
||||
return distro.trim().to_ascii_lowercase();
|
||||
}
|
||||
let lower = raw.to_ascii_lowercase();
|
||||
// 两个 PowerShell 各有稳定 id,先精确匹配 id 再看显示名:同一台 shell
|
||||
// 经 `TabLaunch::Default`(喂 id)和 `TabLaunch::Shell`(喂菜单名)两条
|
||||
// 管道进来,必须标成同一个词,否则「新建的 tab」和「恢复出来的 tab」
|
||||
// 明明是同一个 shell 却挂着两个短标。
|
||||
match lower.as_str() {
|
||||
"pwsh" => return "pwsh".into(),
|
||||
"powershell" | "ps" => return "ps".into(),
|
||||
_ => {},
|
||||
}
|
||||
if lower.contains("powershell") {
|
||||
// 用户口头就用 pwsh 区分 7 和老 5.1,只有 Windows PowerShell 保留全名。
|
||||
return if lower.contains("windows") { "powershell".into() } else { "pwsh".into() };
|
||||
// 用户口头就用 pwsh 指 7、ps 指系统自带的 5.1;两者都是缩写,且
|
||||
// 同时装着两版时在侧栏一眼分得开。
|
||||
return if lower.contains("windows") { "ps".into() } else { "pwsh".into() };
|
||||
}
|
||||
if lower.contains("bash") {
|
||||
return "bash".into();
|
||||
@@ -559,12 +569,30 @@ mod tests {
|
||||
assert_eq!(shell_short_tag("WSL · Debian"), "debian");
|
||||
assert_eq!(shell_short_tag("PowerShell 7"), "pwsh");
|
||||
assert_eq!(shell_short_tag("pwsh"), "pwsh");
|
||||
assert_eq!(shell_short_tag("Windows PowerShell"), "powershell");
|
||||
assert_eq!(shell_short_tag("Windows PowerShell"), "ps");
|
||||
assert_eq!(shell_short_tag("Git Bash"), "bash");
|
||||
assert_eq!(shell_short_tag("Nushell"), "nu");
|
||||
assert_eq!(shell_short_tag("CMD"), "cmd");
|
||||
}
|
||||
|
||||
/// 同一台 shell 的 settings id 与菜单显示名必须给出同一个短标。
|
||||
/// 回归锁:`powershell`(id) 曾标成 `pwsh` 而「Windows PowerShell」(名)
|
||||
/// 标成 `powershell`——同一台机器因为 tab 的启动方式不同挂了两个词,
|
||||
/// 而其中一个还不是缩写。
|
||||
#[test]
|
||||
fn shell_short_tags_agree_across_id_and_display_name() {
|
||||
use super::{display_name_for_id, shell_short_tag};
|
||||
for id in ["pwsh", "powershell", "cmd", "bash", "nu", "wsl:Ubuntu"] {
|
||||
assert_eq!(
|
||||
shell_short_tag(id),
|
||||
shell_short_tag(&display_name_for_id(id)),
|
||||
"id `{id}` 与它的显示名短标不一致"
|
||||
);
|
||||
}
|
||||
// 两个 PowerShell 仍然分得开。
|
||||
assert_ne!(shell_short_tag("powershell"), shell_short_tag("pwsh"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
//! SVN 工作拷贝状态:直接读 `.svn/wc.db`(SQLite),零外部进程依赖。
|
||||
//!
|
||||
//! 为什么不调 svn.exe:这台产品的目标机器未必装命令行客户端(TortoiseSVN
|
||||
//! 默认只装 GUI),而且逐次 spawn 进程拉状态既慢又抖。SVN 1.7+ 的工作拷贝
|
||||
//! 元数据是稳定的 SQLite 库(`NODES` / `ACTUAL_NODE` 表),TortoiseSVN 的
|
||||
//! TSVNCache 走的就是这条路:只读打开、快速路径按 `recorded_size` /
|
||||
//! `recorded_time` 判嫌疑、嫌疑文件再做 SHA-1 精判——本模块同一合同。
|
||||
//!
|
||||
//! 范围(v1):
|
||||
//! - 状态子集:Added / Replaced / Deleted / Modified / Missing / Conflicted /
|
||||
//! Unversioned;`svn:ignore` 属性尚未解析(未忽略项会以 Unversioned 出现)。
|
||||
//! - `svnadmin create` 出来的**服务端仓库**(conf/db/hooks/format)不是
|
||||
//! 工作拷贝,[`classify_dir`] 会识别并明确报告,不做 FSFS 解析。
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha1::{Digest as _, Sha1};
|
||||
|
||||
/// 修改嫌疑文件做 SHA-1 精判的尺寸上限;超过就直接按 Modified 报告,
|
||||
/// 避免状态刷新读几百 MB 大文件(TSVNCache 同款取舍)。
|
||||
const SHA1_VERIFY_LIMIT: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// 一个目录相对 SVN 的身份。
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SvnDirKind {
|
||||
/// 位于某个工作拷贝内;携带工作拷贝根(含 `.svn/` 的目录)。
|
||||
WorkingCopy(PathBuf),
|
||||
/// `svnadmin create` 生成的服务端仓库:只能被 svnserve/httpd 提供服务
|
||||
/// 或用 `file://` 协议 checkout,本身没有"文件状态"可言。
|
||||
Repository,
|
||||
/// 与 SVN 无关。
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// 判定 `dir` 的 SVN 身份:向上找 `.svn/wc.db`(工作拷贝),否则按
|
||||
/// `format` + `conf/db/hooks` 特征识别服务端仓库。
|
||||
pub fn classify_dir(dir: &Path) -> SvnDirKind {
|
||||
let mut probe = Some(dir);
|
||||
while let Some(current) = probe {
|
||||
if current.join(".svn").join("wc.db").is_file() {
|
||||
return SvnDirKind::WorkingCopy(current.to_owned());
|
||||
}
|
||||
probe = current.parent();
|
||||
}
|
||||
let is_repository = dir.join("format").is_file()
|
||||
&& dir.join("conf").is_dir()
|
||||
&& dir.join("db").is_dir()
|
||||
&& dir.join("hooks").is_dir();
|
||||
if is_repository { SvnDirKind::Repository } else { SvnDirKind::Plain }
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub enum SvnState {
|
||||
Added,
|
||||
Replaced,
|
||||
Deleted,
|
||||
Modified,
|
||||
Missing,
|
||||
Conflicted,
|
||||
Unversioned,
|
||||
}
|
||||
|
||||
impl SvnState {
|
||||
/// 单字母角标(与 `svn status` 输出的第一列同字符集)。
|
||||
pub fn letter(self) -> &'static str {
|
||||
match self {
|
||||
Self::Added => "A",
|
||||
Self::Replaced => "R",
|
||||
Self::Deleted => "D",
|
||||
Self::Modified => "M",
|
||||
Self::Missing => "!",
|
||||
Self::Conflicted => "C",
|
||||
Self::Unversioned => "?",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SvnChange {
|
||||
/// 相对工作拷贝根的正斜杠路径。
|
||||
pub rel_path: String,
|
||||
pub state: SvnState,
|
||||
}
|
||||
|
||||
/// 读出整个工作拷贝的变更清单(按路径排序)。
|
||||
///
|
||||
/// 干净判定的快速路径:`recorded_size` 不同 → Modified;大小相同而
|
||||
/// `recorded_time`(µs)不同 → ≤ [`SHA1_VERIFY_LIMIT`] 时做 SHA-1 精判,
|
||||
/// 只有内容真的不同才报 Modified(touch 过的文件不误报)。
|
||||
pub fn working_copy_status(root: &Path) -> Result<Vec<SvnChange>, String> {
|
||||
let db_path = root.join(".svn").join("wc.db");
|
||||
let connection = rusqlite::Connection::open_with_flags(
|
||||
&db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)
|
||||
.map_err(|error| format!("无法打开 {}: {error}", db_path.display()))?;
|
||||
// svn 客户端可能正持有写锁;等一小会而不是立刻失败。
|
||||
connection.busy_timeout(std::time::Duration::from_millis(300)).ok();
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NodeRow {
|
||||
op_depth: i64,
|
||||
presence: String,
|
||||
kind: String,
|
||||
checksum: Option<String>,
|
||||
recorded_size: Option<i64>,
|
||||
recorded_time: Option<i64>,
|
||||
has_base: bool,
|
||||
}
|
||||
|
||||
// 每个 relpath 取 op_depth 最大的行(当前操作层),并记录是否存在
|
||||
// base 层(Added vs Replaced 的分界)。
|
||||
let mut nodes: BTreeMap<String, NodeRow> = BTreeMap::new();
|
||||
{
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT local_relpath, op_depth, presence, kind, checksum,
|
||||
recorded_size, recorded_time
|
||||
FROM nodes ORDER BY local_relpath, op_depth",
|
||||
)
|
||||
.map_err(|error| format!("wc.db 结构不符合预期: {error}"))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
NodeRow {
|
||||
op_depth: row.get(1)?,
|
||||
presence: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
checksum: row.get(4)?,
|
||||
recorded_size: row.get(5)?,
|
||||
recorded_time: row.get(6)?,
|
||||
has_base: false,
|
||||
},
|
||||
))
|
||||
})
|
||||
.map_err(|error| format!("读取 NODES 失败: {error}"))?;
|
||||
for row in rows {
|
||||
let (relpath, node) = row.map_err(|error| format!("读取 NODES 失败: {error}"))?;
|
||||
match nodes.get_mut(&relpath) {
|
||||
// 行按 op_depth 升序到达:后到的层覆盖前面的,base 层的
|
||||
// 存在性单独记账。
|
||||
Some(existing) => {
|
||||
let has_base = existing.has_base || existing.op_depth == 0;
|
||||
if node.op_depth >= existing.op_depth {
|
||||
*existing = NodeRow { has_base, ..node };
|
||||
} else {
|
||||
existing.has_base = has_base || node.op_depth == 0;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let has_base = node.op_depth == 0;
|
||||
nodes.insert(relpath, NodeRow { has_base, ..node });
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 冲突从 ACTUAL_NODE 来(树冲突/文本冲突统一报 Conflicted)。
|
||||
let mut conflicted: HashSet<String> = HashSet::new();
|
||||
if let Ok(mut statement) = connection
|
||||
.prepare("SELECT local_relpath FROM actual_node WHERE conflict_data IS NOT NULL")
|
||||
{
|
||||
if let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(0)) {
|
||||
for relpath in rows.flatten() {
|
||||
conflicted.insert(relpath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mut versioned_dirs: Vec<String> = Vec::new();
|
||||
for (relpath, node) in &nodes {
|
||||
if relpath.is_empty() {
|
||||
versioned_dirs.push(String::new());
|
||||
continue;
|
||||
}
|
||||
if conflicted.contains(relpath) {
|
||||
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Conflicted });
|
||||
continue;
|
||||
}
|
||||
let disk = root.join(relpath.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
match node.presence.as_str() {
|
||||
"base-deleted" => {
|
||||
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Deleted });
|
||||
},
|
||||
"normal" if node.op_depth > 0 => {
|
||||
let state = if node.has_base { SvnState::Replaced } else { SvnState::Added };
|
||||
changes.push(SvnChange { rel_path: relpath.clone(), state });
|
||||
if node.kind == "dir" {
|
||||
versioned_dirs.push(relpath.clone());
|
||||
}
|
||||
},
|
||||
"normal" => {
|
||||
if node.kind == "dir" {
|
||||
versioned_dirs.push(relpath.clone());
|
||||
continue;
|
||||
}
|
||||
let Ok(metadata) = std::fs::metadata(&disk) else {
|
||||
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Missing });
|
||||
continue;
|
||||
};
|
||||
if is_modified(&disk, &metadata, node.recorded_size, node.recorded_time, node.checksum.as_deref())
|
||||
{
|
||||
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Modified });
|
||||
}
|
||||
},
|
||||
// excluded/server-excluded/not-present 等:本地无实体,不报告。
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
// 未版本化:已版本化目录的磁盘直接子项里,不在 NODES 且不是 .svn 的。
|
||||
for dir in versioned_dirs {
|
||||
let disk_dir =
|
||||
if dir.is_empty() { root.to_owned() } else { root.join(dir.replace('/', std::path::MAIN_SEPARATOR_STR)) };
|
||||
let Ok(entries) = std::fs::read_dir(&disk_dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name == ".svn" {
|
||||
continue;
|
||||
}
|
||||
let relpath =
|
||||
if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") };
|
||||
if !nodes.contains_key(&relpath) {
|
||||
changes.push(SvnChange { rel_path: relpath, state: SvnState::Unversioned });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// 工作拷贝根的当前修订号(`NODES` 根行的 `revision` 列)。
|
||||
pub fn working_copy_revision(root: &Path) -> Option<i64> {
|
||||
let connection = rusqlite::Connection::open_with_flags(
|
||||
root.join(".svn").join("wc.db"),
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)
|
||||
.ok()?;
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT revision FROM nodes WHERE local_relpath = '' AND op_depth = 0",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 干净判定:大小 → 时间戳 → SHA-1 三级快速路径。
|
||||
fn is_modified(
|
||||
path: &Path,
|
||||
metadata: &std::fs::Metadata,
|
||||
recorded_size: Option<i64>,
|
||||
recorded_time: Option<i64>,
|
||||
checksum: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(size) = recorded_size {
|
||||
if metadata.len() != size as u64 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let disk_micros = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_micros() as i64);
|
||||
if let (Some(disk), Some(recorded)) = (disk_micros, recorded_time) {
|
||||
if disk == recorded {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 时间戳变了(或缺记录):尺寸相同的小文件做内容精判,防 touch 误报。
|
||||
let Some(expected) = checksum.and_then(|value| value.strip_prefix("$sha1$")) else {
|
||||
return true;
|
||||
};
|
||||
if metadata.len() > SHA1_VERIFY_LIMIT {
|
||||
return true;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return true;
|
||||
};
|
||||
let digest = Sha1::digest(&bytes);
|
||||
let mut actual = String::with_capacity(40);
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(actual, "{byte:02x}");
|
||||
}
|
||||
actual != expected
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 手工构造最小 wc.db:只建状态推导用到的列,与真实 schema 的子集
|
||||
/// 保持同名同义。
|
||||
fn fake_working_copy(dir: &Path) -> rusqlite::Connection {
|
||||
std::fs::create_dir_all(dir.join(".svn")).unwrap();
|
||||
let connection = rusqlite::Connection::open(dir.join(".svn").join("wc.db")).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE nodes (
|
||||
local_relpath TEXT, op_depth INTEGER, presence TEXT, kind TEXT,
|
||||
checksum TEXT, recorded_size INTEGER, recorded_time INTEGER
|
||||
);
|
||||
CREATE TABLE actual_node (local_relpath TEXT, conflict_data BLOB);",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
}
|
||||
|
||||
fn insert_node(
|
||||
connection: &rusqlite::Connection,
|
||||
relpath: &str,
|
||||
op_depth: i64,
|
||||
presence: &str,
|
||||
kind: &str,
|
||||
checksum: Option<String>,
|
||||
size: Option<i64>,
|
||||
time: Option<i64>,
|
||||
) {
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO nodes VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
rusqlite::params![relpath, op_depth, presence, kind, checksum, size, time],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn sha1_of(content: &str) -> String {
|
||||
let digest = Sha1::digest(content.as_bytes());
|
||||
let mut hex = String::new();
|
||||
for byte in digest {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
format!("$sha1${hex}")
|
||||
}
|
||||
|
||||
fn state_of<'a>(changes: &'a [SvnChange], path: &str) -> Option<&'a SvnChange> {
|
||||
changes.iter().find(|change| change.rel_path == path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_repository_and_working_copy() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repo = temp.path().join("repo");
|
||||
for sub in ["conf", "db", "hooks"] {
|
||||
std::fs::create_dir_all(repo.join(sub)).unwrap();
|
||||
}
|
||||
std::fs::write(repo.join("format"), "8\n").unwrap();
|
||||
assert_eq!(classify_dir(&repo), SvnDirKind::Repository);
|
||||
|
||||
let wc = temp.path().join("wc");
|
||||
fake_working_copy(&wc);
|
||||
let inner = wc.join("src");
|
||||
std::fs::create_dir_all(&inner).unwrap();
|
||||
assert_eq!(classify_dir(&inner), SvnDirKind::WorkingCopy(wc.clone()));
|
||||
|
||||
assert_eq!(classify_dir(temp.path()), SvnDirKind::Plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_the_full_state_alphabet_from_wc_db() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let connection = fake_working_copy(root);
|
||||
|
||||
insert_node(&connection, "", 0, "normal", "dir", None, None, None);
|
||||
// 干净文件:大小/时间都与记录一致。
|
||||
std::fs::write(root.join("clean.txt"), "clean").unwrap();
|
||||
let clean_meta = std::fs::metadata(root.join("clean.txt")).unwrap();
|
||||
let clean_micros = clean_meta
|
||||
.modified()
|
||||
.unwrap()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_micros() as i64;
|
||||
insert_node(
|
||||
&connection,
|
||||
"clean.txt",
|
||||
0,
|
||||
"normal",
|
||||
"file",
|
||||
Some(sha1_of("clean")),
|
||||
Some(5),
|
||||
Some(clean_micros),
|
||||
);
|
||||
// touch 过但内容相同:时间戳不同 + SHA-1 相同 → 不报告。
|
||||
std::fs::write(root.join("touched.txt"), "same!").unwrap();
|
||||
insert_node(
|
||||
&connection,
|
||||
"touched.txt",
|
||||
0,
|
||||
"normal",
|
||||
"file",
|
||||
Some(sha1_of("same!")),
|
||||
Some(5),
|
||||
Some(1),
|
||||
);
|
||||
// 真改过:大小不同。
|
||||
std::fs::write(root.join("changed.txt"), "different content").unwrap();
|
||||
insert_node(
|
||||
&connection,
|
||||
"changed.txt",
|
||||
0,
|
||||
"normal",
|
||||
"file",
|
||||
Some(sha1_of("old")),
|
||||
Some(3),
|
||||
Some(1),
|
||||
);
|
||||
// 新增 / 替换 / 删除 / 丢失。
|
||||
std::fs::write(root.join("added.txt"), "new").unwrap();
|
||||
insert_node(&connection, "added.txt", 1, "normal", "file", None, None, None);
|
||||
std::fs::write(root.join("replaced.txt"), "re-added").unwrap();
|
||||
insert_node(&connection, "replaced.txt", 0, "normal", "file", None, Some(1), Some(1));
|
||||
insert_node(&connection, "replaced.txt", 1, "normal", "file", None, None, None);
|
||||
insert_node(&connection, "deleted.txt", 0, "normal", "file", None, Some(1), Some(1));
|
||||
insert_node(&connection, "deleted.txt", 1, "base-deleted", "file", None, None, None);
|
||||
insert_node(
|
||||
&connection,
|
||||
"missing.txt",
|
||||
0,
|
||||
"normal",
|
||||
"file",
|
||||
None,
|
||||
Some(1),
|
||||
Some(1),
|
||||
);
|
||||
// 冲突覆盖其他状态。
|
||||
std::fs::write(root.join("conflict.txt"), "x").unwrap();
|
||||
insert_node(&connection, "conflict.txt", 0, "normal", "file", None, Some(1), Some(1));
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO actual_node VALUES ('conflict.txt', x'00')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// 未版本化的散文件。
|
||||
std::fs::write(root.join("stray.txt"), "?").unwrap();
|
||||
drop(connection);
|
||||
|
||||
let changes = working_copy_status(root).unwrap();
|
||||
|
||||
assert!(state_of(&changes, "clean.txt").is_none(), "clean file must stay silent");
|
||||
assert!(state_of(&changes, "touched.txt").is_none(), "touch without edit must stay silent");
|
||||
assert_eq!(state_of(&changes, "changed.txt").unwrap().state, SvnState::Modified);
|
||||
assert_eq!(state_of(&changes, "added.txt").unwrap().state, SvnState::Added);
|
||||
assert_eq!(state_of(&changes, "replaced.txt").unwrap().state, SvnState::Replaced);
|
||||
assert_eq!(state_of(&changes, "deleted.txt").unwrap().state, SvnState::Deleted);
|
||||
assert_eq!(state_of(&changes, "missing.txt").unwrap().state, SvnState::Missing);
|
||||
assert_eq!(state_of(&changes, "conflict.txt").unwrap().state, SvnState::Conflicted);
|
||||
assert_eq!(state_of(&changes, "stray.txt").unwrap().state, SvnState::Unversioned);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,16 @@ use crate::event::{Event, EventType};
|
||||
use crate::message_bar::{Message, MessageType};
|
||||
|
||||
const RELEASES_API: &str = "https://api.github.com/repos/Kuddev/nebula/releases/latest";
|
||||
const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases";
|
||||
pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases";
|
||||
|
||||
/// 设置主页手动检查所需的完整结果。启动检查与手动检查共用这一份版本
|
||||
/// 比较,避免两处对预发布后缀或分段数字产生不同结论。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UpdateCheckResult {
|
||||
pub current: String,
|
||||
pub latest: String,
|
||||
pub update_available: bool,
|
||||
}
|
||||
|
||||
/// Kick off the once-per-process background check. The result (if any)
|
||||
/// arrives as a regular [`EventType::Message`] banner, which the message bar
|
||||
@@ -28,7 +37,13 @@ pub fn spawn_once(proxy: EventLoopProxy<Event>) {
|
||||
let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || {
|
||||
// 等窗口与首个会话安顿好再查,别和启动抢磁盘/网络。
|
||||
std::thread::sleep(Duration::from_secs(12));
|
||||
let Some(latest) = fetch_latest_version() else { return };
|
||||
let latest = match fetch_latest_version() {
|
||||
Ok(latest) => latest,
|
||||
Err(error) => {
|
||||
log::debug!("update-check: {error}");
|
||||
return;
|
||||
},
|
||||
};
|
||||
let current = env!("CARGO_PKG_VERSION");
|
||||
if !is_newer(&latest, current) {
|
||||
log::debug!("update-check: v{current} is current (latest v{latest})");
|
||||
@@ -45,7 +60,15 @@ pub fn spawn_once(proxy: EventLoopProxy<Event>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_latest_version() -> Option<String> {
|
||||
/// 立即检查 GitHub 最新 release;调用方必须把它放到后台执行器,避免
|
||||
/// `curl` 的网络等待阻塞 UI 线程。
|
||||
pub fn check_now() -> Result<UpdateCheckResult, String> {
|
||||
let latest = fetch_latest_version()?;
|
||||
let current = env!("CARGO_PKG_VERSION").to_owned();
|
||||
Ok(UpdateCheckResult { update_available: is_newer(&latest, ¤t), current, latest })
|
||||
}
|
||||
|
||||
fn fetch_latest_version() -> Result<String, String> {
|
||||
let mut command = Command::new("curl");
|
||||
command.args([
|
||||
"-fsSL",
|
||||
@@ -63,14 +86,28 @@ fn fetch_latest_version() -> Option<String> {
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let output = command.output().ok()?;
|
||||
let output = command.output().map_err(|error| format!("无法启动 curl:{error}"))?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
let detail = String::from_utf8_lossy(&output.stderr);
|
||||
let detail = detail.trim();
|
||||
return Err(if detail.is_empty() {
|
||||
format!("GitHub 请求失败(curl {})", output.status)
|
||||
} else {
|
||||
format!("GitHub 请求失败:{detail}")
|
||||
});
|
||||
}
|
||||
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
|
||||
let tag = json.get("tag_name")?.as_str()?;
|
||||
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("GitHub 返回了无效数据:{error}"))?;
|
||||
let tag = json
|
||||
.get("tag_name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "GitHub release 缺少 tag_name".to_owned())?;
|
||||
let version = tag.trim().trim_start_matches(['v', 'V']);
|
||||
(!version.is_empty()).then(|| version.to_owned())
|
||||
if version.is_empty() {
|
||||
Err("GitHub release 的版本号为空".to_owned())
|
||||
} else {
|
||||
Ok(version.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare dotted numeric prefixes ("0.7.10" > "0.7.9"); anything after the
|
||||
|
||||
@@ -2177,6 +2177,9 @@ impl WindowContext {
|
||||
state.agent_hook_seen = true;
|
||||
state.agent_status_source = crate::ai_agents::AgentStatusSource::Hook;
|
||||
state.agent_status_rule = None;
|
||||
// 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的拍数
|
||||
// 不能把新回合的第一个空闲闪现立即降级)。
|
||||
state.idle_screen_streak = 0;
|
||||
// 会话身份跟着事件走:同一个 pane 里 /clear、重开会话都会带来
|
||||
// 新 id,最后一次上报永远是权威。133;D(CLI 退回提示符)清除。
|
||||
if let Some(id) = ev.session_id.as_deref() {
|
||||
@@ -2216,13 +2219,15 @@ impl WindowContext {
|
||||
},
|
||||
crate::ai_hook::AiHookKind::ToolComplete => {
|
||||
let state = &mut self.panes[idx].nebula_state;
|
||||
// Tool completion is meaningful chiefly after a permission
|
||||
// wait: the user answered and work resumed.
|
||||
if state.agent_status == crate::ai_agents::AgentStatus::Blocked {
|
||||
state.agent_status = crate::ai_agents::AgentStatus::Working;
|
||||
state.awaiting_input = false;
|
||||
state.needs_attention = false;
|
||||
}
|
||||
// 一个工具刚跑完 = agent 正在干活,这是无条件事实。此前只
|
||||
// 认 Blocked→Working,漏掉了「回合经不发 PromptSubmit 的路径
|
||||
// 继续(授权点头、队列消息)后状态还挂在 Done」的场景——
|
||||
// 也就是用户看到的「还在执行却已经显示完成蓝点」。
|
||||
state.agent_status = crate::ai_agents::AgentStatus::Working;
|
||||
state.awaiting_input = false;
|
||||
state.needs_attention = false;
|
||||
state.finished_unseen = false;
|
||||
state.command_started.get_or_insert_with(Instant::now);
|
||||
},
|
||||
crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => {
|
||||
// codex 的 notify 只有"回合完成"一种事件:弹出交互式提问时
|
||||
@@ -2338,11 +2343,29 @@ impl WindowContext {
|
||||
};
|
||||
|
||||
let state = &mut pane.nebula_state;
|
||||
// An exact hook-done must not be downgraded merely because an idle
|
||||
// prompt is visible. Strong live blocker/working chrome may still
|
||||
// correct a missed/new event.
|
||||
if detection.status == crate::ai_agents::AgentStatus::Idle && state.agent_hook_seen {
|
||||
continue;
|
||||
// 空闲提示符降级要分三档(#「转圈不停」的根修):
|
||||
// - hook 报过的 Done/Blocked 是精确终态,不被提示符降级;
|
||||
// - Working 可能是丢了 TurnDone 的僵尸态(打断的回合没有 Stop
|
||||
// 事件),但单拍空闲可能只是重绘间隙——连续两拍才收场;
|
||||
// - 其余状态照常应用。
|
||||
if detection.status == crate::ai_agents::AgentStatus::Idle {
|
||||
if state.agent_hook_seen
|
||||
&& matches!(
|
||||
state.agent_status,
|
||||
crate::ai_agents::AgentStatus::Done
|
||||
| crate::ai_agents::AgentStatus::Blocked
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if state.agent_status == crate::ai_agents::AgentStatus::Working {
|
||||
state.idle_screen_streak = state.idle_screen_streak.saturating_add(1);
|
||||
if state.idle_screen_streak < 2 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.idle_screen_streak = 0;
|
||||
}
|
||||
let previous = state.agent_status;
|
||||
if previous != detection.status
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 文件行数预算守护(docs/project-constraints.md 第 1 条)。
|
||||
//!
|
||||
//! 单个 `.rs` 文件硬上限 2000 行;存量超标文件在 [`GRANDFATHERED`] 登记,
|
||||
//! 登记值只允许下调。测试失败的正确动作是拆文件,不是上调预算。
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 新代码红线:超过它就必须拆(docs/project-constraints.md)。
|
||||
const MAX_LINES: usize = 2000;
|
||||
|
||||
/// 存量豁免清单(相对仓库根,正斜杠路径 → 行数上限)。
|
||||
///
|
||||
/// 登记值 = 收录当天的行数加不足百行的呼吸空间:小修小补不触线,成规模
|
||||
/// 新增必须伴随拆分。拆小后同步下调或删除对应条目。
|
||||
const GRANDFATHERED: &[(&str, usize)] = &[
|
||||
("nebula_app/src/display/mod.rs", 12000),
|
||||
("nebula_app/src/display/settings.rs", 8650),
|
||||
// 2026-08-17 上调(4850 → 5150):git 树 VS Code 化 + 源码 tab 接线;
|
||||
// 下一步应把 VCS 面板拆成独立文件后下调。
|
||||
("nebula_app/src/gpui_shell/workspace.rs", 5150),
|
||||
// 2026-08-17 下调(4600 → 3600):SSH 区拆到 ssh_settings.rs 的成果锁定。
|
||||
("nebula_app/src/gpui_shell/settings_pane.rs", 3600),
|
||||
("nebula_app/src/display/command_palette.rs", 3980),
|
||||
("nebula_app/src/window_context.rs", 3800),
|
||||
("nebula_app/src/event.rs", 3780),
|
||||
("nebula_terminal/src/term/mod.rs", 3650),
|
||||
// 2026-08-17 上调(2900 → 2970):draw_overlays 几何抽成两壳共享的 plan。
|
||||
("nebula_app/src/display/terminal_math.rs", 2970),
|
||||
// 2026-08-17 上调(2680 → 2760):SVN wc.db 回退 + 单文件 stage/discard。
|
||||
("nebula_app/src/display/side_panel.rs", 2760),
|
||||
("nebula_app/src/gpui_shell/terminal/view.rs", 2540),
|
||||
("nebula_app/src/display/chrome.rs", 2550),
|
||||
("nebula_app/src/display/markdown_view.rs", 2150),
|
||||
];
|
||||
|
||||
/// 这些目录不受预算约束:构建产物、vendored 第三方代码、版本库内部。
|
||||
fn excluded(component: &str) -> bool {
|
||||
component == ".git"
|
||||
|| component == "third_party"
|
||||
|| component == "target"
|
||||
|| component.starts_with(".target")
|
||||
}
|
||||
|
||||
fn collect_rust_files(root: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// 隐藏项(.git、.tmp_* 草稿等)不是源码,不受预算约束。
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
if !excluded(&name) {
|
||||
collect_rust_files(&path, out);
|
||||
}
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_source_file_respects_its_line_budget() {
|
||||
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("nebula_app sits directly under the repo root")
|
||||
.to_path_buf();
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_rust_files(&repo_root, &mut files);
|
||||
assert!(
|
||||
files.len() > 100,
|
||||
"行数守护扫到的文件数异常({}),排除规则可能吞掉了源码目录",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut violations = Vec::new();
|
||||
let mut stale_grants = Vec::new();
|
||||
for path in files {
|
||||
let relative = path
|
||||
.strip_prefix(&repo_root)
|
||||
.expect("collected files live under the repo root")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let Ok(content) = fs::read(&path) else {
|
||||
panic!("无法读取 {relative}");
|
||||
};
|
||||
// 按换行字节计数:与编码无关,空文件为 0 行。
|
||||
let lines = content.iter().filter(|byte| **byte == b'\n').count()
|
||||
+ usize::from(!content.is_empty() && content.last() != Some(&b'\n'));
|
||||
let grant = GRANDFATHERED
|
||||
.iter()
|
||||
.find(|(name, _)| *name == relative)
|
||||
.map(|(_, budget)| *budget);
|
||||
let budget = grant.unwrap_or(MAX_LINES);
|
||||
if lines > budget {
|
||||
violations.push(format!("{relative}: {lines} 行 > 预算 {budget}"));
|
||||
}
|
||||
// 豁免文件瘦身到红线以内后,条目必须删除,防止预算复胖。
|
||||
if let Some(budget) = grant
|
||||
&& lines <= MAX_LINES
|
||||
{
|
||||
stale_grants.push(format!("{relative}: 已降到 {lines} 行(≤{MAX_LINES}),豁免条目({budget})应删除"));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"以下文件超出行数预算(docs/project-constraints.md 第 1 条,正确动作是拆文件):\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
assert!(
|
||||
stale_grants.is_empty(),
|
||||
"以下豁免条目已过期:\n{}",
|
||||
stale_grants.join("\n")
|
||||
);
|
||||
}
|
||||
@@ -550,6 +550,35 @@ impl NewTabPositionName {
|
||||
}
|
||||
}
|
||||
|
||||
/// 侧栏版本控制视图的数据源:自动探测(就近的 .svn 提示 + git 优先),
|
||||
/// 或强制只认 Git / SVN(混合仓库、或想屏蔽其中一种时用)。
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub enum VcsDisplayName {
|
||||
#[default]
|
||||
Auto,
|
||||
Git,
|
||||
Svn,
|
||||
}
|
||||
|
||||
impl VcsDisplayName {
|
||||
pub fn from_settings(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(Self::Auto),
|
||||
"git" => Some(Self::Git),
|
||||
"svn" => Some(Self::Svn),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Git => "git",
|
||||
Self::Svn => "svn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub enum CellWidthModeName {
|
||||
#[default]
|
||||
@@ -631,6 +660,8 @@ pub struct RuntimeSettings {
|
||||
pub density: DensityName,
|
||||
pub new_tab_position: NewTabPositionName,
|
||||
pub cell_width_mode: CellWidthModeName,
|
||||
/// 侧栏版本控制视图的数据源(auto/git/svn)。
|
||||
pub vcs_display: VcsDisplayName,
|
||||
/// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。
|
||||
pub fetch: bool,
|
||||
pub keep_session: bool,
|
||||
@@ -707,6 +738,10 @@ impl RuntimeSettings {
|
||||
.value("cell_width_mode")
|
||||
.and_then(CellWidthModeName::from_settings)
|
||||
.unwrap_or_default(),
|
||||
vcs_display: raw
|
||||
.value("vcs_display")
|
||||
.and_then(VcsDisplayName::from_settings)
|
||||
.unwrap_or_default(),
|
||||
fetch: raw.bool_on("fetch").unwrap_or(false),
|
||||
keep_session: raw.bool_on("keep_session").unwrap_or(false),
|
||||
restore_session: raw.bool_on("restore_session").unwrap_or(true),
|
||||
|
||||
@@ -210,6 +210,12 @@ pub struct CursorSnapshot {
|
||||
pub shape: CursorShape,
|
||||
/// Cursor sits on a wide char: draw it two cells wide.
|
||||
pub wide: bool,
|
||||
/// Glyph under the cursor. Claude Code / Codex hide DECSCUSR and draw a
|
||||
/// reverse-video space or block element; the frontend needs the cell to
|
||||
/// recolor that fake cursor (old shell `is_application_cursor_cell`).
|
||||
pub cell_ch: char,
|
||||
pub cell_flags: Flags,
|
||||
pub cell_bg: Color,
|
||||
}
|
||||
|
||||
/// A cell rendered by built-in geometry ([`boxdraw`]) instead of a font:
|
||||
@@ -389,13 +395,16 @@ impl RenderSnapshot {
|
||||
flush(&mut snap.segments, &mut open);
|
||||
|
||||
if let Some(vp) = cursor_vp {
|
||||
if vp.line < rows && vp.column.0 < cols && cursor_shape != CursorShape::Hidden {
|
||||
let wide = term.grid()[content.cursor.point].flags.contains(Flags::WIDE_CHAR);
|
||||
if vp.line < rows && vp.column.0 < cols {
|
||||
let cell = &term.grid()[content.cursor.point];
|
||||
snap.cursor = Some(CursorSnapshot {
|
||||
row: vp.line as u16,
|
||||
col: vp.column.0 as u16,
|
||||
shape: cursor_shape,
|
||||
wide,
|
||||
wide: cell.flags.contains(Flags::WIDE_CHAR),
|
||||
cell_ch: cell.c,
|
||||
cell_flags: cell.flags,
|
||||
cell_bg: cell.bg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -553,6 +562,40 @@ mod tests {
|
||||
assert!(snap.cursor.expect("cursor").wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_keeps_a_hidden_cursor_and_its_cell() {
|
||||
use crate::vte::ansi::{Handler, NamedPrivateMode};
|
||||
|
||||
let mut term = term_with(&[" "]);
|
||||
term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0));
|
||||
term.grid_mut()[Line(0)][Column(0)].flags.insert(Flags::INVERSE);
|
||||
term.unset_private_mode(NamedPrivateMode::ShowCursor.into());
|
||||
let snap = RenderSnapshot::capture(&term, &cfg(4, 8));
|
||||
let cursor = snap.cursor.expect("hidden cursor still has a cell");
|
||||
assert_eq!(cursor.shape, CursorShape::Hidden);
|
||||
assert_eq!((cursor.row, cursor.col), (0, 0));
|
||||
assert!(cursor.cell_flags.contains(Flags::INVERSE));
|
||||
assert_eq!(cursor.cell_ch, ' ');
|
||||
assert!(
|
||||
snap.bg_runs.iter().any(|run| run.row == 0 && run.start <= 0 && 0 < run.end),
|
||||
"inverse space still emits a bg run so the frontend can skip the black cell"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_keeps_decscusr_hidden_block_glyph() {
|
||||
use crate::vte::ansi::Handler;
|
||||
|
||||
let mut term = term_with(&["█"]);
|
||||
term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0));
|
||||
term.set_cursor_shape(CursorShape::Hidden);
|
||||
let snap = RenderSnapshot::capture(&term, &cfg(4, 8));
|
||||
let cursor = snap.cursor.expect("DECSCUSR hidden cursor still has a cell");
|
||||
assert_eq!(cursor.shape, CursorShape::Hidden);
|
||||
assert_eq!(cursor.cell_ch, '█');
|
||||
assert_eq!(snap.box_glyphs.iter().map(|g| g.ch).collect::<Vec<_>>(), vec!['█']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_routes_builtin_glyphs_to_geometry() {
|
||||
let term = term_with(&["a─█b"]);
|
||||
|
||||
+39
-3
@@ -24,6 +24,32 @@ public class W {
|
||||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
|
||||
[DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
|
||||
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
|
||||
public delegate bool EnumProc(IntPtr h, IntPtr lparam);
|
||||
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lparam);
|
||||
// Process.MainWindowHandle picks whatever top-level window answers first --
|
||||
// for nebula that is a 6x6 helper window (and a minimized one reports
|
||||
// -32000,-32000). Enumerate instead and keep the largest visible window
|
||||
// owned by the pid: that is the real shell window in both shells.
|
||||
public static IntPtr FindMainWindow(int pid) {
|
||||
IntPtr best = IntPtr.Zero;
|
||||
long bestArea = 0;
|
||||
EnumWindows(delegate(IntPtr h, IntPtr l) {
|
||||
uint wpid;
|
||||
GetWindowThreadProcessId(h, out wpid);
|
||||
if (wpid != (uint)pid) return true;
|
||||
if (!IsWindowVisible(h)) return true;
|
||||
RECT r;
|
||||
GetWindowRect(h, out r);
|
||||
long area = (long)(r.R - r.L) * (long)(r.B - r.T);
|
||||
// Minimized windows sit at -32000; never let one win the comparison.
|
||||
if (r.L <= -30000 || r.T <= -30000) return true;
|
||||
if (area > bestArea) { bestArea = area; best = h; }
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return best;
|
||||
}
|
||||
public struct RECT { public int L, T, R, B; }
|
||||
}
|
||||
'@
|
||||
@@ -45,8 +71,14 @@ if ($ProcId -eq 0) {
|
||||
$proc = Get-Process -Id $ProcId -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { Write-Output "PROCESS $ProcId GONE"; exit 1 }
|
||||
if ($Kill) { $proc.Kill(); Write-Output "killed $ProcId"; exit 0 }
|
||||
$h = $proc.MainWindowHandle
|
||||
if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 }
|
||||
$h = [W]::FindMainWindow($ProcId)
|
||||
if ($h -eq [IntPtr]::Zero) {
|
||||
# Fall back to the (unreliable) handle so a minimized-only state still
|
||||
# reports something actionable instead of looking like a dead process.
|
||||
$h = $proc.MainWindowHandle
|
||||
if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 }
|
||||
Write-Output "WARN: no visible top-level window; falling back to MainWindowHandle"
|
||||
}
|
||||
$r = New-Object W+RECT
|
||||
[void][W]::GetWindowRect($h, [ref]$r)
|
||||
Write-Output "pid=$ProcId rect=($($r.L),$($r.T),$($r.R),$($r.B)) size=$($r.R-$r.L)x$($r.B-$r.T) dpi=$([W]::GetDpiForWindow($h))"
|
||||
@@ -80,7 +112,11 @@ if ($RightClick -ne "") {
|
||||
if ($Scroll -ne 0) {
|
||||
$sx = [int]($r.L + ($r.R - $r.L) * 0.6); $sy = [int]($r.T + ($r.B - $r.T) * 0.5)
|
||||
[void][W]::SetCursorPos($sx, $sy); Start-Sleep -Milliseconds 150
|
||||
[W]::mouse_event(0x0800, 0, 0, [uint32]($Scroll * 120), [UIntPtr]::Zero)
|
||||
# WHEEL_DELTA is signed (negative scrolls down) but the P/Invoke signature
|
||||
# declares dwData as uint: casting a negative int straight to [uint32]
|
||||
# throws InvalidCast. Mask to the two's-complement bit pattern instead.
|
||||
$wheel = [uint32]([int64]($Scroll * 120) -band 0xFFFFFFFFL)
|
||||
[W]::mouse_event(0x0800, 0, 0, $wheel, [UIntPtr]::Zero)
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($CtrlScroll -ne 0) {
|
||||
|
||||
Reference in new issue
Block a user