mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge remote-tracking branch 'origin/main' into dependabot/cargo/resvg-0.47.0
This commit is contained in:
@@ -5,6 +5,50 @@ All notable changes to tty7 are documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Panes are told which terminal they're running in** — every pane now carries
|
||||
`TERM_PROGRAM=tty7` and `TERM_PROGRAM_VERSION`, the de-facto standard pair
|
||||
Apple Terminal introduced and iTerm2, WezTerm, Ghostty, VS Code and tmux all
|
||||
set. `TERM` names terminfo capabilities and can't answer "which program is
|
||||
this", so without the pair, capability probes (`supports-color`,
|
||||
`supports-hyperlinks`, and the CLI ecosystem built on them), editors applying
|
||||
terminal-specific workarounds, and shell prompts all fell back to their most
|
||||
conservative behaviour. tty7's own `TTY7` marker doesn't help them — it exists
|
||||
so globally-installed agent hooks stay silent in other terminals, and nothing
|
||||
third-party knows to look for it. Unlike `TERM` and `COLORTERM`, both new
|
||||
variables can be overridden from `env` in `config.json`: they name an
|
||||
identity, not a capability, and posing as another terminal is a legitimate way
|
||||
to get a tool that only recognises a fixed list to light up. Local panes only
|
||||
— ssh forwards environment variables solely by agreement between client and
|
||||
server, so a remote host still sees whatever it sets for itself. (#212)
|
||||
|
||||
- **Inactive panes only fade if you want them to** — a split tab dims every pane
|
||||
but the focused one so the active terminal reads as foreground. That is the
|
||||
right default, but it is not free: at 55% opacity a dim theme's comment color
|
||||
or a long-running build's output in the pane you are *watching* rather than
|
||||
typing into gets harder to read, and some people track panes by cursor alone
|
||||
and never needed the cue. Settings → Appearance → Transparency now carries a
|
||||
"Dim inactive panes" switch. On by default, so nothing changes for anyone who
|
||||
was happy; off renders every pane at full opacity. (#214)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Italic CJK rendered as unrelated CJK on Windows** — every character came out
|
||||
as a different character, one for one, consistently, so it read as a broken
|
||||
locale or a mangled encoding. It was neither. Hack, the bundled default, has no
|
||||
CJK, so those cells are shaped by the font-fallback chain; gpui's Windows
|
||||
backend then threw away the face DirectWrite shaped with and looked a fresh one
|
||||
up by family, weight and style. That round trip mapped DirectWrite's *italic*
|
||||
to *oblique* — the two are numbered the other way around in the API — and a
|
||||
family with no oblique face resolved to its upright one. The glyph indices were
|
||||
right; the outlines they were pointing into belonged to a different face. Fixed
|
||||
in our gpui fork by rasterizing the face DirectWrite actually chose, which also
|
||||
closes a latent use-after-free in the same cache: it keyed fonts by a raw
|
||||
pointer to a face nothing held a reference to.
|
||||
|
||||
## [26.7.5] - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+33
-33
@@ -1369,7 +1369,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "collections"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"gpui_util",
|
||||
"indexmap",
|
||||
@@ -1927,7 +1927,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "derive_refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3096,7 +3096,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui"
|
||||
version = "0.2.2"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
@@ -3287,7 +3287,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_linux"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_unix",
|
||||
@@ -3338,7 +3338,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macos"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_macos",
|
||||
@@ -3385,7 +3385,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -3396,7 +3396,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_platform"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"gpui",
|
||||
@@ -3409,7 +3409,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_shared_string"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -3419,7 +3419,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
@@ -3428,7 +3428,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_web"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console_error_panic_hook",
|
||||
@@ -3452,7 +3452,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_wgpu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
@@ -3481,7 +3481,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "gpui_windows"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_windows",
|
||||
@@ -3800,7 +3800,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-compression",
|
||||
@@ -3825,7 +3825,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "http_client_tls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
@@ -4035,9 +4035,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.26"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d"
|
||||
checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
@@ -4607,9 +4607,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libflate"
|
||||
@@ -4964,7 +4964,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
|
||||
[[package]]
|
||||
name = "media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bindgen",
|
||||
@@ -6062,7 +6062,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
[[package]]
|
||||
name = "perf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"collections",
|
||||
"serde",
|
||||
@@ -7048,7 +7048,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "refineable"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"derive_refineable",
|
||||
]
|
||||
@@ -7091,7 +7091,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||
[[package]]
|
||||
name = "reqwest_client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -7639,7 +7639,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "scheduler"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"backtrace",
|
||||
@@ -8446,7 +8446,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
[[package]]
|
||||
name = "sum_tree"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"heapless",
|
||||
"log",
|
||||
@@ -8959,9 +8959,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.0"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -9221,9 +9221,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.24.1"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
|
||||
checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dirs",
|
||||
@@ -9883,7 +9883,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
[[package]]
|
||||
name = "util"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-fs",
|
||||
@@ -9922,7 +9922,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "util_macros"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"perf",
|
||||
"quote",
|
||||
@@ -11728,7 +11728,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "zlog"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -11773,7 +11773,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ztracing"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -11784,7 +11784,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ztracing_macro"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#24ed55f4cf1c6f90c2fb4a1db0f33aca949a7f02"
|
||||
source = "git+https://github.com/l0ng-ai/zed?branch=tty7#87ce3e2d5c16d5704a45196a761c8809e4a386c7"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
|
||||
+15
-6
@@ -320,12 +320,21 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# ---- gpui fork ------------------------------------------------------------
|
||||
# Our `tty7` branch (cut from the pinned upstream rev, one commit on top) carries
|
||||
# a single patch: `prefers_ime_for_printable_keys` takes the keystroke, so an
|
||||
# input handler can answer per key instead of per view. tty7 needs it for
|
||||
# Option-as-Meta — macOS routes ⌥-chords to the IME whenever a CJK input source
|
||||
# is active, and without the keystroke there is no way to decline just those
|
||||
# chords (see `terminal::input::prefers_ime_for_printable_keys`, issue #177).
|
||||
# Our `tty7` branch (cut from the pinned upstream rev, two commits on top) carries:
|
||||
#
|
||||
# 1. `prefers_ime_for_printable_keys` takes the keystroke, so an input handler can
|
||||
# answer per key instead of per view. tty7 needs it for Option-as-Meta — macOS
|
||||
# routes ⌥-chords to the IME whenever a CJK input source is active, and without
|
||||
# the keystroke there is no way to decline just those chords (see
|
||||
# `terminal::input::prefers_ime_for_printable_keys`, issue #177).
|
||||
#
|
||||
# 2. gpui's Windows backend rasterizes a font-fallback run with the face
|
||||
# DirectWrite actually shaped it with, instead of re-deriving one from the
|
||||
# face's family/weight/style. The round trip mapped DirectWrite's italic to
|
||||
# oblique, so italic CJK — which every pane reaches through the fallback chain,
|
||||
# Hack having no CJK — drew a *different* face's outlines at the shaped glyph
|
||||
# indices. Every character rendered as an unrelated character, one for one,
|
||||
# which reads as mojibake rather than as a font bug.
|
||||
#
|
||||
# Patching by source rather than editing the `gpui`/`gpui_platform` pins above is
|
||||
# deliberate: `gpui-component` declares its own `gpui` from the upstream URL, and
|
||||
|
||||
@@ -41,7 +41,7 @@ Native builds for each platform on [**Releases**](https://github.com/l0ng-ai/tty
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · <kbd>⌃ R</kbd> fuzzy history |
|
||||
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · eight themes · IME |
|
||||
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · nine themes · IME |
|
||||
| **Coding agents** | per-pane agent detection (~17 CLIs): status dot, notifications, branch + diff, resume after reboot, tray icon that signals "needs your input" |
|
||||
| **SSH** | native russh stack: profiles with keychain secrets, SFTP panel, port forwarding, jump hosts |
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
- **Command palette** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
|
||||
- **⌘/Ctrl-click links** (⌘ on macOS, Ctrl on Windows/Linux) · desktop notifications · copy on select (opt-in, Settings → Terminal → Clipboard)
|
||||
- **Smart double-click selection** — double-click grabs the whole URL, file path, bracket/quote pair, or dictionary-segmented CJK word under the cursor; Shift-click extends a selection (toggle in Settings → Terminal → Mouse; word separators via `word_separators` in `config.json`)
|
||||
- **Eight themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker
|
||||
- **Nine themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker
|
||||
- **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`)
|
||||
- **Window opacity & blur** — Settings → Appearance → Window; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur`
|
||||
- **CJK / IME input**
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- **命令面板** <kbd>⌘ P</kbd> · 回滚搜索 <kbd>⌘ F</kbd>
|
||||
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板)
|
||||
- **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置)
|
||||
- **8 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择
|
||||
- **9 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择
|
||||
- **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`)
|
||||
- **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur`
|
||||
- **CJK / 输入法输入**
|
||||
|
||||
@@ -60,6 +60,11 @@ pub struct Config {
|
||||
pub window_opacity: Option<f32>,
|
||||
/// Global window-blur override. `None` follows the active theme's `blur`.
|
||||
pub window_blur: Option<bool>,
|
||||
/// Fade unfocused panes in a split tab so the focused terminal reads as
|
||||
/// foreground. On by default; when off every pane renders at full opacity
|
||||
/// and only focus (cursor, etc.) distinguishes the active one.
|
||||
#[serde(default = "default_true")]
|
||||
pub dim_inactive_panes: bool,
|
||||
/// Optional keybinding overrides: action name (e.g. "NewTab") → keystroke
|
||||
/// (e.g. "secondary-t", which is ⌘ on macOS and Ctrl elsewhere). Unknown
|
||||
/// actions and unparseable keystrokes are ignored (with a warning) so a bad
|
||||
@@ -596,6 +601,7 @@ impl Default for Config {
|
||||
theme_preset_dark: "dark".to_string(),
|
||||
window_opacity: None,
|
||||
window_blur: None,
|
||||
dim_inactive_panes: true,
|
||||
keybindings: HashMap::new(),
|
||||
keybinding_preset: default_preset(),
|
||||
prefix: default_prefix(),
|
||||
@@ -1089,6 +1095,25 @@ mod tests {
|
||||
assert!(!newer.confirm_window_close);
|
||||
}
|
||||
|
||||
/// Also opt-*out*: every config written before the switch existed predates
|
||||
/// the choice, and those users have been looking at dimmed panes all along —
|
||||
/// defaulting to `false` would silently change how every split tab looks on
|
||||
/// upgrade. And once someone does turn it off, the `false` has to survive a
|
||||
/// save/load cycle, or the effect they opted out of returns on next launch.
|
||||
#[test]
|
||||
fn dim_inactive_panes_defaults_on_and_round_trips() {
|
||||
assert!(Config::default().dim_inactive_panes);
|
||||
|
||||
let old: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
|
||||
assert!(old.dim_inactive_panes);
|
||||
|
||||
let off: Config = serde_json::from_str(r#"{"dim_inactive_panes": false}"#).unwrap();
|
||||
assert!(!off.dim_inactive_panes);
|
||||
let json = serde_json::to_string(&off).unwrap();
|
||||
let back: Config = serde_json::from_str(&json).unwrap();
|
||||
assert!(!back.dim_inactive_panes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_follow_system_defaults_and_round_trips() {
|
||||
// Old configs (no follow-system keys) must land on off + the built-in
|
||||
|
||||
+174
-18
@@ -422,29 +422,84 @@ fn system_locale_identifier() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What tty7 answers to in `TERM_PROGRAM`. Terminals name themselves in the
|
||||
/// form they brand themselves in — `Apple_Terminal`, `iTerm.app`, `WezTerm`,
|
||||
/// `ghostty`, `vscode` — so ours is the lowercase product name.
|
||||
const TERM_PROGRAM_NAME: &str = "tty7";
|
||||
|
||||
/// Env keys that describe our emulator's real capabilities. A user's `env` map
|
||||
/// must not override these: the answer isn't a preference, it's a fact about
|
||||
/// what the pane on the other end can decode.
|
||||
const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"];
|
||||
|
||||
/// Whether a configured `env` key names one of [`CAPABILITY_ENV`]. Windows
|
||||
/// environment blocks are case-insensitive — `portable-pty` keeps one slot per
|
||||
/// lowercased key, so a configured `Term` there would replace `TERM` just as
|
||||
/// surely as the exact spelling — so the filter must use the platform's own
|
||||
/// notion of "the same variable". On Unix a differently-cased key is a genuinely
|
||||
/// distinct variable and stays the user's to set.
|
||||
fn names_capability_env(key: &str) -> bool {
|
||||
CAPABILITY_ENV.iter().any(|cap| {
|
||||
if cfg!(windows) {
|
||||
key.eq_ignore_ascii_case(cap)
|
||||
} else {
|
||||
key == *cap
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The environment every pane starts with, in application order — tty7's own
|
||||
/// advertisements first, then the user's `env` map, which overrides all but
|
||||
/// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the
|
||||
/// precedence is testable without a `CommandBuilder` or a real `config.json`.
|
||||
fn pane_environment(
|
||||
extra_env: &std::collections::HashMap<String, String>,
|
||||
) -> Vec<(String, String)> {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let mut env = vec![
|
||||
// A widely-available terminfo + truecolor.
|
||||
("TERM".to_string(), "xterm-256color".to_string()),
|
||||
("COLORTERM".to_string(), "truecolor".to_string()),
|
||||
// Mark the session as tty7's, for tooling that adapts to its host
|
||||
// terminal — most importantly the `tty7 agent-hook` emitter, which
|
||||
// stays silent without it so globally-installed agent hooks can't leak
|
||||
// escape sequences into other terminals (see `core::agent_hooks`).
|
||||
(
|
||||
crate::core::agent_hooks::TTY7_ENV_MARKER.to_string(),
|
||||
version.to_string(),
|
||||
),
|
||||
// The de-facto standard pair for "which terminal is this": Apple
|
||||
// Terminal introduced it, and iTerm2, WezTerm, Ghostty, VS Code and
|
||||
// tmux all set it. `TERM` describes terminfo capabilities and can't
|
||||
// answer this — but capability probes (`supports-color`,
|
||||
// `supports-hyperlinks` and the JS CLI ecosystem built on them),
|
||||
// editors applying terminal-specific workarounds, and shell prompts all
|
||||
// branch on the program name, falling back to their most conservative
|
||||
// behaviour when it's missing. `TTY7` doesn't help them: it's ours, and
|
||||
// nothing third-party knows to look for it.
|
||||
//
|
||||
// Deliberately overridable below, unlike the capability keys: this
|
||||
// names an identity, and posing as another terminal is a legitimate way
|
||||
// to get a tool that only recognises a fixed list to light up.
|
||||
("TERM_PROGRAM".to_string(), TERM_PROGRAM_NAME.to_string()),
|
||||
("TERM_PROGRAM_VERSION".to_string(), version.to_string()),
|
||||
];
|
||||
env.extend(
|
||||
extra_env
|
||||
.iter()
|
||||
.filter(|(k, _)| !names_capability_env(k))
|
||||
.map(|(k, v)| (k.clone(), v.clone())),
|
||||
);
|
||||
env
|
||||
}
|
||||
|
||||
fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<PathBuf>) {
|
||||
if let Some(dir) = initial_cwd {
|
||||
cmd.cwd(dir);
|
||||
}
|
||||
// Advertise a widely-available terminfo + truecolor.
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
// Mark the session as tty7's, for tooling that adapts to its host terminal
|
||||
// — most importantly the `tty7 agent-hook` emitter, which stays silent
|
||||
// without it so globally-installed agent hooks can't leak escape sequences
|
||||
// into other terminals (see `core::agent_hooks`).
|
||||
cmd.env(
|
||||
crate::core::agent_hooks::TTY7_ENV_MARKER,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
);
|
||||
|
||||
// User-configured environment variables override inherited values (but not
|
||||
// TERM/COLORTERM above, which reflect our emulator's real capabilities).
|
||||
let extra_env = crate::core::config::extra_env();
|
||||
for (k, v) in &extra_env {
|
||||
if k != "TERM" && k != "COLORTERM" {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
for (k, v) in pane_environment(&extra_env) {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
// LaunchServices commonly starts a macOS app with no locale variables at
|
||||
@@ -4042,6 +4097,107 @@ mod tests {
|
||||
assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once");
|
||||
}
|
||||
|
||||
/// Every pane is told which terminal it is running in, under the names the
|
||||
/// rest of the world reads (`TERM_PROGRAM`/`TERM_PROGRAM_VERSION`) as well
|
||||
/// as our own `TTY7` marker. Nothing third-party looks for the marker, so
|
||||
/// dropping the standard pair would leave capability probes guessing.
|
||||
#[test]
|
||||
fn pane_environment_advertises_the_terminal_under_the_standard_names() {
|
||||
let env: std::collections::HashMap<_, _> =
|
||||
pane_environment(&std::collections::HashMap::new())
|
||||
.into_iter()
|
||||
.collect();
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
assert_eq!(env.get("TERM_PROGRAM").map(String::as_str), Some("tty7"));
|
||||
assert_eq!(
|
||||
env.get("TERM_PROGRAM_VERSION").map(String::as_str),
|
||||
Some(version)
|
||||
);
|
||||
assert_eq!(
|
||||
env.get(crate::core::agent_hooks::TTY7_ENV_MARKER)
|
||||
.map(String::as_str),
|
||||
Some(version)
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("TERM").map(String::as_str),
|
||||
Some("xterm-256color"),
|
||||
"terminfo name is what the pane's decoder actually implements"
|
||||
);
|
||||
}
|
||||
|
||||
/// The user's `env` map may rename the terminal — posing as another program
|
||||
/// is how you get a tool that only recognises a fixed list to light up —
|
||||
/// but it may not contradict what our emulator can decode. Later entries
|
||||
/// win, so the ordering is the precedence.
|
||||
#[test]
|
||||
fn pane_environment_lets_configured_env_override_identity_but_not_capability() {
|
||||
let configured = [
|
||||
("TERM_PROGRAM", "iTerm.app"),
|
||||
("TERM_PROGRAM_VERSION", "3.5.0"),
|
||||
("TERM", "dumb"),
|
||||
("COLORTERM", ""),
|
||||
("EDITOR", "hx"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||
.collect();
|
||||
|
||||
let applied: std::collections::HashMap<_, _> =
|
||||
pane_environment(&configured).into_iter().collect();
|
||||
|
||||
assert_eq!(
|
||||
applied.get("TERM_PROGRAM").map(String::as_str),
|
||||
Some("iTerm.app")
|
||||
);
|
||||
assert_eq!(
|
||||
applied.get("TERM_PROGRAM_VERSION").map(String::as_str),
|
||||
Some("3.5.0")
|
||||
);
|
||||
assert_eq!(applied.get("EDITOR").map(String::as_str), Some("hx"));
|
||||
assert_eq!(
|
||||
applied.get("TERM").map(String::as_str),
|
||||
Some("xterm-256color")
|
||||
);
|
||||
assert_eq!(
|
||||
applied.get("COLORTERM").map(String::as_str),
|
||||
Some("truecolor")
|
||||
);
|
||||
}
|
||||
|
||||
/// Windows environment blocks are case-insensitive — `portable-pty` keeps
|
||||
/// one slot per lowercased key — so a configured `Term` would replace
|
||||
/// `TERM` just as surely as the exact spelling. The capability filter must
|
||||
/// therefore drop any casing of a capability key, not just the canonical
|
||||
/// one. (On Unix a differently-cased key is a distinct variable and passes
|
||||
/// through untouched.)
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() {
|
||||
let configured = [("Term", "dumb"), ("ColorTerm", ""), ("term_program", "x")]
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||
.collect();
|
||||
|
||||
let applied = pane_environment(&configured);
|
||||
|
||||
assert!(
|
||||
!applied.iter().any(|(k, _)| k == "Term" || k == "ColorTerm"),
|
||||
"a recased capability key must be filtered out, or it would land \
|
||||
in the same case-folded slot and win by coming later"
|
||||
);
|
||||
let get = |key: &str| {
|
||||
applied
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
};
|
||||
assert_eq!(get("TERM"), Some("xterm-256color"));
|
||||
assert_eq!(get("COLORTERM"), Some("truecolor"));
|
||||
// Identity keys stay overridable in any casing the user spells.
|
||||
assert_eq!(get("term_program"), Some("x"));
|
||||
}
|
||||
|
||||
/// The macOS UTF-8 fallback applies only when the inherited environment has
|
||||
/// no locale and the user has not taken control through the generic `env`
|
||||
/// map. Key presence is authoritative there, including an empty value.
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
//! Native box-drawing: the U+2500–U+257F box characters and U+2580–U+259F
|
||||
//! block elements, drawn as geometry sized to the actual cell instead of as
|
||||
//! font glyphs.
|
||||
//!
|
||||
//! Why the font can't do this job: a glyph fills (at most) the font's own line
|
||||
//! height, but the cell it paints into is `font_size × Config::line_height` —
|
||||
//! 1.4 by default. At any line height above 1.0 a `│` covers only the middle of
|
||||
//! its cell, so every vertical run of box characters breaks into dashes with a
|
||||
//! gap at each row boundary: a two-line shell prompt's `╭`/`╰` no longer
|
||||
//! connect, a TUI frame is perforated down both sides. Horizontal continuity
|
||||
//! has the same problem in miniature whenever a fallback face's advance
|
||||
//! disagrees with the cell width.
|
||||
//!
|
||||
//! Drawing the range natively pins every stroke to the cell's real edges, so
|
||||
//! adjacent cells join seamlessly at any line height, any font, any fallback
|
||||
//! chain. This is the same special case every terminal with a line-height
|
||||
//! setting ships (kitty, alacritty, WezTerm, iTerm2), and the same approach the
|
||||
//! Powerline separators in `element.rs` already use — they skip fonts entirely.
|
||||
//!
|
||||
//! [`glyph`] returns the character's ink as rectangles and filled paths in cell
|
||||
//! coordinates; `paint_glyphs` fills them with the cell's foreground. A char
|
||||
//! outside the range returns `None` and falls back to the font.
|
||||
|
||||
use gpui::{Bounds, Pixels, point, px, size};
|
||||
|
||||
/// One paintable piece of a box-drawing glyph.
|
||||
pub(crate) enum Ink {
|
||||
/// A solid rectangle in the cell's foreground color.
|
||||
Rect(Bounds<Pixels>),
|
||||
/// A rectangle at a fraction of the foreground's alpha — the ░▒▓ shades,
|
||||
/// which fake their dither by translucency exactly as WezTerm does.
|
||||
Shade(Bounds<Pixels>, f32),
|
||||
/// A filled path — rounded corners and diagonals, the two shapes a
|
||||
/// rectangle can't express.
|
||||
Path(gpui::Path<Pixels>),
|
||||
}
|
||||
|
||||
/// The ink for `c` sized to `bounds`, or `None` for anything that isn't a
|
||||
/// box-drawing/block character (which then renders through the font).
|
||||
///
|
||||
/// `scale` is the window's device scale factor. Every straight stroke is
|
||||
/// snapped to the *device pixel* grid it implies — not for crispness alone,
|
||||
/// but for continuity: a cell boundary at a fractional device pixel gets an
|
||||
/// antialiasing ramp on both sides, and two abutting 50%-coverage edges
|
||||
/// composite to 75% opacity, which perforated every multi-row `│` with a
|
||||
/// lighter band at each row boundary. Snapped edges rasterize with no ramp at
|
||||
/// all, so adjacent cells butt into one continuous solid — the same reason
|
||||
/// kitty's cell-aligned box bitmaps tile seamlessly.
|
||||
pub(crate) fn glyph(c: char, bounds: Bounds<Pixels>, scale: f32) -> Option<Vec<Ink>> {
|
||||
if !('\u{2500}'..='\u{259f}').contains(&c) {
|
||||
return None;
|
||||
}
|
||||
let g = Cell::new(&bounds, scale);
|
||||
if let Some((u, d, l, r)) = arms_of(c) {
|
||||
return Some(g.arms(u, d, l, r));
|
||||
}
|
||||
g.doubles(c)
|
||||
.or_else(|| g.rounded(c))
|
||||
.or_else(|| g.dashed(c))
|
||||
.or_else(|| g.diagonal(c))
|
||||
.or_else(|| g.blocks(c))
|
||||
}
|
||||
|
||||
/// The weight of one arm (centre → edge) of a box character.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Arm {
|
||||
None,
|
||||
Light,
|
||||
Heavy,
|
||||
}
|
||||
|
||||
/// Cell geometry in f32, plus the light stroke thickness `t` (see
|
||||
/// [`light_thickness`] for how that one is chosen).
|
||||
struct Cell {
|
||||
x0: f32,
|
||||
y0: f32,
|
||||
x1: f32,
|
||||
y1: f32,
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
t: f32,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
/// The light stroke thickness for a cell `cell_width` wide, in logical pixels.
|
||||
///
|
||||
/// Two rules, in order:
|
||||
///
|
||||
/// 1. Derive from the cell *width* — a pure font-size proxy — never the height:
|
||||
/// the height carries the line-height stretch, and a `─` that fattens when
|
||||
/// the user opens up their line spacing would look broken.
|
||||
/// 2. Then quantise so the result covers a whole number of device pixels.
|
||||
///
|
||||
/// Rule 2 keeps the nominal weight and the painted weight in agreement:
|
||||
/// [`Cell::vstroke`] lays a stroke off in whole device pixels, and everything
|
||||
/// positioned relative to `t` (the arm overshoot, the double-line separation,
|
||||
/// `heavy = 2 × light`) should be reasoning about the same value the rasteriser
|
||||
/// will actually produce.
|
||||
///
|
||||
/// Rounding the logical value *first* is what keeps 1x and 2x byte-identical to
|
||||
/// what this module shipped with — those are the scales it was tuned and
|
||||
/// visually verified at, so the fractional-scale fix must not disturb them.
|
||||
fn light_thickness(cell_width: f32, scale: f32) -> f32 {
|
||||
let logical = (cell_width * 0.15).round().max(1.);
|
||||
(logical * scale).round().max(1.) / scale
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
fn new(b: &Bounds<Pixels>, scale: f32) -> Self {
|
||||
let x0 = b.origin.x.as_f32();
|
||||
let y0 = b.origin.y.as_f32();
|
||||
let x1 = x0 + b.size.width.as_f32();
|
||||
let y1 = y0 + b.size.height.as_f32();
|
||||
let scale = scale.max(0.1);
|
||||
Cell {
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
cx: (x0 + x1) / 2.,
|
||||
cy: (y0 + y1) / 2.,
|
||||
t: light_thickness(x1 - x0, scale),
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a logical coordinate onto the device pixel grid.
|
||||
fn snap(&self, v: f32) -> f32 {
|
||||
(v * self.scale).round() / self.scale
|
||||
}
|
||||
|
||||
/// A rectangle with every edge snapped to device pixels (see [`glyph`]).
|
||||
/// Snapping the two edges — not origin + size — is what keeps a shared
|
||||
/// cell boundary shared: both cells snap the same coordinate to the same
|
||||
/// pixel line, so consecutive `│` cells tile with zero gap and zero
|
||||
/// overlap whatever the window position.
|
||||
fn rectb(&self, x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
|
||||
let (sx0, sy0) = (self.snap(x), self.snap(y));
|
||||
let (sx1, sy1) = (self.snap(x + w), self.snap(y + h));
|
||||
Bounds::new(point(px(sx0), px(sy0)), size(px(sx1 - sx0), px(sy1 - sy0)))
|
||||
}
|
||||
|
||||
fn rect(&self, x: f32, y: f32, w: f32, h: f32) -> Ink {
|
||||
Ink::Rect(self.rectb(x, y, w, h))
|
||||
}
|
||||
|
||||
/// A logical thickness as a whole number of device pixels, back in logical
|
||||
/// units. Never zero: a stroke that rounds away is worse than one that is
|
||||
/// a touch too thick.
|
||||
fn stroke_px(&self, w: f32) -> f32 {
|
||||
(w * self.scale).round().max(1.) / self.scale
|
||||
}
|
||||
|
||||
/// A vertical stroke of logical width `w`, centred on `x`, spanning
|
||||
/// `ya..yb`.
|
||||
///
|
||||
/// The two *ends* snap like any other edge, so a stroke that runs to a cell
|
||||
/// boundary still shares that boundary exactly with the cell beyond it —
|
||||
/// the tiling property [`rectb`](Self::rectb) exists for.
|
||||
///
|
||||
/// The *width* is deliberately not a second pair of independent snaps. Two
|
||||
/// edges `w` apart land `w × scale` device pixels apart, and unless that is
|
||||
/// exactly a whole number the two `round`s straddle it — rounding apart in
|
||||
/// some cells and together in others, which made vertical rules alternate
|
||||
/// thin/thick across the columns of a TUI table at Windows' default 125% /
|
||||
/// 150% scaling. [`light_thickness`] picks `w` so the product is integral,
|
||||
/// but `f32` cannot always represent it exactly (a `1.5×` scale gives
|
||||
/// `2/1.5 × 1.5 = 2.0000001`), and a coordinate landing on a `.5` tie then
|
||||
/// rounds whichever way the error points. Laying the width off from the
|
||||
/// snapped near edge sidesteps the tie entirely: same weight everywhere,
|
||||
/// by construction rather than by luck.
|
||||
fn vstroke(&self, x: f32, w: f32, ya: f32, yb: f32) -> Ink {
|
||||
let (x0, y0, y1) = (self.snap(x - w / 2.), self.snap(ya), self.snap(yb));
|
||||
Ink::Rect(Bounds::new(
|
||||
point(px(x0), px(y0)),
|
||||
size(px(self.stroke_px(w)), px(y1 - y0)),
|
||||
))
|
||||
}
|
||||
|
||||
/// A horizontal stroke of logical width `w`, centred on `y`, spanning
|
||||
/// `xa..xb`. See [`vstroke`](Self::vstroke).
|
||||
fn hstroke(&self, y: f32, w: f32, xa: f32, xb: f32) -> Ink {
|
||||
let (y0, x0, x1) = (self.snap(y - w / 2.), self.snap(xa), self.snap(xb));
|
||||
Ink::Rect(Bounds::new(
|
||||
point(px(x0), px(y0)),
|
||||
size(px(x1 - x0), px(self.stroke_px(w))),
|
||||
))
|
||||
}
|
||||
|
||||
/// The light/heavy arm combinations: one rectangle per arm, each running
|
||||
/// from its cell edge to just past the centre.
|
||||
///
|
||||
/// The overshoot (`m`, half the thickest arm) is what makes a corner: two
|
||||
/// perpendicular strokes that merely *meet* at the centre point leave a
|
||||
/// notch at the outside of the turn. Same-color opaque overlap costs
|
||||
/// nothing, so every arm overshoots by the same amount and any combination
|
||||
/// of weights joins solid.
|
||||
fn arms(&self, u: Arm, d: Arm, l: Arm, r: Arm) -> Vec<Ink> {
|
||||
let w = |a: Arm| match a {
|
||||
Arm::None => 0.,
|
||||
Arm::Light => self.t,
|
||||
Arm::Heavy => self.t * 2.,
|
||||
};
|
||||
let (wu, wd, wl, wr) = (w(u), w(d), w(l), w(r));
|
||||
let m = wu.max(wd).max(wl).max(wr) / 2.;
|
||||
let mut ink = Vec::new();
|
||||
if wu > 0. {
|
||||
ink.push(self.vstroke(self.cx, wu, self.y0, self.cy + m));
|
||||
}
|
||||
if wd > 0. {
|
||||
ink.push(self.vstroke(self.cx, wd, self.cy - m, self.y1));
|
||||
}
|
||||
if wl > 0. {
|
||||
ink.push(self.hstroke(self.cy, wl, self.x0, self.cx + m));
|
||||
}
|
||||
if wr > 0. {
|
||||
ink.push(self.hstroke(self.cy, wr, self.cx - m, self.x1));
|
||||
}
|
||||
ink
|
||||
}
|
||||
|
||||
/// The double-line set (U+2550–U+256C), spelled out stroke by stroke.
|
||||
///
|
||||
/// Doubles can't reuse the [`arms`](Self::arms) overshoot trick: their
|
||||
/// junctions are *open* — ╬ is four corner pieces around a hole, ╠'s inner
|
||||
/// stroke breaks where the branch leaves — so each character lists exactly
|
||||
/// the segments the Unicode chart draws, with endpoints snapped half a
|
||||
/// stroke past the line they join so corners close without crossing the
|
||||
/// gap.
|
||||
fn doubles(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let t = self.t;
|
||||
let h = t / 2.;
|
||||
// The parallel strokes sit at centre ± d. At the 1px thickness of
|
||||
// ordinary font sizes this leaves a 3px gap — wide enough to survive
|
||||
// subpixel placement without the two strokes bleeding into one.
|
||||
let d = (t * 1.5).max(2.0);
|
||||
let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy);
|
||||
let (va, vb) = (cx - d, cx + d);
|
||||
let (ha, hb) = (cy - d, cy + d);
|
||||
let v = |x: f32, ya: f32, yb: f32| self.vstroke(x, t, ya, yb);
|
||||
let hz = |y: f32, xa: f32, xb: f32| self.hstroke(y, t, xa, xb);
|
||||
Some(match c {
|
||||
'═' => vec![hz(ha, x0, x1), hz(hb, x0, x1)],
|
||||
'║' => vec![v(va, y0, y1), v(vb, y0, y1)],
|
||||
'╒' => vec![hz(ha, cx - h, x1), hz(hb, cx - h, x1), v(cx, ha - h, y1)],
|
||||
'╓' => vec![hz(cy, va - h, x1), v(va, cy - h, y1), v(vb, cy - h, y1)],
|
||||
'╔' => vec![
|
||||
v(va, ha - h, y1),
|
||||
hz(ha, va - h, x1),
|
||||
v(vb, hb - h, y1),
|
||||
hz(hb, vb - h, x1),
|
||||
],
|
||||
'╕' => vec![hz(ha, x0, cx + h), hz(hb, x0, cx + h), v(cx, ha - h, y1)],
|
||||
'╖' => vec![hz(cy, x0, vb + h), v(va, cy - h, y1), v(vb, cy - h, y1)],
|
||||
'╗' => vec![
|
||||
v(vb, ha - h, y1),
|
||||
hz(ha, x0, vb + h),
|
||||
v(va, hb - h, y1),
|
||||
hz(hb, x0, va + h),
|
||||
],
|
||||
'╘' => vec![v(cx, y0, hb + h), hz(ha, cx - h, x1), hz(hb, cx - h, x1)],
|
||||
'╙' => vec![v(va, y0, cy + h), v(vb, y0, cy + h), hz(cy, va - h, x1)],
|
||||
'╚' => vec![
|
||||
v(va, y0, hb + h),
|
||||
hz(hb, va - h, x1),
|
||||
v(vb, y0, ha + h),
|
||||
hz(ha, vb - h, x1),
|
||||
],
|
||||
'╛' => vec![v(cx, y0, hb + h), hz(ha, x0, cx + h), hz(hb, x0, cx + h)],
|
||||
'╜' => vec![v(va, y0, cy + h), v(vb, y0, cy + h), hz(cy, x0, vb + h)],
|
||||
'╝' => vec![
|
||||
v(vb, y0, hb + h),
|
||||
hz(hb, x0, vb + h),
|
||||
v(va, y0, ha + h),
|
||||
hz(ha, x0, va + h),
|
||||
],
|
||||
'╞' => vec![v(cx, y0, y1), hz(ha, cx - h, x1), hz(hb, cx - h, x1)],
|
||||
'╟' => vec![v(va, y0, y1), v(vb, y0, y1), hz(cy, vb - h, x1)],
|
||||
'╠' => vec![
|
||||
v(va, y0, y1),
|
||||
v(vb, y0, ha + h),
|
||||
v(vb, hb - h, y1),
|
||||
hz(ha, vb - h, x1),
|
||||
hz(hb, vb - h, x1),
|
||||
],
|
||||
'╡' => vec![v(cx, y0, y1), hz(ha, x0, cx + h), hz(hb, x0, cx + h)],
|
||||
'╢' => vec![v(va, y0, y1), v(vb, y0, y1), hz(cy, x0, va + h)],
|
||||
'╣' => vec![
|
||||
v(vb, y0, y1),
|
||||
v(va, y0, ha + h),
|
||||
v(va, hb - h, y1),
|
||||
hz(ha, x0, va + h),
|
||||
hz(hb, x0, va + h),
|
||||
],
|
||||
'╤' => vec![hz(ha, x0, x1), hz(hb, x0, x1), v(cx, hb - h, y1)],
|
||||
'╥' => vec![hz(cy, x0, x1), v(va, cy - h, y1), v(vb, cy - h, y1)],
|
||||
'╦' => vec![
|
||||
hz(ha, x0, x1),
|
||||
hz(hb, x0, va + h),
|
||||
hz(hb, vb - h, x1),
|
||||
v(va, hb - h, y1),
|
||||
v(vb, hb - h, y1),
|
||||
],
|
||||
'╧' => vec![hz(ha, x0, x1), hz(hb, x0, x1), v(cx, y0, ha + h)],
|
||||
'╨' => vec![hz(cy, x0, x1), v(va, y0, cy + h), v(vb, y0, cy + h)],
|
||||
'╩' => vec![
|
||||
hz(hb, x0, x1),
|
||||
hz(ha, x0, va + h),
|
||||
hz(ha, vb - h, x1),
|
||||
v(va, y0, ha + h),
|
||||
v(vb, y0, ha + h),
|
||||
],
|
||||
'╪' => vec![v(cx, y0, y1), hz(ha, x0, x1), hz(hb, x0, x1)],
|
||||
'╫' => vec![v(va, y0, y1), v(vb, y0, y1), hz(cy, x0, x1)],
|
||||
'╬' => vec![
|
||||
v(va, y0, ha + h),
|
||||
v(vb, y0, ha + h),
|
||||
v(va, hb - h, y1),
|
||||
v(vb, hb - h, y1),
|
||||
hz(ha, x0, va + h),
|
||||
hz(ha, vb - h, x1),
|
||||
hz(hb, x0, va + h),
|
||||
hz(hb, vb - h, x1),
|
||||
],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The rounded corners ╭ ╮ ╯ ╰ — two straight stubs to the cell edges plus
|
||||
/// a quarter-circle band between them. `sx`/`sy` name the quadrant the arms
|
||||
/// leave through: ╭ runs down (+1) and right (+1).
|
||||
///
|
||||
/// The band is a fan of small convex quads, one per arc step, NOT a single
|
||||
/// outer-arc/inner-arc outline. That outline is concave, and gpui fills a
|
||||
/// path as a triangle fan from its first vertex — a concave contour gets
|
||||
/// its whole hollow covered, which rendered every corner as a solid
|
||||
/// quarter-disc blob the first time around. Each quad is convex, so each
|
||||
/// fills exactly itself, and at stroke widths of a few pixels twelve steps
|
||||
/// are indistinguishable from a true arc.
|
||||
fn rounded(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (sx, sy): (f32, f32) = match c {
|
||||
'╭' => (1., 1.),
|
||||
'╮' => (-1., 1.),
|
||||
'╯' => (-1., -1.),
|
||||
'╰' => (1., -1.),
|
||||
_ => return None,
|
||||
};
|
||||
let h = self.t / 2.;
|
||||
// The largest radius that keeps the arc inside the cell on its short
|
||||
// axis; the straight stubs cover whatever the long axis has left over.
|
||||
let r = ((self.x1 - self.x0).min(self.y1 - self.y0) / 2.).max(h * 2.);
|
||||
let (cx, cy) = (self.cx, self.cy);
|
||||
let mut ink = Vec::new();
|
||||
// Straight stubs from the arc's ends to the cell edges (zero-length
|
||||
// when the radius already spans the half-axis). Each stub reaches one
|
||||
// device pixel *into* the arc band: the stub is pixel-snapped, the arc
|
||||
// isn't, and without the overlap that mismatch reopens a hairline
|
||||
// seam exactly where they hand off.
|
||||
let lap = 1. / self.scale;
|
||||
if sy > 0. {
|
||||
ink.push(self.vstroke(cx, self.t, cy + r - lap, self.y1));
|
||||
} else {
|
||||
ink.push(self.vstroke(cx, self.t, self.y0, cy - r + lap));
|
||||
}
|
||||
if sx > 0. {
|
||||
ink.push(self.hstroke(cy, self.t, cx + r - lap, self.x1));
|
||||
} else {
|
||||
ink.push(self.hstroke(cy, self.t, self.x0, cx - r + lap));
|
||||
}
|
||||
// The arc band, from the vertical stub (θ=0) to the horizontal one
|
||||
// (θ=π/2) around the arc centre one radius into the quadrant.
|
||||
//
|
||||
// How this renders decides whether the corner looks like kitty's or
|
||||
// not, and gpui's pipeline dictates the shape (learned the hard way,
|
||||
// twice):
|
||||
//
|
||||
// * A path contour is filled as a triangle FAN from its start vertex,
|
||||
// and coverage in the intermediate texture only accumulates — there
|
||||
// is no winding cancellation. A whole-band outline is concave, so
|
||||
// its fan covered the hollow and every corner rendered as a solid
|
||||
// quarter-disc blob. Each contour must therefore be *star-shaped
|
||||
// from its start vertex*: 30° slices of a thin band are, a 90° band
|
||||
// is not.
|
||||
// * All contours ride in ONE Path. Paths composite as premultiplied
|
||||
// sprites, so two separately painted segments overlap their
|
||||
// antialiased edges at 75% opacity — the seam at every joint of the
|
||||
// first polyline attempt. Within a single path the 4x-MSAA samples
|
||||
// partition cleanly across shared edges instead.
|
||||
// * The outer edge is a real quadratic (`curve_to`), which the shader
|
||||
// antialiases *analytically* (Loop–Blinn signed distance) — the
|
||||
// smooth continuous ramp kitty gets from supersampling. The inner
|
||||
// edge can't be a curve: with no winding, a concave-side bulge can
|
||||
// only over-cover. It is a fine polyline instead, whose chord error
|
||||
// at 7.5° steps (< 0.1px at cell sizes) hides inside the MSAA.
|
||||
let (ax, ay) = (cx + sx * r, cy + sy * r);
|
||||
let at = |radius: f32, theta: f32| {
|
||||
let (x, y) = (
|
||||
ax - sx * radius * theta.cos(),
|
||||
ay - sy * radius * theta.sin(),
|
||||
);
|
||||
point(px(x), px(y))
|
||||
};
|
||||
const SEGS: usize = 3;
|
||||
const INNER_PTS: usize = 4;
|
||||
let step = std::f32::consts::FRAC_PI_2 / SEGS as f32;
|
||||
let mut path: Option<gpui::Path<Pixels>> = None;
|
||||
for i in 0..SEGS {
|
||||
let t0 = step * i as f32;
|
||||
let t1 = step * (i + 1) as f32;
|
||||
let start = at(r + h, t0);
|
||||
let p = match path.as_mut() {
|
||||
Some(p) => {
|
||||
p.move_to(start);
|
||||
p
|
||||
}
|
||||
None => path.insert(gpui::Path::new(start)),
|
||||
};
|
||||
// Control point at the tangents' intersection: the exact
|
||||
// quadratic through both endpoints for this arc slice.
|
||||
let ctrl = at((r + h) / (step / 2.).cos(), (t0 + t1) / 2.);
|
||||
p.curve_to(at(r + h, t1), ctrl);
|
||||
p.line_to(at(r - h, t1));
|
||||
for k in (0..INNER_PTS).rev() {
|
||||
p.line_to(at(r - h, t0 + (t1 - t0) * k as f32 / INNER_PTS as f32));
|
||||
}
|
||||
}
|
||||
if let Some(p) = path {
|
||||
ink.push(Ink::Path(p));
|
||||
}
|
||||
Some(ink)
|
||||
}
|
||||
|
||||
/// The dashed lines: n dashes, each 70% of its slot, centred. Deliberately
|
||||
/// *not* edge-to-edge — a dashed line is supposed to read as broken, and
|
||||
/// this matches how the font glyphs space them.
|
||||
fn dashed(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (n, heavy, vertical) = match c {
|
||||
'╌' => (2, false, false),
|
||||
'╍' => (2, true, false),
|
||||
'╎' => (2, false, true),
|
||||
'╏' => (2, true, true),
|
||||
'┄' => (3, false, false),
|
||||
'┅' => (3, true, false),
|
||||
'┆' => (3, false, true),
|
||||
'┇' => (3, true, true),
|
||||
'┈' => (4, false, false),
|
||||
'┉' => (4, true, false),
|
||||
'┊' => (4, false, true),
|
||||
'┋' => (4, true, true),
|
||||
_ => return None,
|
||||
};
|
||||
let w = if heavy { self.t * 2. } else { self.t };
|
||||
let (a0, a1) = if vertical {
|
||||
(self.y0, self.y1)
|
||||
} else {
|
||||
(self.x0, self.x1)
|
||||
};
|
||||
let seg = (a1 - a0) / n as f32;
|
||||
let ink = (0..n)
|
||||
.map(|i| {
|
||||
let s = a0 + seg * (i as f32 + 0.15);
|
||||
let len = seg * 0.7;
|
||||
if vertical {
|
||||
self.vstroke(self.cx, w, s, s + len)
|
||||
} else {
|
||||
self.hstroke(self.cy, w, s, s + len)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(ink)
|
||||
}
|
||||
|
||||
/// The diagonals ╱ ╲ ╳ as corner-to-corner parallelograms. The offset is
|
||||
/// vertical (not perpendicular) so every vertex stays inside the cell; its
|
||||
/// length is scaled so the *perpendicular* stroke width still comes out at
|
||||
/// the light thickness.
|
||||
fn diagonal(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (w, hgt) = (self.x1 - self.x0, self.y1 - self.y0);
|
||||
let v = self.t * (w * w + hgt * hgt).sqrt() / w;
|
||||
let p = |x: f32, y: f32| point(px(x), px(y));
|
||||
let quad = |top_x: f32, bot_x: f32| {
|
||||
let mut path = gpui::Path::new(p(top_x, self.y0));
|
||||
path.line_to(p(top_x, self.y0 + v));
|
||||
path.line_to(p(bot_x, self.y1));
|
||||
path.line_to(p(bot_x, self.y1 - v));
|
||||
Ink::Path(path)
|
||||
};
|
||||
Some(match c {
|
||||
'╱' => vec![quad(self.x1, self.x0)],
|
||||
'╲' => vec![quad(self.x0, self.x1)],
|
||||
'╳' => vec![quad(self.x1, self.x0), quad(self.x0, self.x1)],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The block elements U+2580–U+259F: eighths, halves, quadrants, and the
|
||||
/// ░▒▓ shades (a full-cell wash at a quarter / half / three quarters of the
|
||||
/// foreground's alpha).
|
||||
fn blocks(&self, c: char) -> Option<Vec<Ink>> {
|
||||
let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy);
|
||||
let (w, hgt) = (x1 - x0, y1 - y0);
|
||||
let r = |x: f32, y: f32, ww: f32, hh: f32| self.rect(x, y, ww, hh);
|
||||
let ul = || r(x0, y0, cx - x0, cy - y0);
|
||||
let ur = || r(cx, y0, x1 - cx, cy - y0);
|
||||
let ll = || r(x0, cy, cx - x0, y1 - cy);
|
||||
let lr = || r(cx, cy, x1 - cx, y1 - cy);
|
||||
Some(match c {
|
||||
'▀' => vec![r(x0, y0, w, hgt / 2.)],
|
||||
// ▁ (1/8) through █ (the full block): lower k eighths.
|
||||
'▁'..='█' => {
|
||||
let k = (c as u32 - 0x2580) as f32;
|
||||
let hh = hgt * k / 8.;
|
||||
vec![r(x0, y1 - hh, w, hh)]
|
||||
}
|
||||
// ▉ (7/8) through ▏ (1/8): left k eighths.
|
||||
'▉'..='▏' => {
|
||||
let k = (0x2590 - c as u32) as f32;
|
||||
vec![r(x0, y0, w * k / 8., hgt)]
|
||||
}
|
||||
'▐' => vec![r(cx, y0, x1 - cx, hgt)],
|
||||
'░' => vec![Ink::Shade(self.rectb(x0, y0, w, hgt), 0.25)],
|
||||
'▒' => vec![Ink::Shade(self.rectb(x0, y0, w, hgt), 0.5)],
|
||||
'▓' => vec![Ink::Shade(self.rectb(x0, y0, w, hgt), 0.75)],
|
||||
'▔' => vec![r(x0, y0, w, hgt / 8.)],
|
||||
'▕' => vec![r(x1 - w / 8., y0, w / 8., hgt)],
|
||||
'▖' => vec![ll()],
|
||||
'▗' => vec![lr()],
|
||||
'▘' => vec![ul()],
|
||||
'▙' => vec![ul(), ll(), lr()],
|
||||
'▚' => vec![ul(), lr()],
|
||||
'▛' => vec![ul(), ur(), ll()],
|
||||
'▜' => vec![ul(), ur(), lr()],
|
||||
'▝' => vec![ur()],
|
||||
'▞' => vec![ur(), ll()],
|
||||
'▟' => vec![ur(), ll(), lr()],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the light/heavy arm combinations: the solid lines, corners, tees and
|
||||
/// crosses of U+2500–U+254B, and the half/mixed lines of U+2574–U+257F. Order
|
||||
/// is (up, down, left, right).
|
||||
fn arms_of(c: char) -> Option<(Arm, Arm, Arm, Arm)> {
|
||||
use Arm::{Heavy as H, Light as L, None as N};
|
||||
Some(match c {
|
||||
'─' => (N, N, L, L),
|
||||
'━' => (N, N, H, H),
|
||||
'│' => (L, L, N, N),
|
||||
'┃' => (H, H, N, N),
|
||||
'┌' => (N, L, N, L),
|
||||
'┍' => (N, L, N, H),
|
||||
'┎' => (N, H, N, L),
|
||||
'┏' => (N, H, N, H),
|
||||
'┐' => (N, L, L, N),
|
||||
'┑' => (N, L, H, N),
|
||||
'┒' => (N, H, L, N),
|
||||
'┓' => (N, H, H, N),
|
||||
'└' => (L, N, N, L),
|
||||
'┕' => (L, N, N, H),
|
||||
'┖' => (H, N, N, L),
|
||||
'┗' => (H, N, N, H),
|
||||
'┘' => (L, N, L, N),
|
||||
'┙' => (L, N, H, N),
|
||||
'┚' => (H, N, L, N),
|
||||
'┛' => (H, N, H, N),
|
||||
'├' => (L, L, N, L),
|
||||
'┝' => (L, L, N, H),
|
||||
'┞' => (H, L, N, L),
|
||||
'┟' => (L, H, N, L),
|
||||
'┠' => (H, H, N, L),
|
||||
'┡' => (H, L, N, H),
|
||||
'┢' => (L, H, N, H),
|
||||
'┣' => (H, H, N, H),
|
||||
'┤' => (L, L, L, N),
|
||||
'┥' => (L, L, H, N),
|
||||
'┦' => (H, L, L, N),
|
||||
'┧' => (L, H, L, N),
|
||||
'┨' => (H, H, L, N),
|
||||
'┩' => (H, L, H, N),
|
||||
'┪' => (L, H, H, N),
|
||||
'┫' => (H, H, H, N),
|
||||
'┬' => (N, L, L, L),
|
||||
'┭' => (N, L, H, L),
|
||||
'┮' => (N, L, L, H),
|
||||
'┯' => (N, L, H, H),
|
||||
'┰' => (N, H, L, L),
|
||||
'┱' => (N, H, H, L),
|
||||
'┲' => (N, H, L, H),
|
||||
'┳' => (N, H, H, H),
|
||||
'┴' => (L, N, L, L),
|
||||
'┵' => (L, N, H, L),
|
||||
'┶' => (L, N, L, H),
|
||||
'┷' => (L, N, H, H),
|
||||
'┸' => (H, N, L, L),
|
||||
'┹' => (H, N, H, L),
|
||||
'┺' => (H, N, L, H),
|
||||
'┻' => (H, N, H, H),
|
||||
'┼' => (L, L, L, L),
|
||||
'┽' => (L, L, H, L),
|
||||
'┾' => (L, L, L, H),
|
||||
'┿' => (L, L, H, H),
|
||||
'╀' => (H, L, L, L),
|
||||
'╁' => (L, H, L, L),
|
||||
'╂' => (H, H, L, L),
|
||||
'╃' => (H, L, H, L),
|
||||
'╄' => (H, L, L, H),
|
||||
'╅' => (L, H, H, L),
|
||||
'╆' => (L, H, L, H),
|
||||
'╇' => (H, L, H, H),
|
||||
'╈' => (L, H, H, H),
|
||||
'╉' => (H, H, H, L),
|
||||
'╊' => (H, H, L, H),
|
||||
'╋' => (H, H, H, H),
|
||||
'╴' => (N, N, L, N),
|
||||
'╵' => (L, N, N, N),
|
||||
'╶' => (N, N, N, L),
|
||||
'╷' => (N, L, N, N),
|
||||
'╸' => (N, N, H, N),
|
||||
'╹' => (H, N, N, N),
|
||||
'╺' => (N, N, N, H),
|
||||
'╻' => (N, H, N, N),
|
||||
'╼' => (N, N, L, H),
|
||||
'╽' => (L, H, N, N),
|
||||
'╾' => (N, N, H, L),
|
||||
'╿' => (H, L, N, N),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A cell with the proportions the bug shipped in: a 15px font's ~9px
|
||||
/// advance stretched to a 21px line by `line_height: 1.4`.
|
||||
fn cell() -> Bounds<Pixels> {
|
||||
Bounds::new(point(px(10.), px(20.)), size(px(9.), px(21.)))
|
||||
}
|
||||
|
||||
/// min_x / max_x / min_y / max_y over every rect corner and path vertex.
|
||||
fn extents(ink: &[Ink]) -> (f32, f32, f32, f32) {
|
||||
let (mut nx, mut xx, mut ny, mut xy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
|
||||
let mut visit = |x: f32, y: f32| {
|
||||
nx = nx.min(x);
|
||||
xx = xx.max(x);
|
||||
ny = ny.min(y);
|
||||
xy = xy.max(y);
|
||||
};
|
||||
for i in ink {
|
||||
match i {
|
||||
Ink::Rect(b) | Ink::Shade(b, _) => {
|
||||
let (x, y) = (b.origin.x.as_f32(), b.origin.y.as_f32());
|
||||
visit(x, y);
|
||||
visit(x + b.size.width.as_f32(), y + b.size.height.as_f32());
|
||||
}
|
||||
Ink::Path(p) => {
|
||||
for v in &p.vertices {
|
||||
visit(v.xy_position.x.as_f32(), v.xy_position.y.as_f32());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(nx, xx, ny, xy)
|
||||
}
|
||||
|
||||
/// Every character in U+2500–U+259F must decode to native ink — one that
|
||||
/// silently falls through to the font reintroduces the row-boundary gap
|
||||
/// for exactly that character, which is worse than uniform behavior in
|
||||
/// either direction.
|
||||
#[test]
|
||||
fn the_whole_range_is_covered() {
|
||||
for cp in 0x2500u32..=0x259f {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
assert!(
|
||||
glyph(c, cell(), 1.).is_some(),
|
||||
"U+{cp:04X} {c} fell through to the font"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing may paint outside its own cell: box characters tile, and one
|
||||
/// cell's overshoot is its neighbor's artifact.
|
||||
///
|
||||
/// The tolerance is half a pixel, not exact: a quadratic's *control point*
|
||||
/// sits slightly outside the ink it bounds (tangent-intersection, ~3.5%
|
||||
/// past the arc radius), and `extents` reads raw vertices. The curve
|
||||
/// itself never leaves the cell.
|
||||
#[test]
|
||||
fn ink_stays_inside_the_cell() {
|
||||
let b = cell();
|
||||
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
|
||||
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
|
||||
for cp in 0x2500u32..=0x259f {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).unwrap());
|
||||
assert!(
|
||||
nx >= x0 - 0.5 && xx <= x1 + 0.5 && ny >= y0 - 0.5 && xy <= y1 + 0.5,
|
||||
"U+{cp:04X} {c} paints outside the cell: \
|
||||
x {nx}..{xx} vs {x0}..{x1}, y {ny}..{xy} vs {y0}..{y1}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression this module exists for: every arm must reach its cell
|
||||
/// edge *exactly*, so vertical runs connect across the line-height gap and
|
||||
/// horizontal runs connect across cells. Checked for the whole arms table
|
||||
/// — including the mixed and half lines — not just `│`.
|
||||
#[test]
|
||||
fn arms_reach_their_edges() {
|
||||
let b = cell();
|
||||
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
|
||||
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
|
||||
for cp in 0x2500u32..=0x259f {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
let Some((u, d, l, r)) = arms_of(c) else {
|
||||
continue;
|
||||
};
|
||||
let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).unwrap());
|
||||
if u != Arm::None {
|
||||
assert_eq!(ny, y0, "{c}: up arm misses the top edge");
|
||||
}
|
||||
if d != Arm::None {
|
||||
assert_eq!(xy, y1, "{c}: down arm misses the bottom edge");
|
||||
}
|
||||
if l != Arm::None {
|
||||
assert_eq!(nx, x0, "{c}: left arm misses the left edge");
|
||||
}
|
||||
if r != Arm::None {
|
||||
assert_eq!(xx, x1, "{c}: right arm misses the right edge");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Same edge guarantee for the shapes that aren't plain arms: the doubles,
|
||||
/// the rounded corners, and the diagonals all tile too.
|
||||
#[test]
|
||||
fn doubles_rounded_and_diagonals_reach_their_edges() {
|
||||
let b = cell();
|
||||
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
|
||||
let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32());
|
||||
// (char, up, down, left, right)
|
||||
let expect = [
|
||||
('═', false, false, true, true),
|
||||
('║', true, true, false, false),
|
||||
('╔', false, true, false, true),
|
||||
('╬', true, true, true, true),
|
||||
('╠', true, true, false, true),
|
||||
('╦', false, true, true, true),
|
||||
('╭', false, true, false, true),
|
||||
('╮', false, true, true, false),
|
||||
('╯', true, false, true, false),
|
||||
('╰', true, false, false, true),
|
||||
('╱', true, true, true, true),
|
||||
('╲', true, true, true, true),
|
||||
];
|
||||
for (c, u, d, l, r) in expect {
|
||||
let (nx, xx, ny, xy) = extents(&glyph(c, b, 1.).unwrap());
|
||||
if u {
|
||||
assert_eq!(ny, y0, "{c}: misses the top edge");
|
||||
}
|
||||
if d {
|
||||
assert_eq!(xy, y1, "{c}: misses the bottom edge");
|
||||
}
|
||||
if l {
|
||||
assert_eq!(nx, x0, "{c}: misses the left edge");
|
||||
}
|
||||
if r {
|
||||
assert_eq!(xx, x1, "{c}: misses the right edge");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ╬ is four corner pieces around an open centre — the one double junction
|
||||
/// where "just extend everything through the middle" would visibly lie.
|
||||
#[test]
|
||||
fn double_cross_keeps_its_open_centre() {
|
||||
let b = cell();
|
||||
let cx = b.origin.x.as_f32() + b.size.width.as_f32() / 2.;
|
||||
let cy = b.origin.y.as_f32() + b.size.height.as_f32() / 2.;
|
||||
for i in glyph('╬', b, 1.).unwrap() {
|
||||
let Ink::Rect(r) = i else {
|
||||
panic!("╬ should be rects only");
|
||||
};
|
||||
let (x, y) = (r.origin.x.as_f32(), r.origin.y.as_f32());
|
||||
let inside = cx > x
|
||||
&& cx < x + r.size.width.as_f32()
|
||||
&& cy > y
|
||||
&& cy < y + r.size.height.as_f32();
|
||||
assert!(!inside, "╬'s centre is covered");
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks: the full block is the full cell, the halves are exact halves,
|
||||
/// and the shades wash the whole cell at their nominal alpha.
|
||||
#[test]
|
||||
fn blocks_cover_their_nominal_area() {
|
||||
let b = cell();
|
||||
let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32());
|
||||
let (w, h) = (b.size.width.as_f32(), b.size.height.as_f32());
|
||||
let (nx, xx, ny, xy) = extents(&glyph('█', b, 1.).unwrap());
|
||||
assert_eq!(
|
||||
(nx, xx, ny, xy),
|
||||
(x0, x0 + w, y0, y0 + h),
|
||||
"█ isn't the full cell"
|
||||
);
|
||||
// Interior edges (the half-cell split) may sit up to half a device
|
||||
// pixel from nominal after snapping; the outer edges stay exact.
|
||||
let (_, _, ny, xy) = extents(&glyph('▀', b, 1.).unwrap());
|
||||
assert_eq!(ny, y0, "▀ doesn't reach the top");
|
||||
assert!((xy - (y0 + h / 2.)).abs() <= 0.5, "▀ isn't the top half");
|
||||
let (_, _, ny, xy) = extents(&glyph('▄', b, 1.).unwrap());
|
||||
assert_eq!(xy, y0 + h, "▄ doesn't reach the bottom");
|
||||
assert!((ny - (y0 + h / 2.)).abs() <= 0.5, "▄ isn't the bottom half");
|
||||
for (c, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] {
|
||||
let ink = glyph(c, b, 1.).unwrap();
|
||||
assert_eq!(ink.len(), 1);
|
||||
let Ink::Shade(r, a) = &ink[0] else {
|
||||
panic!("{c} should be a shade");
|
||||
};
|
||||
assert_eq!(*a, alpha);
|
||||
assert_eq!(r.size.width.as_f32(), w, "{c} doesn't wash the full cell");
|
||||
}
|
||||
}
|
||||
|
||||
/// Heavy strokes must actually be heavier than light ones, and a light
|
||||
/// stroke never vanishes (≥ 1px) however small the cell.
|
||||
#[test]
|
||||
fn stroke_weights_are_ordered_and_visible() {
|
||||
let light = {
|
||||
let Ink::Rect(r) = &glyph('│', cell(), 1.).unwrap()[0] else {
|
||||
panic!()
|
||||
};
|
||||
r.size.width.as_f32()
|
||||
};
|
||||
let heavy = {
|
||||
let Ink::Rect(r) = &glyph('┃', cell(), 1.).unwrap()[0] else {
|
||||
panic!()
|
||||
};
|
||||
r.size.width.as_f32()
|
||||
};
|
||||
assert!(light >= 1., "light stroke thinner than a pixel");
|
||||
assert!(heavy > light, "heavy stroke isn't heavier");
|
||||
// A pathologically narrow cell still yields visible ink.
|
||||
let tiny = Bounds::new(point(px(0.), px(0.)), size(px(2.), px(4.)));
|
||||
let Ink::Rect(r) = &glyph('│', tiny, 1.).unwrap()[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert!(r.size.width.as_f32() >= 1.);
|
||||
}
|
||||
|
||||
/// The seam regression: with the window at a fractional device-pixel
|
||||
/// offset, every straight stroke must still land on whole device pixels.
|
||||
/// An unsnapped edge rasterizes an antialiasing ramp, and two abutting
|
||||
/// ramps composite to 75% opacity — the perforated `│` runs this module
|
||||
/// was reported for a second time over.
|
||||
#[test]
|
||||
fn straight_strokes_snap_to_device_pixels() {
|
||||
let scale = 2.0;
|
||||
// Deliberately misaligned: fractional origin and cell width.
|
||||
let b = Bounds::new(point(px(10.37), px(20.11)), size(px(9.03), px(21.)));
|
||||
let on_grid = |v: f32| ((v * scale).round() - v * scale).abs() < 1e-3;
|
||||
for cp in 0x2500u32..=0x259f {
|
||||
let c = char::from_u32(cp).unwrap();
|
||||
for i in glyph(c, b, scale).unwrap() {
|
||||
let (Ink::Rect(r) | Ink::Shade(r, _)) = i else {
|
||||
continue; // arcs and diagonals antialias on purpose
|
||||
};
|
||||
let (x, y) = (r.origin.x.as_f32(), r.origin.y.as_f32());
|
||||
let (x2, y2) = (x + r.size.width.as_f32(), y + r.size.height.as_f32());
|
||||
assert!(
|
||||
on_grid(x) && on_grid(y) && on_grid(x2) && on_grid(y2),
|
||||
"U+{cp:04X} {c}: stroke edge off the device grid \
|
||||
({x}, {y})..({x2}, {y2}) at scale {scale}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// And two vertically adjacent `│` cells must share their boundary
|
||||
// exactly — same coordinate in, same snapped pixel line out.
|
||||
let below = Bounds::new(point(px(10.37), px(41.11)), size(px(9.03), px(21.)));
|
||||
let bottom = extents(&glyph('│', b, scale).unwrap()).3;
|
||||
let top = extents(&glyph('│', below, scale).unwrap()).2;
|
||||
assert_eq!(bottom, top, "adjacent │ cells no longer tile");
|
||||
}
|
||||
|
||||
/// Every column must draw `│` at the *same* weight, and every row must draw
|
||||
/// `─` at the same weight, at any scale factor — not just the integer ones.
|
||||
///
|
||||
/// Note what the test above does *not* catch: it asserts each edge lands on
|
||||
/// the device grid, which a 1-device-pixel stroke and a 2-device-pixel
|
||||
/// stroke both satisfy. Windows' default 125%/150% display scaling put a
|
||||
/// 1-logical-pixel stroke a non-integer number of device pixels wide, and
|
||||
/// the two independent edge snaps then rounded apart in some columns and
|
||||
/// together in others: vertical rules alternated thin/thick across a TUI
|
||||
/// table, horizontal rules alternated down it. Both 1x and 2x are blind to
|
||||
/// it by construction, so the earlier fixtures could never have failed.
|
||||
#[test]
|
||||
fn stroke_weight_is_uniform_across_cells_at_any_scale() {
|
||||
// Realistic cell metrics: a 13/15/16px font's advance, line_height 1.4.
|
||||
for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] {
|
||||
for scale in [1.0f32, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] {
|
||||
let widths: Vec<f32> = (0..24)
|
||||
.map(|i| {
|
||||
let b = Bounds::new(point(px(cw * i as f32), px(0.)), size(px(cw), px(lh)));
|
||||
let Ink::Rect(r) = &glyph('│', b, scale).unwrap()[0] else {
|
||||
panic!("│ should be a rect")
|
||||
};
|
||||
(r.size.width.as_f32() * scale).round()
|
||||
})
|
||||
.collect();
|
||||
let (lo, hi) = (
|
||||
widths.iter().cloned().fold(f32::MAX, f32::min),
|
||||
widths.iter().cloned().fold(f32::MIN, f32::max),
|
||||
);
|
||||
assert_eq!(
|
||||
lo, hi,
|
||||
"│ weight varies {lo}..{hi} device px across columns \
|
||||
(cell_width {cw}, scale {scale}): {widths:?}"
|
||||
);
|
||||
assert!(lo >= 1., "│ thinner than a device pixel at scale {scale}");
|
||||
|
||||
let heights: Vec<f32> = (0..24)
|
||||
.map(|r| {
|
||||
let b = Bounds::new(point(px(0.), px(lh * r as f32)), size(px(cw), px(lh)));
|
||||
let Ink::Rect(rect) = &glyph('─', b, scale).unwrap()[0] else {
|
||||
panic!("─ should be a rect")
|
||||
};
|
||||
(rect.size.height.as_f32() * scale).round()
|
||||
})
|
||||
.collect();
|
||||
let (lo, hi) = (
|
||||
heights.iter().cloned().fold(f32::MAX, f32::min),
|
||||
heights.iter().cloned().fold(f32::MIN, f32::max),
|
||||
);
|
||||
assert_eq!(
|
||||
lo, hi,
|
||||
"─ weight varies {lo}..{hi} device px across rows \
|
||||
(cell_width {cw}, scale {scale}): {heights:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quantising the thickness in device space must not change what 1x and 2x
|
||||
/// already rendered — those are the two scales the module was tuned and
|
||||
/// visually verified at.
|
||||
#[test]
|
||||
fn integer_scales_keep_their_previous_thickness() {
|
||||
for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] {
|
||||
for scale in [1.0f32, 2.0, 3.0] {
|
||||
let previous = (cw * 0.15).round().max(1.);
|
||||
assert_eq!(
|
||||
light_thickness(cw, scale),
|
||||
previous,
|
||||
"cell_width {cw} at scale {scale} changed weight"
|
||||
);
|
||||
// And heavy stays exactly twice light, as `arms` assumes.
|
||||
let b = Bounds::new(point(px(0.), px(0.)), size(px(cw), px(lh)));
|
||||
let Ink::Rect(l) = &glyph('│', b, scale).unwrap()[0] else {
|
||||
panic!()
|
||||
};
|
||||
let Ink::Rect(h) = &glyph('┃', b, scale).unwrap()[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert!(h.size.width.as_f32() > l.size.width.as_f32());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ pub struct CmdEditor {
|
||||
/// shuttle states between the two.
|
||||
undo: Vec<(Vec<char>, usize)>,
|
||||
redo: Vec<(Vec<char>, usize)>,
|
||||
/// What the last *kill* removed, for [`Self::yank`] to put back — readline's
|
||||
/// kill ring, one slot deep. Only the word/line kills (⌃W, ⌃U, ⌃K, ⌥D and
|
||||
/// the arrow-key spellings of them) write here; a plain character delete is
|
||||
/// not a kill and leaves it untouched.
|
||||
kill: String,
|
||||
}
|
||||
|
||||
/// Cap on undo history, so a long editing session can't grow it without bound.
|
||||
@@ -409,6 +414,15 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `s..e` and stash it as the kill ring's contents. The four chords
|
||||
/// below are readline *kills*, not deletes: what they take is meant to come
|
||||
/// back out under [`Self::yank`].
|
||||
fn kill_range(&mut self, s: usize, e: usize) {
|
||||
self.kill = self.chars[s..e].iter().collect();
|
||||
self.chars.drain(s..e);
|
||||
self.shift_anchor_for_removal(s, e);
|
||||
}
|
||||
|
||||
/// Delete the word after the cursor (Alt+Delete): skip following whitespace,
|
||||
/// then the word.
|
||||
pub fn delete_word_right(&mut self) {
|
||||
@@ -421,8 +435,7 @@ impl CmdEditor {
|
||||
while e < n && !self.chars[e].is_whitespace() {
|
||||
e += 1;
|
||||
}
|
||||
self.chars.drain(self.cursor..e);
|
||||
self.shift_anchor_for_removal(self.cursor, e);
|
||||
self.kill_range(self.cursor, e);
|
||||
}
|
||||
|
||||
/// Delete the word before the cursor (Ctrl+W / Alt+Backspace).
|
||||
@@ -430,25 +443,33 @@ impl CmdEditor {
|
||||
self.checkpoint();
|
||||
let end = self.cursor;
|
||||
self.move_word_left();
|
||||
self.chars.drain(self.cursor..end);
|
||||
self.shift_anchor_for_removal(self.cursor, end);
|
||||
self.kill_range(self.cursor, end);
|
||||
}
|
||||
|
||||
/// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace).
|
||||
pub fn delete_to_start(&mut self) {
|
||||
self.checkpoint();
|
||||
self.chars.drain(0..self.cursor);
|
||||
let end = self.cursor;
|
||||
self.cursor = 0;
|
||||
self.shift_anchor_for_removal(0, end);
|
||||
self.kill_range(0, end);
|
||||
}
|
||||
|
||||
/// Delete from the cursor to the end of the line (Ctrl+K).
|
||||
pub fn delete_to_end(&mut self) {
|
||||
self.checkpoint();
|
||||
let end = self.chars.len();
|
||||
self.chars.drain(self.cursor..);
|
||||
self.shift_anchor_for_removal(self.cursor, end);
|
||||
self.kill_range(self.cursor, end);
|
||||
}
|
||||
|
||||
/// Reinsert the most recent kill at the cursor (Ctrl+Y). A no-op — undo
|
||||
/// checkpoint included — when nothing has been killed yet.
|
||||
pub fn yank(&mut self) {
|
||||
if self.kill.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kill = std::mem::take(&mut self.kill);
|
||||
self.insert_str(&kill);
|
||||
self.kill = kill;
|
||||
}
|
||||
|
||||
/// Clear the line and reset the cursor and undo history (after submit).
|
||||
@@ -572,6 +593,58 @@ mod tests {
|
||||
assert_eq!(d.cursor(), 9);
|
||||
}
|
||||
|
||||
/// The four readline *kill* chords stash what they removed so ⌃Y can put it
|
||||
/// back; the ring holds the most recent kill only.
|
||||
#[test]
|
||||
fn kills_fill_the_kill_buffer_and_yank_puts_it_back() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
e.delete_word_left();
|
||||
assert_eq!(e.text(), "git push ");
|
||||
e.yank();
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("git push origin", 15));
|
||||
|
||||
let mut k = ed("hello world", 5);
|
||||
k.delete_to_end();
|
||||
assert_eq!(k.text(), "hello");
|
||||
k.yank();
|
||||
assert_eq!(k.text(), "hello world");
|
||||
|
||||
let mut u = ed("hello world", 6);
|
||||
u.delete_to_start();
|
||||
assert_eq!(u.text(), "world");
|
||||
u.move_end();
|
||||
u.yank();
|
||||
assert_eq!(u.text(), "worldhello ");
|
||||
|
||||
let mut d = ed("git push origin", 4);
|
||||
d.delete_word_right();
|
||||
assert_eq!(d.text(), "git origin");
|
||||
d.yank();
|
||||
assert_eq!(d.text(), "git push origin");
|
||||
}
|
||||
|
||||
/// A plain character delete is not a kill — readline keeps the two apart,
|
||||
/// so backspacing must not clobber the word ⌃W stashed a moment ago.
|
||||
#[test]
|
||||
fn character_deletes_leave_the_kill_buffer_alone() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
e.delete_word_left();
|
||||
e.backspace();
|
||||
e.delete();
|
||||
assert_eq!(e.text(), "git push");
|
||||
e.yank();
|
||||
assert_eq!(e.text(), "git pushorigin");
|
||||
}
|
||||
|
||||
/// Nothing killed yet: ⌃Y leaves the line and the caret exactly as they
|
||||
/// were rather than inserting an empty string.
|
||||
#[test]
|
||||
fn yank_without_a_kill_does_nothing() {
|
||||
let mut e = ed("hello", 3);
|
||||
e.yank();
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("hello", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_to_start_and_end() {
|
||||
let mut s = ed("hello world", 6);
|
||||
|
||||
+257
-22
@@ -490,18 +490,51 @@ enum RowSeg {
|
||||
/// drawing, accented Latin, …) that may route to a fallback face whose
|
||||
/// advance isn't the cell width.
|
||||
Solo { col: usize },
|
||||
/// A base plus the combining marks stacked on it, shaped as one string so
|
||||
/// the marks reach the shaper. Never batched with neighbours: the marks add
|
||||
/// A base with everything that has to shape alongside it — the combining
|
||||
/// marks stacked on it, and a following SARA AM — as one string, so the
|
||||
/// shaper sees the whole cluster. Never batched with neighbours: marks add
|
||||
/// characters without adding columns, which is exactly the correspondence
|
||||
/// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`].
|
||||
///
|
||||
/// An absorbed SARA AM takes the base's style rather than its own. Unlike
|
||||
/// a [`RowSeg::Run`], the cluster can't break on a style change: split off,
|
||||
/// SARA AM has no base to reorder its nikhahit onto and renders as a dotted
|
||||
/// circle. A recoloured vowel beats a broken one.
|
||||
Cluster {
|
||||
col: usize,
|
||||
/// Columns the base occupies — 2 once the grid marked it wide.
|
||||
/// Columns the whole cluster occupies — 2 for a wide base, or for a
|
||||
/// narrow base that absorbed a following SARA AM.
|
||||
cells: usize,
|
||||
text: String,
|
||||
/// Whether `cells == 2` because the *base* is wide, rather than because
|
||||
/// a spacing character joined it. The two need opposite pinning: a wide
|
||||
/// base is one glyph across two columns, an absorbed SARA AM is two
|
||||
/// glyphs of one column each.
|
||||
wide_base: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Append a cell's character followed by any combining marks riding on it.
|
||||
fn push_cell(text: &mut String, cell: &RenderCell) {
|
||||
text.push(cell.c);
|
||||
text.extend(cell.marks.iter().flat_map(|marks| marks.iter()));
|
||||
}
|
||||
|
||||
/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not
|
||||
/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA
|
||||
/// and moves the nikhahit backwards over any above-base marks onto the base
|
||||
/// consonant. Shaped in a run of its own it has no base to reorder onto, and
|
||||
/// comes out as a dotted circle.
|
||||
fn is_sara_am(c: char) -> bool {
|
||||
matches!(c, '\u{0E33}' | '\u{0EB3}')
|
||||
}
|
||||
|
||||
/// Does `col` hold a SARA AM that should join the preceding cell's cluster?
|
||||
fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> {
|
||||
row.get(col)
|
||||
.filter(|cell| !cell.spacer && is_sara_am(cell.c))
|
||||
}
|
||||
|
||||
/// Split one grid row into paintable segments.
|
||||
///
|
||||
/// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the
|
||||
@@ -530,15 +563,26 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
|
||||
// Combining marks come first: they can sit on an ASCII base too, and
|
||||
// either way the whole cluster has to reach the shaper in one string.
|
||||
if let Some(marks) = &cell.marks {
|
||||
let cells = if col + 1 < row.len() && row[col + 1].spacer {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let wide_base = col + 1 < row.len() && row[col + 1].spacer;
|
||||
let mut cells = if wide_base { 2 } else { 1 };
|
||||
let mut text = String::with_capacity(1 + marks.len());
|
||||
text.push(cell.c);
|
||||
text.extend(marks.iter());
|
||||
segs.push(RowSeg::Cluster { col, cells, text });
|
||||
push_cell(&mut text, cell);
|
||||
// A wide base already owns both columns, so only a narrow one has a
|
||||
// column spare for SARA AM to join it in. A SARA AM is not itself a
|
||||
// base to absorb onto — two in a row stay separate.
|
||||
if !wide_base
|
||||
&& !is_sara_am(cell.c)
|
||||
&& let Some(am) = sara_am_at(row, col + 1)
|
||||
{
|
||||
push_cell(&mut text, am);
|
||||
cells = 2;
|
||||
}
|
||||
segs.push(RowSeg::Cluster {
|
||||
col,
|
||||
cells,
|
||||
text,
|
||||
wide_base,
|
||||
});
|
||||
col += cells;
|
||||
continue;
|
||||
}
|
||||
@@ -569,6 +613,23 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
|
||||
cells: col - start,
|
||||
text,
|
||||
});
|
||||
} else if !is_sara_am(cell.c)
|
||||
&& let Some(am) = sara_am_at(row, col + 1)
|
||||
{
|
||||
// An unmarked base still has to shape with its SARA AM. A
|
||||
// baseless SARA AM is not a base for the next one: absorbing
|
||||
// there would pin the second one's glyphs outside the cluster's
|
||||
// clip, so two in a row stay separate and both stay visible.
|
||||
let mut text = String::with_capacity(2);
|
||||
push_cell(&mut text, cell);
|
||||
push_cell(&mut text, am);
|
||||
segs.push(RowSeg::Cluster {
|
||||
col,
|
||||
cells: 2,
|
||||
text,
|
||||
wide_base: false,
|
||||
});
|
||||
col += 2;
|
||||
} else {
|
||||
segs.push(RowSeg::Solo { col });
|
||||
col += 1;
|
||||
@@ -738,6 +799,27 @@ fn powerline_path(bounds: Bounds<Pixels>, shape: PowerlineShape) -> gpui::Path<P
|
||||
}
|
||||
}
|
||||
|
||||
/// What a natively-drawn cell — a Powerline separator or a box-drawing/block
|
||||
/// character, both painted as geometry rather than as a font glyph — still has
|
||||
/// to send through the text pipeline after its ink is on screen.
|
||||
///
|
||||
/// `None` for the common case: the geometry *is* the whole cell, so the shaping
|
||||
/// and painting below can be skipped entirely.
|
||||
///
|
||||
/// `Some(' ')` when the style draws on blanks, i.e. it carries an underline (or
|
||||
/// is part of a hovered link). Underlines are not painted per-cell — they ride
|
||||
/// on the [`TextRun`] that `paint_glyphs` builds, so a cell that returns early
|
||||
/// silently loses its underline, leaving a one-column hole in an `ESC[4m` span
|
||||
/// or a hovered URL. Shaping a *space* in the cell's own style closes the hole:
|
||||
/// a space puts no glyph ink over the geometry already painted, and gpui draws
|
||||
/// the line from the same [`gpui::UnderlineStyle`] (curly and double included)
|
||||
/// it uses for every other cell, so weight, offset and colour match exactly.
|
||||
/// The space comes from the primary monospace face, whose advance *is* the cell
|
||||
/// width, so it needs no `force_width` to cover its column.
|
||||
fn native_cell_residue(style: &GlyphStyle) -> Option<char> {
|
||||
style.draws_on_blanks().then_some(' ')
|
||||
}
|
||||
|
||||
/// The width `paint_glyphs` clips a segment's paint to.
|
||||
///
|
||||
/// A batched `Run`/`Wide` segment clips to its exact column span (`cells`
|
||||
@@ -834,25 +916,71 @@ fn paint_glyphs(
|
||||
// for a single glyph — it paints at the run origin regardless.
|
||||
RowSeg::Solo { col } => {
|
||||
let cell = &buf[row_base + col];
|
||||
if let Some(shape) = PowerlineShape::of(cell.c) {
|
||||
let cell_bounds = Bounds::new(
|
||||
point(geom.origin.x + geom.cell_width * (col as f32), y),
|
||||
size(geom.cell_width, geom.line_height),
|
||||
);
|
||||
let cell_bounds = Bounds::new(
|
||||
point(geom.origin.x + geom.cell_width * (col as f32), y),
|
||||
size(geom.cell_width, geom.line_height),
|
||||
);
|
||||
// Two families paint as native geometry rather than as a
|
||||
// font glyph: Powerline separators, and the box-drawing /
|
||||
// block characters (`boxdraw`) — a font glyph only covers
|
||||
// the font's own line height, which broke every vertical
|
||||
// run of `│`/`╭`/`╰` into dashes at line_height > 1.0.
|
||||
// Either way the cell may still owe an underline, so this
|
||||
// records whether the ink is already down rather than
|
||||
// returning outright.
|
||||
let native = if let Some(shape) = PowerlineShape::of(cell.c) {
|
||||
let path = powerline_path(cell_bounds, shape);
|
||||
window.paint_path(path, GlyphStyle::of(cell).fg);
|
||||
continue;
|
||||
true
|
||||
} else if let Some(ink) =
|
||||
super::boxdraw::glyph(cell.c, cell_bounds, window.scale_factor())
|
||||
{
|
||||
let fg = GlyphStyle::of(cell).fg;
|
||||
for piece in ink {
|
||||
match piece {
|
||||
super::boxdraw::Ink::Rect(r) => window.paint_quad(fill(r, fg)),
|
||||
super::boxdraw::Ink::Shade(r, alpha) => {
|
||||
let mut c = fg;
|
||||
c.a *= alpha;
|
||||
window.paint_quad(fill(r, c));
|
||||
}
|
||||
super::boxdraw::Ink::Path(p) => window.paint_path(p, fg),
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !native {
|
||||
(col, 1, char_string(cell.c), None, true)
|
||||
} else {
|
||||
match native_cell_residue(&GlyphStyle::of(cell)) {
|
||||
None => continue,
|
||||
// `solo: false` clips the space to its own single
|
||||
// column so the underline can't spill sideways.
|
||||
Some(c) => (col, 1, char_string(c), None, false),
|
||||
}
|
||||
}
|
||||
(col, 1, char_string(cell.c), None, true)
|
||||
}
|
||||
// Same pinning as the batched runs, just for one base: two
|
||||
// columns get `force_width` so a fallback emoji face can't
|
||||
// drift, one column paints at the origin like `Solo`.
|
||||
RowSeg::Cluster { col, cells, text } => (
|
||||
// Two columns pin per *base glyph*, and which that is depends
|
||||
// on why the cluster is two cells wide: a wide base is one
|
||||
// glyph spanning both, an absorbed SARA AM is two glyphs of one
|
||||
// column each. `force_width` classifies by advance, so the
|
||||
// marks ride their base under either. One column paints at the
|
||||
// origin like `Solo`.
|
||||
RowSeg::Cluster {
|
||||
col,
|
||||
cells,
|
||||
text,
|
||||
wide_base,
|
||||
} => (
|
||||
col,
|
||||
cells,
|
||||
SharedString::from(text),
|
||||
(cells == 2).then(|| geom.cell_width * 2.),
|
||||
(cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }),
|
||||
cells == 1,
|
||||
),
|
||||
};
|
||||
@@ -2070,6 +2198,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A natively-drawn cell keeps its underline.
|
||||
///
|
||||
/// Underlines ride on the `TextRun`, so the Solo arm's early return for
|
||||
/// Powerline separators and box-drawing characters used to drop them: an
|
||||
/// `ESC[4m` span or a hovered URL containing `─`, `│` or `` showed a
|
||||
/// one-column hole where the line should have run through. The residue is
|
||||
/// what closes it — a space shaped in the cell's own style, carrying the
|
||||
/// underline and no glyph ink.
|
||||
#[test]
|
||||
fn natively_drawn_cells_still_carry_their_underline() {
|
||||
let plain = GlyphStyle::of(&cell('│'));
|
||||
assert_eq!(
|
||||
native_cell_residue(&plain),
|
||||
None,
|
||||
"an unstyled box character has nothing left to shape"
|
||||
);
|
||||
|
||||
for kind in [
|
||||
UnderlineKind::Single,
|
||||
UnderlineKind::Double,
|
||||
UnderlineKind::Curly,
|
||||
] {
|
||||
let mut c = cell('│');
|
||||
c.underline = kind;
|
||||
assert_eq!(
|
||||
native_cell_residue(&GlyphStyle::of(&c)),
|
||||
Some(' '),
|
||||
"{kind:?} underline dropped on a box-drawing cell"
|
||||
);
|
||||
}
|
||||
|
||||
// A hovered link underlines even without an emulator underline, and
|
||||
// the characters it spans may well be box drawing or a separator.
|
||||
for ch in ['│', '─', '╭', '█', '\u{e0b0}'] {
|
||||
let mut c = cell(ch);
|
||||
c.link_hover = true;
|
||||
assert_eq!(
|
||||
native_cell_residue(&GlyphStyle::of(&c)),
|
||||
Some(' '),
|
||||
"hovered-link underline dropped on U+{:04X}",
|
||||
ch as u32
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_row_keeps_powerline_separators_solo() {
|
||||
// The native-draw intercept lives in the Solo arm of `paint_glyphs`;
|
||||
@@ -2127,6 +2300,16 @@ mod tests {
|
||||
col,
|
||||
cells,
|
||||
text: text.to_string(),
|
||||
wide_base: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn wide_cluster(col: usize, cells: usize, text: &str) -> RowSeg {
|
||||
RowSeg::Cluster {
|
||||
col,
|
||||
cells,
|
||||
text: text.to_string(),
|
||||
wide_base: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2145,7 +2328,7 @@ mod tests {
|
||||
// spacer too (❤ + U+FE0F).
|
||||
let mut row = wide_cells("\u{2764}");
|
||||
row[0].marks = Some(Box::from(['\u{FE0F}']));
|
||||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{2764}\u{FE0F}")]);
|
||||
assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]);
|
||||
|
||||
// Several marks on one base: an above-base vowel and a tone mark both
|
||||
// sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48).
|
||||
@@ -2157,6 +2340,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid
|
||||
/// gives it its own column — but the shaper decomposes it into NIKHAHIT +
|
||||
/// SARA AA and reorders the nikhahit backwards onto the base consonant.
|
||||
/// Shaped in its own run it has no base to reorder onto and comes out as a
|
||||
/// dotted circle, so it has to join the preceding cell's cluster.
|
||||
#[test]
|
||||
fn segment_row_absorbs_sara_am_into_its_base() {
|
||||
// น + ้ (tone) + ำ — the base already carries a mark.
|
||||
let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')];
|
||||
row[0].marks = Some(Box::from(['\u{0E49}']));
|
||||
assert_eq!(
|
||||
segment_row(&row),
|
||||
[cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")]
|
||||
);
|
||||
|
||||
// ก + ำ — an unmarked base still has to shape with it.
|
||||
let row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
|
||||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
|
||||
|
||||
// Lao SARA AM (U+0EB3) takes the same shaper path.
|
||||
let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')];
|
||||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]);
|
||||
|
||||
// A style change does not break the cluster, unlike a `Run` or `Wide`
|
||||
// batch: split off, the vowel has no base and paints a dotted circle,
|
||||
// so it takes the base's style instead.
|
||||
let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
|
||||
row[1].fg = gpui::red();
|
||||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
|
||||
}
|
||||
|
||||
/// With nothing to attach to, SARA AM paints alone — a dotted circle is the
|
||||
/// shaper's honest answer for an orphaned mark, and inventing a base would
|
||||
/// be worse.
|
||||
#[test]
|
||||
fn segment_row_leaves_a_baseless_sara_am_alone() {
|
||||
let row = vec![cell('\u{0E33}'), cell('a')];
|
||||
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]);
|
||||
|
||||
// A blank before it is not a base either.
|
||||
let row = vec![cell(' '), cell('\u{0E33}')];
|
||||
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]);
|
||||
|
||||
// Nor is another SARA AM: absorbing would pin the second one's glyphs
|
||||
// past the cluster's two-cell clip and swallow it entirely.
|
||||
let row = vec![cell('\u{0E33}'), cell('\u{0E33}')];
|
||||
assert_eq!(
|
||||
segment_row(&row),
|
||||
[RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }]
|
||||
);
|
||||
}
|
||||
|
||||
/// A marked cell never joins a batch: marks add characters without adding
|
||||
/// columns, which would desync `force_width`'s glyph-per-column pinning.
|
||||
#[test]
|
||||
@@ -2176,7 +2411,7 @@ mod tests {
|
||||
segment_row(&row),
|
||||
[
|
||||
wide(0, 2, "你"),
|
||||
cluster(2, 2, "好\u{FE0F}"),
|
||||
wide_cluster(2, 2, "好\u{FE0F}"),
|
||||
wide(4, 2, "世"),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//! `TermSize` / `RemoteTerminal` are re-exported here so the rest of the crate
|
||||
//! can refer to `terminal::RemoteTerminal` without reaching into submodules.
|
||||
|
||||
mod boxdraw;
|
||||
mod cmd_editor;
|
||||
mod completion;
|
||||
pub mod element;
|
||||
|
||||
+565
-42
@@ -340,6 +340,12 @@ pub struct TerminalView {
|
||||
/// The in-progress line saved when history navigation starts, so pressing ↓
|
||||
/// past the newest entry restores what the user was typing.
|
||||
history_stash: String,
|
||||
/// Position of a run of ⌥. presses (readline's `yank-last-arg`): which
|
||||
/// `history` entry the last press took its word from, and the char span it
|
||||
/// left in the line — the next press replaces that span with the word from
|
||||
/// the entry before it. Any other key clears this, so the following ⌥.
|
||||
/// starts a fresh walk at the newest entry.
|
||||
last_word_nav: Option<LastWordWalk>,
|
||||
/// A submitted command whose history-file record is deferred until the
|
||||
/// shell reports back at its prompt, so the record can carry the command's
|
||||
/// exit code (see [`PendingHistory`]).
|
||||
@@ -448,6 +454,23 @@ struct PendingHistory {
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// Where a run of ⌥. presses has walked to (see
|
||||
/// [`TerminalView::last_word_nav`]).
|
||||
struct LastWordWalk {
|
||||
/// Index into `history` the last press took its word from.
|
||||
entry: usize,
|
||||
/// Char offset of the word it inserted — the next press swaps that span
|
||||
/// for the word from an older entry.
|
||||
at: usize,
|
||||
/// The word itself: both the span's length and a fingerprint. Edits that
|
||||
/// bypass `handle_editor_key` (IME-committed text, a paste, a completion
|
||||
/// pick, ⌘Z) can't clear `last_word_nav`, so before resuming, the walk
|
||||
/// checks the line still holds this word at `at` with the caret at its
|
||||
/// end — anything else means an edit intervened and the walk starts over
|
||||
/// rather than eating it.
|
||||
word: String,
|
||||
}
|
||||
|
||||
/// Seconds since the unix epoch — the timestamp history records carry.
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
@@ -1172,6 +1195,7 @@ impl TerminalView {
|
||||
ranked_cwd: None,
|
||||
history_nav: None,
|
||||
history_stash: String::new(),
|
||||
last_word_nav: None,
|
||||
pending_history: None,
|
||||
completion: None,
|
||||
completion_generation: 0,
|
||||
@@ -1655,11 +1679,7 @@ impl TerminalView {
|
||||
// Keep the cursor solid while typing (resets the blink phase).
|
||||
self.cursor_visible = true;
|
||||
// Typing clears the selection and jumps to the prompt.
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
// Consume so the key isn't also re-sent through the IME path.
|
||||
cx.stop_propagation();
|
||||
@@ -1776,11 +1796,43 @@ impl TerminalView {
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
self.cursor_visible = true;
|
||||
// The raw key path does this per keystroke; the editor owns the keyboard
|
||||
// at the prompt and every arm below returns early, so it has to happen
|
||||
// once here instead. Without it a key pressed while scrolled up edits a
|
||||
// line the viewport isn't showing (#208).
|
||||
self.jump_to_prompt();
|
||||
|
||||
// ⌃P / ⌃N are readline's spelling of ↑ / ↓ (0x10 / 0x0e on the wire, and
|
||||
// what the shell's own keymap answers when the editor isn't holding the
|
||||
// line). Rewrite them into the arrow keys here rather than giving them
|
||||
// arms of their own, so the two spellings can't drift apart — history
|
||||
// recall, multi-line steps, the completion picker and the reverse-search
|
||||
// menu all treat them identically from this point down.
|
||||
let aliased;
|
||||
let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") {
|
||||
aliased = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers::default(),
|
||||
key: if key == "p" { "up" } else { "down" }.to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
&aliased
|
||||
} else {
|
||||
ks
|
||||
};
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
|
||||
// Any key other than a vertical step drops the sticky goal column, so the
|
||||
// next ↑/↓ takes its column from wherever the caret ends up.
|
||||
if key != "up" && key != "down" {
|
||||
self.editor_goal_col = None;
|
||||
}
|
||||
// Likewise, only a repeat of ⌥. continues an insert-last-word walk —
|
||||
// anything else and the next press starts fresh at the newest entry
|
||||
// rather than swallowing whatever now sits left of the caret.
|
||||
if !(m.alt && key == ".") {
|
||||
self.last_word_nav = None;
|
||||
}
|
||||
|
||||
// A reverse search, when active, owns the keyboard.
|
||||
if self.reverse_search.is_some() {
|
||||
@@ -1845,8 +1897,9 @@ impl TerminalView {
|
||||
self.close_completion();
|
||||
|
||||
// Readline-style control combinations, delegated so this dispatcher stays
|
||||
// scannable. Every Ctrl chord is swallowed at the prompt (recognized or
|
||||
// not), so this always notifies and returns.
|
||||
// scannable. A chord the editor answers is consumed here; one it doesn't
|
||||
// goes on to the shell rather than dying at the prompt. Either way this
|
||||
// branch returns.
|
||||
if m.control && !m.platform && !m.alt {
|
||||
// Off macOS, word navigation and deletion live on Ctrl (the Windows /
|
||||
// Linux convention): Ctrl+←/→ move by word (Shift extends the
|
||||
@@ -1901,19 +1954,33 @@ impl TerminalView {
|
||||
self.handoff_line_to_shell(&[0x12], cx);
|
||||
return;
|
||||
}
|
||||
self.apply_readline_ctrl(key);
|
||||
cx.notify();
|
||||
if self.apply_readline_ctrl(key) {
|
||||
cx.notify();
|
||||
} else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) {
|
||||
// No local widget answers this chord. Swallowing it is the one
|
||||
// thing we mustn't do — the key worked before shell integration
|
||||
// engaged, and zle's keymap (⌃T transpose, a `bindkey` widget,
|
||||
// an fzf binding…) still knows what to do with it.
|
||||
self.handoff_line_to_shell(&bytes, cx);
|
||||
} else {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Readline-style Meta word chords on the edited line: M-b / M-f motions
|
||||
// and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On
|
||||
// macOS these are reachable only with `macos_option_as_alt` on — with it
|
||||
// off the chord composes a character upstream and arrives here altless,
|
||||
// through the printable-text arm. Other Alt+letter chords stay swallowed
|
||||
// no-ops as before (the local editor can't mirror every zle widget).
|
||||
// Readline-style Meta chords on the edited line: M-b / M-f motions,
|
||||
// M-d delete-word (mirroring the Alt+←/→/Delete handling below) and
|
||||
// M-. insert-last-word. On macOS these are reachable only with
|
||||
// `macos_option_as_alt` on — with it off the chord composes a character
|
||||
// upstream and arrives here altless, through the printable-text arm.
|
||||
// Meta chords with no arm here reach the shell instead of dying (see
|
||||
// the fallthrough at the bottom of the dispatcher).
|
||||
if m.alt && !m.platform && !m.control {
|
||||
match key {
|
||||
"." => {
|
||||
self.insert_last_word(cx);
|
||||
return;
|
||||
}
|
||||
"b" => {
|
||||
self.editor_move_h(false, m.shift, true);
|
||||
cx.notify();
|
||||
@@ -2038,7 +2105,7 @@ impl TerminalView {
|
||||
// events carrying `key_char`; feed them through the same commit path
|
||||
// the IME would use so the local editor sees the text. Skip control /
|
||||
// Cmd chords and any non-printable char (function keys have no
|
||||
// `key_char`; Alt combos stay editor no-ops as before).
|
||||
// `key_char`).
|
||||
_ => {
|
||||
if !m.control && !m.platform && !m.alt {
|
||||
if let Some(ch) = ks.key_char.as_deref() {
|
||||
@@ -2048,6 +2115,29 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Meta chord with nothing local behind it (M-t transpose-word,
|
||||
// M-u/M-l/M-c case widgets, whatever the user bound) goes to the
|
||||
// shell rather than dying here — same reasoning as the Ctrl side
|
||||
// above. The shared encoder goes first (it knows the shifted
|
||||
// character and the Kitty form when `key_char` is there to
|
||||
// consult), but the platforms that deliver Alt chords at all
|
||||
// don't reliably carry one — then fall back to ESC + the key
|
||||
// name, uppercased under Shift, as a raw terminal would send.
|
||||
if m.alt && !m.control && !m.platform && key.chars().count() == 1 {
|
||||
let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags())
|
||||
.unwrap_or_else(|| {
|
||||
let name = if m.shift {
|
||||
key.to_uppercase()
|
||||
} else {
|
||||
key.to_string()
|
||||
};
|
||||
let mut b = vec![0x1b];
|
||||
b.extend_from_slice(name.as_bytes());
|
||||
b
|
||||
});
|
||||
self.handoff_line_to_shell(&bytes, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
@@ -2055,14 +2145,18 @@ impl TerminalView {
|
||||
|
||||
/// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F
|
||||
/// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions
|
||||
/// (each removing the selection first if there is one), Ctrl-L clear-screen,
|
||||
/// Ctrl-R reverse search, Ctrl-C interrupt, and Ctrl-D EOF/forward-delete.
|
||||
/// Unrecognized chords are no-ops (the caller swallows every Ctrl combo at
|
||||
/// the prompt regardless).
|
||||
/// (each removing the selection first if there is one), Ctrl-Y yanking the
|
||||
/// last kill back, Ctrl-L clear-screen, Ctrl-R reverse search, Ctrl-C
|
||||
/// interrupt, and Ctrl-D EOF/forward-delete.
|
||||
///
|
||||
/// The caller resolves Ctrl-J / Ctrl-M (accept-line) and, when the history
|
||||
/// menu is switched off, Ctrl-R before this point — neither reaches here.
|
||||
fn apply_readline_ctrl(&mut self, key: &str) {
|
||||
/// Returns whether the chord was recognized: the caller hands the ones that
|
||||
/// weren't to the shell, so a widget tty7 has no answer for still reaches
|
||||
/// the keymap that does.
|
||||
///
|
||||
/// The caller resolves Ctrl-J / Ctrl-M (accept-line), Ctrl-P / Ctrl-N (the
|
||||
/// arrow keys by another name) and, when the history menu is switched off,
|
||||
/// Ctrl-R before this point — none of them reach here.
|
||||
fn apply_readline_ctrl(&mut self, key: &str) -> bool {
|
||||
match key {
|
||||
"r" => self.start_reverse_search(),
|
||||
"a" => {
|
||||
@@ -2103,6 +2197,10 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
"h" => self.cmd.backspace(),
|
||||
// Yank: the other half of ⌃W / ⌃U / ⌃K. Answered locally rather
|
||||
// than handed to the shell — zle keeps its own kill ring, and
|
||||
// yanking from it would paste text this editor never cut.
|
||||
"y" => self.cmd.yank(),
|
||||
"l" => {
|
||||
// Clear screen belongs to the shell/readline layer: send the
|
||||
// same form-feed byte the raw terminal path emits for Ctrl+L.
|
||||
@@ -2133,8 +2231,9 @@ impl TerminalView {
|
||||
self.cmd.delete();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Horizontal caret motion in the editor with selection semantics: Shift
|
||||
@@ -2272,6 +2371,19 @@ impl TerminalView {
|
||||
super::input::tab_bytes(shift, self.kitty_flags())
|
||||
}
|
||||
|
||||
/// The housekeeping every input path shares: drop the selection the key
|
||||
/// invalidated and bring the viewport back to the live prompt, whole lines
|
||||
/// (`display_offset`) and sub-line remainder (`scroll_frac`) alike. Acting
|
||||
/// on a line the user can't see is the thing to avoid — so this runs for
|
||||
/// keys handled locally too, not only for bytes that reach the PTY.
|
||||
fn jump_to_prompt(&mut self) {
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
drop(term);
|
||||
self.scroll_frac = 0.;
|
||||
}
|
||||
|
||||
/// Write a fixed byte sequence to the PTY (for keystrokes delivered as
|
||||
/// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying
|
||||
/// the same cursor / selection / scroll housekeeping as normal typing.
|
||||
@@ -2281,11 +2393,7 @@ impl TerminalView {
|
||||
}
|
||||
self.terminal.write(bytes.to_vec());
|
||||
self.cursor_visible = true;
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -3500,11 +3608,72 @@ impl TerminalView {
|
||||
self.terminal.write(submit_bytes(&line, bracketed));
|
||||
self.cmd.clear();
|
||||
self.cursor_visible = true;
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Readline's `yank-last-arg` (⌥.): drop the last word of the previous
|
||||
/// command at the caret. Repeating the chord walks further back through the
|
||||
/// history, each press swapping out the word the one before it inserted, so
|
||||
/// a run of presses leaves exactly one word behind. Entries with no words
|
||||
/// are stepped over rather than inserting nothing.
|
||||
fn insert_last_word(&mut self, cx: &mut Context<Self>) {
|
||||
// Only trust the recorded walk while the line still shows it: its word
|
||||
// sitting at `at`, caret at the word's end, nothing selected. The keys
|
||||
// this dispatcher sees reset `last_word_nav` themselves, but edits that
|
||||
// bypass it (IME-committed text, a paste, a completion pick, ⌘Z) don't
|
||||
// — resuming over those would delete text the walk never inserted.
|
||||
let resumed = self.last_word_nav.take().filter(|walk| {
|
||||
let len = walk.word.chars().count();
|
||||
self.cmd.cursor() == walk.at + len
|
||||
&& self.cmd.selection().is_none()
|
||||
&& self
|
||||
.cmd
|
||||
.text()
|
||||
.chars()
|
||||
.skip(walk.at)
|
||||
.take(len)
|
||||
.eq(walk.word.chars())
|
||||
});
|
||||
// A repeat resumes one entry older than the last press; a fresh walk
|
||||
// starts at the newest entry.
|
||||
let start = match &resumed {
|
||||
Some(walk) => walk.entry.checked_sub(1),
|
||||
None => self.history.len().checked_sub(1),
|
||||
};
|
||||
let Some(mut entry) = start else {
|
||||
// Nothing older to reach (or no history at all) — leave the line as
|
||||
// it stands, the word the previous press inserted included.
|
||||
self.last_word_nav = resumed;
|
||||
return;
|
||||
};
|
||||
let word = loop {
|
||||
if let Some(w) = self.history[entry].split_whitespace().next_back() {
|
||||
break w.to_string();
|
||||
}
|
||||
let Some(older) = entry.checked_sub(1) else {
|
||||
self.last_word_nav = resumed;
|
||||
return;
|
||||
};
|
||||
entry = older;
|
||||
};
|
||||
|
||||
// Take back what the previous press left, so the walk swaps words in
|
||||
// place rather than piling them up.
|
||||
if let Some(walk) = resumed {
|
||||
self.cmd.clear_selection();
|
||||
self.cmd.set_cursor(walk.at);
|
||||
self.cmd.extend_to(walk.at + walk.word.chars().count());
|
||||
self.cmd.delete_selection();
|
||||
}
|
||||
self.cmd.insert_str(&word);
|
||||
// `insert_str` replaces a live selection first, which moves the caret
|
||||
// to the selection's start — so the word's position is wherever the
|
||||
// caret landed minus the word, not the pre-insert cursor.
|
||||
let at = self.cmd.cursor() - word.chars().count();
|
||||
self.last_word_nav = Some(LastWordWalk { entry, at, word });
|
||||
// The line is now the user's own edit, not a recalled entry.
|
||||
self.history_nav = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -4228,6 +4397,9 @@ impl TerminalView {
|
||||
self.cmd.insert_str(text);
|
||||
self.history_nav = None;
|
||||
self.editor_goal_col = None;
|
||||
// Typed text ends an ⌥. run: IME-committed text bypasses
|
||||
// `handle_editor_key`'s reset, so it has to happen here too.
|
||||
self.last_word_nav = None;
|
||||
self.completion_refilter();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
@@ -4239,11 +4411,7 @@ impl TerminalView {
|
||||
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
|
||||
// Keep the cursor solid while committing input (resets the blink phase).
|
||||
self.cursor_visible = true;
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -7926,10 +8094,365 @@ mod gpui_tests {
|
||||
assert_eq!(view.cmd.cursor(), 0);
|
||||
view.handle_editor_key(&meta("f"), cx);
|
||||
assert_eq!(view.cmd.cursor(), 4);
|
||||
// Other Meta letters stay swallowed no-ops (line untouched).
|
||||
// Other Meta letters have no local widget, so they hand the line
|
||||
// to the shell rather than dying here — see
|
||||
// `an_unknown_meta_chord_goes_to_the_shell_with_the_line`.
|
||||
view.handle_editor_key(&meta("z"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo ");
|
||||
assert_eq!(view.cmd.cursor(), 4);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Fill the scrollback and park the viewport `offset` lines up inside it,
|
||||
/// so a test can watch a keystroke snap it back to the live prompt.
|
||||
fn scroll_into_history(view: &TerminalView, offset: usize) {
|
||||
let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default();
|
||||
let mut term = view.terminal.term.lock();
|
||||
parser.advance(&mut *term, &b"line\r\n".repeat(60));
|
||||
term.scroll_display(Scroll::Delta(offset as i32));
|
||||
assert_eq!(
|
||||
term.grid().display_offset(),
|
||||
offset,
|
||||
"the viewport starts parked in the scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
fn display_offset(view: &TerminalView) -> usize {
|
||||
view.terminal.term.lock().grid().display_offset()
|
||||
}
|
||||
|
||||
/// Scrolled up into the scrollback, recalling history with ↑ must bring the
|
||||
/// viewport back to the live prompt (#208). The local editor owns ↑ and
|
||||
/// returns early, so it never reached the "typing jumps to the prompt"
|
||||
/// housekeeping on the raw key path — leaving the user editing a line they
|
||||
/// cannot see.
|
||||
#[gpui::test]
|
||||
fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.history = vec!["echo hello".to_string()];
|
||||
scroll_into_history(view, 10);
|
||||
view.scroll_frac = 0.5;
|
||||
|
||||
view.handle_editor_key(&key("up"), cx);
|
||||
|
||||
assert_eq!(view.cmd.text(), "echo hello", "↑ recalled the entry");
|
||||
assert_eq!(display_offset(view), 0, "and the viewport followed it down");
|
||||
assert_eq!(view.scroll_frac, 0., "the sub-line remainder reset too");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// ⌃P / ⌃N are readline's history motions, and a raw terminal passes them
|
||||
/// to the shell as 0x10 / 0x0e. The local editor swallows every Ctrl chord
|
||||
/// at the prompt, so without arms of their own they went from "works" to
|
||||
/// "does nothing" the moment shell integration engaged.
|
||||
#[gpui::test]
|
||||
fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.history = ["git status", "cargo build", "echo hello"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
// ⌃P walks back from the newest entry.
|
||||
view.handle_editor_key(&key("ctrl-p"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello");
|
||||
view.handle_editor_key(&key("ctrl-p"), cx);
|
||||
assert_eq!(view.cmd.text(), "cargo build");
|
||||
// ⌃N walks forward again.
|
||||
view.handle_editor_key(&key("ctrl-n"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello");
|
||||
// Past the newest entry the in-progress line comes back.
|
||||
view.handle_editor_key(&key("ctrl-n"), cx);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A Ctrl chord the local editor has no widget for used to be swallowed, so
|
||||
/// engaging shell integration *removed* ⌃T, ⌥T, ⌥U and every `bindkey`
|
||||
/// widget the user had bound. Hand the line to zle instead and let its
|
||||
/// keymap answer — the same escape hatch ⌃R already uses.
|
||||
#[gpui::test]
|
||||
fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
// ⌃T is readline's transpose-chars; tty7 has no widget for it.
|
||||
view.handle_editor_key(&key("ctrl-t"), cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"",
|
||||
"the line left for the shell, so the local buffer is empty"
|
||||
);
|
||||
assert!(
|
||||
view.editor_handoff.is_some(),
|
||||
"the local editor stands down for the rest of the line"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
|
||||
assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell");
|
||||
}
|
||||
|
||||
/// The Meta half of the same gap: ⌥U (upcase-word) and friends were dead at
|
||||
/// the prompt. Unrecognized Meta chords ship the line and the ESC-prefixed
|
||||
/// key, the way a raw terminal would have.
|
||||
#[gpui::test]
|
||||
fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
view.handle_editor_key(
|
||||
&gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: "u".to_string(),
|
||||
key_char: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
|
||||
assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec());
|
||||
}
|
||||
|
||||
/// ⌃W / ⌃U / ⌃K are *kills*, and ⌃Y is what puts a kill back — without it
|
||||
/// the pair was half-implemented: the editor cut text with nowhere to paste
|
||||
/// it from. ⌃Y has to stay local rather than reaching the shell, because
|
||||
/// zle's kill ring is a different buffer and would yank unrelated text.
|
||||
#[gpui::test]
|
||||
fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hello world");
|
||||
view.handle_editor_key(&key("ctrl-w"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello ");
|
||||
view.handle_editor_key(&key("ctrl-y"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello world");
|
||||
assert!(
|
||||
view.editor_handoff.is_none(),
|
||||
"the line never left for the shell"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// ⌥. is readline's `yank-last-arg`: it pulls the last word of the previous
|
||||
/// command into the line, and repeating it walks further back through the
|
||||
/// history, replacing what the last press inserted. Frequent enough that
|
||||
/// paying the handoff cost (ghost text and completion gone for the rest of
|
||||
/// the line) on every press would be the wrong trade — tty7 holds the same
|
||||
/// history, so it answers locally.
|
||||
#[gpui::test]
|
||||
fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["git status", "cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.cmd.set("ls ");
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls world", "newest entry's last word");
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back");
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls status");
|
||||
// Nothing older to reach: the line holds what it had.
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls status");
|
||||
// The caret sits after the inserted word, ready to keep typing.
|
||||
assert_eq!(view.cmd.cursor(), "ls status".chars().count());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The walk is only a walk while ⌥. repeats. Once another key edits the
|
||||
/// line, the next ⌥. starts over from the newest entry instead of eating
|
||||
/// whatever happens to sit left of the caret.
|
||||
#[gpui::test]
|
||||
fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "world");
|
||||
view.handle_editor_key(&key("left"), cx);
|
||||
view.handle_editor_key(&key("end"), cx);
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"worldworld",
|
||||
"a fresh walk appends rather than replacing the earlier word"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Edits that bypass `handle_editor_key` — IME-committed text is the
|
||||
/// everyday one (it's how all typing arrives on macOS and Windows) — must
|
||||
/// end the walk too. Without that, the next ⌥. deletes the span the walk
|
||||
/// recorded even though the user's typing now sits inside it.
|
||||
#[gpui::test]
|
||||
fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
// `commit_text` edits the local line only while the editor is engaged
|
||||
// at a shell prompt; anywhere else it writes gap text to the PTY.
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: None,
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait_for_input_active(&window, cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "world");
|
||||
view.commit_text("x", cx);
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"worldxworld",
|
||||
"the typed char survives; the walk starts over after it"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// ⌥. with a selection active: the word replaces the selection (insertion
|
||||
/// replaces selections everywhere in this editor), and the walk records
|
||||
/// where the word actually landed — the caret the selection collapsed to,
|
||||
/// not where the caret stood before the insert — so a repeat swaps the
|
||||
/// word cleanly.
|
||||
#[gpui::test]
|
||||
fn meta_dot_over_a_selection_records_where_the_word_landed(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.cmd.set("ls foo");
|
||||
// Select "foo" with the caret at the selection's far end.
|
||||
view.cmd.set_cursor(3);
|
||||
view.cmd.extend_to(6);
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"ls world",
|
||||
"the word replaced the selection"
|
||||
);
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"ls --release",
|
||||
"the repeat swapped the word, not some other span"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A shifted Meta chord must ship the shifted character: ⌥⇧U is `ESC U`
|
||||
/// on the wire (upcase-region in zsh's keymap), not the `ESC u` of plain
|
||||
/// ⌥U — gpui reports the key name unshifted, so the handoff has to apply
|
||||
/// Shift itself when no `key_char` is there to consult.
|
||||
#[gpui::test]
|
||||
fn a_shifted_meta_chord_hands_off_the_shifted_character(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
view.handle_editor_key(
|
||||
&gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: "u".to_string(),
|
||||
key_char: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
|
||||
assert_eq!(next_input(&mut daemon), b"\x1bU".to_vec());
|
||||
}
|
||||
|
||||
/// Chords the editor *does* answer stay local — handing off would forfeit
|
||||
/// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are
|
||||
/// exactly the keys pressed most often mid-edit.
|
||||
#[gpui::test]
|
||||
fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
view.handle_editor_key(&key("ctrl-w"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo ", "⌃W cut the word locally");
|
||||
assert!(view.editor_handoff.is_none());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
+12
-3
@@ -2506,6 +2506,12 @@ impl Tty7App {
|
||||
self.update_config(cx, |cfg| cfg.check_for_updates = on);
|
||||
}
|
||||
|
||||
/// Toggle inactive-pane dimming. Applies on the next render — `update_config`
|
||||
/// notifies, and this view's render is what hands the flag to the pane tree.
|
||||
pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.dim_inactive_panes = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.cursor_blink = on);
|
||||
// Turning blink off mid-cycle could leave the cursor in its hidden phase;
|
||||
@@ -5208,7 +5214,7 @@ impl Render for Tty7App {
|
||||
.get(self.active)
|
||||
.and_then(|t| t.pane.focused_or_first(window, cx))
|
||||
.and_then(|leaf| self.render_ssh_status_strip(&leaf, cx));
|
||||
// Render the active tab's pane tree; show focus rings only when split.
|
||||
// Render the active tab's pane tree.
|
||||
let body = match self.tabs.get(self.active) {
|
||||
// Zero tabs: the window's own face — the home page (see `ui::home`).
|
||||
None => self.render_home(cx).into_any_element(),
|
||||
@@ -5229,8 +5235,11 @@ impl Render for Tty7App {
|
||||
.child(leaf.clone())
|
||||
.into_any_element(),
|
||||
None => {
|
||||
let show_focus = active_tab.pane.leaves().len() > 1;
|
||||
active_tab.pane.render(show_focus, window, cx)
|
||||
// Fading the unfocused panes only says anything once the
|
||||
// tab is actually split, and the user can turn it off.
|
||||
let dim_inactive = active_tab.pane.leaves().len() > 1
|
||||
&& cx.global::<Config>().dim_inactive_panes;
|
||||
active_tab.pane.render(dim_inactive, window, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-14
@@ -524,26 +524,33 @@ impl Pane<Entity<TerminalView>> {
|
||||
self.close_leaf_where(&|v| v.entity_id() == target.entity_id())
|
||||
}
|
||||
|
||||
/// Render the subtree. `show_focus` draws a focus ring on the active leaf
|
||||
/// (suppressed when the tab has a single pane).
|
||||
pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement {
|
||||
/// Render the subtree. `dim_inactive` fades every leaf but the focused one;
|
||||
/// the caller decides it — it is off for an unsplit tab (nothing to
|
||||
/// distinguish) and off when the user turned `dim_inactive_panes` off. Kept
|
||||
/// a parameter rather than a `Config` global read here so the tree stays
|
||||
/// renderable without one, as the rest of this module is.
|
||||
pub fn render(
|
||||
&self,
|
||||
dim_inactive: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> gpui::AnyElement {
|
||||
match self {
|
||||
Pane::Empty => div().into_any_element(),
|
||||
Pane::Leaf(v) => {
|
||||
let focused = show_focus && v.read(cx).focus_handle.contains_focused(window, cx);
|
||||
let focused = v.read(cx).focus_handle.contains_focused(window, cx);
|
||||
// No full border (it reads as a hard rectangle).
|
||||
div()
|
||||
.size_full()
|
||||
.relative()
|
||||
.overflow_hidden()
|
||||
// Inactive panes (only when the tab is actually split) fade back
|
||||
// so the focused terminal reads as foreground without a hard
|
||||
// border. Element opacity multiplies through the whole subtree
|
||||
// (terminal glyphs + cell fills), unlike a background-tinted
|
||||
// scrim which is near-invisible on a light theme (white on
|
||||
// white). Applied to the container, so a click still lands on
|
||||
// the terminal and focuses it.
|
||||
.when(show_focus && !focused, |d| d.opacity(0.55))
|
||||
// Inactive panes fade back so the focused terminal reads as
|
||||
// foreground without a hard border. Element opacity multiplies
|
||||
// through the whole subtree (terminal glyphs + cell fills),
|
||||
// unlike a background-tinted scrim which is near-invisible on a
|
||||
// light theme (white on white). Applied to the container, so a
|
||||
// click still lands on the terminal and focuses it.
|
||||
.when(dim_inactive && !focused, |d| d.opacity(0.55))
|
||||
.child(v.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -674,7 +681,7 @@ impl Pane<Entity<TerminalView>> {
|
||||
.flex_basis(px(0.))
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
.child(a.render(show_focus, window, cx)),
|
||||
.child(a.render(dim_inactive, window, cx)),
|
||||
)
|
||||
.child(divider)
|
||||
.child(
|
||||
@@ -684,7 +691,7 @@ impl Pane<Entity<TerminalView>> {
|
||||
.flex_basis(px(0.))
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
.child(b.render(show_focus, window, cx)),
|
||||
.child(b.render(dim_inactive, window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
+35
-3
@@ -1109,7 +1109,7 @@ struct BuiltinSpec {
|
||||
}
|
||||
|
||||
/// A hand-picked set of familiar terminal palettes.
|
||||
static BUILTINS: [BuiltinSpec; 8] = [
|
||||
static BUILTINS: [BuiltinSpec; 9] = [
|
||||
BuiltinSpec {
|
||||
id: "light",
|
||||
name: "Light",
|
||||
@@ -1295,6 +1295,35 @@ static BUILTINS: [BuiltinSpec; 8] = [
|
||||
(0xff, 0xff, 0xff),
|
||||
],
|
||||
},
|
||||
BuiltinSpec {
|
||||
id: "one_dark_pro",
|
||||
name: "One Dark Pro",
|
||||
background: 0x282c34,
|
||||
foreground: 0xabb2bf,
|
||||
// The editor cursor / focus blue, not the syntax blue `#61afef`: the
|
||||
// accent doubles as the switch's checked track, and `#61afef` sits at
|
||||
// the same luminance as the `#abb2bf` knob (1.11:1 — invisible).
|
||||
accent: 0x528bff,
|
||||
caret: None,
|
||||
ansi16: [
|
||||
(0x3f, 0x44, 0x51),
|
||||
(0xe0, 0x6c, 0x75),
|
||||
(0x98, 0xc3, 0x79),
|
||||
(0xe5, 0xc0, 0x7b),
|
||||
(0x61, 0xaf, 0xef),
|
||||
(0xc6, 0x78, 0xdd),
|
||||
(0x56, 0xb6, 0xc2),
|
||||
(0xab, 0xb2, 0xbf),
|
||||
(0x5c, 0x63, 0x70),
|
||||
(0xff, 0x61, 0x6e),
|
||||
(0xa5, 0xe0, 0x75),
|
||||
(0xf0, 0xa4, 0x5d),
|
||||
(0x4d, 0xc4, 0xff),
|
||||
(0xde, 0x73, 0xff),
|
||||
(0x4c, 0xd1, 0xe0),
|
||||
(0xe6, 0xe6, 0xe6),
|
||||
],
|
||||
},
|
||||
BuiltinSpec {
|
||||
id: "rose_pine",
|
||||
name: "Rosé Pine",
|
||||
@@ -1341,7 +1370,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Brightness is inferred correctly: the four light built-ins classify light,
|
||||
/// the four dark ones dark.
|
||||
/// the five dark ones dark.
|
||||
#[test]
|
||||
fn dark_is_inferred_from_background() {
|
||||
let dark: Vec<_> = builtins()
|
||||
@@ -1349,7 +1378,10 @@ mod tests {
|
||||
.filter(|t| t.dark)
|
||||
.map(|t| t.id)
|
||||
.collect();
|
||||
assert_eq!(dark, ["dark", "dracula", "harbor", "rose_pine"]);
|
||||
assert_eq!(
|
||||
dark,
|
||||
["dark", "dracula", "harbor", "one_dark_pro", "rose_pine"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The selection surface must stay a *tint* — decisively on the background's
|
||||
|
||||
+25
-4
@@ -144,6 +144,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Blur",
|
||||
keywords: "transparency translucent frosted vibrancy window background",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Dim inactive panes",
|
||||
keywords: "fade unfocused inactive split pane focus opacity highlight active dimming",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Appearance,
|
||||
title: "Font size",
|
||||
@@ -1518,10 +1523,12 @@ impl Tty7App {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Window section (Appearance): global opacity slider + blur switch that
|
||||
/// apply to every theme. Both are config *overrides* — until touched they
|
||||
/// follow the active theme's own `opacity`/`blur`, and "Follow theme"
|
||||
/// clears them back to that state.
|
||||
/// Window section (Appearance): the global opacity slider and blur switch
|
||||
/// that apply to every theme, then the inactive-pane dimming switch. The
|
||||
/// first two are config *overrides* — until touched they follow the active
|
||||
/// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that
|
||||
/// state; the dimming switch is a plain flag no theme carries a value for,
|
||||
/// so it sits below that button and "Follow theme" leaves it alone.
|
||||
fn render_window_section(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(slider) = self
|
||||
.active_settings()
|
||||
@@ -1531,6 +1538,7 @@ impl Tty7App {
|
||||
};
|
||||
let config = cx.global::<Config>();
|
||||
let overridden = config.window_opacity.is_some() || config.window_blur.is_some();
|
||||
let dim_inactive_panes = config.dim_inactive_panes;
|
||||
let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx));
|
||||
let opacity = Tty7App::effective_window_opacity(cx);
|
||||
let blur = cx.global::<Config>().window_blur.unwrap_or(theme.blur);
|
||||
@@ -1554,6 +1562,10 @@ impl Tty7App {
|
||||
cx.listener(|this, on: &bool, window, cx| this.set_window_blur(*on, window, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
let dim_switch = crate::ui::theme::switch("dim-inactive-panes", cx)
|
||||
.checked(dim_inactive_panes)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_dim_inactive_panes(*on, cx)))
|
||||
.into_any_element();
|
||||
|
||||
v_flex()
|
||||
// Not "Window": Settings → Window & Tabs owns that word for the
|
||||
@@ -1587,6 +1599,14 @@ impl Tty7App {
|
||||
),
|
||||
)
|
||||
})
|
||||
// Below "Follow theme", which resets the two rows above it and not
|
||||
// this one — a plain setting with no theme value behind it.
|
||||
.child(self.settings_row(
|
||||
"Dim inactive panes",
|
||||
"Fade unfocused panes in a split so the active one stands out.",
|
||||
dim_switch,
|
||||
cx,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -4612,6 +4632,7 @@ mod tests {
|
||||
"Sidebar grouping",
|
||||
"Tab completion",
|
||||
"History search",
|
||||
"Dim inactive panes",
|
||||
] {
|
||||
assert!(
|
||||
settings_search_entries().iter().any(|e| e.title == title),
|
||||
|
||||
Reference in New Issue
Block a user