aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-06 22:05:00 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-06 22:56:01 +0300
commit6ccf156a3dd23f419c03892ddc1136e72957f66a (patch)
tree1d5465e61d03344a63683ad3b9bbdec9a4476a5c
parent62b751b2b551d866e2aebfdecf89f7a003d64ebc (diff)
refactor: use libghostty-native selection text reading
-rw-r--r--src/ghostty/mod.rs90
-rw-r--r--src/pane.rs45
-rw-r--r--src/selection.rs4
-rw-r--r--src/workspace.rs27
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/terminal.h48
-rw-r--r--vendor/libghostty-vt/src/lib_vt.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/c/terminal.zig70
-rw-r--r--vendor/libghostty-vt/src/terminal/c/types.zig1
9 files changed, 228 insertions, 59 deletions
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index a4846db4..e9ca1f86 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -223,6 +223,23 @@ struct WritePtyCallbackState {
callback: Box<dyn FnMut(&[u8]) + Send>,
}
+#[repr(C)]
+struct GhosttyTerminalSelection {
+ start: ffi::GhosttyPoint,
+ end: ffi::GhosttyPoint,
+ rectangle: bool,
+}
+
+unsafe extern "C" {
+ fn ghostty_terminal_read_text(
+ terminal: ffi::GhosttyTerminal_ptr,
+ selection: GhosttyTerminalSelection,
+ allocator: *const ffi::GhosttyAllocator,
+ out_ptr: *mut *mut u8,
+ out_len: *mut usize,
+ ) -> ffi::GhosttyResult;
+}
+
unsafe extern "C" fn write_pty_trampoline(
_terminal: ffi::GhosttyTerminal_ptr,
userdata: *mut c_void,
@@ -438,6 +455,46 @@ impl Terminal {
Ok(buffer)
}
+ pub fn read_text_viewport(
+ &self,
+ start: (u16, u32),
+ end: (u16, u32),
+ rectangle: bool,
+ ) -> Result<String, Error> {
+ let selection = GhosttyTerminalSelection {
+ start: ghostty_viewport_point(start.0, start.1),
+ end: ghostty_viewport_point(end.0, end.1),
+ rectangle,
+ };
+ let mut out_ptr = ptr::null_mut();
+ let mut out_len = 0usize;
+ unsafe {
+ ghostty_terminal_read_text(
+ self.raw,
+ selection,
+ ptr::null(),
+ &mut out_ptr,
+ &mut out_len,
+ )
+ .into_result()?;
+ }
+
+ let text = if out_len == 0 {
+ String::new()
+ } else {
+ let bytes = unsafe { slice::from_raw_parts(out_ptr.cast_const(), out_len) };
+ String::from_utf8_lossy(bytes).into_owned()
+ };
+
+ if !out_ptr.is_null() {
+ unsafe {
+ ffi::ghostty_free(ptr::null(), out_ptr, out_len);
+ }
+ }
+
+ Ok(text)
+ }
+
pub fn scroll_viewport_bottom(&mut self) {
let viewport = ffi::GhosttyTerminalScrollViewport {
tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_BOTTOM,
@@ -504,6 +561,15 @@ impl Drop for Terminal {
}
}
+fn ghostty_viewport_point(x: u16, y: u32) -> ffi::GhosttyPoint {
+ ffi::GhosttyPoint {
+ tag: ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT,
+ value: ffi::GhosttyPointValue {
+ coordinate: ffi::GhosttyPointCoordinate { x, y },
+ },
+ }
+}
+
pub struct RenderState {
raw: ffi::GhosttyRenderState_ptr,
}
@@ -1104,6 +1170,30 @@ mod tests {
}
#[test]
+ fn terminal_read_text_viewport_unwraps_soft_wrapped_selection() {
+ let mut terminal = Terminal::new(5, 3, 0).unwrap();
+ terminal.write("1ABCD2EFGH3IJKL".as_bytes());
+
+ let text = terminal.read_text_viewport((0, 1), (2, 2), false).unwrap();
+ assert_eq!(text, "2EFGH3IJ");
+ }
+
+ #[test]
+ fn terminal_read_text_viewport_handles_wide_chars() {
+ let mut terminal = Terminal::new(5, 3, 0).unwrap();
+ terminal.write("1A⚡".as_bytes());
+
+ let full = terminal.read_text_viewport((0, 0), (3, 0), false).unwrap();
+ assert_eq!(full, "1A⚡");
+
+ let through_wide_head = terminal.read_text_viewport((0, 0), (2, 0), false).unwrap();
+ assert_eq!(through_wide_head, "1A⚡");
+
+ let wide_only = terminal.read_text_viewport((3, 0), (3, 0), false).unwrap();
+ assert_eq!(wide_only, "⚡");
+ }
+
+ #[test]
fn terminal_and_render_state_smoke_test() {
let mut terminal = Terminal::new(8, 3, 100).unwrap();
assert_eq!(terminal.cols().unwrap(), 8);
diff --git a/src/pane.rs b/src/pane.rs
index 314554e1..a170d295 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1059,47 +1059,12 @@ fn ghostty_extract_selection(
core: &mut GhosttyPaneCore,
selection: &crate::selection::Selection,
) -> Result<String, crate::ghostty::Error> {
- let GhosttyPaneCore {
- terminal,
- render_state,
- } = core;
- render_state.update(terminal)?;
- let mut row_iterator = crate::ghostty::RowIterator::new()?;
- let mut row_cells = crate::ghostty::RowCells::new()?;
- let mut rows = render_state.populate_row_iterator(&mut row_iterator)?;
- let mut lines = Vec::new();
- let mut row_index = 0u16;
- let ((start_row, _), (end_row, _)) = selection.ordered_cells();
- while rows.next() {
- if row_index > end_row {
- break;
- }
- if row_index >= start_row {
- let mut cells = rows.populate_cells(&mut row_cells)?;
- let (start_col, end_col) = selection_cols_for_row(selection, row_index);
- let mut line = String::new();
- for x in start_col..=end_col {
- cells.select(x)?;
- line.push_str(&ghostty_cell_symbol(&cells)?);
- }
- lines.push(line.trim_end().to_string());
- }
- row_index += 1;
- }
- Ok(lines.join("\n"))
-}
-
-fn selection_cols_for_row(selection: &crate::selection::Selection, row: u16) -> (u16, u16) {
let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells();
- if start_row == end_row {
- (start_col, end_col)
- } else if row == start_row {
- (start_col, selection.pane_width().saturating_sub(1))
- } else if row == end_row {
- (0, end_col)
- } else {
- (0, selection.pane_width().saturating_sub(1))
- }
+ core.terminal.read_text_viewport(
+ (start_col, u32::from(start_row)),
+ (end_col, u32::from(end_row)),
+ false,
+ )
}
fn ghostty_screen_row(
diff --git a/src/selection.rs b/src/selection.rs
index 5531a189..bb5a76e8 100644
--- a/src/selection.rs
+++ b/src/selection.rs
@@ -104,10 +104,6 @@ impl Selection {
self.ordered()
}
- pub(crate) fn pane_width(&self) -> u16 {
- self.pane_inner.width
- }
-
/// Check whether a pane-relative cell (row, col) is inside the selection.
pub fn contains(&self, row: u16, col: u16) -> bool {
if !self.is_visible() {
diff --git a/src/workspace.rs b/src/workspace.rs
index 25de6568..01a96b3f 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -670,13 +670,26 @@ pub fn derive_label_from_cwd(cwd: &Path) -> String {
}
pub fn git_branch(cwd: &Path) -> Option<String> {
- let repo_root = git_repo_root(cwd)?;
- let head_path = repo_root.join(".git").join("HEAD");
- let content = std::fs::read_to_string(head_path).ok()?;
- let trimmed = content.trim();
- trimmed
- .strip_prefix("ref: refs/heads/")
- .map(|s| s.to_string())
+ git_repo_root(cwd)?;
+
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(cwd)
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8(output.stdout).ok()?;
+ let branch = stdout.trim();
+ if branch.is_empty() || branch == "HEAD" {
+ None
+ } else {
+ Some(branch.to_string())
+ }
}
fn git_repo_root(start: &Path) -> Option<PathBuf> {
diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h
index b43e37cf..b466bd84 100644
--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h
+++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h
@@ -254,6 +254,26 @@ typedef struct {
} GhosttyTerminalScrollbar;
/**
+ * Selection bounds for terminal text extraction.
+ *
+ * The start and end points may be in any supported point coordinate system
+ * (active, viewport, screen, or history). Text is always returned in reading
+ * order. Set `rectangle` to true for rectangular selection semantics.
+ *
+ * @ingroup terminal
+ */
+typedef struct {
+ /** Selection start point. */
+ GhosttyPoint start;
+
+ /** Selection end point. */
+ GhosttyPoint end;
+
+ /** Whether to treat the selection as a rectangle. */
+ bool rectangle;
+} GhosttyTerminalSelection;
+
+/**
* Callback function type for bell.
*
* Called when the terminal receives a BEL character (0x07).
@@ -974,6 +994,34 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal,
GhosttyPoint point,
GhosttyGridRef *out_ref);
+/**
+ * Read terminal text for an arbitrary selection.
+ *
+ * Formats the selected region as plain text using Ghostty's native selection
+ * semantics. Soft-wrapped lines are unwrapped and trailing whitespace is
+ * trimmed on non-blank lines.
+ *
+ * The returned buffer is allocated using the provided allocator, or the
+ * default allocator if NULL is passed. Free it with ghostty_free(), passing
+ * the same allocator pointer and returned length.
+ *
+ * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
+ * @param selection The selection bounds to read
+ * @param allocator Pointer to allocator, or NULL to use the default allocator
+ * @param[out] out_ptr On success, set to the allocated output buffer
+ * @param[out] out_len On success, set to the output length in bytes
+ * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
+ * is NULL or the selection points are out of bounds,
+ * GHOSTTY_OUT_OF_MEMORY on allocation failure
+ *
+ * @ingroup terminal
+ */
+GHOSTTY_API GhosttyResult ghostty_terminal_read_text(GhosttyTerminal terminal,
+ GhosttyTerminalSelection selection,
+ const GhosttyAllocator* allocator,
+ uint8_t** out_ptr,
+ size_t* out_len);
+
/** @} */
#ifdef __cplusplus
diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig
index 9f5ee6c4..9d2442a8 100644
--- a/vendor/libghostty-vt/src/lib_vt.zig
+++ b/vendor/libghostty-vt/src/lib_vt.zig
@@ -214,6 +214,7 @@ comptime {
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
@export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" });
+ @export(&c.terminal_read_text, .{ .name = "ghostty_terminal_read_text" });
@export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" });
@export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" });
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig
index 17056779..34d1a8f4 100644
--- a/vendor/libghostty-vt/src/terminal/c/main.zig
+++ b/vendor/libghostty-vt/src/terminal/c/main.zig
@@ -142,6 +142,7 @@ pub const terminal_mode_get = terminal.mode_get;
pub const terminal_mode_set = terminal.mode_set;
pub const terminal_get = terminal.get;
pub const terminal_grid_ref = terminal.grid_ref;
+pub const terminal_read_text = terminal.read_text;
pub const type_json = types.get_json;
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index ec749ffa..fa421f21 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -13,6 +13,8 @@ const size = @import("../size.zig");
const device_attributes = @import("../device_attributes.zig");
const device_status = @import("../device_status.zig");
const size_report = @import("../size_report.zig");
+const formatterpkg = @import("../formatter.zig");
+const selectionpkg = @import("../Selection.zig");
const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const grid_ref_c = @import("grid_ref.zig");
@@ -398,6 +400,21 @@ pub const DeviceAttributes = Effects.CDeviceAttributes;
/// C: GhosttyTerminalScrollViewport
pub const ScrollViewport = ZigTerminal.ScrollViewport.C;
+/// C: GhosttyTerminalSelection
+pub const Selection = extern struct {
+ start: point.Point.C,
+ end: point.Point.C,
+ rectangle: bool,
+
+ fn toZig(self: Selection, terminal: *const ZigTerminal) ?selectionpkg {
+ return selectionpkg.init(
+ pinFromPoint(terminal, self.start) orelse return null,
+ pinFromPoint(terminal, self.end) orelse return null,
+ self.rectangle,
+ );
+ }
+};
+
pub fn scroll_viewport(
terminal_: Terminal,
behavior: ScrollViewport,
@@ -608,24 +625,61 @@ fn getTyped(
return .success;
}
+fn pointToZig(pt: point.Point.C) point.Point {
+ return switch (pt.tag) {
+ .active => .{ .active = pt.value.active },
+ .viewport => .{ .viewport = pt.value.viewport },
+ .screen => .{ .screen = pt.value.screen },
+ .history => .{ .history = pt.value.history },
+ };
+}
+
+fn pinFromPoint(terminal: *const ZigTerminal, pt: point.Point.C) ?PageList.Pin {
+ return terminal.screens.active.pages.pin(pointToZig(pt));
+}
+
pub fn grid_ref(
terminal_: Terminal,
pt: point.Point.C,
out_ref: ?*grid_ref_c.CGridRef,
) callconv(lib.calling_conv) Result {
const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
- const zig_pt: point.Point = switch (pt.tag) {
- .active => .{ .active = pt.value.active },
- .viewport => .{ .viewport = pt.value.viewport },
- .screen => .{ .screen = pt.value.screen },
- .history => .{ .history = pt.value.history },
- };
- const p = t.screens.active.pages.pin(zig_pt) orelse
- return .invalid_value;
+ const p = pinFromPoint(t, pt) orelse return .invalid_value;
if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p);
return .success;
}
+pub fn read_text(
+ terminal_: Terminal,
+ selection: Selection,
+ alloc_: ?*const CAllocator,
+ out_ptr: *?[*]u8,
+ out_len: *usize,
+) callconv(lib.calling_conv) Result {
+ out_ptr.* = null;
+ out_len.* = 0;
+
+ const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
+ const alloc = lib.alloc.default(alloc_);
+ const sel = selection.toZig(t) orelse return .invalid_value;
+
+ var formatter: formatterpkg.ScreenFormatter = .init(t.screens.active, .{
+ .emit = .plain,
+ .unwrap = true,
+ .trim = true,
+ });
+ formatter.content = .{ .selection = sel };
+
+ var aw: std.Io.Writer.Allocating = .init(alloc);
+ defer aw.deinit();
+ formatter.format(&aw.writer) catch return .out_of_memory;
+
+ const buf = aw.toOwnedSlice() catch return .out_of_memory;
+ out_ptr.* = buf.ptr;
+ out_len.* = buf.len;
+ return .success;
+}
+
pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void {
const wrapper = terminal_ orelse return;
const t = wrapper.terminal;
diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig
index b808bf38..51184b47 100644
--- a/vendor/libghostty-vt/src/terminal/c/types.zig
+++ b/vendor/libghostty-vt/src/terminal/c/types.zig
@@ -41,6 +41,7 @@ pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
+ .{ "GhosttyTerminalSelection", StructInfo.init(terminal.Selection) },
});
/// The comptime-generated JSON string of all structs.