526 lines
18 KiB
Zig
526 lines
18 KiB
Zig
const std = @import("std");
|
||
const Allocator = std.mem.Allocator;
|
||
|
||
const tp = @import("thespian");
|
||
const cbor = @import("cbor");
|
||
const command = @import("command");
|
||
const vaxis = @import("renderer").vaxis;
|
||
const shell = @import("shell");
|
||
|
||
const Plane = @import("renderer").Plane;
|
||
const Widget = @import("Widget.zig");
|
||
const WidgetList = @import("WidgetList.zig");
|
||
const MessageFilter = @import("MessageFilter.zig");
|
||
const tui = @import("tui.zig");
|
||
const input = @import("input");
|
||
const keybind = @import("keybind");
|
||
pub const Mode = keybind.Mode;
|
||
|
||
pub const name = @typeName(Self);
|
||
|
||
const Self = @This();
|
||
const widget_type: Widget.Type = .panel;
|
||
|
||
const Terminal = vaxis.widgets.Terminal;
|
||
|
||
allocator: Allocator,
|
||
plane: Plane,
|
||
focused: bool = false,
|
||
input_mode: Mode,
|
||
hover: bool = false,
|
||
vt: *Vt,
|
||
commands: Commands = undefined,
|
||
|
||
pub fn create(allocator: Allocator, parent: Plane) !Widget {
|
||
return create_with_args(allocator, parent, .{});
|
||
}
|
||
|
||
pub fn create_with_args(allocator: Allocator, parent: Plane, ctx: command.Context) !Widget {
|
||
const container = try WidgetList.createHStyled(
|
||
allocator,
|
||
parent,
|
||
"panel_frame",
|
||
.dynamic,
|
||
widget_type,
|
||
);
|
||
|
||
var plane = try Plane.init(&(Widget.Box{}).opts(name), parent);
|
||
errdefer plane.deinit();
|
||
|
||
var env = try std.process.getEnvMap(allocator);
|
||
errdefer env.deinit();
|
||
|
||
var cmd_arg: []const u8 = "";
|
||
const argv_msg: ?tp.message = if (ctx.args.match(.{tp.extract(&cmd_arg)}) catch false and cmd_arg.len > 0)
|
||
try shell.parse_arg0_to_argv(allocator, &cmd_arg)
|
||
else
|
||
null;
|
||
defer if (argv_msg) |msg| allocator.free(msg.buf);
|
||
|
||
var argv_list: std.ArrayListUnmanaged([]const u8) = .empty;
|
||
defer argv_list.deinit(allocator);
|
||
if (argv_msg) |msg| {
|
||
var iter = msg.buf;
|
||
var len = try cbor.decodeArrayHeader(&iter);
|
||
while (len > 0) : (len -= 1) {
|
||
var arg: []const u8 = undefined;
|
||
if (try cbor.matchValue(&iter, cbor.extract(&arg)))
|
||
try argv_list.append(allocator, arg);
|
||
}
|
||
} else {
|
||
try argv_list.append(allocator, env.get("SHELL") orelse "bash");
|
||
}
|
||
|
||
// Use the current plane dimensions for the initial pty size. The plane
|
||
// starts at 0×0 before the first resize, so use a sensible fallback
|
||
// so the pty isn't created with a zero-cell screen.
|
||
const cols: u16 = @intCast(@max(80, plane.dim_x()));
|
||
const rows: u16 = @intCast(@max(24, plane.dim_y()));
|
||
|
||
if (global_vt == null) try Vt.init(allocator, argv_list.items, env, rows, cols);
|
||
|
||
const self = try allocator.create(Self);
|
||
errdefer allocator.destroy(self);
|
||
|
||
self.* = .{
|
||
.allocator = allocator,
|
||
.plane = plane,
|
||
.input_mode = try keybind.mode("terminal", allocator, .{ .insert_command = "do_nothing" }),
|
||
.vt = &global_vt.?,
|
||
};
|
||
|
||
try self.commands.init(self);
|
||
try tui.message_filters().add(MessageFilter.bind(self, receive_filter));
|
||
|
||
container.ctx = self;
|
||
try container.add(Widget.to(self));
|
||
|
||
return container.widget();
|
||
}
|
||
|
||
pub fn receive(self: *Self, from: tp.pid_ref, m: tp.message) error{Exit}!bool {
|
||
if (try m.match(.{ "terminal_view", "output" })) {
|
||
tui.need_render(@src());
|
||
return true;
|
||
} else if (try m.match(.{ "H", tp.extract(&self.hover) })) {
|
||
tui.rdr().request_mouse_cursor_default(self.hover);
|
||
tui.need_render(@src());
|
||
return true;
|
||
}
|
||
if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON1), tp.more }) or
|
||
try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON2), tp.more }) or
|
||
try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON3), tp.more }))
|
||
switch (tui.set_focus_by_mouse_event()) {
|
||
.changed => return true,
|
||
.same, .notfound => {},
|
||
};
|
||
|
||
if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON4), tp.more })) {
|
||
if (self.vt.vt.scroll(3)) tui.need_render(@src());
|
||
return true;
|
||
}
|
||
if (try m.match(.{ "B", input.event.press, @intFromEnum(input.mouse.BUTTON5), tp.more })) {
|
||
if (self.vt.vt.scroll(-3)) tui.need_render(@src());
|
||
return true;
|
||
}
|
||
|
||
if (!(try m.match(.{ "I", tp.more })
|
||
// or
|
||
// try m.match(.{ "B", tp.more }) or
|
||
// try m.match(.{ "D", tp.more }) or
|
||
// try m.match(.{ "M", tp.more })
|
||
))
|
||
return false;
|
||
|
||
if (!self.focused) return false;
|
||
|
||
if (try self.input_mode.bindings.receive(from, m))
|
||
return true;
|
||
|
||
var event: input.Event = 0;
|
||
var keypress: input.Key = 0;
|
||
var keypress_shifted: input.Key = 0;
|
||
var text: []const u8 = "";
|
||
var modifiers: u8 = 0;
|
||
|
||
if (!try m.match(.{ "I", tp.extract(&event), tp.extract(&keypress), tp.extract(&keypress_shifted), tp.extract(&text), tp.extract(&modifiers) }))
|
||
return false;
|
||
|
||
// Only forward press and repeat events; ignore releases.
|
||
if (event != input.event.press and event != input.event.repeat) return true;
|
||
const key: vaxis.Key = .{
|
||
.codepoint = keypress,
|
||
.shifted_codepoint = if (keypress_shifted != keypress) keypress_shifted else null,
|
||
.mods = @bitCast(modifiers),
|
||
.text = if (text.len > 0) text else null,
|
||
};
|
||
self.vt.vt.scrollToBottom();
|
||
self.vt.vt.update(.{ .key_press = key }) catch |e|
|
||
std.log.err("terminal_view: input failed: {}", .{e});
|
||
tui.need_render(@src());
|
||
return true;
|
||
}
|
||
|
||
pub fn toggle_focus(self: *Self) void {
|
||
if (self.focused) self.unfocus() else self.focus();
|
||
}
|
||
|
||
pub fn focus(self: *Self) void {
|
||
self.focused = true;
|
||
tui.set_keyboard_focus(Widget.to(self));
|
||
}
|
||
|
||
pub fn unfocus(self: *Self) void {
|
||
self.focused = false;
|
||
tui.release_keyboard_focus(Widget.to(self));
|
||
}
|
||
|
||
pub fn deinit(self: *Self, allocator: Allocator) void {
|
||
if (self.focused) tui.release_keyboard_focus(Widget.to(self));
|
||
self.commands.unregister();
|
||
self.plane.deinit();
|
||
allocator.destroy(self);
|
||
}
|
||
|
||
pub fn shutdown(allocator: Allocator) void {
|
||
if (global_vt) |*vt| {
|
||
vt.deinit(allocator);
|
||
global_vt = null;
|
||
}
|
||
}
|
||
|
||
pub fn render(self: *Self, theme: *const Widget.Theme) bool {
|
||
// Drain the vt event queue.
|
||
while (self.vt.vt.tryEvent()) |event| {
|
||
switch (event) {
|
||
.exited => |code| {
|
||
self.show_exit_message(code);
|
||
tui.need_render(@src());
|
||
},
|
||
.redraw, .bell => {},
|
||
.pwd_change => |path| {
|
||
self.vt.cwd.clearRetainingCapacity();
|
||
self.vt.cwd.appendSlice(self.allocator, path) catch {};
|
||
},
|
||
.title_change => |t| {
|
||
self.vt.title.clearRetainingCapacity();
|
||
self.vt.title.appendSlice(self.allocator, t) catch {};
|
||
},
|
||
.color_change => |cc| {
|
||
self.vt.app_fg = cc.fg;
|
||
self.vt.app_bg = cc.bg;
|
||
self.vt.app_cursor = cc.cursor;
|
||
},
|
||
.osc_copy => |text| {
|
||
// Terminal app wrote to clipboard via OSC 52.
|
||
// Add to flow clipboard history and forward to system clipboard.
|
||
const owned = tui.clipboard_allocator().dupe(u8, text) catch break;
|
||
tui.clipboard_clear_all();
|
||
tui.clipboard_start_group();
|
||
tui.clipboard_add_chunk(owned);
|
||
tui.clipboard_send_to_system() catch {};
|
||
},
|
||
.osc_paste_request => {
|
||
// Terminal app requested clipboard contents via OSC 52.
|
||
// Assemble from flow clipboard history and respond.
|
||
if (tui.clipboard_get_history()) |history| {
|
||
var buf: std.Io.Writer.Allocating = .init(self.allocator);
|
||
defer buf.deinit();
|
||
var first = true;
|
||
for (history) |chunk| {
|
||
if (first) first = false else buf.writer.writeByte('\n') catch break;
|
||
buf.writer.writeAll(chunk.text) catch break;
|
||
}
|
||
self.vt.vt.respondOsc52Paste(buf.written());
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
// Update the terminal's fg/bg color cache from the current theme so that
|
||
// OSC 10/11 colour queries return accurate values.
|
||
if (theme.editor.fg) |fg| {
|
||
const c = fg.color;
|
||
self.vt.vt.fg_color = .{ @truncate(c >> 16), @truncate(c >> 8), @truncate(c) };
|
||
}
|
||
if (theme.editor.bg) |bg| {
|
||
const c = bg.color;
|
||
self.vt.vt.bg_color = .{ @truncate(c >> 16), @truncate(c >> 8), @truncate(c) };
|
||
}
|
||
|
||
// Blit the terminal's front screen into our vaxis.Window.
|
||
self.vt.vt.draw(self.allocator, self.plane.window, self.focused) catch |e| {
|
||
std.log.err("terminal_view: draw failed: {}", .{e});
|
||
};
|
||
|
||
return false;
|
||
}
|
||
|
||
fn show_exit_message(self: *Self, code: u8) void {
|
||
var msg: std.Io.Writer.Allocating = .init(self.allocator);
|
||
defer msg.deinit();
|
||
const w = &msg.writer;
|
||
w.writeAll("\r\n") catch {};
|
||
w.writeAll("\x1b[0m\x1b[2m") catch {};
|
||
w.writeAll("[process exited") catch {};
|
||
if (code != 0)
|
||
w.print(" with code {d}", .{code}) catch {};
|
||
w.writeAll("]\x1b[0m\r\n") catch {};
|
||
var parser: pty.Parser = .{ .buf = .init(self.allocator) };
|
||
defer parser.buf.deinit();
|
||
_ = self.vt.vt.processOutput(&parser, msg.written()) catch {};
|
||
}
|
||
|
||
pub fn handle_resize(self: *Self, pos: Widget.Box) void {
|
||
self.plane.move_yx(@intCast(pos.y), @intCast(pos.x)) catch return;
|
||
self.plane.resize_simple(@intCast(pos.h), @intCast(pos.w)) catch return;
|
||
self.vt.resize(pos);
|
||
}
|
||
|
||
fn receive_filter(_: *Self, _: tp.pid_ref, m: tp.message) MessageFilter.Error!bool {
|
||
if (m.match(.{ "terminal_view", "output" }) catch false) {
|
||
tui.need_render(@src());
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
const Commands = command.Collection(cmds);
|
||
|
||
const cmds = struct {
|
||
pub const Target = Self;
|
||
const Ctx = command.Context;
|
||
const Meta = command.Metadata;
|
||
const Result = command.Result;
|
||
|
||
pub fn terminal_scroll_up(self: *Self, _: Ctx) Result {
|
||
const half_page = @max(1, self.vt.vt.front_screen.height / 2);
|
||
if (self.vt.vt.scroll(@intCast(half_page)))
|
||
tui.need_render(@src());
|
||
}
|
||
pub const terminal_scroll_up_meta: Meta = .{ .description = "Terminal: Scroll up" };
|
||
|
||
pub fn terminal_scroll_down(self: *Self, _: Ctx) Result {
|
||
const half_page = @max(1, self.vt.vt.front_screen.height / 2);
|
||
if (self.vt.vt.scroll(-@as(i32, @intCast(half_page))))
|
||
tui.need_render(@src());
|
||
}
|
||
pub const terminal_scroll_down_meta: Meta = .{ .description = "Terminal: Scroll down" };
|
||
};
|
||
|
||
const Vt = struct {
|
||
vt: Terminal,
|
||
env: std.process.EnvMap,
|
||
write_buf: [4096]u8,
|
||
pty_pid: ?tp.pid = null,
|
||
cwd: std.ArrayListUnmanaged(u8) = .empty,
|
||
title: std.ArrayListUnmanaged(u8) = .empty,
|
||
/// App-specified override colours (from OSC 10/11/12). null = use theme.
|
||
app_fg: ?[3]u8 = null,
|
||
app_bg: ?[3]u8 = null,
|
||
app_cursor: ?[3]u8 = null,
|
||
|
||
fn init(allocator: std.mem.Allocator, argv: []const []const u8, env: std.process.EnvMap, rows: u16, cols: u16) !void {
|
||
const home = env.get("HOME") orelse "/tmp";
|
||
|
||
global_vt = .{
|
||
.vt = undefined,
|
||
.env = env,
|
||
.write_buf = undefined, // managed via self.vt's pty_writer pointer
|
||
.pty_pid = null,
|
||
};
|
||
const self = &global_vt.?;
|
||
self.vt = try Terminal.init(
|
||
allocator,
|
||
argv,
|
||
&env,
|
||
.{
|
||
.winsize = .{ .rows = rows, .cols = cols, .x_pixel = 0, .y_pixel = 0 },
|
||
.scrollback_size = tui.config().terminal_scrollback_size,
|
||
.initial_working_directory = blk: {
|
||
const project = tp.env.get().str("project");
|
||
break :blk if (project.len > 0) project else home;
|
||
},
|
||
},
|
||
&self.write_buf,
|
||
);
|
||
|
||
try self.vt.spawn();
|
||
self.pty_pid = try pty.spawn(allocator, &self.vt);
|
||
}
|
||
|
||
fn deinit(self: *@This(), allocator: std.mem.Allocator) void {
|
||
self.cwd.deinit(allocator);
|
||
self.title.deinit(allocator);
|
||
if (self.pty_pid) |pid| {
|
||
pid.send(.{"quit"}) catch {};
|
||
pid.deinit();
|
||
self.pty_pid = null;
|
||
}
|
||
self.vt.deinit();
|
||
self.env.deinit();
|
||
std.log.debug("terminal: vt destroyed", .{});
|
||
}
|
||
|
||
pub fn resize(self: *@This(), pos: Widget.Box) void {
|
||
const cols: u16 = @intCast(@max(1, pos.w));
|
||
const rows: u16 = @intCast(@max(1, pos.h));
|
||
self.vt.resize(.{
|
||
.rows = rows,
|
||
.cols = cols,
|
||
.x_pixel = 0,
|
||
.y_pixel = 0,
|
||
}) catch |e| {
|
||
std.log.err("terminal: resize failed: {}", .{e});
|
||
};
|
||
}
|
||
};
|
||
var global_vt: ?Vt = null;
|
||
|
||
const pty = struct {
|
||
const Parser = Terminal.Parser;
|
||
|
||
const Receiver = tp.Receiver(*@This());
|
||
|
||
allocator: std.mem.Allocator,
|
||
vt: *Terminal,
|
||
fd: tp.file_descriptor,
|
||
pty_fd: std.posix.fd_t,
|
||
parser: Parser,
|
||
receiver: Receiver,
|
||
parent: tp.pid,
|
||
|
||
pub fn spawn(allocator: std.mem.Allocator, vt: *Terminal) !tp.pid {
|
||
const self = try allocator.create(@This());
|
||
errdefer allocator.destroy(self);
|
||
self.* = .{
|
||
.allocator = allocator,
|
||
.vt = vt,
|
||
.fd = undefined,
|
||
.pty_fd = vt.ptyFd(),
|
||
.parser = .{ .buf = try .initCapacity(allocator, 128) },
|
||
.receiver = Receiver.init(pty_receive, self),
|
||
.parent = tp.self_pid().clone(),
|
||
};
|
||
return tp.spawn_link(allocator, self, start, "pty_actor");
|
||
}
|
||
|
||
fn deinit(self: *@This()) void {
|
||
std.log.debug("terminal: pty actor deinit (pid={?})", .{self.vt.cmd.pid});
|
||
self.fd.deinit();
|
||
self.parser.buf.deinit();
|
||
self.parent.deinit();
|
||
self.allocator.destroy(self);
|
||
}
|
||
|
||
fn start(self: *@This()) tp.result {
|
||
errdefer self.deinit();
|
||
self.fd = tp.file_descriptor.init("pty", self.pty_fd) catch |e| {
|
||
std.log.debug("terminal: pty fd init failed: {}", .{e});
|
||
return tp.exit_error(e, @errorReturnTrace());
|
||
};
|
||
self.fd.wait_read() catch |e| {
|
||
std.log.debug("terminal: pty initial wait_read failed: {}", .{e});
|
||
return tp.exit_error(e, @errorReturnTrace());
|
||
};
|
||
tp.receive(&self.receiver);
|
||
}
|
||
|
||
fn pty_receive(self: *@This(), _: tp.pid_ref, m: tp.message) tp.result {
|
||
errdefer self.deinit();
|
||
|
||
if (try m.match(.{ "fd", "pty", "read_ready" })) {
|
||
self.read_and_process() catch |e| return switch (e) {
|
||
error.Terminated => {
|
||
std.log.debug("terminal: pty exiting: read loop terminated (process exited)", .{});
|
||
return tp.exit_normal();
|
||
},
|
||
error.InputOutput => {
|
||
std.log.debug("terminal: pty exiting: EIO on read (process exited)", .{});
|
||
return tp.exit_normal();
|
||
},
|
||
error.SendFailed => {
|
||
std.log.debug("terminal: pty exiting: send to parent failed", .{});
|
||
return tp.exit_normal();
|
||
},
|
||
error.Unexpected => {
|
||
std.log.debug("terminal: pty exiting: unexpected error (see preceding log)", .{});
|
||
return tp.exit_normal();
|
||
},
|
||
};
|
||
} else if (try m.match(.{"quit"})) {
|
||
std.log.debug("terminal: pty exiting: received quit", .{});
|
||
return tp.exit_normal();
|
||
} else {
|
||
std.log.debug("terminal: pty exiting: unexpected message", .{});
|
||
return tp.unexpected(m);
|
||
}
|
||
}
|
||
|
||
fn read_and_process(self: *@This()) error{ Terminated, InputOutput, SendFailed, Unexpected }!void {
|
||
var buf: [4096]u8 = undefined;
|
||
|
||
while (true) {
|
||
const n = std.posix.read(self.vt.ptyFd(), &buf) catch |e| switch (e) {
|
||
error.WouldBlock => break,
|
||
error.InputOutput => {
|
||
const code = self.vt.cmd.wait();
|
||
std.log.debug("terminal: read EIO, process exited with code={d}", .{code});
|
||
self.vt.event_queue.push(.{ .exited = code });
|
||
self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||
return error.InputOutput;
|
||
},
|
||
error.SystemResources,
|
||
error.IsDir,
|
||
error.OperationAborted,
|
||
error.BrokenPipe,
|
||
error.ConnectionResetByPeer,
|
||
error.ConnectionTimedOut,
|
||
error.NotOpenForReading,
|
||
error.SocketNotConnected,
|
||
error.Canceled,
|
||
error.AccessDenied,
|
||
error.ProcessNotFound,
|
||
error.LockViolation,
|
||
error.Unexpected,
|
||
=> {
|
||
std.log.debug("terminal: read unexpected error: {} (pid={?})", .{ e, self.vt.cmd.pid });
|
||
return error.Unexpected;
|
||
},
|
||
};
|
||
if (n == 0) {
|
||
const code = self.vt.cmd.wait();
|
||
std.log.debug("terminal: read returned 0 bytes (EOF), process exited with code={d}", .{code});
|
||
self.vt.event_queue.push(.{ .exited = code });
|
||
self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||
return error.Terminated;
|
||
}
|
||
|
||
defer self.parent.send(.{ "terminal_view", "output" }) catch {};
|
||
|
||
switch (self.vt.processOutput(&self.parser, buf[0..n]) catch |e| switch (e) {
|
||
error.WriteFailed,
|
||
error.ReadFailed,
|
||
error.OutOfMemory,
|
||
error.Utf8InvalidStartByte,
|
||
=> {
|
||
std.log.debug("terminal: processOutput error: {} (pid={?})", .{ e, self.vt.cmd.pid });
|
||
return error.Unexpected;
|
||
},
|
||
}) {
|
||
.exited => {
|
||
std.log.debug("terminal: processOutput returned .exited (process EOF)", .{});
|
||
return error.Terminated;
|
||
},
|
||
.running => {},
|
||
}
|
||
}
|
||
|
||
self.fd.wait_read() catch |e| switch (e) {
|
||
error.ThespianFileDescriptorWaitReadFailed => {
|
||
std.log.debug("terminal: wait_read failed: {} (pid={?})", .{ e, self.vt.cmd.pid });
|
||
return error.Unexpected;
|
||
},
|
||
};
|
||
}
|
||
};
|