parent
6f9ac7c108
commit
8d2af4e046
|
|
@ -11,7 +11,6 @@ use crate::layout::{find_in_direction, NavDirection};
|
|||
use crate::selection::Selection;
|
||||
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
|
||||
use crate::workspace::WorkspaceGitStatus;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::state::{
|
||||
text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow,
|
||||
|
|
@ -2220,7 +2219,7 @@ pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec<VisibleText
|
|||
pending_wrap = false;
|
||||
}
|
||||
|
||||
let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
|
||||
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
|
||||
cells.push(VisibleTextCell {
|
||||
byte_index,
|
||||
ch,
|
||||
|
|
@ -2252,7 +2251,7 @@ pub(crate) fn logical_cell_for_visible_cell(
|
|||
visible_text_cells(text, pane_width)
|
||||
.into_iter()
|
||||
.find(|cell| {
|
||||
let width = UnicodeWidthChar::width(cell.ch).unwrap_or(0) as u16;
|
||||
let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32));
|
||||
cell.screen_row == target_row
|
||||
&& if width == 0 {
|
||||
target_col == cell.screen_col
|
||||
|
|
@ -2285,7 +2284,7 @@ fn text_cells(row: &str) -> Vec<TextCell> {
|
|||
let mut next_col = 0u16;
|
||||
row.chars()
|
||||
.map(|ch| {
|
||||
let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
|
||||
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
|
||||
let start_col = if width == 0 {
|
||||
next_col.saturating_sub(1)
|
||||
} else {
|
||||
|
|
@ -3190,7 +3189,7 @@ mod tests {
|
|||
let prefix = &row[..byte_idx];
|
||||
prefix
|
||||
.chars()
|
||||
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
|
||||
.map(|ch| u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)))
|
||||
.sum()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
|
|
@ -941,7 +940,7 @@ fn last_character_col(text: &str) -> Option<u16> {
|
|||
let mut col = 0u16;
|
||||
let mut last_col = None;
|
||||
for ch in text.chars() {
|
||||
let width = UnicodeWidthChar::width(ch).unwrap_or(1) as u16;
|
||||
let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
|
||||
if width > 0 {
|
||||
last_col = Some(col);
|
||||
col = col.saturating_add(width);
|
||||
|
|
@ -951,7 +950,7 @@ fn last_character_col(text: &str) -> Option<u16> {
|
|||
}
|
||||
|
||||
fn char_cell_width(ch: char) -> u16 {
|
||||
UnicodeWidthChar::width(ch).unwrap_or(1).max(1) as u16
|
||||
u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1)
|
||||
}
|
||||
|
||||
fn copy_mode_page_lines(height: u16, half_page: bool) -> usize {
|
||||
|
|
@ -1703,6 +1702,11 @@ mod tests {
|
|||
|
||||
submit_copy_search(&mut app, '?', "000000");
|
||||
assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0);
|
||||
let copy_mode = app.state.copy_mode.as_ref().expect("copy mode");
|
||||
assert_eq!(
|
||||
copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row),
|
||||
0
|
||||
);
|
||||
let runtime = app
|
||||
.state
|
||||
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
)]
|
||||
pub mod bindings;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::c_void;
|
||||
|
|
@ -442,8 +443,10 @@ impl CellWide {
|
|||
|
||||
type WritePtyCallback = dyn FnMut(&[u8]) + Send;
|
||||
|
||||
struct WritePtyCallbackState {
|
||||
callback: Box<WritePtyCallback>,
|
||||
#[derive(Default)]
|
||||
struct TerminalCallbackState {
|
||||
write_pty: Option<Box<WritePtyCallback>>,
|
||||
pwd_changes: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn write_pty_trampoline(
|
||||
|
|
@ -452,19 +455,43 @@ unsafe extern "C" fn write_pty_trampoline(
|
|||
data: *const u8,
|
||||
len: usize,
|
||||
) {
|
||||
if userdata.is_null() {
|
||||
if userdata.is_null() || (data.is_null() && len != 0) {
|
||||
return;
|
||||
}
|
||||
if data.is_null() && len != 0 {
|
||||
let state = unsafe { &mut *(userdata.cast::<TerminalCallbackState>()) };
|
||||
let Some(callback) = state.write_pty.as_mut() else {
|
||||
return;
|
||||
}
|
||||
let state = unsafe { &mut *(userdata.cast::<WritePtyCallbackState>()) };
|
||||
};
|
||||
let bytes = if len == 0 {
|
||||
&[]
|
||||
} else {
|
||||
unsafe { slice::from_raw_parts(data, len) }
|
||||
};
|
||||
(state.callback)(bytes);
|
||||
callback(bytes);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn pwd_changed_trampoline(terminal: ffi::GhosttyTerminal, userdata: *mut c_void) {
|
||||
if terminal.is_null() || userdata.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut pwd = ffi::GhosttyString::default();
|
||||
let result = unsafe {
|
||||
ffi::ghostty_terminal_get(
|
||||
terminal,
|
||||
ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_PWD,
|
||||
(&mut pwd as *mut ffi::GhosttyString).cast(),
|
||||
)
|
||||
};
|
||||
if result != ffi::GhosttyResult_GHOSTTY_SUCCESS || (pwd.ptr.is_null() && pwd.len != 0) {
|
||||
return;
|
||||
}
|
||||
let bytes = if pwd.len == 0 {
|
||||
Vec::new()
|
||||
} else {
|
||||
unsafe { slice::from_raw_parts(pwd.ptr, pwd.len) }.to_vec()
|
||||
};
|
||||
let state = unsafe { &mut *(userdata.cast::<TerminalCallbackState>()) };
|
||||
state.pwd_changes.push(bytes);
|
||||
}
|
||||
|
||||
fn install_png_decoder_once() {
|
||||
|
|
@ -556,6 +583,18 @@ fn decode_png_rgba(bytes: &[u8]) -> Option<DecodedPng> {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn unicode_codepoint_width(codepoint: u32) -> u8 {
|
||||
unsafe { ffi::ghostty_unicode_codepoint_width(codepoint) }
|
||||
}
|
||||
|
||||
pub fn unicode_grapheme_width(codepoints: &[u32]) -> (usize, u8) {
|
||||
let mut width = 0u8;
|
||||
let consumed = unsafe {
|
||||
ffi::ghostty_unicode_grapheme_width(codepoints.as_ptr(), codepoints.len(), &mut width)
|
||||
};
|
||||
(consumed, width)
|
||||
}
|
||||
|
||||
pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> {
|
||||
let mut required = 0usize;
|
||||
// SAFETY: null buffer + out len is the documented way to query required size.
|
||||
|
|
@ -582,8 +621,9 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> {
|
|||
|
||||
pub struct Terminal {
|
||||
raw: ffi::GhosttyTerminal,
|
||||
write_pty_callback: Option<Box<WritePtyCallbackState>>,
|
||||
callback_state: Box<TerminalCallbackState>,
|
||||
kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>,
|
||||
kitty_empty_generation: Cell<Option<u64>>,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
|
|
@ -599,13 +639,27 @@ impl Terminal {
|
|||
ffi::ghostty_terminal_new(ptr::null(), &mut raw, options).into_result()?;
|
||||
}
|
||||
|
||||
let terminal = Self {
|
||||
let mut terminal = Self {
|
||||
raw,
|
||||
write_pty_callback: None,
|
||||
callback_state: Box::default(),
|
||||
kitty_fingerprints: Mutex::new(HashMap::new()),
|
||||
kitty_empty_generation: Cell::new(None),
|
||||
};
|
||||
let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast();
|
||||
let glyph_protocol = false;
|
||||
unsafe {
|
||||
ffi::ghostty_terminal_set(
|
||||
terminal.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_USERDATA,
|
||||
userdata,
|
||||
)
|
||||
.into_result()?;
|
||||
ffi::ghostty_terminal_set(
|
||||
terminal.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_PWD_CHANGED,
|
||||
(pwd_changed_trampoline as *const ()).cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
ffi::ghostty_terminal_set(
|
||||
terminal.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL,
|
||||
|
|
@ -688,17 +742,7 @@ impl Terminal {
|
|||
where
|
||||
F: FnMut(&[u8]) + Send + 'static,
|
||||
{
|
||||
let mut state = Box::new(WritePtyCallbackState {
|
||||
callback: Box::new(callback),
|
||||
});
|
||||
let userdata = (&mut *state as *mut WritePtyCallbackState).cast::<c_void>();
|
||||
unsafe {
|
||||
ffi::ghostty_terminal_set(
|
||||
self.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_USERDATA,
|
||||
userdata.cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
ffi::ghostty_terminal_set(
|
||||
self.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_WRITE_PTY,
|
||||
|
|
@ -706,10 +750,14 @@ impl Terminal {
|
|||
)
|
||||
.into_result()?;
|
||||
}
|
||||
self.write_pty_callback = Some(state);
|
||||
self.callback_state.write_pty = Some(Box::new(callback));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn take_pwd_changes(&mut self) -> Vec<Vec<u8>> {
|
||||
mem::take(&mut self.callback_state.pwd_changes)
|
||||
}
|
||||
|
||||
pub fn mode_get(&self, mode: u16) -> Result<bool, Error> {
|
||||
let mut out = false;
|
||||
unsafe { ffi::ghostty_terminal_mode_get(self.raw, mode, &mut out).into_result()? };
|
||||
|
|
@ -1072,6 +1120,17 @@ impl Terminal {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn scroll_viewport_row(&mut self, row: usize) {
|
||||
let viewport = ffi::GhosttyTerminalScrollViewport {
|
||||
tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_ROW,
|
||||
value: ffi::GhosttyTerminalScrollViewportValue { row },
|
||||
};
|
||||
// SAFETY: self.raw is valid and viewport value matches the tag.
|
||||
unsafe {
|
||||
ffi::ghostty_terminal_scroll_viewport(self.raw, viewport);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> Result<u16, Error> {
|
||||
self.get_u16(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_COLS)
|
||||
}
|
||||
|
|
@ -1153,6 +1212,30 @@ impl Terminal {
|
|||
}
|
||||
}
|
||||
|
||||
fn kitty_graphics(&self) -> Result<ffi::GhosttyKittyGraphics, Error> {
|
||||
let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut();
|
||||
unsafe {
|
||||
ffi::ghostty_terminal_get(
|
||||
self.raw,
|
||||
ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS,
|
||||
(&mut graphics as *mut ffi::GhosttyKittyGraphics).cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
}
|
||||
Ok(graphics)
|
||||
}
|
||||
|
||||
pub fn kitty_graphics_generation(&self) -> Result<u64, Error> {
|
||||
let graphics = self.kitty_graphics()?;
|
||||
if graphics.is_null() {
|
||||
return Ok(0);
|
||||
}
|
||||
kitty_graphics_u64(
|
||||
graphics,
|
||||
ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn kitty_image_placements(&self) -> Result<Vec<KittyImagePlacement>, Error> {
|
||||
self.kitty_image_placements_with_data_filter(|_| true)
|
||||
}
|
||||
|
|
@ -1164,18 +1247,17 @@ impl Terminal {
|
|||
where
|
||||
F: FnMut(KittyImageDescriptor) -> bool,
|
||||
{
|
||||
let mut graphics: ffi::GhosttyKittyGraphics = ptr::null_mut();
|
||||
unsafe {
|
||||
ffi::ghostty_terminal_get(
|
||||
self.raw,
|
||||
ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS,
|
||||
(&mut graphics as *mut ffi::GhosttyKittyGraphics).cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
}
|
||||
let graphics = self.kitty_graphics()?;
|
||||
if graphics.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let generation = kitty_graphics_u64(
|
||||
graphics,
|
||||
ffi::GhosttyKittyGraphicsData_GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION,
|
||||
)?;
|
||||
if generation == 0 || self.kitty_empty_generation.get() == Some(generation) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut iterator: ffi::GhosttyKittyGraphicsPlacementIterator = ptr::null_mut();
|
||||
unsafe {
|
||||
|
|
@ -1191,13 +1273,21 @@ impl Terminal {
|
|||
let _guard = KittyPlacementIteratorGuard { raw: iterator };
|
||||
|
||||
let mut placements = Vec::new();
|
||||
let mut storage_has_placements = false;
|
||||
while unsafe { ffi::ghostty_kitty_graphics_placement_next(iterator) } {
|
||||
storage_has_placements = true;
|
||||
if let Some(placement) =
|
||||
self.kitty_image_placement(graphics, iterator, &mut needs_data)?
|
||||
{
|
||||
placements.push(placement);
|
||||
}
|
||||
}
|
||||
if !storage_has_placements {
|
||||
self.kitty_empty_generation.set(Some(generation));
|
||||
self.prune_kitty_fingerprints(&[]);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
placements.extend(self.kitty_virtual_image_placements(graphics, &mut needs_data)?);
|
||||
placements.sort_by_key(|placement| placement.z);
|
||||
self.prune_kitty_fingerprints(&placements);
|
||||
|
|
@ -1890,6 +1980,18 @@ impl KittyVirtualRun {
|
|||
}
|
||||
}
|
||||
|
||||
fn kitty_graphics_u64(
|
||||
graphics: ffi::GhosttyKittyGraphics,
|
||||
data: ffi::GhosttyKittyGraphicsData,
|
||||
) -> Result<u64, Error> {
|
||||
let mut out = 0u64;
|
||||
unsafe {
|
||||
ffi::ghostty_kitty_graphics_get(graphics, data, (&mut out as *mut u64).cast())
|
||||
.into_result()?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn kitty_image_u32(
|
||||
image: ffi::GhosttyKittyGraphicsImage,
|
||||
data: ffi::GhosttyKittyGraphicsImageData,
|
||||
|
|
@ -2984,6 +3086,48 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_storage_generation_skips_only_proven_empty_storage() {
|
||||
let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap();
|
||||
terminal.enable_kitty_graphics().unwrap();
|
||||
terminal.resize(10, 5, 8, 16).unwrap();
|
||||
|
||||
assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0);
|
||||
assert!(terminal.kitty_image_placements().unwrap().is_empty());
|
||||
|
||||
terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\");
|
||||
let transmitted = terminal.kitty_graphics_generation().unwrap();
|
||||
assert_ne!(transmitted, 0);
|
||||
assert!(terminal.kitty_image_placements().unwrap().is_empty());
|
||||
assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted));
|
||||
|
||||
terminal.write(b"plain text");
|
||||
assert_eq!(terminal.kitty_graphics_generation().unwrap(), transmitted);
|
||||
assert!(terminal.kitty_image_placements().unwrap().is_empty());
|
||||
|
||||
terminal.write(b"\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\");
|
||||
let placed = terminal.kitty_graphics_generation().unwrap();
|
||||
assert_ne!(placed, transmitted);
|
||||
assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
|
||||
|
||||
terminal.resize(10, 5, 12, 24).unwrap();
|
||||
assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed);
|
||||
assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
|
||||
|
||||
write_numbered_lines(&mut terminal, 20);
|
||||
assert_eq!(terminal.kitty_graphics_generation().unwrap(), placed);
|
||||
assert!(terminal.kitty_image_placements().unwrap().is_empty());
|
||||
assert_ne!(terminal.kitty_empty_generation.get(), Some(placed));
|
||||
terminal.scroll_viewport_row(0);
|
||||
assert_eq!(terminal.kitty_image_placements().unwrap().len(), 1);
|
||||
|
||||
terminal.write(b"\x1b_Ga=d,d=A\x1b\\");
|
||||
let deleted = terminal.kitty_graphics_generation().unwrap();
|
||||
assert_ne!(deleted, placed);
|
||||
assert!(terminal.kitty_image_placements().unwrap().is_empty());
|
||||
assert_eq!(terminal.kitty_empty_generation.get(), Some(deleted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_info_contract_matches_expected_vendored_features() {
|
||||
let _simd = build_info_bool(ffi::GhosttyBuildInfo_GHOSTTY_BUILD_INFO_SIMD);
|
||||
|
|
@ -3096,6 +3240,38 @@ mod tests {
|
|||
assert_eq!(placements[0].render.grid_rows, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_width_helpers_match_terminal_layout_rules() {
|
||||
assert_eq!(unicode_codepoint_width('A' as u32), 1);
|
||||
assert_eq!(unicode_codepoint_width('\u{301}' as u32), 0);
|
||||
assert_eq!(unicode_codepoint_width('界' as u32), 2);
|
||||
assert_eq!(unicode_codepoint_width(0x11_0000), 1);
|
||||
|
||||
let cases: &[(&[u32], usize, u8)] = &[
|
||||
(&[], 0, 0),
|
||||
(&['e' as u32, '\u{301}' as u32], 2, 1),
|
||||
(&['⚠' as u32, '\u{fe0f}' as u32], 2, 2),
|
||||
(&['⚠' as u32, '\u{fe0e}' as u32], 2, 1),
|
||||
(&['🇧' as u32, '🇷' as u32], 2, 2),
|
||||
(&['👍' as u32, '🏽' as u32], 2, 2),
|
||||
(
|
||||
&[
|
||||
'👨' as u32,
|
||||
'\u{200d}' as u32,
|
||||
'👩' as u32,
|
||||
'\u{200d}' as u32,
|
||||
'👧' as u32,
|
||||
],
|
||||
5,
|
||||
2,
|
||||
),
|
||||
(&[0x11_0000, 'A' as u32], 1, 1),
|
||||
];
|
||||
for &(codepoints, consumed, width) in cases {
|
||||
assert_eq!(unicode_grapheme_width(codepoints), (consumed, width));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_encoding_matches_expected_sequences() {
|
||||
assert_eq!(encode_focus(FocusEvent::Gained).unwrap(), b"\x1b[I");
|
||||
|
|
@ -3103,7 +3279,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn write_pty_callback_receives_terminal_query_responses() {
|
||||
fn terminal_callbacks_report_pty_responses_and_pwd_changes() {
|
||||
let mut terminal = Terminal::new(8, 3, 100).unwrap();
|
||||
let responses = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
|
||||
let sink = responses.clone();
|
||||
|
|
@ -3111,11 +3287,12 @@ mod tests {
|
|||
.set_write_pty_callback(move |bytes| sink.lock().unwrap().extend_from_slice(bytes))
|
||||
.unwrap();
|
||||
|
||||
terminal.write(b"\x1b[6n");
|
||||
terminal.write(b"\x1b[6n\x1b]7;file:///tmp/herdr\x07");
|
||||
|
||||
let output = responses.lock().unwrap().clone();
|
||||
assert!(!output.is_empty());
|
||||
assert!(String::from_utf8_lossy(&output).contains("R"));
|
||||
assert_eq!(terminal.take_pwd_changes(), [b"file:///tmp/herdr".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3226,6 +3403,23 @@ mod tests {
|
|||
assert_eq!(after.len, before.len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_scroll_row_round_trips_and_clamps() {
|
||||
let mut terminal = Terminal::new(80, 3, 1_000_000).unwrap();
|
||||
write_numbered_lines(&mut terminal, 1000);
|
||||
|
||||
let before = terminal.scrollbar().unwrap();
|
||||
let max_row = before.total.saturating_sub(before.len);
|
||||
assert!(max_row > 0);
|
||||
|
||||
for row in [0, max_row / 2, max_row, usize::MAX] {
|
||||
terminal.scroll_viewport_row(row);
|
||||
let after = terminal.scrollbar().unwrap();
|
||||
assert_eq!(after.offset, row.min(max_row));
|
||||
assert_eq!(after.len, before.len);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_scrollback_resize_preserves_unicode_and_hyperlinks() {
|
||||
use std::fmt::Write as _;
|
||||
|
|
|
|||
123
src/pane/osc.rs
123
src/pane/osc.rs
|
|
@ -396,84 +396,13 @@ impl Osc52Forwarder {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reconstructs cwd-reporting OSC sequences from child output. Shell
|
||||
/// integrations commonly use OSC 7 (`file://...`), while Windows Terminal
|
||||
/// documents OSC 9;9 for the same practical purpose.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct CwdOscTracker {
|
||||
state: Osc52ForwarderState,
|
||||
body: Vec<u8>,
|
||||
pending: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl CwdOscTracker {
|
||||
pub(super) fn observe(&mut self, bytes: &[u8]) {
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
Osc52ForwarderState::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::Escape => {
|
||||
if byte == b']' {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
} else if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
} else {
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::OscBody => match byte {
|
||||
0x07 => {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
0x1b => self.state = Osc52ForwarderState::OscEscape,
|
||||
_ => self.body.push(byte),
|
||||
},
|
||||
Osc52ForwarderState::OscEscape => {
|
||||
if byte == b'\\' {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
} else {
|
||||
self.body.push(0x1b);
|
||||
self.body.push(byte);
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.body.len() > 4096 {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
pub(super) fn parse_reported_cwd(value: &[u8]) -> Option<PathBuf> {
|
||||
let value = std::str::from_utf8(value).ok()?.trim();
|
||||
if value.starts_with("file://") {
|
||||
return parse_file_uri_cwd(value);
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if let Some(cwd) = parse_cwd_osc(&self.body) {
|
||||
self.pending.push(cwd);
|
||||
}
|
||||
self.body.clear();
|
||||
}
|
||||
|
||||
pub(super) fn drain_latest(&mut self) -> Option<PathBuf> {
|
||||
self.pending.drain(..).next_back()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> {
|
||||
let body = std::str::from_utf8(body).ok()?;
|
||||
if let Some(uri) = body.strip_prefix("7;") {
|
||||
return parse_file_uri_cwd(uri);
|
||||
}
|
||||
if let Some(path) = body.strip_prefix("9;9;") {
|
||||
let path = path.trim().trim_matches('"');
|
||||
return (!path.is_empty()).then(|| PathBuf::from(path));
|
||||
}
|
||||
None
|
||||
let path = value.trim_matches('"');
|
||||
(!path.is_empty()).then(|| PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Maximum retained string length for agent OSC title and progress payloads.
|
||||
|
|
@ -1077,46 +1006,28 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_osc_tracker_detects_split_osc7_sequence() {
|
||||
let mut tracker = CwdOscTracker::default();
|
||||
|
||||
tracker.observe(b"\x1b]7;file:///tmp/herdr%20repo");
|
||||
assert_eq!(tracker.drain_latest(), None);
|
||||
tracker.observe(b"\x07");
|
||||
|
||||
fn reported_cwd_parses_file_uri_and_bare_paths() {
|
||||
assert_eq!(
|
||||
tracker.drain_latest(),
|
||||
parse_reported_cwd(b"file:///tmp/herdr%20repo"),
|
||||
Some(std::path::PathBuf::from("/tmp/herdr repo"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_osc_tracker_detects_windows_terminal_cwd_sequence() {
|
||||
let mut tracker = CwdOscTracker::default();
|
||||
|
||||
tracker.observe(b"\x1b]9;9;C:\\Users\\herdr\\src\\herdr\x1b\\");
|
||||
|
||||
assert_eq!(
|
||||
tracker.drain_latest(),
|
||||
parse_reported_cwd(b"C:\\Users\\herdr\\src\\herdr"),
|
||||
Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr"))
|
||||
);
|
||||
}
|
||||
|
||||
// The quoted form is what Windows Terminal's documented shell integration
|
||||
// snippet emits. Herdr's own injected prompt integration deliberately
|
||||
// emits the path unquoted; see WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND.
|
||||
#[test]
|
||||
fn cwd_osc_tracker_detects_quoted_powershell_prompt_cwd_sequence() {
|
||||
let mut tracker = CwdOscTracker::default();
|
||||
|
||||
tracker.observe(b"PS C:\\my proj> \x1b]9;9;\"C:\\my proj\"\x1b\\");
|
||||
|
||||
assert_eq!(
|
||||
tracker.drain_latest(),
|
||||
parse_reported_cwd(b"\"C:\\my proj\""),
|
||||
Some(std::path::PathBuf::from("C:\\my proj"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_cwd_rejects_invalid_or_empty_values() {
|
||||
assert_eq!(parse_reported_cwd(b""), None);
|
||||
assert_eq!(parse_reported_cwd(b"\xff"), None);
|
||||
assert_eq!(parse_reported_cwd(b"file://remote/tmp"), None);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// AgentOscStateTracker tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ use super::{
|
|||
kitty_keyboard::KittyKeyboardTracker,
|
||||
osc::{
|
||||
contains_scrollback_clear_sequence, current_transient_default_color_owner,
|
||||
maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
|
||||
write_host_terminal_theme_selective, AgentOscStateTracker, CwdOscTracker,
|
||||
DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery,
|
||||
DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker,
|
||||
maybe_filter_primary_screen_scrollback_clear, parse_reported_cwd,
|
||||
restore_host_terminal_theme_if_needed, write_host_terminal_theme_selective,
|
||||
AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker,
|
||||
DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker,
|
||||
},
|
||||
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
|
||||
};
|
||||
|
|
@ -162,7 +162,6 @@ pub(crate) struct GhosttyPaneCore {
|
|||
pub child_default_foreground_changed: bool,
|
||||
pub child_default_background_changed: bool,
|
||||
pub osc52_forwarder: Osc52Forwarder,
|
||||
pub cwd_osc_tracker: CwdOscTracker,
|
||||
pub osc_debug_tracker: OscDebugTracker,
|
||||
pub agent_osc_state: AgentOscStateTracker,
|
||||
pub xtgettcap_query_tracker: XtgettcapQueryTracker,
|
||||
|
|
@ -917,7 +916,6 @@ impl GhosttyPaneTerminal {
|
|||
child_default_foreground_changed: false,
|
||||
child_default_background_changed: false,
|
||||
osc52_forwarder: Osc52Forwarder::default(),
|
||||
cwd_osc_tracker: CwdOscTracker::default(),
|
||||
osc_debug_tracker: OscDebugTracker::default(),
|
||||
agent_osc_state: AgentOscStateTracker::default(),
|
||||
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
|
||||
|
|
@ -1050,6 +1048,7 @@ impl GhosttyPaneTerminal {
|
|||
};
|
||||
};
|
||||
|
||||
let _ = core.terminal.take_pwd_changes();
|
||||
let default_color_observation = core.default_color_tracker.observe(bytes);
|
||||
if shell_pid > 0 && default_color_observation {
|
||||
if let Some(owner_pgid) = current_transient_default_color_owner(shell_pid) {
|
||||
|
|
@ -1063,8 +1062,6 @@ impl GhosttyPaneTerminal {
|
|||
|
||||
core.osc52_forwarder.observe(bytes);
|
||||
let clipboard_writes = core.osc52_forwarder.drain_pending();
|
||||
core.cwd_osc_tracker.observe(bytes);
|
||||
let reported_cwd = core.cwd_osc_tracker.drain_latest();
|
||||
core.osc_debug_tracker.observe(bytes);
|
||||
for event in core.osc_debug_tracker.drain_pending() {
|
||||
debug!(
|
||||
|
|
@ -1119,6 +1116,12 @@ impl GhosttyPaneTerminal {
|
|||
xtgettcap_responses,
|
||||
&mut terminal_responses,
|
||||
);
|
||||
let reported_cwd = core
|
||||
.terminal
|
||||
.take_pwd_changes()
|
||||
.into_iter()
|
||||
.filter_map(|value| parse_reported_cwd(&value))
|
||||
.next_back();
|
||||
#[cfg(windows)]
|
||||
windows_recent_fallback::update(&mut core);
|
||||
crate::render_prof::duration_since("pty.ghostty_write", write_started);
|
||||
|
|
@ -1406,7 +1409,7 @@ impl GhosttyPaneTerminal {
|
|||
core.terminal.write(ansi.as_bytes());
|
||||
}
|
||||
}
|
||||
ghostty_restore_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom);
|
||||
ghostty_set_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom);
|
||||
if offset_from_bottom > 0 {
|
||||
let mut remaining = offset_from_bottom.min(resize_recovery_probe_lines);
|
||||
while remaining > 0
|
||||
|
|
@ -1444,10 +1447,7 @@ impl GhosttyPaneTerminal {
|
|||
|
||||
pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
|
||||
if let Ok(mut core) = self.core.lock() {
|
||||
core.terminal.scroll_viewport_bottom();
|
||||
if lines > 0 {
|
||||
core.terminal.scroll_viewport_delta(-(lines as isize));
|
||||
}
|
||||
ghostty_set_scroll_offset_from_bottom(&mut core.terminal, lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2381,22 +2381,16 @@ fn ghostty_recent_read_range(
|
|||
Ok(Some((start, end, cols)))
|
||||
}
|
||||
|
||||
fn ghostty_restore_scroll_offset_from_bottom(
|
||||
fn ghostty_set_scroll_offset_from_bottom(
|
||||
terminal: &mut crate::ghostty::Terminal,
|
||||
offset_from_bottom: usize,
|
||||
) {
|
||||
terminal.scroll_viewport_bottom();
|
||||
if offset_from_bottom == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(scrollbar) = terminal.scrollbar() else {
|
||||
terminal.scroll_viewport_bottom();
|
||||
return;
|
||||
};
|
||||
let max_offset = scrollbar.total.saturating_sub(scrollbar.len);
|
||||
let offset = offset_from_bottom.min(max_offset).min(isize::MAX as usize) as isize;
|
||||
if offset > 0 {
|
||||
terminal.scroll_viewport_delta(-offset);
|
||||
}
|
||||
terminal.scroll_viewport_row(max_offset.saturating_sub(offset_from_bottom));
|
||||
}
|
||||
|
||||
fn ghostty_extract_selection(
|
||||
|
|
@ -3188,6 +3182,52 @@ mod tests {
|
|||
Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_pty_bytes_reports_latest_libghostty_pwd_callback() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
|
||||
let pane_id = PaneId::from_raw(1);
|
||||
|
||||
let partial = pane.process_pty_bytes(pane_id, 0, b"\x1b]7;file:///tmp/herdr%20", &tx);
|
||||
assert_eq!(partial.reported_cwd, None);
|
||||
|
||||
let completed = pane.process_pty_bytes(pane_id, 0, b"repo\x07", &tx);
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(
|
||||
completed.reported_cwd,
|
||||
Some(std::path::PathBuf::from("/tmp/herdr repo"))
|
||||
);
|
||||
#[cfg(windows)]
|
||||
assert_eq!(
|
||||
completed.reported_cwd,
|
||||
Some(std::path::PathBuf::from("\\tmp\\herdr repo"))
|
||||
);
|
||||
|
||||
let latest = pane.process_pty_bytes(
|
||||
pane_id,
|
||||
0,
|
||||
b"\x1b]9;9;/tmp/conemu\x1b\\\x1b]1337;CurrentDir=/tmp/iterm2\x1b\\",
|
||||
&tx,
|
||||
);
|
||||
assert_eq!(
|
||||
latest.reported_cwd,
|
||||
Some(std::path::PathBuf::from("/tmp/iterm2"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_history_pwd_does_not_leak_into_live_output() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
|
||||
pane.seed_history_ansi("\x1b]7;file:///tmp/restored\x07");
|
||||
|
||||
let result = pane.process_pty_bytes(PaneId::from_raw(1), 0, b"live output", &tx);
|
||||
|
||||
assert_eq!(result.reported_cwd, None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_powershell_prompt_cwd_uses_latest_default_prompt_path() {
|
||||
|
|
@ -4125,7 +4165,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn pane_scrollback_controls_reach_top_without_ui_interference() {
|
||||
fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap();
|
||||
write_numbered_lines(&mut terminal, 1000);
|
||||
|
|
@ -4135,10 +4175,20 @@ mod tests {
|
|||
assert!(before.max_offset_from_bottom > 0);
|
||||
assert_eq!(before.offset_from_bottom, 0);
|
||||
|
||||
pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom);
|
||||
for offset in [
|
||||
0,
|
||||
before.max_offset_from_bottom / 2,
|
||||
before.max_offset_from_bottom,
|
||||
usize::MAX,
|
||||
] {
|
||||
pane.set_scroll_offset_from_bottom(offset);
|
||||
let after = pane.scroll_metrics().expect("scroll metrics after scroll");
|
||||
assert_eq!(
|
||||
after.offset_from_bottom,
|
||||
offset.min(after.max_offset_from_bottom)
|
||||
);
|
||||
}
|
||||
|
||||
let after = pane.scroll_metrics().expect("scroll metrics after scroll");
|
||||
assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom);
|
||||
assert!(pane.visible_text().contains("000000"));
|
||||
}
|
||||
|
||||
|
|
@ -4270,11 +4320,17 @@ mod tests {
|
|||
assert!(!pane.visible_text().trim().is_empty());
|
||||
|
||||
for (rows, cols) in [(4, 10), (4, 7), (6, 18), (3, 9), (5, 12)] {
|
||||
let before_resize = pane.scroll_metrics().expect("scroll metrics before resize");
|
||||
pane.resize(rows, cols, 0, 0);
|
||||
|
||||
let metrics = pane.scroll_metrics().expect("scroll metrics after resize");
|
||||
assert_eq!(metrics.viewport_rows, rows as usize);
|
||||
assert!(metrics.offset_from_bottom <= metrics.max_offset_from_bottom);
|
||||
assert_eq!(
|
||||
metrics.offset_from_bottom,
|
||||
before_resize
|
||||
.offset_from_bottom
|
||||
.min(metrics.max_offset_from_bottom)
|
||||
);
|
||||
assert!(
|
||||
metrics.offset_from_bottom > 0,
|
||||
"resize should preserve a scrolled viewport instead of jumping to bottom"
|
||||
|
|
|
|||
Loading…
Reference in New Issue