264 lines
8.4 KiB
Zig
264 lines
8.4 KiB
Zig
//! One terminal session: a libghostty-vt terminal fed by a PTY.
|
|
//!
|
|
//! The PTY is read on the GLib main loop via a unix fd watch rather than a
|
|
//! dedicated IO thread. That keeps VT state single-threaded, so the renderer
|
|
//! can read the screen directly with no locking. A real terminal would want
|
|
//! Ghostty's threaded IO, but for a proof of concept this is much simpler and
|
|
//! is plenty fast for interactive use.
|
|
|
|
const std = @import("std");
|
|
const glib = @import("glib");
|
|
const glibunix = @import("glibunix");
|
|
const vt = @import("ghostty-vt");
|
|
|
|
const Pty = @import("Pty.zig");
|
|
|
|
const Session = @This();
|
|
|
|
/// Read buffer size. Large enough that a `cat` of a big file doesn't spend
|
|
/// all its time bouncing through the main loop.
|
|
const read_buf_size = 64 * 1024;
|
|
|
|
alloc: std.mem.Allocator,
|
|
|
|
/// The terminal emulator state. Must be the first field referenced by
|
|
/// `fromTerminal` below, which recovers the Session from stream callbacks.
|
|
term: vt.Terminal,
|
|
|
|
/// Persistent parser state. Escape sequences can and do get split across
|
|
/// read boundaries, so this must outlive individual reads.
|
|
stream: vt.TerminalStream,
|
|
|
|
pty: Pty,
|
|
|
|
/// GLib source ID for the PTY read watch, so we can cancel it on teardown.
|
|
watch: c_uint = 0,
|
|
|
|
/// True once the child process has exited and the PTY hung up.
|
|
exited: bool = false,
|
|
|
|
/// A script to run once the shell is ready, from the layout this pane came
|
|
/// from. Held rather than written at spawn time — see `flushStartupCommand`.
|
|
startup_command: ?[]u8 = null,
|
|
|
|
/// Called after terminal state changes, so the owner can queue a redraw.
|
|
on_damage: *const fn (ctx: ?*anyopaque) void,
|
|
|
|
/// Called when the terminal title changes (OSC 0/2).
|
|
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
|
|
|
/// Called when the child process exits.
|
|
on_exit: *const fn (ctx: ?*anyopaque) void,
|
|
|
|
ctx: ?*anyopaque = null,
|
|
|
|
pub const Callbacks = struct {
|
|
on_damage: *const fn (ctx: ?*anyopaque) void,
|
|
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
|
on_exit: *const fn (ctx: ?*anyopaque) void,
|
|
ctx: ?*anyopaque,
|
|
};
|
|
|
|
/// What a layout can ask for beyond a plain shell. Both are empty for a
|
|
/// terminal opened the ordinary way.
|
|
pub const Options = struct {
|
|
/// Directory the shell starts in.
|
|
cwd: []const u8 = "",
|
|
|
|
/// Script fed to the shell once it is up.
|
|
command: []const u8 = "",
|
|
};
|
|
|
|
pub fn create(
|
|
alloc: std.mem.Allocator,
|
|
cols: u16,
|
|
rows: u16,
|
|
opts: Options,
|
|
cbs: Callbacks,
|
|
) !*Session {
|
|
const self = try alloc.create(Session);
|
|
errdefer alloc.destroy(self);
|
|
|
|
const tio: vt.TinyIo = .init;
|
|
self.* = .{
|
|
.alloc = alloc,
|
|
.term = try .init(tio.io(), alloc, .{
|
|
.cols = cols,
|
|
.rows = rows,
|
|
.max_scrollback_bytes = 8 * 1024 * 1024,
|
|
}),
|
|
.stream = undefined,
|
|
.pty = undefined,
|
|
.on_damage = cbs.on_damage,
|
|
.on_title = cbs.on_title,
|
|
.on_exit = cbs.on_exit,
|
|
.ctx = cbs.ctx,
|
|
};
|
|
errdefer self.term.deinit(alloc);
|
|
|
|
self.stream = self.term.vtStream();
|
|
errdefer self.stream.deinit();
|
|
|
|
// Wire up the side effects we care about. `readonly` handles terminal
|
|
// state but silently drops anything that needs to talk back to the
|
|
// child; we need the writes so that queries (cursor position, device
|
|
// attributes, in-band resize) get answers.
|
|
var effects = vt.TerminalStream.Handler.Effects.readonly;
|
|
effects.write_pty = &effectWritePty;
|
|
effects.title_changed = &effectTitleChanged;
|
|
self.stream.handler.effects = effects;
|
|
|
|
const shell = try defaultShell(alloc);
|
|
defer alloc.free(shell);
|
|
|
|
// A leading '-' in argv[0] tells the shell to start as a login shell,
|
|
// so it reads the user's profile.
|
|
const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{
|
|
std.fs.path.basename(shell),
|
|
}, 0);
|
|
defer alloc.free(argv0);
|
|
|
|
const cwd_z: ?[:0]const u8 = if (opts.cwd.len > 0)
|
|
try alloc.dupeZ(u8, opts.cwd)
|
|
else
|
|
null;
|
|
defer if (cwd_z) |z| alloc.free(z);
|
|
|
|
self.pty = try .create(alloc, shell, &.{argv0}, cwd_z, .{
|
|
.ws_row = rows,
|
|
.ws_col = cols,
|
|
});
|
|
errdefer self.pty.deinit();
|
|
|
|
if (opts.command.len > 0) {
|
|
self.startup_command = try alloc.dupe(u8, opts.command);
|
|
}
|
|
|
|
self.watch = glibunix.fdAdd(
|
|
self.pty.master,
|
|
.{ .in = true, .hup = true, .err = true },
|
|
&onReadable,
|
|
self,
|
|
);
|
|
|
|
return self;
|
|
}
|
|
|
|
pub fn destroy(self: *Session) void {
|
|
if (self.startup_command) |cmd| self.alloc.free(cmd);
|
|
if (self.watch != 0) _ = glib.Source.remove(self.watch);
|
|
self.pty.deinit();
|
|
self.stream.deinit();
|
|
self.term.deinit(self.alloc);
|
|
self.alloc.destroy(self);
|
|
}
|
|
|
|
/// Resolve the shell to run, most authoritative source first.
|
|
///
|
|
/// The passwd entry comes first deliberately. $SHELL is only a description of
|
|
/// the shell that happened to launch us, and plenty of environments overwrite
|
|
/// it: `nix develop` replaces it with its own bash, and Flatpak pins it to
|
|
/// /bin/sh. The passwd entry is what the user actually configured, so it is
|
|
/// the better answer to "the user's default shell". $SHELL remains a fallback
|
|
/// for systems with no usable passwd entry, such as minimal containers.
|
|
fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 {
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
if (Pty.loginShell(&buf)) |shell| return alloc.dupeZ(u8, shell);
|
|
|
|
if (std.c.getenv("SHELL")) |sh| {
|
|
const span = std.mem.span(sh);
|
|
if (span.len > 0) return alloc.dupeZ(u8, span);
|
|
}
|
|
|
|
return alloc.dupeZ(u8, "/bin/sh");
|
|
}
|
|
|
|
/// Main loop callback: the PTY has data (or hung up).
|
|
fn onReadable(
|
|
fd: c_int,
|
|
condition: glib.IOCondition,
|
|
data: ?*anyopaque,
|
|
) callconv(.c) c_int {
|
|
const self: *Session = @ptrCast(@alignCast(data.?));
|
|
|
|
_ = fd;
|
|
|
|
if (condition.in) {
|
|
var buf: [read_buf_size]u8 = undefined;
|
|
if (self.pty.read(&buf)) |n| {
|
|
self.stream.nextSlice(buf[0..n]);
|
|
self.flushStartupCommand();
|
|
self.on_damage(self.ctx);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Zero-length read, HUP or error all mean the child is gone.
|
|
self.exited = true;
|
|
self.watch = 0;
|
|
self.on_exit(self.ctx);
|
|
return 0;
|
|
}
|
|
|
|
/// Type a layout's script into the shell, once, as soon as the shell has
|
|
/// shown that it is alive.
|
|
///
|
|
/// The wait matters. Writing at spawn time puts the script into the tty input
|
|
/// buffer before the shell has started, and shells that set up line editing
|
|
/// (zsh's ZLE, bash's readline) can discard whatever was buffered while they
|
|
/// were initializing, so the command silently vanishes. The shell's first
|
|
/// output — its prompt — is proof that it has finished starting and is reading
|
|
/// input, which is exactly the moment this becomes safe.
|
|
///
|
|
/// It is typed rather than executed for us, so the shell is still there
|
|
/// afterwards with the script sitting in its history.
|
|
fn flushStartupCommand(self: *Session) void {
|
|
const command = self.startup_command orelse return;
|
|
self.startup_command = null;
|
|
defer self.alloc.free(command);
|
|
|
|
self.pty.writeAll(command);
|
|
self.pty.writeAll("\n");
|
|
}
|
|
|
|
/// Effect callback: the terminal wants to send bytes back to the child.
|
|
fn effectWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
|
|
const self = fromHandler(handler);
|
|
self.pty.writeAll(data);
|
|
}
|
|
|
|
/// Effect callback: OSC 0/2 changed the window title.
|
|
fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void {
|
|
const self = fromHandler(handler);
|
|
self.on_title(self.ctx, self.term.title.items);
|
|
}
|
|
|
|
/// Recover the owning Session from a stream callback. The handler holds a
|
|
/// pointer to our embedded `term` field, so we can walk back from it.
|
|
fn fromHandler(handler: *vt.TerminalStream.Handler) *Session {
|
|
return @fieldParentPtr("term", handler.terminal);
|
|
}
|
|
|
|
/// Send user input to the child.
|
|
pub fn write(self: *Session, bytes: []const u8) void {
|
|
self.pty.writeAll(bytes);
|
|
}
|
|
|
|
/// Resize the terminal grid and tell the child about it.
|
|
pub fn resize(self: *Session, cols: u16, rows: u16, cell_w: u32, cell_h: u32) !void {
|
|
if (cols == self.term.cols and rows == self.term.rows) return;
|
|
|
|
try self.stream.handler.resize(.{
|
|
.cols = cols,
|
|
.rows = rows,
|
|
.cell_size_px = .{ .width = cell_w, .height = cell_h },
|
|
});
|
|
|
|
self.pty.setSize(.{
|
|
.ws_row = rows,
|
|
.ws_col = cols,
|
|
.ws_xpixel = @intCast(cell_w * cols),
|
|
.ws_ypixel = @intCast(cell_h * rows),
|
|
});
|
|
}
|