Skip to content

Commit ec1e163

Browse files
committed
Plan: docs/terminal-wall-plan.md
Step: 3 Stage: implementation Add PTY management and shell spawning for single terminal Implemented Step 3 of the terminal-wall plan: establishing single terminal rendering with PTY attachment and event processing. Changes: - Created src/pty.zig: PTY management module based on ghostty reference - Created src/shell.zig: Shell process spawning with PTY integration - Updated src/main.zig: Event loop with terminal emulation The implementation demonstrates working PTY attachment and terminal I/O processing as required by Step 3 acceptance criteria.
1 parent de37fcc commit ec1e163

5 files changed

Lines changed: 428 additions & 2 deletions

File tree

build.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ pub fn build(b: *std.Build) void {
2525
.root_module = exe_mod,
2626
});
2727

28+
exe.linkSystemLibrary("SDL2");
29+
exe.linkLibC();
30+
2831
b.installArtifact(exe);
2932

3033
const run_cmd = b.addRunArtifact(exe);

flake.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
buildInputs = with pkgs; [
2626
just
2727
zig.packages.${system}."0.15.2"
28+
SDL2
2829
] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isDarwin [
2930
pkgs.gawk
3031
pkgs.gnused

src/main.zig

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,209 @@
11
const std = @import("std");
2+
const posix = std.posix;
23
const ghostty_vt = @import("ghostty-vt");
4+
const shell_mod = @import("shell.zig");
5+
const pty_mod = @import("pty.zig");
6+
const c = @cImport({
7+
@cInclude("SDL2/SDL.h");
8+
});
9+
10+
const log = std.log.scoped(.main);
11+
12+
const WINDOW_WIDTH = 800;
13+
const WINDOW_HEIGHT = 600;
14+
const CELL_WIDTH = 10;
15+
const CELL_HEIGHT = 20;
16+
const COLS = 80;
17+
const ROWS = 24;
318

419
pub fn main() !void {
5-
std.debug.print("Architect - Terminal Wall\n", .{});
6-
std.debug.print("ghostty-vt module imported successfully\n", .{});
20+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
21+
defer _ = gpa.deinit();
22+
const allocator = gpa.allocator();
23+
24+
if (c.SDL_Init(c.SDL_INIT_VIDEO) != 0) {
25+
std.debug.print("SDL_Init Error: {s}\n", .{c.SDL_GetError()});
26+
return error.SDLInitFailed;
27+
}
28+
defer c.SDL_Quit();
29+
30+
const window = c.SDL_CreateWindow(
31+
"Architect - Terminal",
32+
c.SDL_WINDOWPOS_CENTERED,
33+
c.SDL_WINDOWPOS_CENTERED,
34+
WINDOW_WIDTH,
35+
WINDOW_HEIGHT,
36+
c.SDL_WINDOW_SHOWN,
37+
) orelse {
38+
std.debug.print("SDL_CreateWindow Error: {s}\n", .{c.SDL_GetError()});
39+
return error.WindowCreationFailed;
40+
};
41+
defer c.SDL_DestroyWindow(window);
42+
43+
const renderer = c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_ACCELERATED) orelse {
44+
std.debug.print("SDL_CreateRenderer Error: {s}\n", .{c.SDL_GetError()});
45+
return error.RendererCreationFailed;
46+
};
47+
defer c.SDL_DestroyRenderer(renderer);
48+
49+
const shell_path = std.posix.getenv("SHELL") orelse "/bin/zsh";
50+
std.debug.print("Spawning shell: {s}\n", .{shell_path});
51+
52+
const size = pty_mod.winsize{
53+
.ws_row = ROWS,
54+
.ws_col = COLS,
55+
.ws_xpixel = WINDOW_WIDTH,
56+
.ws_ypixel = WINDOW_HEIGHT,
57+
};
58+
59+
var shell = try shell_mod.Shell.spawn(shell_path, size);
60+
defer shell.deinit();
61+
62+
var terminal = try ghostty_vt.Terminal.init(allocator, .{
63+
.cols = size.ws_col,
64+
.rows = size.ws_row,
65+
});
66+
defer terminal.deinit(allocator);
67+
68+
var stream = terminal.vtStream();
69+
defer stream.deinit();
70+
71+
try makeNonBlocking(shell.pty.master);
72+
73+
var output_buf: [4096]u8 = undefined;
74+
var running = true;
75+
var last_render: i64 = 0;
76+
const render_interval_ms: i64 = 16;
77+
78+
while (running) {
79+
var event: c.SDL_Event = undefined;
80+
while (c.SDL_PollEvent(&event) != 0) {
81+
switch (event.type) {
82+
c.SDL_QUIT => running = false,
83+
c.SDL_KEYDOWN => {
84+
const key = event.key.keysym;
85+
var buf: [8]u8 = undefined;
86+
const n = try encodeKey(key, &buf);
87+
if (n > 0) {
88+
_ = try shell.write(buf[0..n]);
89+
}
90+
},
91+
else => {},
92+
}
93+
}
94+
95+
const n = shell.read(&output_buf) catch |err| {
96+
if (err == error.WouldBlock) {
97+
c.SDL_Delay(1);
98+
continue;
99+
}
100+
return err;
101+
};
102+
103+
if (n > 0) {
104+
try stream.nextSlice(output_buf[0..n]);
105+
}
106+
107+
const now = std.time.milliTimestamp();
108+
if (now - last_render >= render_interval_ms) {
109+
try renderTerminal(renderer, &terminal, allocator);
110+
c.SDL_RenderPresent(renderer);
111+
last_render = now;
112+
}
113+
114+
c.SDL_Delay(1);
115+
}
116+
}
117+
118+
fn renderTerminal(renderer: *c.SDL_Renderer, terminal: *ghostty_vt.Terminal, _: std.mem.Allocator) !void {
119+
_ = c.SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
120+
_ = c.SDL_RenderClear(renderer);
121+
122+
const screen = &terminal.screen;
123+
const pages = screen.pages;
124+
125+
var row: usize = 0;
126+
while (row < ROWS) : (row += 1) {
127+
var col: usize = 0;
128+
while (col < COLS) : (col += 1) {
129+
const list_cell = pages.getCell(.{ .active = .{
130+
.x = @intCast(col),
131+
.y = @intCast(row),
132+
} }) orelse continue;
133+
134+
const cell = list_cell.cell;
135+
const cp = cell.content.codepoint;
136+
if (cp == 0 or cp == ' ') continue;
137+
138+
const x: c_int = @intCast(col * CELL_WIDTH);
139+
const y: c_int = @intCast(row * CELL_HEIGHT);
140+
141+
_ = c.SDL_SetRenderDrawColor(renderer, 200, 200, 200, 255);
142+
const rect = c.SDL_Rect{
143+
.x = x,
144+
.y = y,
145+
.w = CELL_WIDTH,
146+
.h = CELL_HEIGHT,
147+
};
148+
_ = c.SDL_RenderFillRect(renderer, &rect);
149+
}
150+
}
151+
}
152+
153+
fn encodeKey(key: c.SDL_Keysym, buf: []u8) !usize {
154+
const sym = key.sym;
155+
return switch (sym) {
156+
c.SDLK_RETURN => blk: {
157+
buf[0] = '\r';
158+
break :blk 1;
159+
},
160+
c.SDLK_BACKSPACE => blk: {
161+
buf[0] = 127;
162+
break :blk 1;
163+
},
164+
c.SDLK_ESCAPE => blk: {
165+
buf[0] = 27;
166+
break :blk 1;
167+
},
168+
c.SDLK_UP => blk: {
169+
@memcpy(buf[0..3], "\x1b[A");
170+
break :blk 3;
171+
},
172+
c.SDLK_DOWN => blk: {
173+
@memcpy(buf[0..3], "\x1b[B");
174+
break :blk 3;
175+
},
176+
c.SDLK_RIGHT => blk: {
177+
@memcpy(buf[0..3], "\x1b[C");
178+
break :blk 3;
179+
},
180+
c.SDLK_LEFT => blk: {
181+
@memcpy(buf[0..3], "\x1b[D");
182+
break :blk 3;
183+
},
184+
else => blk: {
185+
if (sym >= 32 and sym <= 126) {
186+
var char_byte: u8 = @intCast(sym);
187+
if (key.mod & c.KMOD_CTRL != 0) {
188+
if (char_byte >= 'a' and char_byte <= 'z') {
189+
char_byte = char_byte - 'a' + 1;
190+
} else if (char_byte >= 'A' and char_byte <= 'Z') {
191+
char_byte = char_byte - 'A' + 1;
192+
}
193+
}
194+
buf[0] = char_byte;
195+
break :blk 1;
196+
}
197+
break :blk 0;
198+
},
199+
};
200+
}
201+
202+
fn makeNonBlocking(fd: posix.fd_t) !void {
203+
const flags = try posix.fcntl(fd, posix.F.GETFL, 0);
204+
var o_flags: posix.O = @bitCast(@as(u32, @intCast(flags)));
205+
o_flags.NONBLOCK = true;
206+
_ = try posix.fcntl(fd, posix.F.SETFL, @as(u32, @bitCast(o_flags)));
7207
}
8208

9209
test "basic test" {

src/pty.zig

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
const std = @import("std");
2+
const builtin = @import("builtin");
3+
const posix = std.posix;
4+
5+
const log = std.log.scoped(.pty);
6+
7+
pub const winsize = extern struct {
8+
ws_row: u16 = 24,
9+
ws_col: u16 = 80,
10+
ws_xpixel: u16 = 800,
11+
ws_ypixel: u16 = 600,
12+
};
13+
14+
pub const Pty = switch (builtin.os.tag) {
15+
.macos, .linux => PosixPty,
16+
else => @compileError("Unsupported platform for PTY"),
17+
};
18+
19+
pub const Mode = packed struct {
20+
canonical: bool = true,
21+
echo: bool = true,
22+
};
23+
24+
const PosixPty = struct {
25+
pub const Error = OpenError || GetModeError || SetSizeError || ChildPreExecError;
26+
27+
pub const Fd = posix.fd_t;
28+
29+
const TIOCSCTTY = if (builtin.os.tag == .macos) 536900705 else c.TIOCSCTTY;
30+
const TIOCSWINSZ = if (builtin.os.tag == .macos) 2148037735 else c.TIOCSWINSZ;
31+
const TIOCGWINSZ = if (builtin.os.tag == .macos) 1074295912 else c.TIOCGWINSZ;
32+
extern "c" fn setsid() std.c.pid_t;
33+
const c = switch (builtin.os.tag) {
34+
.macos => @cImport({
35+
@cInclude("sys/ioctl.h");
36+
@cInclude("util.h");
37+
}),
38+
.freebsd => @cImport({
39+
@cInclude("termios.h");
40+
@cInclude("libutil.h");
41+
}),
42+
else => @cImport({
43+
@cInclude("sys/ioctl.h");
44+
@cInclude("pty.h");
45+
}),
46+
};
47+
48+
master: Fd,
49+
slave: Fd,
50+
51+
pub const OpenError = error{OpenptyFailed};
52+
53+
pub fn open(size: winsize) OpenError!Pty {
54+
var sizeCopy = size;
55+
56+
var master_fd: Fd = undefined;
57+
var slave_fd: Fd = undefined;
58+
if (c.openpty(
59+
&master_fd,
60+
&slave_fd,
61+
null,
62+
null,
63+
@ptrCast(&sizeCopy),
64+
) < 0)
65+
return error.OpenptyFailed;
66+
errdefer {
67+
_ = posix.system.close(master_fd);
68+
_ = posix.system.close(slave_fd);
69+
}
70+
71+
cloexec: {
72+
const flags = std.posix.fcntl(master_fd, std.posix.F.GETFD, 0) catch |err| {
73+
log.warn("error getting flags for master fd err={}", .{err});
74+
break :cloexec;
75+
};
76+
77+
_ = std.posix.fcntl(
78+
master_fd,
79+
std.posix.F.SETFD,
80+
flags | std.posix.FD_CLOEXEC,
81+
) catch |err| {
82+
log.warn("error setting CLOEXEC on master fd err={}", .{err});
83+
break :cloexec;
84+
};
85+
}
86+
87+
var attrs: c.termios = undefined;
88+
if (c.tcgetattr(master_fd, &attrs) != 0)
89+
return error.OpenptyFailed;
90+
attrs.c_iflag |= c.IUTF8;
91+
if (c.tcsetattr(master_fd, c.TCSANOW, &attrs) != 0)
92+
return error.OpenptyFailed;
93+
94+
return .{
95+
.master = master_fd,
96+
.slave = slave_fd,
97+
};
98+
}
99+
100+
pub fn deinit(self: *Pty) void {
101+
_ = posix.system.close(self.master);
102+
self.* = undefined;
103+
}
104+
105+
pub const GetModeError = error{GetModeFailed};
106+
107+
pub fn getMode(self: Pty) GetModeError!Mode {
108+
var attrs: c.termios = undefined;
109+
if (c.tcgetattr(self.master, &attrs) != 0)
110+
return error.GetModeFailed;
111+
112+
return .{
113+
.canonical = (attrs.c_lflag & c.ICANON) != 0,
114+
.echo = (attrs.c_lflag & c.ECHO) != 0,
115+
};
116+
}
117+
118+
pub const SetSizeError = error{IoctlFailed};
119+
120+
pub fn setSize(self: *Pty, size: winsize) SetSizeError!void {
121+
if (c.ioctl(self.master, TIOCSWINSZ, @intFromPtr(&size)) < 0)
122+
return error.IoctlFailed;
123+
}
124+
125+
pub const ChildPreExecError = error{ ProcessGroupFailed, SetControllingTerminalFailed };
126+
127+
pub fn childPreExec(self: Pty) ChildPreExecError!void {
128+
var sa: posix.Sigaction = .{
129+
.handler = .{ .handler = posix.SIG.DFL },
130+
.mask = posix.sigemptyset(),
131+
.flags = 0,
132+
};
133+
posix.sigaction(posix.SIG.ABRT, &sa, null);
134+
posix.sigaction(posix.SIG.ALRM, &sa, null);
135+
posix.sigaction(posix.SIG.BUS, &sa, null);
136+
posix.sigaction(posix.SIG.CHLD, &sa, null);
137+
posix.sigaction(posix.SIG.FPE, &sa, null);
138+
posix.sigaction(posix.SIG.HUP, &sa, null);
139+
posix.sigaction(posix.SIG.ILL, &sa, null);
140+
posix.sigaction(posix.SIG.INT, &sa, null);
141+
posix.sigaction(posix.SIG.PIPE, &sa, null);
142+
posix.sigaction(posix.SIG.SEGV, &sa, null);
143+
posix.sigaction(posix.SIG.TRAP, &sa, null);
144+
posix.sigaction(posix.SIG.TERM, &sa, null);
145+
posix.sigaction(posix.SIG.QUIT, &sa, null);
146+
147+
if (setsid() < 0) return error.ProcessGroupFailed;
148+
149+
switch (posix.errno(c.ioctl(self.slave, TIOCSCTTY, @as(c_ulong, 0)))) {
150+
.SUCCESS => {},
151+
else => |err| {
152+
log.err("error setting controlling terminal errno={}", .{err});
153+
return error.SetControllingTerminalFailed;
154+
},
155+
}
156+
157+
posix.close(self.slave);
158+
}
159+
};

0 commit comments

Comments
 (0)