mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
Merge pull request #128 from l0ng-ai/feat/smart-select
feat(terminal): smart double-click selection with CJK segmentation
This commit is contained in:
Generated
+178
-1
@@ -116,6 +116,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "adler32"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.6.1"
|
||||
@@ -1007,6 +1013,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
@@ -1152,6 +1164,15 @@ dependencies = [
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cedarwood"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
@@ -1814,6 +1835,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dary_heap"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
@@ -4035,6 +4062,39 @@ version = "1.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
|
||||
|
||||
[[package]]
|
||||
name = "include-flate"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce"
|
||||
dependencies = [
|
||||
"include-flate-codegen",
|
||||
"include-flate-compress",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate-codegen"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0"
|
||||
dependencies = [
|
||||
"include-flate-compress",
|
||||
"proc-macro-error3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate-compress"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd"
|
||||
dependencies = [
|
||||
"libflate",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -4221,6 +4281,30 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jieba-macros"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38fc0f3831de71556de69643b80a08a5c8cd260a23c6b8dbeb7cd923c779cac5"
|
||||
dependencies = [
|
||||
"phf_codegen 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jieba-rs"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a813bbf185c8c62eb6fcf54a223177b644824d91612045dfd80bb779acd080eb"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"cedarwood",
|
||||
"include-flate",
|
||||
"jieba-macros",
|
||||
"phf 0.13.1",
|
||||
"regex",
|
||||
"rustc-hash 2.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -4476,6 +4560,30 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libflate"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd96e993e5f3368b0cb8497dae6c860c22af8ff18388c61c6c0b86c58d86b5df"
|
||||
dependencies = [
|
||||
"adler32",
|
||||
"crc32fast",
|
||||
"dary_heap",
|
||||
"libflate_lz77",
|
||||
"no_std_io2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libflate_lz77"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"no_std_io2",
|
||||
"rle-decode-fast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.13"
|
||||
@@ -4758,7 +4866,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
"phf_codegen 0.11.3",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
@@ -5950,6 +6058,16 @@ dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
@@ -6370,6 +6488,16 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr3"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
@@ -6382,6 +6510,18 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error3"
|
||||
version = "3.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -6956,6 +7096,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rle-decode-fast"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422"
|
||||
|
||||
[[package]]
|
||||
name = "ropey"
|
||||
version = "2.0.0-beta.1"
|
||||
@@ -9026,12 +9172,14 @@ version = "26.7.1"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"core-foundation 0.10.0",
|
||||
"getrandom 0.3.4",
|
||||
"gpui",
|
||||
"gpui-component",
|
||||
"gpui-component-assets",
|
||||
"gpui_platform",
|
||||
"image",
|
||||
"jieba-rs",
|
||||
"keyring",
|
||||
"ksni",
|
||||
"libc",
|
||||
@@ -9045,6 +9193,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"plist",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest_client",
|
||||
"resvg",
|
||||
"russh",
|
||||
@@ -11110,6 +11259,34 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ztracing"
|
||||
version = "0.1.0"
|
||||
|
||||
+17
@@ -21,6 +21,9 @@ gpui-component-assets = { workspace = true }
|
||||
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
# Smart double-click selection patterns (URL/email/path). Already in the tree
|
||||
# transitively, so pinning it here adds no new native code.
|
||||
regex = "1"
|
||||
smol.workspace = true
|
||||
smallvec.workspace = true
|
||||
serde = { workspace = true }
|
||||
@@ -171,12 +174,26 @@ winresource = "0.1"
|
||||
# app appearance to match the active theme (see `ui::theme::sync_native_appearance`)
|
||||
# so the native traffic-light buttons render in the right light/dark style.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# CFStringTokenizer FFI for dictionary-based CJK word segmentation on
|
||||
# double-click (`terminal::smart_select`). Already in the tree transitively,
|
||||
# and it makes jieba unnecessary here — see the non-macos section below.
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics", "NSImage"] }
|
||||
# NSData feeds the runtime Dock icon for bare (non-bundled) binaries — see
|
||||
# `set_dock_icon_for_bare_binary` in main.rs.
|
||||
objc2-foundation = { version = "0.3", features = ["NSData"] }
|
||||
|
||||
# Dictionary-based Chinese word segmentation for double-click selection
|
||||
# (`terminal::smart_select`), as a *fallback* where the OS has no tokenizer of
|
||||
# its own. macOS is excluded on purpose: CFStringTokenizer segments Chinese
|
||||
# about as well (and Japanese/Korean far better) at zero cost, while jieba's
|
||||
# table costs ~55 MB resident once built and ~2 MB of binary for the embedded
|
||||
# dictionary. Keeping the dep off macOS means that dictionary isn't even
|
||||
# linked into the build that can't use it.
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
jieba-rs = "0.10"
|
||||
|
||||
# x11/wayland are the Linux windowing backends; only pull them on Linux. The
|
||||
# Windows backend (`gpui_windows`) and macOS backend are selected by gpui_platform
|
||||
# itself via `cfg`, so no feature is needed for them.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
- **Repo-grouped sidebar** — the left tab sidebar groups rows under a header per git repository, non-repo tabs in a trailing *Scratch* section; branch switches and in-repo `cd`s never move a row (`sidebar_grouping` in `config.json`: `repo` default, `none` for a flat list)
|
||||
- **Command palette** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
|
||||
- **⌘-click links** · 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
|
||||
- **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`
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *Scratch* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表)
|
||||
- **命令面板** <kbd>⌘ P</kbd> · 回滚搜索 <kbd>⌘ F</kbd>
|
||||
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 终端 → 剪贴板)
|
||||
- **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 终端 → 鼠标可开关;分隔符用 `config.json` 的 `word_separators` 配置)
|
||||
- **8 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择
|
||||
- **跟随系统外观** — 设置 → Appearance;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`)
|
||||
- **窗口透明与模糊** — 设置 → Appearance → Window;对所有主题生效,*Follow theme* 恢复主题自带的 `opacity` / `blur`
|
||||
|
||||
@@ -182,6 +182,19 @@ pub struct Config {
|
||||
/// the clipboard is never overwritten by a stray selection unless opted
|
||||
/// into.
|
||||
pub copy_on_select: bool,
|
||||
/// Double-click smart selection: expand the selection to the whole URL,
|
||||
/// email address, file path, or matching bracket pair under the cursor
|
||||
/// when the plain word sits inside one. On by default; off restores the
|
||||
/// bare word-boundary double-click.
|
||||
#[serde(default = "default_true")]
|
||||
pub smart_select: bool,
|
||||
/// Characters (besides whitespace) that end a double-click word
|
||||
/// selection, in both the terminal grid and the prompt's command editor.
|
||||
/// The default mirrors alacritty's semantic escape set — note `/ . - _`
|
||||
/// are *not* separators, so paths select as one word. JSON-only (no GUI
|
||||
/// widget yet).
|
||||
#[serde(default = "default_word_separators")]
|
||||
pub word_separators: String,
|
||||
/// Window state at launch: normal / maximized / fullscreen.
|
||||
#[serde(default, deserialize_with = "de_lenient")]
|
||||
pub startup_mode: StartupMode,
|
||||
@@ -494,6 +507,8 @@ impl Default for Config {
|
||||
mouse_reporting: true,
|
||||
clipboard_trim_trailing_spaces: false,
|
||||
copy_on_select: false,
|
||||
smart_select: true,
|
||||
word_separators: default_word_separators(),
|
||||
startup_mode: StartupMode::Normal,
|
||||
remember_window_size: true,
|
||||
working_directory: WorkingDirectory::default(),
|
||||
@@ -762,6 +777,13 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::word_separators`]: alacritty's stock semantic
|
||||
/// escape set, the boundary characters double-click word selection used
|
||||
/// before this was configurable.
|
||||
fn default_word_separators() -> String {
|
||||
",│`|:\"' ()[]{}<>\t".to_string()
|
||||
}
|
||||
|
||||
/// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a
|
||||
/// command had to cross before this was configurable.
|
||||
fn default_notify_threshold_secs() -> u64 {
|
||||
|
||||
+96
-18
@@ -222,24 +222,76 @@ impl CmdEditor {
|
||||
self.cursor = self.chars.len();
|
||||
}
|
||||
|
||||
/// Bounds `(start, end)` of the word (run of non-whitespace) containing char
|
||||
/// index `idx`. On whitespace this collapses to `(idx, idx)`.
|
||||
pub fn word_bounds(&self, idx: usize) -> (usize, usize) {
|
||||
/// Bounds `(start, end)` of the word containing char index `idx`: the run
|
||||
/// of chars that are neither whitespace nor in `separators` (the
|
||||
/// configured word-separator set, shared with the grid's semantic
|
||||
/// selection). A separator char is its own one-char word, matching the
|
||||
/// grid; on whitespace the run collapses and the leftward walk snaps to
|
||||
/// the previous word's start.
|
||||
///
|
||||
/// `smart` mirrors `Config::smart_select`: with it off this is exactly
|
||||
/// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt
|
||||
/// editor and the grid alike.
|
||||
pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
|
||||
let idx = idx.min(self.chars.len());
|
||||
if !smart {
|
||||
return self.plain_word_bounds(idx, separators, smart);
|
||||
}
|
||||
// A bracket or quote selects through its match, same as the grid.
|
||||
// Checked before CJK segmentation so full-width `()`/`“”` pair
|
||||
// instead of being segmented as lone punctuation tokens. Only for
|
||||
// the double-click itself — drags use `plain_word_bounds` so the
|
||||
// selection doesn't lurch when the pointer crosses a quote.
|
||||
if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) {
|
||||
return (s, e + 1);
|
||||
}
|
||||
// CJK prose has no separators between words: segment it with the
|
||||
// platform dictionary instead of selecting the whole unbroken run.
|
||||
if let Some(&c) = self.chars.get(idx)
|
||||
&& super::smart_select::is_cjk(c)
|
||||
{
|
||||
let text: String = self.chars.iter().collect();
|
||||
if let Some((s, e)) = super::smart_select::cjk_word_range(&text, idx) {
|
||||
return (s, e + 1);
|
||||
}
|
||||
}
|
||||
self.plain_word_bounds(idx, separators, smart)
|
||||
}
|
||||
|
||||
/// [`Self::word_bounds`] without the pair/segmentation smarts: the plain
|
||||
/// separator-walk word. Used for word-granular drags, where pair matching
|
||||
/// would make the selection jump around as the pointer crosses a quote.
|
||||
/// `smart` still governs the mixed-script narrowing, so a drag matches
|
||||
/// what the double-click that started it selected.
|
||||
fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) {
|
||||
let idx = idx.min(self.chars.len());
|
||||
if let Some(&c) = self.chars.get(idx)
|
||||
&& !c.is_whitespace()
|
||||
&& separators.contains(c)
|
||||
{
|
||||
return (idx, idx + 1);
|
||||
}
|
||||
let boundary = |c: char| c.is_whitespace() || separators.contains(c);
|
||||
let mut s = idx;
|
||||
while s > 0 && !self.chars[s - 1].is_whitespace() {
|
||||
while s > 0 && !boundary(self.chars[s - 1]) {
|
||||
s -= 1;
|
||||
}
|
||||
let mut e = idx;
|
||||
while e < self.chars.len() && !self.chars[e].is_whitespace() {
|
||||
while e < self.chars.len() && !boundary(self.chars[e]) {
|
||||
e += 1;
|
||||
}
|
||||
// Mixed-script runs (a Latin word glued to CJK text) shrink to the
|
||||
// clicked char's script class — same correction as the grid's.
|
||||
if smart && idx < e {
|
||||
let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1);
|
||||
return (ns, ne + 1);
|
||||
}
|
||||
(s, e)
|
||||
}
|
||||
|
||||
/// Select the word (run of non-whitespace) containing char index `idx`.
|
||||
pub fn select_word_at(&mut self, idx: usize) {
|
||||
let (s, e) = self.word_bounds(idx);
|
||||
/// Select the word containing char index `idx` (see [`Self::word_bounds`]).
|
||||
pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) {
|
||||
let (s, e) = self.word_bounds(idx, separators, smart);
|
||||
self.anchor = Some(s);
|
||||
self.cursor = e;
|
||||
}
|
||||
@@ -249,8 +301,15 @@ impl CmdEditor {
|
||||
/// selection grows by whole words: dragging past the anchor word selects
|
||||
/// forward to the far edge of the word under `idx`, dragging before it selects
|
||||
/// backward to that word's near edge. The cursor sits at the moving edge.
|
||||
pub fn extend_word_to(&mut self, anchor_start: usize, anchor_end: usize, idx: usize) {
|
||||
let (ws, we) = self.word_bounds(idx.min(self.chars.len()));
|
||||
pub fn extend_word_to(
|
||||
&mut self,
|
||||
anchor_start: usize,
|
||||
anchor_end: usize,
|
||||
idx: usize,
|
||||
separators: &str,
|
||||
smart: bool,
|
||||
) {
|
||||
let (ws, we) = self.plain_word_bounds(idx.min(self.chars.len()), separators, smart);
|
||||
if we >= anchor_end {
|
||||
self.anchor = Some(anchor_start);
|
||||
self.cursor = we;
|
||||
@@ -573,15 +632,34 @@ mod tests {
|
||||
assert_eq!(e.selection(), None);
|
||||
}
|
||||
|
||||
/// The default word-separator set (mirrors `Config::word_separators`).
|
||||
const SEPS: &str = ",│`|:\"' ()[]{}<>\t";
|
||||
|
||||
#[test]
|
||||
fn select_word_and_all() {
|
||||
let mut e = ed("git push origin", 6);
|
||||
e.select_word_at(6); // cursor on "push"
|
||||
e.select_word_at(6, SEPS, true); // cursor on "push"
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push"));
|
||||
e.select_all();
|
||||
assert_eq!(e.selection(), Some((0, 15)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_word_stops_at_separators() {
|
||||
// Quotes and commas bound a word; a separator char is its own word.
|
||||
let mut e = ed("echo 'a,b'", 0);
|
||||
e.select_word_at(6, SEPS, true); // on "a"
|
||||
assert_eq!(e.selected_text().as_deref(), Some("a"));
|
||||
e.select_word_at(7, SEPS, true); // on the comma itself
|
||||
assert_eq!(e.selected_text().as_deref(), Some(","));
|
||||
e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close
|
||||
assert_eq!(e.selected_text().as_deref(), Some("'a,b'"));
|
||||
// `/ . - _ =` are not separators: a path stays one word.
|
||||
let mut e = ed("cat ./a-b/c_d.txt", 0);
|
||||
e.select_word_at(8, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_to_keeps_anchor() {
|
||||
let mut e = ed("abcdef", 2);
|
||||
@@ -595,20 +673,20 @@ mod tests {
|
||||
fn extend_word_to_grows_by_whole_words_both_directions() {
|
||||
// Double-click "push" (chars 4..8), then drag over later/earlier words.
|
||||
let mut e = ed("git push origin main", 4);
|
||||
e.select_word_at(6);
|
||||
e.select_word_at(6, SEPS, true);
|
||||
let (s, a) = e.selection().unwrap(); // (4, 8) == "push"
|
||||
assert_eq!((s, a), (4, 8));
|
||||
|
||||
// Drag forward into "origin": selection reaches that word's far edge.
|
||||
e.extend_word_to(s, a, 10);
|
||||
e.extend_word_to(s, a, 10, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push origin"));
|
||||
// Drag on into "main": grows to its end.
|
||||
e.extend_word_to(s, a, 18);
|
||||
e.extend_word_to(s, a, 18, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("push origin main"));
|
||||
|
||||
// Drag backward before the anchor word into "git": anchor flips to the
|
||||
// word's far edge, selection covers "git push".
|
||||
e.extend_word_to(s, a, 1);
|
||||
e.extend_word_to(s, a, 1, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("git push"));
|
||||
}
|
||||
|
||||
@@ -673,15 +751,15 @@ mod tests {
|
||||
// that word — the same left-scan that makes a double-click at the end
|
||||
// of the line select the last word.
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.select_word_at(2); // the space between the words
|
||||
e.select_word_at(2, SEPS, true); // the space between the words
|
||||
assert_eq!(e.selected_text().as_deref(), Some("ab"));
|
||||
// Index at/past the end selects the trailing word, clamped.
|
||||
e.select_word_at(99);
|
||||
e.select_word_at(99, SEPS, true);
|
||||
assert_eq!(e.selected_text().as_deref(), Some("cd"));
|
||||
// On a gap wider than one cell there is no adjacent word to the left of
|
||||
// the clicked cell: the empty range collapses to no selection.
|
||||
let mut e = ed("ab cd", 0);
|
||||
e.select_word_at(3); // second space: both neighbours are whitespace
|
||||
e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace
|
||||
assert_eq!(e.selection(), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -1209,7 +1209,7 @@ impl TerminalElement {
|
||||
return;
|
||||
}
|
||||
if button == MouseButton::Left {
|
||||
v.on_select_start(col, row, left, clicks, cx);
|
||||
v.on_select_start(col, row, left, clicks, mods.shift, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ mod reverse_search;
|
||||
pub mod search;
|
||||
mod signature;
|
||||
mod size;
|
||||
mod smart_select;
|
||||
mod typeahead;
|
||||
pub mod view;
|
||||
|
||||
|
||||
@@ -1511,6 +1511,7 @@ fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Confi
|
||||
Config {
|
||||
scrolling_history: user_config.scrollback_limit,
|
||||
default_cursor_style: alacritty_cursor_style(user_config.cursor_style),
|
||||
semantic_escape_chars: user_config.word_separators.clone(),
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+42
-5
@@ -2849,7 +2849,9 @@ impl TerminalView {
|
||||
self.editor_drag_word = None;
|
||||
}
|
||||
2 => {
|
||||
self.cmd.select_word_at(idx);
|
||||
let cfg = cx.global::<Config>();
|
||||
let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select);
|
||||
self.cmd.select_word_at(idx, &seps, smart);
|
||||
// Drag now grows the selection by whole words around this one.
|
||||
self.editor_selecting = true;
|
||||
self.editor_drag_word = self.cmd.selection();
|
||||
@@ -2880,7 +2882,9 @@ impl TerminalView {
|
||||
};
|
||||
// A drag begun on a double-click extends by whole words; otherwise by char.
|
||||
if let Some((s, e)) = self.editor_drag_word {
|
||||
self.cmd.extend_word_to(s, e, idx);
|
||||
let cfg = cx.global::<Config>();
|
||||
let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select);
|
||||
self.cmd.extend_word_to(s, e, idx, &seps, smart);
|
||||
} else {
|
||||
self.cmd.extend_to(idx);
|
||||
}
|
||||
@@ -3637,18 +3641,51 @@ impl TerminalView {
|
||||
row: usize,
|
||||
left: bool,
|
||||
clicks: usize,
|
||||
shift: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let smart = cx.global::<Config>().smart_select;
|
||||
let mut term = self.terminal.term.lock();
|
||||
let display_offset = term.grid().display_offset() as i32;
|
||||
let point = Point::new(Line(row as i32 - display_offset), Column(col));
|
||||
let side = if left { Side::Left } else { Side::Right };
|
||||
// Shift+click extends the existing selection to the click instead of
|
||||
// starting over (à la iTerm2). A plain click always leaves a
|
||||
// collapsed Simple selection behind, so the anchor is wherever the
|
||||
// last gesture ended.
|
||||
if shift && clicks == 1 && term.selection.is_some() {
|
||||
if let Some(sel) = term.selection.as_mut() {
|
||||
sel.update(point, side);
|
||||
}
|
||||
drop(term);
|
||||
self.selecting = true;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let ty = match clicks {
|
||||
2 => SelectionType::Semantic, // word
|
||||
n if n >= 3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
term.selection = Some(Selection::new(ty, point, side));
|
||||
let mut selection = Selection::new(ty, point, side);
|
||||
// Double-click smart selection: a URL / path / email / bracket pair /
|
||||
// CJK word containing the clicked word replaces the plain word span.
|
||||
// Boundary-flanked candidates anchor a Semantic selection (keeping
|
||||
// the drag gesture word-wise); exact ones use Simple so alacritty
|
||||
// can't re-expand the endpoints past the smart boundary.
|
||||
if clicks == 2
|
||||
&& smart
|
||||
&& let Some(r) = super::smart_select::grid_smart_range(&term, point)
|
||||
{
|
||||
let ty = if r.exact {
|
||||
SelectionType::Simple
|
||||
} else {
|
||||
SelectionType::Semantic
|
||||
};
|
||||
selection = Selection::new(ty, r.start, Side::Left);
|
||||
selection.update(r.end, Side::Right);
|
||||
}
|
||||
term.selection = Some(selection);
|
||||
drop(term);
|
||||
self.selecting = true;
|
||||
cx.notify();
|
||||
@@ -6908,7 +6945,7 @@ mod gpui_tests {
|
||||
let drag_hello = |cx: &mut TestAppContext| {
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.on_select_start(0, 0, true, 1, cx);
|
||||
view.on_select_start(0, 0, true, 1, false, cx);
|
||||
view.on_select_update(4, 0, false, cx);
|
||||
view.on_select_end(cx);
|
||||
})
|
||||
@@ -6973,7 +7010,7 @@ mod gpui_tests {
|
||||
.update(cx, |view, window, cx| {
|
||||
// Mouse-select "hello" (copy-on-select is off by default, so
|
||||
// the selection survives mouse-up).
|
||||
view.on_select_start(0, 0, true, 1, cx);
|
||||
view.on_select_start(0, 0, true, 1, false, cx);
|
||||
view.on_select_update(4, 0, false, cx);
|
||||
view.on_select_end(cx);
|
||||
assert!(view.has_selection(), "the drag must leave a selection");
|
||||
|
||||
@@ -1992,6 +1992,10 @@ impl Tty7App {
|
||||
self.update_config(cx, |cfg| cfg.copy_on_select = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_smart_select(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.smart_select = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_startup_mode(
|
||||
&mut self,
|
||||
mode: crate::core::config::StartupMode,
|
||||
|
||||
@@ -177,6 +177,11 @@ fn settings_search_entries() -> &'static [SearchEntry] {
|
||||
title: "Forward SSH loopback links",
|
||||
keywords: "ssh remote port tunnel localhost forward",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Smart selection",
|
||||
keywords: "double click word url path select semantic",
|
||||
},
|
||||
SearchEntry {
|
||||
section: Terminal,
|
||||
title: "Copy on select",
|
||||
@@ -2763,6 +2768,7 @@ impl Tty7App {
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
let copy_on_select = cfg.copy_on_select;
|
||||
let mouse_reporting = cfg.mouse_reporting;
|
||||
let smart_select = cfg.smart_select;
|
||||
let bell = cfg.bell;
|
||||
// Map the persisted threshold onto its preset radio index (nearest slot
|
||||
// for any off-preset value a hand-edit might leave).
|
||||
@@ -2848,6 +2854,10 @@ impl Tty7App {
|
||||
.checked(mouse_reporting)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
|
||||
.into_any_element();
|
||||
let smart_select_switch = Switch::new("term-smart-select")
|
||||
.checked(smart_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let bell_idx = match bell {
|
||||
BellMode::None => 0,
|
||||
BellMode::Visual => 1,
|
||||
@@ -2948,6 +2958,12 @@ impl Tty7App {
|
||||
mouse_report_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Smart selection",
|
||||
"Double-click selects the whole URL, file path, email, or bracket pair under the cursor.",
|
||||
smart_select_switch,
|
||||
cx,
|
||||
))
|
||||
.when_some(option_alt_row, |v, row| {
|
||||
v.child(self.section_rule(cx))
|
||||
.child(self.section_header("Keyboard", cx))
|
||||
|
||||
Reference in New Issue
Block a user