Multi-Window

This adds the ability to have multiple windows by introducing new keybind actions `new_window`, `close_window`, and `quit`. These are bound on macOS by default to the standard `cmd+n`, `cmd+w`, and `cmd+q`, respectively.

The multi-window logic is absolutely minimal: we don't do any GPU data sharing between windows, so each window recreates a full font texture for example. We can continue to further optimize this in the future.

DevMode is also pretty limited (arguably broken) with multi-window: DevMode only works on the _first_ window opened. If you close the first window, DevMode is no longer available. This is due to complexities around multi-threading and imgui. It is possible to fix this but I deferred it since it was a bit of a mess!
This commit is contained in:
Mitchell Hashimoto
2022-11-06 17:31:19 -08:00
committed by GitHub
11 changed files with 342 additions and 59 deletions

View File

@ -14,6 +14,10 @@ pub const Context = opaque {
c.igDestroyContext(self.cval());
}
pub fn setCurrent(self: *Context) void {
c.igSetCurrentContext(self.cval());
}
pub inline fn cval(self: *Context) *c.ImGuiContext {
return @ptrCast(
*c.ImGuiContext,
@ -25,4 +29,6 @@ pub const Context = opaque {
test {
var ctx = try Context.create();
defer ctx.destroy();
ctx.setCurrent();
}

View File

@ -9,46 +9,138 @@ const glfw = @import("glfw");
const Window = @import("Window.zig");
const tracy = @import("tracy");
const Config = @import("config.zig").Config;
const BlockingQueue = @import("./blocking_queue.zig").BlockingQueue;
const renderer = @import("renderer.zig");
const log = std.log.scoped(.app);
const WindowList = std.ArrayListUnmanaged(*Window);
/// The type used for sending messages to the app thread.
pub const Mailbox = BlockingQueue(Message, 64);
/// General purpose allocator
alloc: Allocator,
/// The primary window for the application. We currently support only
/// single window operations.
window: *Window,
/// The list of windows that are currently open
windows: WindowList,
// The configuration for the app.
config: *const Config,
/// The mailbox that can be used to send this thread messages. Note
/// this is a blocking queue so if it is full you will get errors (or block).
mailbox: *Mailbox,
/// Set to true once we're quitting. This never goes false again.
quit: bool,
/// Initialize the main app instance. This creates the main window, sets
/// up the renderer state, compiles the shaders, etc. This is the primary
/// "startup" logic.
pub fn init(alloc: Allocator, config: *const Config) !App {
// Create the window
var window = try Window.create(alloc, config);
errdefer window.destroy();
pub fn create(alloc: Allocator, config: *const Config) !*App {
// The mailbox for messaging this thread
var mailbox = try Mailbox.create(alloc);
errdefer mailbox.destroy(alloc);
return App{
var app = try alloc.create(App);
errdefer alloc.destroy(app);
app.* = .{
.alloc = alloc,
.window = window,
.windows = .{},
.config = config,
.mailbox = mailbox,
.quit = false,
};
errdefer app.windows.deinit(alloc);
// Create the first window
try app.newWindow();
return app;
}
pub fn deinit(self: *App) void {
self.window.destroy();
self.* = undefined;
pub fn destroy(self: *App) void {
// Clean up all our windows
for (self.windows.items) |window| window.destroy();
self.windows.deinit(self.alloc);
self.mailbox.destroy(self.alloc);
self.alloc.destroy(self);
}
pub fn run(self: App) !void {
while (!self.window.shouldClose()) {
// Block for any glfw events. This may also be an "empty" event
// posted by the libuv watcher so that we trigger a libuv loop tick.
/// Wake up the app event loop. This should be called after any messages
/// are sent to the mailbox.
pub fn wakeup(self: App) void {
_ = self;
glfw.postEmptyEvent() catch {};
}
/// Run the main event loop for the application. This blocks until the
/// application quits or every window is closed.
pub fn run(self: *App) !void {
while (!self.quit and self.windows.items.len > 0) {
// Block for any glfw events.
try glfw.waitEvents();
// Mark this so we're in a totally different "frame"
tracy.frameMark();
// If any windows are closing, destroy them
var i: usize = 0;
while (i < self.windows.items.len) {
const window = self.windows.items[i];
if (window.shouldClose()) {
window.destroy();
_ = self.windows.swapRemove(i);
continue;
}
i += 1;
}
// Drain our mailbox only if we're not quitting.
if (!self.quit) try self.drainMailbox();
}
}
/// Drain the mailbox.
fn drainMailbox(self: *App) !void {
var drain = self.mailbox.drain();
defer drain.deinit();
while (drain.next()) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
.new_window => try self.newWindow(),
.quit => try self.setQuit(),
}
}
}
/// Create a new window
fn newWindow(self: *App) !void {
var window = try Window.create(self.alloc, self, self.config);
errdefer window.destroy();
try self.windows.append(self.alloc, window);
errdefer _ = self.windows.pop();
}
/// Start quitting
fn setQuit(self: *App) !void {
if (self.quit) return;
self.quit = true;
// Mark that all our windows should close
for (self.windows.items) |window| {
window.window.setShouldClose(true);
}
}
/// The message types that can be sent to the app thread.
pub const Message = union(enum) {
/// Create a new terminal window.
new_window: void,
/// Quit
quit: void,
};

View File

@ -22,6 +22,7 @@ const terminal = @import("terminal/main.zig");
const Config = @import("config.zig").Config;
const input = @import("input.zig");
const DevMode = @import("DevMode.zig");
const App = @import("App.zig");
// Get native API access on certain platforms so we can do more customization.
const glfwNative = glfw.Native(.{
@ -36,6 +37,9 @@ const Renderer = renderer.Renderer;
/// Allocator
alloc: Allocator,
/// The app that this window is a part of.
app: *App,
/// The font structures
font_lib: font.Library,
font_group: *font.GroupCache,
@ -107,7 +111,7 @@ const Mouse = struct {
/// Create a new window. This allocates and returns a pointer because we
/// need a stable pointer for user data callbacks. Therefore, a stack-only
/// initialization is not currently possible.
pub fn create(alloc: Allocator, config: *const Config) !*Window {
pub fn create(alloc: Allocator, app: *App, config: *const Config) !*Window {
var self = try alloc.create(Window);
errdefer alloc.destroy(self);
@ -136,6 +140,10 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
};
// Find all the fonts for this window
//
// Future: we can share the font group amongst all windows to save
// some new window init time and some memory. This will require making
// thread-safe changes to font structs.
var font_lib = try font.Library.init();
errdefer font_lib.deinit();
var font_group = try alloc.create(font.GroupCache);
@ -247,6 +255,7 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
// Create our terminal grid with the initial window size
var renderer_impl = try Renderer.init(alloc, font_group);
errdefer renderer_impl.deinit();
renderer_impl.background = .{
.r = config.background.r,
.g = config.background.g,
@ -307,8 +316,14 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
var io_thread = try termio.Thread.init(alloc, &self.io);
errdefer io_thread.deinit();
// True if this window is hosting devmode. We only host devmode on
// the first window since imgui is not threadsafe. We need to do some
// work to make DevMode work with multiple threads.
const host_devmode = DevMode.enabled and DevMode.instance.window == null;
self.* = .{
.alloc = alloc,
.app = app,
.font_lib = font_lib,
.font_group = font_group,
.window = window,
@ -323,7 +338,7 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
.visible = true,
},
.terminal = &self.io.terminal,
.devmode = if (!DevMode.enabled) null else &DevMode.instance,
.devmode = if (!host_devmode) null else &DevMode.instance,
},
.renderer_thr = undefined,
.mouse = .{},
@ -333,7 +348,7 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
.grid_size = grid_size,
.config = config,
.imgui_ctx = if (!DevMode.enabled) void else try imgui.Context.create(),
.imgui_ctx = if (!DevMode.enabled) {} else try imgui.Context.create(),
};
errdefer if (DevMode.enabled) self.imgui_ctx.destroy();
@ -361,7 +376,7 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
// Load imgui. This must be done LAST because it has to be done after
// all our GLFW setup is complete.
if (DevMode.enabled) {
if (DevMode.enabled and DevMode.instance.window == null) {
const dev_io = try imgui.IO.get();
dev_io.cval().IniFilename = "ghostty_dev_mode.ini";
@ -376,13 +391,16 @@ pub fn create(alloc: Allocator, config: *const Config) !*Window {
const style = try imgui.Style.get();
style.colorsDark();
// Add our window to the instance
// Add our window to the instance if it isn't set.
DevMode.instance.window = self;
// Let our renderer setup
try renderer_impl.initDevMode(window);
}
// Give the renderer one more opportunity to finalize any window
// setup on the main thread prior to spinning up the rendering thread.
try renderer_impl.finalizeInit(window);
try renderer_impl.finalizeWindowInit(window);
// Start our renderer thread
self.renderer_thr = try std.Thread.spawn(
@ -412,18 +430,22 @@ pub fn destroy(self: *Window) void {
self.renderer.threadEnter(self.window) catch unreachable;
self.renderer_thread.deinit();
// If we are devmode-owning, clean that up.
if (DevMode.enabled and DevMode.instance.window == self) {
// Let our renderer clean up
self.renderer.deinitDevMode();
// Clear the window
DevMode.instance.window = null;
// Uninitialize imgui
self.imgui_ctx.destroy();
}
// Deinit our renderer
self.renderer.deinit();
}
if (DevMode.enabled) {
// Clear the window
DevMode.instance.window = null;
// Uninitialize imgui
self.imgui_ctx.destroy();
}
{
// Stop our IO thread
self.io_thread.stop.send() catch |err|
@ -748,6 +770,22 @@ fn keyCallback(
DevMode.instance.visible = !DevMode.instance.visible;
win.queueRender() catch unreachable;
} else log.warn("dev mode was not compiled into this binary", .{}),
.new_window => {
_ = win.app.mailbox.push(.{
.new_window = {},
}, .{ .instant = {} });
win.app.wakeup();
},
.close_window => win.window.setShouldClose(true),
.quit => {
_ = win.app.mailbox.push(.{
.quit = {},
}, .{ .instant = {} });
win.app.wakeup();
},
}
// Bindings always result in us ignoring the char if printable

View File

@ -157,6 +157,25 @@ pub const Config = struct {
.{ .toggle_dev_mode = {} },
);
// Windowing
try result.keybind.set.put(
alloc,
.{ .key = .n, .mods = .{ .super = true } },
.{ .new_window = {} },
);
try result.keybind.set.put(
alloc,
.{ .key = .w, .mods = .{ .super = true } },
.{ .close_window = {} },
);
if (builtin.os.tag == .macos) {
try result.keybind.set.put(
alloc,
.{ .key = .q, .mods = .{ .super = true } },
.{ .quit = {} },
);
}
return result;
}

View File

@ -133,6 +133,15 @@ pub const Action = union(enum) {
/// Dev mode
toggle_dev_mode: void,
/// Open a new window
new_window: void,
/// Close the current window
close_window: void,
/// Quit ghostty
quit: void,
};
/// Trigger is the associated key state that can trigger an action.

View File

@ -22,6 +22,9 @@ pub fn main() !void {
}
std.log.info("renderer={}", .{renderer.Renderer});
// First things first, we fix our file descriptors
fixMaxFiles();
const GPA = std.heap.GeneralPurposeAllocator(.{});
var gpa: ?GPA = gpa: {
// Use the libc allocator if it is available beacuse it is WAY
@ -128,8 +131,8 @@ pub fn main() !void {
defer glfw.terminate();
// Run our app
var app = try App.init(alloc, &config);
defer app.deinit();
var app = try App.create(alloc, &config);
defer app.destroy();
try app.run();
}
@ -188,6 +191,54 @@ fn glfwErrorCallback(code: glfw.Error, desc: [:0]const u8) void {
std.log.warn("glfw error={} message={s}", .{ code, desc });
}
/// This maximizes the number of file descriptors we can have open. We
/// need to do this because each window consumes at least a handful of fds.
/// This is extracted from the Zig compiler source code.
fn fixMaxFiles() void {
if (!@hasDecl(std.os.system, "rlimit")) return;
const posix = std.os;
var lim = posix.getrlimit(.NOFILE) catch {
std.log.warn("failed to query file handle limit, may limit max windows", .{});
return; // Oh well; we tried.
};
if (comptime builtin.target.isDarwin()) {
// On Darwin, `NOFILE` is bounded by a hardcoded value `OPEN_MAX`.
// According to the man pages for setrlimit():
// setrlimit() now returns with errno set to EINVAL in places that historically succeeded.
// It no longer accepts "rlim_cur = RLIM.INFINITY" for RLIM.NOFILE.
// Use "rlim_cur = min(OPEN_MAX, rlim_max)".
lim.max = std.math.min(std.os.darwin.OPEN_MAX, lim.max);
}
// If we're already at the max, we're done.
if (lim.cur >= lim.max) {
std.log.debug("file handle limit already maximized value={}", .{lim.cur});
return;
}
// Do a binary search for the limit.
var min: posix.rlim_t = lim.cur;
var max: posix.rlim_t = 1 << 20;
// But if there's a defined upper bound, don't search, just set it.
if (lim.max != posix.RLIM.INFINITY) {
min = lim.max;
max = lim.max;
}
while (true) {
lim.cur = min + @divTrunc(max - min, 2); // on freebsd rlim_t is signed
if (posix.setrlimit(.NOFILE, lim)) |_| {
min = lim.cur;
} else |_| {
max = lim.cur;
}
if (min + 1 >= max) break;
}
std.log.debug("file handle limit raised value={}", .{lim.cur});
}
test {
_ = @import("Atlas.zig");
_ = @import("Pty.zig");

View File

@ -240,11 +240,6 @@ pub fn init(alloc: Allocator, font_group: *font.GroupCache) !Metal {
}
pub fn deinit(self: *Metal) void {
if (DevMode.enabled) {
imgui.ImplMetal.shutdown();
imgui.ImplGlfw.shutdown();
}
self.cells.deinit(self.alloc);
self.font_shaper.deinit();
@ -255,7 +250,7 @@ pub fn deinit(self: *Metal) void {
/// This is called just prior to spinning up the renderer thread for
/// final main thread setup requirements.
pub fn finalizeInit(self: *const Metal, window: glfw.Window) !void {
pub fn finalizeWindowInit(self: *const Metal, window: glfw.Window) !void {
// Set our window backing layer to be our swapchain
const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(window).?);
const contentView = objc.Object.fromId(nswindow.getProperty(?*anyopaque, "contentView").?);
@ -268,7 +263,10 @@ pub fn finalizeInit(self: *const Metal, window: glfw.Window) !void {
const layer = contentView.getProperty(objc.Object, "layer");
const scaleFactor = nswindow.getProperty(macos.graphics.c.CGFloat, "backingScaleFactor");
layer.setProperty("contentsScale", scaleFactor);
}
/// This is called if this renderer runs DevMode.
pub fn initDevMode(self: *const Metal, window: glfw.Window) !void {
if (DevMode.enabled) {
// Initialize for our window
assert(imgui.ImplGlfw.initForOther(
@ -279,6 +277,16 @@ pub fn finalizeInit(self: *const Metal, window: glfw.Window) !void {
}
}
/// This is called if this renderer runs DevMode.
pub fn deinitDevMode(self: *const Metal) void {
_ = self;
if (DevMode.enabled) {
imgui.ImplMetal.shutdown();
imgui.ImplGlfw.shutdown();
}
}
/// Callback called by renderer.Thread when it begins.
pub fn threadEnter(self: *const Metal, window: glfw.Window) !void {
_ = self;

View File

@ -300,11 +300,6 @@ pub fn init(alloc: Allocator, font_group: *font.GroupCache) !OpenGL {
}
pub fn deinit(self: *OpenGL) void {
if (DevMode.enabled) {
imgui.ImplOpenGL3.shutdown();
imgui.ImplGlfw.shutdown();
}
self.font_shaper.deinit();
self.alloc.free(self.font_shaper.cell_buf);
@ -387,7 +382,15 @@ pub fn windowInit(window: glfw.Window) !void {
/// This is called just prior to spinning up the renderer thread for
/// final main thread setup requirements.
pub fn finalizeInit(self: *const OpenGL, window: glfw.Window) !void {
pub fn finalizeWindowInit(self: *const OpenGL, window: glfw.Window) !void {
_ = self;
_ = window;
}
/// This is called if this renderer runs DevMode.
pub fn initDevMode(self: *const OpenGL, window: glfw.Window) !void {
_ = self;
if (DevMode.enabled) {
// Initialize for our window
assert(imgui.ImplGlfw.initForOpenGL(
@ -396,9 +399,16 @@ pub fn finalizeInit(self: *const OpenGL, window: glfw.Window) !void {
));
assert(imgui.ImplOpenGL3.init("#version 330 core"));
}
}
// Call thread exit to clean up our context
self.threadExit();
/// This is called if this renderer runs DevMode.
pub fn deinitDevMode(self: *const OpenGL) void {
_ = self;
if (DevMode.enabled) {
imgui.ImplOpenGL3.shutdown();
imgui.ImplGlfw.shutdown();
}
}
/// Callback called by renderer.Thread when it begins.

View File

@ -65,7 +65,11 @@ pub fn init(
// Create our event loop.
var loop = try libuv.Loop.init(alloc);
errdefer loop.deinit(alloc);
errdefer {
// Run the loop once to close any of our handles
_ = loop.run(.nowait) catch 0;
loop.deinit(alloc);
}
loop.setData(allocPtr);
// This async handle is used to "wake up" the renderer and force a render.

View File

@ -15,6 +15,12 @@ const renderer = @import("../renderer.zig");
const log = std.log.scoped(.io_exec);
const c = @cImport({
@cInclude("errno.h");
@cInclude("signal.h");
@cInclude("unistd.h");
});
/// Allocator
alloc: Allocator,
@ -78,8 +84,8 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec {
.env = &env,
.cwd = opts.config.@"working-directory",
.pre_exec = (struct {
fn callback(c: *Command) void {
const p = c.getData(Pty) orelse unreachable;
fn callback(cmd: *Command) void {
const p = cmd.getData(Pty) orelse unreachable;
p.childPreExec() catch |err|
log.err("error initializing child: {}", .{err});
}
@ -113,9 +119,9 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec {
}
pub fn deinit(self: *Exec) void {
// Deinitialize the pty. This closes the pty handles. This should
// cause a close in the our subprocess so just wait for that.
self.pty.deinit();
// Kill our command
self.killCommand() catch |err|
log.err("error sending SIGHUP to command, may hang: {}", .{err});
_ = self.command.wait() catch |err|
log.err("error waiting for command to exit: {}", .{err});
@ -123,6 +129,42 @@ pub fn deinit(self: *Exec) void {
self.terminal.deinit(self.alloc);
}
/// Kill the underlying subprocess. This closes the pty file handle and
/// sends a SIGHUP to the child process. This doesn't wait for the child
/// process to be exited.
fn killCommand(self: *Exec) !void {
// Close our PTY
self.pty.deinit();
// We need to get our process group ID and send a SIGHUP to it.
if (self.command.pid) |pid| {
const pgid_: ?c.pid_t = pgid: {
const pgid = c.getpgid(pid);
// Don't know why it would be zero but its not a valid pid
if (pgid == 0) break :pgid null;
// If the pid doesn't exist then... okay.
if (pgid == c.ESRCH) break :pgid null;
// If we have an error...
if (pgid < 0) {
log.warn("error getting pgid for kill", .{});
break :pgid null;
}
break :pgid pgid;
};
if (pgid_) |pgid| {
if (c.killpg(pgid, c.SIGHUP) < 0) {
log.warn("error killing process group pgid={}", .{pgid});
return error.KillFailed;
}
}
}
}
pub fn threadEnter(self: *Exec, loop: libuv.Loop) !ThreadData {
assert(self.data == null);
@ -367,15 +409,15 @@ fn ttyRead(t: *libuv.Tty, n: isize, buf: []const u8) void {
var i: usize = 0;
const end = @intCast(usize, n);
if (ev.terminal_stream.parser.state == .ground) {
for (buf[i..end]) |c| {
switch (terminal.parse_table.table[c][@enumToInt(terminal.Parser.State.ground)].action) {
for (buf[i..end]) |ch| {
switch (terminal.parse_table.table[ch][@enumToInt(terminal.Parser.State.ground)].action) {
// Print, call directly.
.print => ev.terminal_stream.handler.print(@intCast(u21, c)) catch |err|
.print => ev.terminal_stream.handler.print(@intCast(u21, ch)) catch |err|
log.err("error processing terminal data: {}", .{err}),
// C0 execute, let our stream handle this one but otherwise
// continue since we're guaranteed to be back in ground.
.execute => ev.terminal_stream.execute(c) catch |err|
.execute => ev.terminal_stream.execute(ch) catch |err|
log.err("error processing terminal data: {}", .{err}),
// Otherwise, break out and go the slow path until we're
@ -413,8 +455,8 @@ const StreamHandler = struct {
try self.ev.queueWrite(data);
}
pub fn print(self: *StreamHandler, c: u21) !void {
try self.terminal.print(c);
pub fn print(self: *StreamHandler, ch: u21) !void {
try self.terminal.print(ch);
}
pub fn bell(self: StreamHandler) !void {

View File

@ -49,7 +49,11 @@ pub fn init(
// Create our event loop.
var loop = try libuv.Loop.init(alloc);
errdefer loop.deinit(alloc);
errdefer {
// Run the loop once to close any of our handles
_ = loop.run(.nowait) catch 0;
loop.deinit(alloc);
}
loop.setData(allocPtr);
// This async handle is used to "wake up" the renderer and force a render.