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:
10 files changed
+227
-76
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user