apprt/embedded: improve text reading APIs (selection, random points)

This commit is contained in:
Mitchell Hashimoto
2025-06-09 15:48:03 -07:00
parent be437f5b64
commit c5f921bb06
5 changed files with 357 additions and 101 deletions

View File

@@ -1292,6 +1292,133 @@ fn recomputeInitialSize(
) catch return error.AppActionFailed;
}
/// Represents text read from the terminal and some metadata about it
/// that is often useful to apprts.
pub const Text = struct {
/// The text that was read from the terminal.
text: [:0]const u8,
/// The viewport information about this text, if it is visible in
/// the viewport.
///
/// NOTE(mitchellh): This will only be non-null currently if the entirety
/// of the selection is contained within the viewport. We don't have a
/// use case currently for partial bounds but we should support this
/// eventually.
viewport: ?Viewport = null,
pub const Viewport = struct {
/// The top-left corner of the selection in pixels within the viewport.
tl_px_x: f64,
tl_px_y: f64,
/// The linear offset of the start of the selection and the length.
/// This is "linear" in the sense that it is the offset in the
/// flattened viewport as a single array of text.
offset_start: u32,
offset_len: u32,
};
pub fn deinit(self: *Text, alloc: Allocator) void {
alloc.free(self.text);
}
};
/// Grab the value of text at the given selection point. Note that the
/// selection structure is used as a way to determine the area of the
/// screen to read from, it doesn't have to match the user's current
/// selection state.
///
/// The returned value contains allocated data and must be deinitialized.
pub fn dumpText(
self: *Surface,
alloc: Allocator,
sel: terminal.Selection,
) !Text {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
return try self.dumpTextLocked(alloc, sel);
}
/// Same as `dumpText` but assumes the renderer state mutex is already
/// held.
pub fn dumpTextLocked(
self: *Surface,
alloc: Allocator,
sel: terminal.Selection,
) !Text {
// Read out the text
const text = try self.io.terminal.screen.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
errdefer alloc.free(text);
// Calculate our viewport info if we can.
const vp: ?Text.Viewport = viewport: {
// If our tl or br is not in the viewport then we don't
// have a viewport. One day we should extend this to support
// partial selections that are in the viewport.
const tl_pt = self.io.terminal.screen.pages.pointFromPin(
.viewport,
sel.topLeft(&self.io.terminal.screen),
) orelse break :viewport null;
const br_pt = self.io.terminal.screen.pages.pointFromPin(
.viewport,
sel.bottomRight(&self.io.terminal.screen),
) orelse break :viewport null;
const tl_coord = tl_pt.coord();
const br_coord = br_pt.coord();
// Our sizes are all scaled so we need to send the unscaled values back.
const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 };
const x: f64 = x: {
// Simple x * cell width gives the left
var x: f64 = @floatFromInt(tl_coord.x * self.size.cell.width);
// Add padding
x += @floatFromInt(self.size.padding.left);
// Scale
x /= content_scale.x;
break :x x;
};
const y: f64 = y: {
// Simple y * cell height gives the top
var y: f64 = @floatFromInt(tl_coord.y * self.size.cell.height);
// We want the text baseline
y += @floatFromInt(self.size.cell.height);
y -= @floatFromInt(self.font_metrics.cell_baseline);
// Add padding
y += @floatFromInt(self.size.padding.top);
// Scale
y /= content_scale.y;
break :y y;
};
// Utilize viewport sizing to convert to offsets
const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x;
const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x;
break :viewport .{
.tl_px_x = x,
.tl_px_y = y,
.offset_start = start,
.offset_len = end - start,
};
};
return .{
.text = text,
.viewport = vp,
};
}
/// Returns true if the terminal has a selection.
pub fn hasSelection(self: *const Surface) bool {
self.renderer_state.mutex.lock();

View File

@@ -1138,13 +1138,6 @@ pub const CAPI = struct {
}
};
const Selection = extern struct {
tl_x_px: f64,
tl_y_px: f64,
offset_start: u32,
offset_len: u32,
};
const SurfaceSize = extern struct {
columns: u16,
rows: u16,
@@ -1154,6 +1147,83 @@ pub const CAPI = struct {
cell_height_px: u32,
};
// ghostty_text_s
const Text = extern struct {
tl_px_x: f64,
tl_px_y: f64,
offset_start: u32,
offset_len: u32,
text: ?[*:0]const u8,
text_len: usize,
pub fn deinit(self: *Text) void {
if (self.text) |ptr| {
global.alloc.free(ptr[0..self.text_len :0]);
}
}
};
// ghostty_point_s
const Point = extern struct {
tag: Tag,
x: u32,
y: u32,
const Tag = enum(c_int) {
active = 0,
viewport = 1,
screen = 2,
history = 3,
};
fn core(self: Point) terminal.Point {
// This comes from the C API so we can't trust the input.
const pt_x = std.math.cast(
terminal.size.CellCountInt,
self.x,
) orelse std.math.maxInt(terminal.size.CellCountInt);
return switch (self.tag) {
inline else => |tag| @unionInit(
terminal.Point,
@tagName(tag),
.{ .x = pt_x, .y = self.y },
),
};
}
fn clamp(self: Point, screen: *const terminal.Screen) Point {
// Clamp our point to the screen bounds.
const clamped_x = @min(self.x, screen.pages.cols -| 1);
const clamped_y = @min(self.y, screen.pages.rows -| 1);
return .{ .tag = self.tag, .x = clamped_x, .y = clamped_y };
}
};
// ghostty_selection_s
const Selection = extern struct {
tl: Point,
br: Point,
rectangle: bool,
fn core(
self: Selection,
screen: *const terminal.Screen,
) ?terminal.Selection {
return .{
.bounds = .{ .untracked = .{
.start = screen.pages.pin(
self.tl.clamp(screen).core(),
) orelse return null,
.end = screen.pages.pin(
self.br.clamp(screen).core(),
) orelse return null,
} },
.rectangle = self.rectangle,
};
}
};
// Reference the conditional exports based on target platform
// so they're included in the C API.
comptime {
@@ -1369,23 +1439,80 @@ pub const CAPI = struct {
return surface.core_surface.hasSelection();
}
/// Copies the surface selection text into the provided buffer and
/// returns the copied size. If the buffer is too small, there is no
/// selection, or there is an error, then 0 is returned.
export fn ghostty_surface_selection(surface: *Surface, buf: [*]u8, cap: usize) usize {
const selection_ = surface.core_surface.selectionString(global.alloc) catch |err| {
log.warn("error getting selection err={}", .{err});
return 0;
/// Same as ghostty_surface_read_text but reads from the user selection,
/// if any.
export fn ghostty_surface_read_selection(
surface: *Surface,
result: *Text,
) bool {
const core_surface = &surface.core_surface;
core_surface.renderer_state.mutex.lock();
defer core_surface.renderer_state.mutex.unlock();
// If we don't have a selection, do nothing.
const core_sel = core_surface.io.terminal.screen.selection orelse return false;
// Read the text from the selection.
return readTextLocked(surface, core_sel, result);
}
/// Read some arbitrary text from the surface.
///
/// This is an expensive operation so it shouldn't be called too
/// often. We recommend that callers cache the result and throttle
/// calls to this function.
export fn ghostty_surface_read_text(
surface: *Surface,
sel: Selection,
result: *Text,
) bool {
surface.core_surface.renderer_state.mutex.lock();
defer surface.core_surface.renderer_state.mutex.unlock();
const core_sel = sel.core(
&surface.core_surface.renderer_state.terminal.screen,
) orelse return false;
return readTextLocked(surface, core_sel, result);
}
fn readTextLocked(
surface: *Surface,
core_sel: terminal.Selection,
result: *Text,
) bool {
const core_surface = &surface.core_surface;
// Get our text directly from the core surface.
const text = core_surface.dumpTextLocked(
global.alloc,
core_sel,
) catch |err| {
log.warn("error reading text err={}", .{err});
return false;
};
const selection = selection_ orelse return 0;
defer global.alloc.free(selection);
// If the buffer is too small, return no selection.
if (selection.len > cap) return 0;
const vp: CoreSurface.Text.Viewport = text.viewport orelse .{
.tl_px_x = -1,
.tl_px_y = -1,
.offset_start = 0,
.offset_len = 0,
};
// Copy into the buffer and return the length
@memcpy(buf[0..selection.len], selection);
return selection.len;
result.* = .{
.tl_px_x = vp.tl_px_x,
.tl_px_y = vp.tl_px_y,
.offset_start = vp.offset_start,
.offset_len = vp.offset_len,
.text = text.text.ptr,
.text_len = text.text.len,
};
return true;
}
export fn ghostty_surface_free_text(ptr: *Text) void {
ptr.deinit();
}
/// Tell the surface that it needs to schedule a render
@@ -1888,21 +2015,12 @@ pub const CAPI = struct {
/// This does not modify the selection active on the surface (if any).
export fn ghostty_surface_quicklook_word(
ptr: *Surface,
buf: [*]u8,
cap: usize,
info: *Selection,
) usize {
result: *Text,
) bool {
const surface = &ptr.core_surface;
surface.renderer_state.mutex.lock();
defer surface.renderer_state.mutex.unlock();
// To make everything in this function easier, we modify the
// selection to be the word under the cursor and call normal APIs.
// We restore the old selection so it isn't ever changed. Since we hold
// the renderer mutex it'll never show up in a frame.
const prev = surface.io.terminal.screen.selection;
defer surface.io.terminal.screen.selection = prev;
// Get our word selection
const sel = sel: {
const screen = &surface.renderer_state.terminal.screen;
@@ -1915,45 +2033,13 @@ pub const CAPI = struct {
},
}) orelse {
if (comptime std.debug.runtime_safety) unreachable;
return 0;
return false;
};
break :sel surface.io.terminal.screen.selectWord(pin) orelse return 0;
break :sel surface.io.terminal.screen.selectWord(pin) orelse return false;
};
// Set the selection
surface.io.terminal.screen.selection = sel;
// No we call normal functions. These require that the lock
// is unlocked. This may cause a frame flicker with the fake
// selection but I think the lack of new complexity is worth it
// for now.
{
surface.renderer_state.mutex.unlock();
defer surface.renderer_state.mutex.lock();
const len = ghostty_surface_selection(ptr, buf, cap);
if (!ghostty_surface_selection_info(ptr, info)) return 0;
return len;
}
}
/// This returns the selection metadata for the current selection.
/// This will return false if there is no selection or the
/// selection is not fully contained in the viewport (since the
/// metadata is all about that).
export fn ghostty_surface_selection_info(
ptr: *Surface,
info: *Selection,
) bool {
const sel = ptr.core_surface.selectionInfo() orelse
return false;
info.* = .{
.tl_x_px = sel.tl_x_px,
.tl_y_px = sel.tl_y_px,
.offset_start = sel.offset_start,
.offset_len = sel.offset_len,
};
return true;
// Read the selection
return readTextLocked(ptr, sel, result);
}
export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {

View File

@@ -35,6 +35,7 @@ pub const Page = page.Page;
pub const PageList = @import("PageList.zig");
pub const Parser = @import("Parser.zig");
pub const Pin = PageList.Pin;
pub const Point = point.Point;
pub const Screen = @import("Screen.zig");
pub const ScreenType = Terminal.ScreenType;
pub const Selection = @import("Selection.zig");