T66: files dropped on the terminal type their paths, which a user asked for after 0.1.6, as macOS Terminal does: each escaped for the shell, or quoted on Windows when it holds a space, followed by a space, sent as a paste; a path not in UTF-8 or holding a control character stops the drop; the font code moves from view.rs to fonts.rs, which view.rs needed room for; shot plays a drop with drop@x,y

This commit is contained in:
ZacharyZhang-NY committed 2026-10-19 16:40:49 -04:00
1 parent 390f2b6176
commit 545fb46249
10 files changed
+227 -76

No files matched your search

+97
View File
@@ -0,0 +1,97 @@
//! Files dropped on the terminal type their paths, as macOS Terminal does.
use std::path::PathBuf;
use anyhow::Context as _;
use gpui::{Context, ExternalPaths};
use super::view::TerminalView;
/// What a shell reads specially in a path, which Ghostty escapes in one dropped.
const SPECIAL: &str = "\\ ()[]{}<>\"'`!#$&;|*?";
/// The text `paths` type, each followed by a space: escaped for a Unix shell, or quoted when it holds a space, as Windows Terminal quotes it.
pub fn typed(paths: &[PathBuf], windows: bool) -> anyhow::Result<String> {
paths
.iter()
.map(|path| {
let text = (path.to_str())
.with_context(|| format!("{} is no UTF-8 a session can type", path.display()))?;
anyhow::ensure!(
!text.chars().any(char::is_control),
"{text:?} holds a control character, which would act as typed"
);
Ok(if windows && text.contains(' ') {
format!("\"{text}\" ")
} else if windows {
format!("{text} ")
} else {
let mut escaped = String::with_capacity(text.len() + 1);
for ch in text.chars() {
if SPECIAL.contains(ch) {
escaped.push('\\');
}
escaped.push(ch);
}
escaped.push(' ');
escaped
})
})
.collect()
}
impl TerminalView {
/// Types the dropped paths at the cursor as a paste; a path it cannot type stops the drop.
pub(super) fn drop_paths(&mut self, dropped: &ExternalPaths, cx: &mut Context<Self>) {
let paths = dropped.paths();
match typed(paths, cfg!(windows)) {
Ok(text) => {
log::info!("terminal {}: {} paths dropped", self.pty.pid, paths.len());
self.paste_text(&text, cx);
}
Err(error) => log::error!(
"terminal {}: the drop types nothing: {error:#}",
self.pty.pid
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(all: &[&str]) -> Vec<PathBuf> {
all.iter().map(PathBuf::from).collect()
}
#[test]
fn a_unix_path_escapes_what_a_shell_reads_specially() {
let typed = |all: &[&str]| typed(&paths(all), false).unwrap();
assert_eq!(typed(&["/tmp/a.txt"]), "/tmp/a.txt ");
assert_eq!(typed(&["/tmp/my file.png"]), "/tmp/my\\ file.png ");
assert_eq!(typed(&["/a/it's (1)&$x"]), "/a/it\\'s\\ \\(1\\)\\&\\$x ");
assert_eq!(typed(&["/a/中文 b"]), "/a/中文\\ b ");
assert_eq!(
typed(&["/a", "/b c"]),
"/a /b\\ c ",
"each followed by a space"
);
}
#[test]
fn a_windows_path_is_quoted_when_it_holds_a_space() {
let typed = |all: &[&str]| typed(&paths(all), true).unwrap();
assert_eq!(typed(&["C:\\a.txt"]), "C:\\a.txt ");
assert_eq!(
typed(&["C:\\My Files\\b.txt", "D:\\c"]),
"\"C:\\My Files\\b.txt\" D:\\c "
);
}
#[test]
fn a_path_with_a_control_character_types_nothing() {
let error = typed(&paths(&["/ok", "/tmp/a\nls"]), false).unwrap_err();
assert!(error.to_string().contains("control character"), "{error}");
}
}
+78
View File
@@ -0,0 +1,78 @@
//! The terminal's font, cell and metrics.
use gpui::{
Font, FontFeatures, FontId, FontStyle, FontWeight, Pixels, TextSystem, Window, px, size,
};
use super::{ink::Ink, paint::Look};
use crate::settings::Settings;
/// The bundled Nerd Font that draws icons any font lacks; gpui loads no font without an `m`.
pub const ICONS: &str = "JetBrainsMono Nerd Font Mono";
/// The bundled code font, the default.
pub const MONO: &str = "JetBrains Mono";
/// `font` as gpui loads it; gpui falls back to another font without a word, and loads none without an `m`.
pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
let id = system.resolve_font(font);
let loaded = system.get_font_for_id(id);
anyhow::ensure!(
loaded.is_some_and(|loaded| loaded.family == font.family),
"font_family is {:?}, which the terminal cannot load",
font.family
);
Ok(id)
}
/// Font, cell and metrics at `points` on this window.
pub(super) fn look(
settings: &Settings,
points: f32,
ink: &Ink,
window: &Window,
) -> anyhow::Result<Look> {
let font = Font {
family: settings.font_family.clone().into(),
features: if settings.ligatures {
FontFeatures::default()
} else {
FontFeatures::disable_ligatures()
},
fallbacks: None,
weight: FontWeight::NORMAL,
style: FontStyle::Normal,
};
let text = px(points);
let system = window.text_system();
let id = load_font(&font, system)?;
let advance = system
.advance(id, text, 'M')
.map_err(|error| anyhow::anyhow!("{} cannot measure M: {error}", settings.font_family))?;
let scale = window.scale_factor();
let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale);
let cell = size(
snap(advance.width).max(px(1.)),
snap(text * settings.line_height),
);
let (ascent, descent) = (system.ascent(id, text), system.descent(id, text).abs());
let icons = Font {
family: ICONS.into(),
features: FontFeatures::default(),
..font.clone()
};
Ok(Look {
font_id: id,
icons,
baseline: (cell.height - ascent - descent) / 2. + ascent,
descent,
rule: snap((text / 14.).max(px(1.))),
size: text,
cell,
font,
cursor: ink.cursor,
cursor_text: ink.cursor_text,
fg: ink.fg,
bg: ink.bg,
})
}
+4 -1
View File
@@ -1,6 +1,8 @@
mod boxes;
mod click;
mod drop;
mod find;
mod fonts;
pub mod frame;
mod herdr;
pub mod ink;
@@ -29,6 +31,7 @@ mod view;
mod win32;
pub mod zoom;
pub use fonts::{ICONS, MONO, load_font};
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
pub use status::{Kind, Record, Records, State};
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
pub use view::{BLINK, TerminalEvent, TerminalView};
+6 -1
View File
@@ -378,6 +378,11 @@ impl TerminalView {
log::debug!("terminal {}: no text to paste", self.pty.pid);
return;
};
self.paste_text(&text, cx);
}
/// `text` to the program as a paste, bracketed when it asked.
pub(super) fn paste_text(&mut self, text: &str, cx: &mut Context<Self>) {
let bracketed = self
.pty
.term
@@ -390,7 +395,7 @@ impl TerminalView {
text.chars().count(),
if bracketed { ", bracketed" } else { "" }
);
self.input(keys::paste(&text, bracketed), cx);
self.input(keys::paste(text, bracketed), cx);
}
}
+6 -73
View File
@@ -12,20 +12,20 @@ use ely_gpui_component::{
};
use futures::StreamExt;
use gpui::{
Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontId, FontStyle,
FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled, Subscription,
Task, TextSystem, Window, div, prelude::*, px, size,
Bounds, Context, EventEmitter, ExternalPaths, FocusHandle, Focusable, IntoElement, MouseButton,
ParentElement, Pixels, Render, Styled, Subscription, Task, Window, div, prelude::*, px,
};
use super::{
click::Shell,
find::Finder,
fonts::look,
frame::frame,
ink::Ink,
input::{self, Find, Held, NextPrompt, PreviousPrompt},
marks::Mark,
mouse::MouseMode,
paint::{self, Look},
paint,
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
reports::Notices,
@@ -33,16 +33,7 @@ use super::{
status::{Records, State},
zoom,
};
use crate::{
settings::{Settings, SettingsStore},
themes,
};
/// The bundled Nerd Font that draws icons any font lacks; gpui loads no font without an `m`.
pub const ICONS: &str = "JetBrainsMono Nerd Font Mono";
/// The bundled code font, the default.
pub const MONO: &str = "JetBrains Mono";
use crate::{settings::SettingsStore, themes};
/// How long the cursor shows, then hides, while it blinks.
pub const BLINK: Duration = Duration::from_millis(600);
@@ -349,65 +340,6 @@ impl TerminalView {
}
}
/// `font` as gpui loads it; gpui falls back to another font without a word, and loads none without an `m`.
pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
let id = system.resolve_font(font);
let loaded = system.get_font_for_id(id);
anyhow::ensure!(
loaded.is_some_and(|loaded| loaded.family == font.family),
"font_family is {:?}, which the terminal cannot load",
font.family
);
Ok(id)
}
/// Font, cell and metrics at `points` on this window.
fn look(settings: &Settings, points: f32, ink: &Ink, window: &Window) -> anyhow::Result<Look> {
let font = Font {
family: settings.font_family.clone().into(),
features: if settings.ligatures {
FontFeatures::default()
} else {
FontFeatures::disable_ligatures()
},
fallbacks: None,
weight: FontWeight::NORMAL,
style: FontStyle::Normal,
};
let text = px(points);
let system = window.text_system();
let id = load_font(&font, system)?;
let advance = system
.advance(id, text, 'M')
.map_err(|error| anyhow::anyhow!("{} cannot measure M: {error}", settings.font_family))?;
let scale = window.scale_factor();
let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale);
let cell = size(
snap(advance.width).max(px(1.)),
snap(text * settings.line_height),
);
let (ascent, descent) = (system.ascent(id, text), system.descent(id, text).abs());
let icons = Font {
family: ICONS.into(),
features: FontFeatures::default(),
..font.clone()
};
Ok(Look {
font_id: id,
icons,
baseline: (cell.height - ascent - descent) / 2. + ascent,
descent,
rule: snap((text / 14.).max(px(1.))),
size: text,
cell,
font,
cursor: ink.cursor,
cursor_text: ink.cursor_text,
fg: ink.fg,
bg: ink.bg,
})
}
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = SettingsStore::get(cx).clone();
@@ -454,6 +386,7 @@ impl Render for TerminalView {
.on_any_mouse_down(cx.listener(Self::mouse_down))
.on_mouse_move(cx.listener(Self::mouse_move))
.on_scroll_wheel(cx.listener(Self::wheel))
.on_drop(cx.listener(|view, paths: &ExternalPaths, _, cx| view.drop_paths(paths, cx)))
.size_full()
.px(px(settings.padding_x))
.py(px(settings.padding_y))