mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
1641 lines
56 KiB
Zig
1641 lines
56 KiB
Zig
//! Window represents a single OS window.
|
|
//!
|
|
//! NOTE(multi-window): This may be premature, but this abstraction is here
|
|
//! to pave the way One Day(tm) for multi-window support. At the time of
|
|
//! writing, we support exactly one window.
|
|
const Window = @This();
|
|
|
|
// TODO: eventually, I want to extract Window.zig into the "window" package
|
|
// so we can also have alternate implementations (i.e. not glfw).
|
|
const message = @import("window/message.zig");
|
|
pub const Mailbox = message.Mailbox;
|
|
pub const Message = message.Message;
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const assert = std.debug.assert;
|
|
const Allocator = std.mem.Allocator;
|
|
const renderer = @import("renderer.zig");
|
|
const termio = @import("termio.zig");
|
|
const objc = @import("objc");
|
|
const glfw = @import("glfw");
|
|
const imgui = @import("imgui");
|
|
const Pty = @import("Pty.zig");
|
|
const font = @import("font/main.zig");
|
|
const Command = @import("Command.zig");
|
|
const trace = @import("tracy").trace;
|
|
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(.{
|
|
.cocoa = builtin.target.isDarwin(),
|
|
});
|
|
|
|
const log = std.log.scoped(.window);
|
|
|
|
// The renderer implementation to use.
|
|
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,
|
|
font_size: font.face.DesiredSize,
|
|
|
|
/// The glfw window handle.
|
|
window: glfw.Window,
|
|
|
|
/// The glfw mouse cursor handle.
|
|
cursor: glfw.Cursor,
|
|
|
|
/// Imgui context
|
|
imgui_ctx: if (DevMode.enabled) *imgui.Context else void,
|
|
|
|
/// The renderer for this window.
|
|
renderer: Renderer,
|
|
|
|
/// The render state
|
|
renderer_state: renderer.State,
|
|
|
|
/// The renderer thread manager
|
|
renderer_thread: renderer.Thread,
|
|
|
|
/// The actual thread
|
|
renderer_thr: std.Thread,
|
|
|
|
/// Mouse state.
|
|
mouse: Mouse,
|
|
|
|
/// The terminal IO handler.
|
|
io: termio.Impl,
|
|
io_thread: termio.Thread,
|
|
io_thr: std.Thread,
|
|
|
|
/// All the cached sizes since we need them at various times.
|
|
screen_size: renderer.ScreenSize,
|
|
grid_size: renderer.GridSize,
|
|
cell_size: renderer.CellSize,
|
|
|
|
/// Explicit padding due to configuration
|
|
padding: renderer.Padding,
|
|
|
|
/// The app configuration
|
|
config: *const Config,
|
|
|
|
/// Set to true for a single GLFW key/char callback cycle to cause the
|
|
/// char callback to ignore. GLFW seems to always do key followed by char
|
|
/// callbacks so we abuse that here. This is to solve an issue where commands
|
|
/// like such as "control-v" will write a "v" even if they're intercepted.
|
|
ignore_char: bool = false,
|
|
|
|
/// Mouse state for the window.
|
|
const Mouse = struct {
|
|
/// The last tracked mouse button state by button.
|
|
click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max,
|
|
|
|
/// The last mods state when the last mouse button (whatever it was) was
|
|
/// pressed or release.
|
|
mods: input.Mods = .{},
|
|
|
|
/// The point at which the left mouse click happened. This is in screen
|
|
/// coordinates so that scrolling preserves the location.
|
|
left_click_point: terminal.point.ScreenPoint = .{},
|
|
|
|
/// The starting xpos/ypos of the left click. Note that if scrolling occurs,
|
|
/// these will point to different "cells", but the xpos/ypos will stay
|
|
/// stable during scrolling relative to the window.
|
|
left_click_xpos: f64 = 0,
|
|
left_click_ypos: f64 = 0,
|
|
|
|
/// The last x/y sent for mouse reports.
|
|
event_point: terminal.point.Viewport = .{},
|
|
};
|
|
|
|
/// 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, app: *App, config: *const Config) !*Window {
|
|
var self = try alloc.create(Window);
|
|
errdefer alloc.destroy(self);
|
|
|
|
// Create our window
|
|
const window = try glfw.Window.create(640, 480, "ghostty", null, null, Renderer.windowHints());
|
|
errdefer window.destroy();
|
|
try Renderer.windowInit(window);
|
|
|
|
// On Mac, enable tabbing
|
|
if (comptime builtin.target.isDarwin()) {
|
|
const NSWindowTabbingMode = enum(usize) { automatic = 0, preferred = 1, disallowed = 2 };
|
|
const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(window).?);
|
|
|
|
// Tabbing mode enables tabbing at all
|
|
nswindow.setProperty("tabbingMode", NSWindowTabbingMode.automatic);
|
|
|
|
// All windows within a tab bar must have a matching tabbing ID.
|
|
// The app sets this up for us.
|
|
nswindow.setProperty("tabbingIdentifier", app.darwin.tabbing_id);
|
|
}
|
|
|
|
// Determine our DPI configurations so we can properly configure
|
|
// font points to pixels and handle other high-DPI scaling factors.
|
|
const content_scale = try window.getContentScale();
|
|
const x_dpi = content_scale.x_scale * font.face.default_dpi;
|
|
const y_dpi = content_scale.y_scale * font.face.default_dpi;
|
|
log.debug("xscale={} yscale={} xdpi={} ydpi={}", .{
|
|
content_scale.x_scale,
|
|
content_scale.y_scale,
|
|
x_dpi,
|
|
y_dpi,
|
|
});
|
|
|
|
// The font size we desire along with the DPI determiend for the window
|
|
const font_size: font.face.DesiredSize = .{
|
|
.points = config.@"font-size",
|
|
.xdpi = @floatToInt(u16, x_dpi),
|
|
.ydpi = @floatToInt(u16, y_dpi),
|
|
};
|
|
|
|
// 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);
|
|
errdefer alloc.destroy(font_group);
|
|
font_group.* = try font.GroupCache.init(alloc, group: {
|
|
var group = try font.Group.init(alloc, font_lib, font_size);
|
|
errdefer group.deinit(alloc);
|
|
|
|
// Search for fonts
|
|
if (font.Discover != void) {
|
|
var disco = font.Discover.init();
|
|
defer disco.deinit();
|
|
|
|
if (config.@"font-family") |family| {
|
|
var disco_it = try disco.discover(.{
|
|
.family = family,
|
|
.size = font_size.points,
|
|
});
|
|
defer disco_it.deinit();
|
|
if (try disco_it.next()) |face| {
|
|
log.debug("font regular: {s}", .{try face.name()});
|
|
try group.addFace(alloc, .regular, face);
|
|
}
|
|
}
|
|
if (config.@"font-family-bold") |family| {
|
|
var disco_it = try disco.discover(.{
|
|
.family = family,
|
|
.size = font_size.points,
|
|
.bold = true,
|
|
});
|
|
defer disco_it.deinit();
|
|
if (try disco_it.next()) |face| {
|
|
log.debug("font bold: {s}", .{try face.name()});
|
|
try group.addFace(alloc, .bold, face);
|
|
}
|
|
}
|
|
if (config.@"font-family-italic") |family| {
|
|
var disco_it = try disco.discover(.{
|
|
.family = family,
|
|
.size = font_size.points,
|
|
.italic = true,
|
|
});
|
|
defer disco_it.deinit();
|
|
if (try disco_it.next()) |face| {
|
|
log.debug("font italic: {s}", .{try face.name()});
|
|
try group.addFace(alloc, .italic, face);
|
|
}
|
|
}
|
|
if (config.@"font-family-bold-italic") |family| {
|
|
var disco_it = try disco.discover(.{
|
|
.family = family,
|
|
.size = font_size.points,
|
|
.bold = true,
|
|
.italic = true,
|
|
});
|
|
defer disco_it.deinit();
|
|
if (try disco_it.next()) |face| {
|
|
log.debug("font bold+italic: {s}", .{try face.name()});
|
|
try group.addFace(alloc, .bold_italic, face);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Our built-in font will be used as a backup
|
|
try group.addFace(
|
|
alloc,
|
|
.regular,
|
|
font.DeferredFace.initLoaded(try font.Face.init(font_lib, face_ttf, font_size)),
|
|
);
|
|
try group.addFace(
|
|
alloc,
|
|
.bold,
|
|
font.DeferredFace.initLoaded(try font.Face.init(font_lib, face_bold_ttf, font_size)),
|
|
);
|
|
|
|
// Emoji fallback. We don't include this on Mac since Mac is expected
|
|
// to always have the Apple Emoji available.
|
|
if (builtin.os.tag != .macos or font.Discover == void) {
|
|
try group.addFace(
|
|
alloc,
|
|
.regular,
|
|
font.DeferredFace.initLoaded(try font.Face.init(font_lib, face_emoji_ttf, font_size)),
|
|
);
|
|
try group.addFace(
|
|
alloc,
|
|
.regular,
|
|
font.DeferredFace.initLoaded(try font.Face.init(font_lib, face_emoji_text_ttf, font_size)),
|
|
);
|
|
}
|
|
|
|
// If we're on Mac, then we try to use the Apple Emoji font for Emoji.
|
|
if (builtin.os.tag == .macos and font.Discover != void) {
|
|
var disco = font.Discover.init();
|
|
defer disco.deinit();
|
|
var disco_it = try disco.discover(.{
|
|
.family = "Apple Color Emoji",
|
|
.size = font_size.points,
|
|
});
|
|
defer disco_it.deinit();
|
|
if (try disco_it.next()) |face| {
|
|
log.debug("font emoji: {s}", .{try face.name()});
|
|
try group.addFace(alloc, .regular, face);
|
|
}
|
|
}
|
|
|
|
break :group group;
|
|
});
|
|
errdefer font_group.deinit(alloc);
|
|
|
|
// Pre-calculate our initial cell size ourselves.
|
|
const cell_size = try renderer.CellSize.init(alloc, font_group);
|
|
|
|
// Convert our padding from points to pixels
|
|
const padding_x = (@intToFloat(f32, config.@"window-padding-x") * x_dpi) / 72;
|
|
const padding_y = (@intToFloat(f32, config.@"window-padding-y") * y_dpi) / 72;
|
|
const padding: renderer.Padding = .{
|
|
.top = padding_y,
|
|
.bottom = padding_y,
|
|
.right = padding_x,
|
|
.left = padding_x,
|
|
};
|
|
|
|
// Create our terminal grid with the initial window size
|
|
var renderer_impl = try Renderer.init(alloc, .{
|
|
.font_group = font_group,
|
|
.padding = .{
|
|
.explicit = padding,
|
|
.balance = config.@"window-padding-balance",
|
|
},
|
|
.window_mailbox = .{ .window = self, .app = app.mailbox },
|
|
});
|
|
errdefer renderer_impl.deinit();
|
|
renderer_impl.background = .{
|
|
.r = config.background.r,
|
|
.g = config.background.g,
|
|
.b = config.background.b,
|
|
};
|
|
renderer_impl.foreground = .{
|
|
.r = config.foreground.r,
|
|
.g = config.foreground.g,
|
|
.b = config.foreground.b,
|
|
};
|
|
|
|
// Calculate our grid size based on known dimensions.
|
|
const window_size = try window.getSize();
|
|
const screen_size: renderer.ScreenSize = .{
|
|
.width = window_size.width,
|
|
.height = window_size.height,
|
|
};
|
|
const grid_size = renderer.GridSize.init(
|
|
screen_size.subPadding(padding),
|
|
cell_size,
|
|
);
|
|
|
|
// Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app
|
|
// but is otherwise somewhat arbitrary.
|
|
try window.setSizeLimits(.{
|
|
.width = @floatToInt(u32, cell_size.width * 10),
|
|
.height = @floatToInt(u32, cell_size.height * 4),
|
|
}, .{ .width = null, .height = null });
|
|
|
|
// Create the cursor
|
|
const cursor = try glfw.Cursor.createStandard(.ibeam);
|
|
errdefer cursor.destroy();
|
|
try window.setCursor(cursor);
|
|
|
|
// The mutex used to protect our renderer state.
|
|
var mutex = try alloc.create(std.Thread.Mutex);
|
|
mutex.* = .{};
|
|
errdefer alloc.destroy(mutex);
|
|
|
|
// Create the renderer thread
|
|
var render_thread = try renderer.Thread.init(
|
|
alloc,
|
|
window,
|
|
&self.renderer,
|
|
&self.renderer_state,
|
|
);
|
|
errdefer render_thread.deinit();
|
|
|
|
// Start our IO implementation
|
|
var io = try termio.Impl.init(alloc, .{
|
|
.grid_size = grid_size,
|
|
.screen_size = screen_size,
|
|
.config = config,
|
|
.renderer_state = &self.renderer_state,
|
|
.renderer_wakeup = render_thread.wakeup,
|
|
.renderer_mailbox = render_thread.mailbox,
|
|
.window_mailbox = .{ .window = self, .app = app.mailbox },
|
|
});
|
|
errdefer io.deinit();
|
|
|
|
// Create the IO thread
|
|
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,
|
|
.font_size = font_size,
|
|
.window = window,
|
|
.cursor = cursor,
|
|
.renderer = renderer_impl,
|
|
.renderer_thread = render_thread,
|
|
.renderer_state = .{
|
|
.mutex = mutex,
|
|
.resize_screen = screen_size,
|
|
.cursor = .{
|
|
.style = .blinking_block,
|
|
.visible = true,
|
|
},
|
|
.terminal = &self.io.terminal,
|
|
.devmode = if (!host_devmode) null else &DevMode.instance,
|
|
},
|
|
.renderer_thr = undefined,
|
|
.mouse = .{},
|
|
.io = io,
|
|
.io_thread = io_thread,
|
|
.io_thr = undefined,
|
|
.screen_size = screen_size,
|
|
.grid_size = grid_size,
|
|
.cell_size = cell_size,
|
|
.padding = padding,
|
|
.config = config,
|
|
|
|
.imgui_ctx = if (!DevMode.enabled) {} else try imgui.Context.create(),
|
|
};
|
|
errdefer if (DevMode.enabled) self.imgui_ctx.destroy();
|
|
|
|
// Setup our callbacks and user data
|
|
window.setUserPointer(self);
|
|
window.setSizeCallback(sizeCallback);
|
|
window.setCharCallback(charCallback);
|
|
window.setKeyCallback(keyCallback);
|
|
window.setFocusCallback(focusCallback);
|
|
window.setRefreshCallback(refreshCallback);
|
|
window.setScrollCallback(scrollCallback);
|
|
window.setCursorPosCallback(cursorPosCallback);
|
|
window.setMouseButtonCallback(mouseButtonCallback);
|
|
|
|
// Call our size callback which handles all our retina setup
|
|
// Note: this shouldn't be necessary and when we clean up the window
|
|
// init stuff we should get rid of this. But this is required because
|
|
// sizeCallback does retina-aware stuff we don't do here and don't want
|
|
// to duplicate.
|
|
sizeCallback(
|
|
window,
|
|
@intCast(i32, window_size.width),
|
|
@intCast(i32, window_size.height),
|
|
);
|
|
|
|
// Load imgui. This must be done LAST because it has to be done after
|
|
// all our GLFW setup is complete.
|
|
if (DevMode.enabled and DevMode.instance.window == null) {
|
|
const dev_io = try imgui.IO.get();
|
|
dev_io.cval().IniFilename = "ghostty_dev_mode.ini";
|
|
|
|
// Add our built-in fonts so it looks slightly better
|
|
const dev_atlas = @ptrCast(*imgui.FontAtlas, dev_io.cval().Fonts);
|
|
dev_atlas.addFontFromMemoryTTF(
|
|
face_ttf,
|
|
@intToFloat(f32, font_size.pixels()),
|
|
);
|
|
|
|
// Default dark style
|
|
const style = try imgui.Style.get();
|
|
style.colorsDark();
|
|
|
|
// 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.finalizeWindowInit(window);
|
|
|
|
// Start our renderer thread
|
|
self.renderer_thr = try std.Thread.spawn(
|
|
.{},
|
|
renderer.Thread.threadMain,
|
|
.{&self.renderer_thread},
|
|
);
|
|
self.renderer_thr.setName("renderer") catch {};
|
|
|
|
// Start our IO thread
|
|
self.io_thr = try std.Thread.spawn(
|
|
.{},
|
|
termio.Thread.threadMain,
|
|
.{&self.io_thread},
|
|
);
|
|
self.io_thr.setName("io") catch {};
|
|
|
|
return self;
|
|
}
|
|
|
|
pub fn destroy(self: *Window) void {
|
|
{
|
|
// Stop rendering thread
|
|
self.renderer_thread.stop.send() catch |err|
|
|
log.err("error notifying renderer thread to stop, may stall err={}", .{err});
|
|
self.renderer_thr.join();
|
|
|
|
// We need to become the active rendering thread again
|
|
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();
|
|
}
|
|
|
|
{
|
|
// Stop our IO thread
|
|
self.io_thread.stop.send() catch |err|
|
|
log.err("error notifying io thread to stop, may stall err={}", .{err});
|
|
self.io_thr.join();
|
|
self.io_thread.deinit();
|
|
|
|
// Deinitialize our terminal IO
|
|
self.io.deinit();
|
|
}
|
|
|
|
if (comptime builtin.target.isDarwin()) {
|
|
// If our tab bar is visible and we are going down to 1 window,
|
|
// hide the tab bar.
|
|
const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(self.window).?);
|
|
const tabgroup = nswindow.getProperty(objc.Object, "tabGroup");
|
|
if (tabgroup.getProperty(bool, "tabBarVisible")) {
|
|
const windows = tabgroup.getProperty(objc.Object, "windows");
|
|
|
|
// count check is 2 because our window will still be present
|
|
// in the tab bar since we haven't destroyed yet
|
|
if (windows.getProperty(usize, "count") == 2) {
|
|
nswindow.msgSend(void, objc.sel("toggleTabBar:"), .{nswindow.value});
|
|
}
|
|
}
|
|
}
|
|
|
|
self.window.destroy();
|
|
|
|
// We can destroy the cursor right away. glfw will just revert any
|
|
// windows using it to the default.
|
|
self.cursor.destroy();
|
|
|
|
self.font_group.deinit(self.alloc);
|
|
self.font_lib.deinit();
|
|
self.alloc.destroy(self.font_group);
|
|
|
|
self.alloc.destroy(self.renderer_state.mutex);
|
|
|
|
self.alloc.destroy(self);
|
|
}
|
|
|
|
pub fn shouldClose(self: Window) bool {
|
|
return self.window.shouldClose();
|
|
}
|
|
|
|
/// Add a window to the tab group of this window.
|
|
pub fn addWindow(self: Window, other: *Window) void {
|
|
assert(builtin.target.isDarwin());
|
|
|
|
const NSWindowOrderingMode = enum(isize) { below = -1, out = 0, above = 1 };
|
|
const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(self.window).?);
|
|
nswindow.msgSend(void, objc.sel("addTabbedWindow:ordered:"), .{
|
|
objc.Object.fromId(glfwNative.getCocoaWindow(other.window).?),
|
|
NSWindowOrderingMode.above,
|
|
});
|
|
}
|
|
|
|
/// Called from the app thread to handle mailbox messages to our specific
|
|
/// window.
|
|
pub fn handleMessage(self: *Window, msg: Message) !void {
|
|
switch (msg) {
|
|
.set_title => |*v| {
|
|
// The ptrCast just gets sliceTo to return the proper type.
|
|
// We know that our title should end in 0.
|
|
const slice = std.mem.sliceTo(@ptrCast([*:0]const u8, v), 0);
|
|
log.debug("changing title \"{s}\"", .{slice});
|
|
try self.window.setTitle(slice.ptr);
|
|
},
|
|
|
|
.cell_size => |size| try self.setCellSize(size),
|
|
}
|
|
}
|
|
|
|
/// Change the cell size for the terminal grid. This can happen as
|
|
/// a result of changing the font size at runtime.
|
|
fn setCellSize(self: *Window, size: renderer.CellSize) !void {
|
|
// Update our new cell size for future calcs
|
|
self.cell_size = size;
|
|
|
|
// Update our grid_size
|
|
self.grid_size = renderer.GridSize.init(
|
|
self.screen_size.subPadding(self.padding),
|
|
self.cell_size,
|
|
);
|
|
|
|
// Notify the terminal
|
|
_ = self.io_thread.mailbox.push(.{
|
|
.resize = .{
|
|
.grid_size = self.grid_size,
|
|
.screen_size = self.screen_size,
|
|
.padding = self.padding,
|
|
},
|
|
}, .{ .forever = {} });
|
|
self.io_thread.wakeup.send() catch {};
|
|
}
|
|
|
|
/// Change the font size.
|
|
///
|
|
/// This can only be called from the main thread.
|
|
pub fn setFontSize(self: *Window, size: font.face.DesiredSize) void {
|
|
// Update our font size so future changes work
|
|
self.font_size = size;
|
|
|
|
// Notify our render thread of the font size. This triggers everything else.
|
|
_ = self.renderer_thread.mailbox.push(.{
|
|
.font_size = size,
|
|
}, .{ .forever = {} });
|
|
|
|
// Schedule render which also drains our mailbox
|
|
self.queueRender() catch unreachable;
|
|
}
|
|
|
|
/// This queues a render operation with the renderer thread. The render
|
|
/// isn't guaranteed to happen immediately but it will happen as soon as
|
|
/// practical.
|
|
fn queueRender(self: *const Window) !void {
|
|
try self.renderer_thread.wakeup.send();
|
|
}
|
|
|
|
/// The cursor position from glfw directly is in screen coordinates but
|
|
/// all our internal state works in pixels.
|
|
fn cursorPosToPixels(self: Window, pos: glfw.Window.CursorPos) glfw.Window.CursorPos {
|
|
// The cursor position is in screen coordinates but we
|
|
// want it in pixels. we need to get both the size of the
|
|
// window in both to get the ratio to make the conversion.
|
|
const size = self.window.getSize() catch unreachable;
|
|
const fb_size = self.window.getFramebufferSize() catch unreachable;
|
|
|
|
// If our framebuffer and screen are the same, then there is no scaling
|
|
// happening and we can short-circuit by returning the pos as-is.
|
|
if (fb_size.width == size.width and fb_size.height == size.height)
|
|
return pos;
|
|
|
|
const x_scale = @intToFloat(f64, fb_size.width) / @intToFloat(f64, size.width);
|
|
const y_scale = @intToFloat(f64, fb_size.height) / @intToFloat(f64, size.height);
|
|
return .{
|
|
.xpos = pos.xpos * x_scale,
|
|
.ypos = pos.ypos * y_scale,
|
|
};
|
|
}
|
|
|
|
fn sizeCallback(window: glfw.Window, width: i32, height: i32) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
// glfw gives us signed integers, but negative width/height is n
|
|
// non-sensical so we use unsigned throughout, so assert.
|
|
assert(width >= 0);
|
|
assert(height >= 0);
|
|
|
|
// Get our framebuffer size since this will give us the size in pixels
|
|
// whereas width/height in this callback is in screen coordinates. For
|
|
// Retina displays (or any other displays that have a scale factor),
|
|
// these will not match.
|
|
const px_size = window.getFramebufferSize() catch |err| err: {
|
|
log.err("error querying window size in pixels, will use screen size err={}", .{err});
|
|
break :err glfw.Window.Size{
|
|
.width = @intCast(u32, width),
|
|
.height = @intCast(u32, height),
|
|
};
|
|
};
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// TODO: if our screen size didn't change, then we should avoid the
|
|
// overhead of inter-thread communication
|
|
|
|
// Save our screen size
|
|
win.screen_size = .{
|
|
.width = px_size.width,
|
|
.height = px_size.height,
|
|
};
|
|
|
|
// Recalculate our grid size
|
|
win.grid_size = renderer.GridSize.init(
|
|
win.screen_size.subPadding(win.padding),
|
|
win.cell_size,
|
|
);
|
|
if (win.grid_size.columns < 5 and (win.padding.left > 0 or win.padding.right > 0)) {
|
|
log.warn("WARNING: very small terminal grid detected with padding " ++
|
|
"set. Is your padding reasonable?", .{});
|
|
}
|
|
if (win.grid_size.rows < 2 and (win.padding.top > 0 or win.padding.bottom > 0)) {
|
|
log.warn("WARNING: very small terminal grid detected with padding " ++
|
|
"set. Is your padding reasonable?", .{});
|
|
}
|
|
|
|
// Mail the IO thread
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.resize = .{
|
|
.grid_size = win.grid_size,
|
|
.screen_size = win.screen_size,
|
|
.padding = win.padding,
|
|
},
|
|
}, .{ .forever = {} });
|
|
win.io_thread.wakeup.send() catch {};
|
|
}
|
|
|
|
fn charCallback(window: glfw.Window, codepoint: u21) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// Dev Mode
|
|
if (DevMode.enabled and DevMode.instance.visible) {
|
|
// If the event was handled by imgui, ignore it.
|
|
if (imgui.IO.get()) |io| {
|
|
if (io.cval().WantCaptureKeyboard) {
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render timer err={}", .{err});
|
|
}
|
|
} else |_| {}
|
|
}
|
|
|
|
// Ignore if requested. See field docs for more information.
|
|
if (win.ignore_char) {
|
|
win.ignore_char = false;
|
|
return;
|
|
}
|
|
|
|
// Critical area
|
|
{
|
|
win.renderer_state.mutex.lock();
|
|
defer win.renderer_state.mutex.unlock();
|
|
|
|
// Clear the selction if we have one.
|
|
if (win.io.terminal.selection != null) {
|
|
win.io.terminal.selection = null;
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render in charCallback err={}", .{err});
|
|
}
|
|
|
|
// We want to scroll to the bottom
|
|
// TODO: detect if we're at the bottom to avoid the render call here.
|
|
win.io.terminal.scrollViewport(.{ .bottom = {} }) catch |err|
|
|
log.err("error scrolling viewport err={}", .{err});
|
|
}
|
|
|
|
// Ask our IO thread to write the data
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
data[0] = @intCast(u8, codepoint);
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = 1,
|
|
},
|
|
}, .{ .forever = {} });
|
|
|
|
// After sending all our messages we have to notify our IO thread
|
|
win.io_thread.wakeup.send() catch {};
|
|
}
|
|
|
|
fn keyCallback(
|
|
window: glfw.Window,
|
|
key: glfw.Key,
|
|
scancode: i32,
|
|
action: glfw.Action,
|
|
mods: glfw.Mods,
|
|
) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// Dev Mode
|
|
if (DevMode.enabled and DevMode.instance.visible) {
|
|
// If the event was handled by imgui, ignore it.
|
|
if (imgui.IO.get()) |io| {
|
|
if (io.cval().WantCaptureKeyboard) {
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render timer err={}", .{err});
|
|
}
|
|
} else |_| {}
|
|
}
|
|
|
|
// Reset the ignore char setting. If we didn't handle the char
|
|
// by here, we aren't going to get it so we just reset this.
|
|
win.ignore_char = false;
|
|
|
|
//log.info("KEY {} {} {} {}", .{ key, scancode, mods, action });
|
|
_ = scancode;
|
|
|
|
if (action == .press or action == .repeat) {
|
|
// Convert our glfw input into a platform agnostic trigger. When we
|
|
// extract the platform out of this file, we'll pull a lot of this out
|
|
// into a function. For now, this is the only place we do it so we just
|
|
// put it right here.
|
|
const trigger: input.Binding.Trigger = .{
|
|
.mods = @bitCast(input.Mods, mods),
|
|
.key = switch (key) {
|
|
.a => .a,
|
|
.b => .b,
|
|
.c => .c,
|
|
.d => .d,
|
|
.e => .e,
|
|
.f => .f,
|
|
.g => .g,
|
|
.h => .h,
|
|
.i => .i,
|
|
.j => .j,
|
|
.k => .k,
|
|
.l => .l,
|
|
.m => .m,
|
|
.n => .n,
|
|
.o => .o,
|
|
.p => .p,
|
|
.q => .q,
|
|
.r => .r,
|
|
.s => .s,
|
|
.t => .t,
|
|
.u => .u,
|
|
.v => .v,
|
|
.w => .w,
|
|
.x => .x,
|
|
.y => .y,
|
|
.z => .z,
|
|
.zero => .zero,
|
|
.one => .one,
|
|
.two => .three,
|
|
.three => .four,
|
|
.four => .four,
|
|
.five => .five,
|
|
.six => .six,
|
|
.seven => .seven,
|
|
.eight => .eight,
|
|
.nine => .nine,
|
|
.up => .up,
|
|
.down => .down,
|
|
.right => .right,
|
|
.left => .left,
|
|
.home => .home,
|
|
.end => .end,
|
|
.page_up => .page_up,
|
|
.page_down => .page_down,
|
|
.escape => .escape,
|
|
.F1 => .f1,
|
|
.F2 => .f2,
|
|
.F3 => .f3,
|
|
.F4 => .f4,
|
|
.F5 => .f5,
|
|
.F6 => .f6,
|
|
.F7 => .f7,
|
|
.F8 => .f8,
|
|
.F9 => .f9,
|
|
.F10 => .f10,
|
|
.F11 => .f11,
|
|
.F12 => .f12,
|
|
.grave_accent => .grave_accent,
|
|
.minus => .minus,
|
|
.equal => .equal,
|
|
else => .invalid,
|
|
},
|
|
};
|
|
|
|
//log.warn("BINDING TRIGGER={}", .{trigger});
|
|
if (win.config.keybind.set.get(trigger)) |binding_action| {
|
|
//log.warn("BINDING ACTION={}", .{binding_action});
|
|
|
|
switch (binding_action) {
|
|
.unbind => unreachable,
|
|
.ignore => {},
|
|
|
|
.csi => |data| {
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.write_stable = "\x1B[",
|
|
}, .{ .forever = {} });
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.write_stable = data,
|
|
}, .{ .forever = {} });
|
|
win.io_thread.wakeup.send() catch {};
|
|
},
|
|
|
|
.copy_to_clipboard => {
|
|
// We can read from the renderer state without holding
|
|
// the lock because only we will write to this field.
|
|
if (win.io.terminal.selection) |sel| {
|
|
var buf = win.io.terminal.screen.selectionString(
|
|
win.alloc,
|
|
sel,
|
|
) catch |err| {
|
|
log.err("error reading selection string err={}", .{err});
|
|
return;
|
|
};
|
|
defer win.alloc.free(buf);
|
|
|
|
glfw.setClipboardString(buf) catch |err| {
|
|
log.err("error setting clipboard string err={}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
},
|
|
|
|
.paste_from_clipboard => {
|
|
const data = glfw.getClipboardString() catch |err| {
|
|
log.warn("error reading clipboard: {}", .{err});
|
|
return;
|
|
};
|
|
|
|
if (data.len > 0) {
|
|
const bracketed = bracketed: {
|
|
win.renderer_state.mutex.lock();
|
|
defer win.renderer_state.mutex.unlock();
|
|
break :bracketed win.io.terminal.modes.bracketed_paste;
|
|
};
|
|
|
|
if (bracketed) {
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.write_stable = "\x1B[200~",
|
|
}, .{ .forever = {} });
|
|
}
|
|
|
|
_ = win.io_thread.mailbox.push(termio.Message.writeReq(
|
|
win.alloc,
|
|
data,
|
|
) catch unreachable, .{ .forever = {} });
|
|
|
|
if (bracketed) {
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.write_stable = "\x1B[201~",
|
|
}, .{ .forever = {} });
|
|
}
|
|
|
|
win.io_thread.wakeup.send() catch {};
|
|
}
|
|
},
|
|
|
|
.increase_font_size => |delta| {
|
|
log.debug("increase font size={}", .{delta});
|
|
|
|
var size = win.font_size;
|
|
size.points +|= delta;
|
|
win.setFontSize(size);
|
|
},
|
|
|
|
.decrease_font_size => |delta| {
|
|
log.debug("decrease font size={}", .{delta});
|
|
|
|
var size = win.font_size;
|
|
size.points = @max(1, size.points -| delta);
|
|
win.setFontSize(size);
|
|
},
|
|
|
|
.reset_font_size => {
|
|
log.debug("reset font size", .{});
|
|
|
|
var size = win.font_size;
|
|
size.points = win.config.@"font-size";
|
|
win.setFontSize(size);
|
|
},
|
|
|
|
.toggle_dev_mode => if (DevMode.enabled) {
|
|
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 = .{
|
|
.font_size = if (win.config.@"window-inherit-font-size")
|
|
win.font_size
|
|
else
|
|
null,
|
|
},
|
|
}, .{ .instant = {} });
|
|
win.app.wakeup();
|
|
},
|
|
|
|
.new_tab => {
|
|
_ = win.app.mailbox.push(.{
|
|
.new_tab = .{
|
|
.parent = win,
|
|
|
|
.font_size = if (win.config.@"window-inherit-font-size")
|
|
win.font_size
|
|
else
|
|
null,
|
|
},
|
|
}, .{ .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
|
|
win.ignore_char = true;
|
|
|
|
// No matter what, if there is a binding then we are done.
|
|
return;
|
|
}
|
|
|
|
// Handle non-printables
|
|
const char: u8 = char: {
|
|
const mods_int = @bitCast(u8, mods);
|
|
const ctrl_only = @bitCast(u8, glfw.Mods{ .control = true });
|
|
|
|
// If we're only pressing control, check if this is a character
|
|
// we convert to a non-printable.
|
|
if (mods_int == ctrl_only) {
|
|
const val: u8 = switch (key) {
|
|
.a => 0x01,
|
|
.b => 0x02,
|
|
.c => 0x03,
|
|
.d => 0x04,
|
|
.e => 0x05,
|
|
.f => 0x06,
|
|
.g => 0x07,
|
|
.h => 0x08,
|
|
.i => 0x09,
|
|
.j => 0x0A,
|
|
.k => 0x0B,
|
|
.l => 0x0C,
|
|
.m => 0x0D,
|
|
.n => 0x0E,
|
|
.o => 0x0F,
|
|
.p => 0x10,
|
|
.q => 0x11,
|
|
.r => 0x12,
|
|
.s => 0x13,
|
|
.t => 0x14,
|
|
.u => 0x15,
|
|
.v => 0x16,
|
|
.w => 0x17,
|
|
.x => 0x18,
|
|
.y => 0x19,
|
|
.z => 0x1A,
|
|
else => 0,
|
|
};
|
|
|
|
if (val > 0) break :char val;
|
|
}
|
|
|
|
// Otherwise, we don't care what modifiers we press we do this.
|
|
break :char @as(u8, switch (key) {
|
|
.backspace => 0x7F,
|
|
.enter => '\r',
|
|
.tab => '\t',
|
|
.escape => 0x1B,
|
|
else => 0,
|
|
});
|
|
};
|
|
if (char > 0) {
|
|
// Ask our IO thread to write the data
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
data[0] = @intCast(u8, char);
|
|
_ = win.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = 1,
|
|
},
|
|
}, .{ .forever = {} });
|
|
|
|
// After sending all our messages we have to notify our IO thread
|
|
win.io_thread.wakeup.send() catch {};
|
|
}
|
|
}
|
|
}
|
|
|
|
fn focusCallback(window: glfw.Window, focused: bool) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// Notify our render thread of the new state
|
|
_ = win.renderer_thread.mailbox.push(.{
|
|
.focus = focused,
|
|
}, .{ .forever = {} });
|
|
|
|
// Schedule render which also drains our mailbox
|
|
win.queueRender() catch unreachable;
|
|
}
|
|
|
|
fn refreshCallback(window: glfw.Window) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// The point of this callback is to schedule a render, so do that.
|
|
win.queueRender() catch unreachable;
|
|
}
|
|
|
|
fn scrollCallback(window: glfw.Window, xoff: f64, yoff: f64) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// If our dev mode window is visible then we always schedule a render on
|
|
// cursor move because the cursor might touch our windows.
|
|
if (DevMode.enabled and DevMode.instance.visible) {
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render timer err={}", .{err});
|
|
|
|
// If the mouse event was handled by imgui, ignore it.
|
|
if (imgui.IO.get()) |io| {
|
|
if (io.cval().WantCaptureMouse) return;
|
|
} else |_| {}
|
|
}
|
|
|
|
//log.info("SCROLL: {} {}", .{ xoff, yoff });
|
|
_ = xoff;
|
|
|
|
// Positive is up
|
|
const sign: isize = if (yoff > 0) -1 else 1;
|
|
const delta: isize = sign * @max(@divFloor(win.grid_size.rows, 15), 1);
|
|
log.info("scroll: delta={}", .{delta});
|
|
|
|
{
|
|
win.renderer_state.mutex.lock();
|
|
defer win.renderer_state.mutex.unlock();
|
|
|
|
// Modify our viewport, this requires a lock since it affects rendering
|
|
win.io.terminal.scrollViewport(.{ .delta = delta }) catch |err|
|
|
log.err("error scrolling viewport err={}", .{err});
|
|
|
|
// If we're scrolling up or down, then send a mouse event. This requires
|
|
// a lock since we read terminal state.
|
|
if (yoff != 0) {
|
|
const pos = window.getCursorPos() catch |err| {
|
|
log.err("error reading cursor position: {}", .{err});
|
|
return;
|
|
};
|
|
|
|
win.mouseReport(if (yoff < 0) .five else .four, .press, win.mouse.mods, pos) catch |err| {
|
|
log.err("error reporting mouse event: {}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
win.queueRender() catch unreachable;
|
|
}
|
|
|
|
/// The type of action to report for a mouse event.
|
|
const MouseReportAction = enum { press, release, motion };
|
|
|
|
fn mouseReport(
|
|
self: *Window,
|
|
button: ?input.MouseButton,
|
|
action: MouseReportAction,
|
|
mods: input.Mods,
|
|
unscaled_pos: glfw.Window.CursorPos,
|
|
) !void {
|
|
// TODO: posToViewport currently clamps to the window boundary,
|
|
// do we want to not report mouse events at all outside the window?
|
|
|
|
// Depending on the event, we may do nothing at all.
|
|
switch (self.io.terminal.modes.mouse_event) {
|
|
.none => return,
|
|
|
|
// X10 only reports clicks with mouse button 1, 2, 3. We verify
|
|
// the button later.
|
|
.x10 => if (action != .press or
|
|
button == null or
|
|
!(button.? == .left or
|
|
button.? == .right or
|
|
button.? == .middle)) return,
|
|
|
|
// Doesn't report motion
|
|
.normal => if (action == .motion) return,
|
|
|
|
// Button must be pressed
|
|
.button => if (button == null) return,
|
|
|
|
// Everything
|
|
.any => {},
|
|
}
|
|
|
|
// This format reports X/Y
|
|
const pos = self.cursorPosToPixels(unscaled_pos);
|
|
const viewport_point = self.posToViewport(pos.xpos, pos.ypos);
|
|
|
|
// For button events, we only report if we moved cells
|
|
if (self.io.terminal.modes.mouse_event == .button or
|
|
self.io.terminal.modes.mouse_event == .any)
|
|
{
|
|
if (self.mouse.event_point.x == viewport_point.x and
|
|
self.mouse.event_point.y == viewport_point.y) return;
|
|
|
|
// Record our new point
|
|
self.mouse.event_point = viewport_point;
|
|
}
|
|
|
|
// Get the code we'll actually write
|
|
const button_code: u8 = code: {
|
|
var acc: u8 = 0;
|
|
|
|
// Determine our initial button value
|
|
if (button == null) {
|
|
// Null button means motion without a button pressed
|
|
acc = 3;
|
|
} else if (action == .release and self.io.terminal.modes.mouse_format != .sgr) {
|
|
// Release is 3. It is NOT 3 in SGR mode because SGR can tell
|
|
// the application what button was released.
|
|
acc = 3;
|
|
} else {
|
|
acc = switch (button.?) {
|
|
.left => 0,
|
|
.right => 1,
|
|
.middle => 2,
|
|
.four => 64,
|
|
.five => 65,
|
|
else => return, // unsupported
|
|
};
|
|
}
|
|
|
|
// X10 doesn't have modifiers
|
|
if (self.io.terminal.modes.mouse_event != .x10) {
|
|
if (mods.shift) acc += 4;
|
|
if (mods.super) acc += 8;
|
|
if (mods.ctrl) acc += 16;
|
|
}
|
|
|
|
// Motion adds another bit
|
|
if (action == .motion) acc += 32;
|
|
|
|
break :code acc;
|
|
};
|
|
|
|
switch (self.io.terminal.modes.mouse_format) {
|
|
.x10 => {
|
|
if (viewport_point.x > 222 or viewport_point.y > 222) {
|
|
log.info("X10 mouse format can only encode X/Y up to 223", .{});
|
|
return;
|
|
}
|
|
|
|
// + 1 below is because our x/y is 0-indexed and proto wants 1
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
assert(data.len >= 5);
|
|
data[0] = '\x1b';
|
|
data[1] = '[';
|
|
data[2] = 'M';
|
|
data[3] = 32 + button_code;
|
|
data[4] = 32 + @intCast(u8, viewport_point.x) + 1;
|
|
data[5] = 32 + @intCast(u8, viewport_point.y) + 1;
|
|
|
|
// Ask our IO thread to write the data
|
|
_ = self.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = 5,
|
|
},
|
|
}, .{ .forever = {} });
|
|
},
|
|
|
|
.utf8 => {
|
|
// Maximum of 12 because at most we have 2 fully UTF-8 encoded chars
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
assert(data.len >= 12);
|
|
data[0] = '\x1b';
|
|
data[1] = '[';
|
|
data[2] = 'M';
|
|
|
|
// The button code will always fit in a single u8
|
|
data[3] = 32 + button_code;
|
|
|
|
// UTF-8 encode the x/y
|
|
var i: usize = 4;
|
|
i += try std.unicode.utf8Encode(@intCast(u21, 32 + viewport_point.x + 1), data[i..]);
|
|
i += try std.unicode.utf8Encode(@intCast(u21, 32 + viewport_point.y + 1), data[i..]);
|
|
|
|
// Ask our IO thread to write the data
|
|
_ = self.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = @intCast(u8, i),
|
|
},
|
|
}, .{ .forever = {} });
|
|
},
|
|
|
|
.sgr => {
|
|
// Final character to send in the CSI
|
|
const final: u8 = if (action == .release) 'm' else 'M';
|
|
|
|
// Response always is at least 4 chars, so this leaves the
|
|
// remainder for numbers which are very large...
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
|
|
button_code,
|
|
viewport_point.x + 1,
|
|
viewport_point.y + 1,
|
|
final,
|
|
});
|
|
|
|
// Ask our IO thread to write the data
|
|
_ = self.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = @intCast(u8, resp.len),
|
|
},
|
|
}, .{ .forever = {} });
|
|
},
|
|
|
|
.urxvt => {
|
|
// Response always is at least 4 chars, so this leaves the
|
|
// remainder for numbers which are very large...
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
const resp = try std.fmt.bufPrint(&data, "\x1B[{d};{d};{d}M", .{
|
|
32 + button_code,
|
|
viewport_point.x + 1,
|
|
viewport_point.y + 1,
|
|
});
|
|
|
|
// Ask our IO thread to write the data
|
|
_ = self.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = @intCast(u8, resp.len),
|
|
},
|
|
}, .{ .forever = {} });
|
|
},
|
|
|
|
.sgr_pixels => {
|
|
// Final character to send in the CSI
|
|
const final: u8 = if (action == .release) 'm' else 'M';
|
|
|
|
// Response always is at least 4 chars, so this leaves the
|
|
// remainder for numbers which are very large...
|
|
var data: termio.Message.WriteReq.Small.Array = undefined;
|
|
const resp = try std.fmt.bufPrint(&data, "\x1B[<{d};{d};{d}{c}", .{
|
|
button_code,
|
|
pos.xpos,
|
|
pos.ypos,
|
|
final,
|
|
});
|
|
|
|
// Ask our IO thread to write the data
|
|
_ = self.io_thread.mailbox.push(.{
|
|
.write_small = .{
|
|
.data = data,
|
|
.len = @intCast(u8, resp.len),
|
|
},
|
|
}, .{ .forever = {} });
|
|
},
|
|
}
|
|
|
|
// After sending all our messages we have to notify our IO thread
|
|
try self.io_thread.wakeup.send();
|
|
}
|
|
|
|
fn mouseButtonCallback(
|
|
window: glfw.Window,
|
|
glfw_button: glfw.MouseButton,
|
|
glfw_action: glfw.Action,
|
|
mods: glfw.Mods,
|
|
) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// If our dev mode window is visible then we always schedule a render on
|
|
// cursor move because the cursor might touch our windows.
|
|
if (DevMode.enabled and DevMode.instance.visible) {
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render timer in cursorPosCallback err={}", .{err});
|
|
|
|
// If the mouse event was handled by imgui, ignore it.
|
|
if (imgui.IO.get()) |io| {
|
|
if (io.cval().WantCaptureMouse) return;
|
|
} else |_| {}
|
|
}
|
|
|
|
// Convert glfw button to input button
|
|
const button: input.MouseButton = switch (glfw_button) {
|
|
.left => .left,
|
|
.right => .right,
|
|
.middle => .middle,
|
|
.four => .four,
|
|
.five => .five,
|
|
.six => .six,
|
|
.seven => .seven,
|
|
.eight => .eight,
|
|
};
|
|
const action: input.MouseButtonState = switch (glfw_action) {
|
|
.press => .press,
|
|
.release => .release,
|
|
else => unreachable,
|
|
};
|
|
|
|
// Always record our latest mouse state
|
|
win.mouse.click_state[@enumToInt(button)] = action;
|
|
win.mouse.mods = @bitCast(input.Mods, mods);
|
|
|
|
win.renderer_state.mutex.lock();
|
|
defer win.renderer_state.mutex.unlock();
|
|
|
|
// Report mouse events if enabled
|
|
if (win.io.terminal.modes.mouse_event != .none) {
|
|
const pos = window.getCursorPos() catch |err| {
|
|
log.err("error reading cursor position: {}", .{err});
|
|
return;
|
|
};
|
|
|
|
const report_action: MouseReportAction = switch (action) {
|
|
.press => .press,
|
|
.release => .release,
|
|
};
|
|
|
|
win.mouseReport(
|
|
button,
|
|
report_action,
|
|
win.mouse.mods,
|
|
pos,
|
|
) catch |err| {
|
|
log.err("error reporting mouse event: {}", .{err});
|
|
return;
|
|
};
|
|
}
|
|
|
|
// For left button clicks we always record some information for
|
|
// selection/highlighting purposes.
|
|
if (button == .left and action == .press) {
|
|
const pos = win.cursorPosToPixels(window.getCursorPos() catch |err| {
|
|
log.err("error reading cursor position: {}", .{err});
|
|
return;
|
|
});
|
|
|
|
// Store it
|
|
const point = win.posToViewport(pos.xpos, pos.ypos);
|
|
win.mouse.left_click_point = point.toScreen(&win.io.terminal.screen);
|
|
win.mouse.left_click_xpos = pos.xpos;
|
|
win.mouse.left_click_ypos = pos.ypos;
|
|
|
|
// Selection is always cleared
|
|
if (win.io.terminal.selection != null) {
|
|
win.io.terminal.selection = null;
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render in mouseButtinCallback err={}", .{err});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cursorPosCallback(
|
|
window: glfw.Window,
|
|
unscaled_xpos: f64,
|
|
unscaled_ypos: f64,
|
|
) void {
|
|
const tracy = trace(@src());
|
|
defer tracy.end();
|
|
|
|
const win = window.getUserPointer(Window) orelse return;
|
|
|
|
// If our dev mode window is visible then we always schedule a render on
|
|
// cursor move because the cursor might touch our windows.
|
|
if (DevMode.enabled and DevMode.instance.visible) {
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render timer in cursorPosCallback err={}", .{err});
|
|
|
|
// If the mouse event was handled by imgui, ignore it.
|
|
if (imgui.IO.get()) |io| {
|
|
if (io.cval().WantCaptureMouse) return;
|
|
} else |_| {}
|
|
}
|
|
|
|
// We are reading/writing state for the remainder
|
|
win.renderer_state.mutex.lock();
|
|
defer win.renderer_state.mutex.unlock();
|
|
|
|
// Do a mouse report
|
|
if (win.io.terminal.modes.mouse_event != .none) {
|
|
// We use the first mouse button we find pressed in order to report
|
|
// since the spec (afaict) does not say...
|
|
const button: ?input.MouseButton = button: for (win.mouse.click_state) |state, i| {
|
|
if (state == .press)
|
|
break :button @intToEnum(input.MouseButton, i);
|
|
} else null;
|
|
|
|
win.mouseReport(button, .motion, win.mouse.mods, .{
|
|
.xpos = unscaled_xpos,
|
|
.ypos = unscaled_ypos,
|
|
}) catch |err| {
|
|
log.err("error reporting mouse event: {}", .{err});
|
|
return;
|
|
};
|
|
|
|
// If we're doing mouse motion tracking, we do not support text
|
|
// selection.
|
|
return;
|
|
}
|
|
|
|
// If the cursor isn't clicked currently, it doesn't matter
|
|
if (win.mouse.click_state[@enumToInt(input.MouseButton.left)] != .press) return;
|
|
|
|
// All roads lead to requiring a re-render at this pont.
|
|
win.queueRender() catch |err|
|
|
log.err("error scheduling render timer in cursorPosCallback err={}", .{err});
|
|
|
|
// Convert to pixels from screen coords
|
|
const pos = win.cursorPosToPixels(.{ .xpos = unscaled_xpos, .ypos = unscaled_ypos });
|
|
const xpos = pos.xpos;
|
|
const ypos = pos.ypos;
|
|
|
|
// Convert to points
|
|
const viewport_point = win.posToViewport(xpos, ypos);
|
|
const screen_point = viewport_point.toScreen(&win.io.terminal.screen);
|
|
|
|
// NOTE(mitchellh): This logic super sucks. There has to be an easier way
|
|
// to calculate this, but this is good for a v1. Selection isn't THAT
|
|
// common so its not like this performance heavy code is running that
|
|
// often.
|
|
// TODO: unit test this, this logic sucks
|
|
|
|
// If we were selecting, and we switched directions, then we restart
|
|
// calculations because it forces us to reconsider if the first cell is
|
|
// selected.
|
|
if (win.io.terminal.selection) |sel| {
|
|
const reset: bool = if (sel.end.before(sel.start))
|
|
sel.start.before(screen_point)
|
|
else
|
|
screen_point.before(sel.start);
|
|
|
|
if (reset) win.io.terminal.selection = null;
|
|
}
|
|
|
|
// Our logic for determing if the starting cell is selected:
|
|
//
|
|
// - The "xboundary" is 60% the width of a cell from the left. We choose
|
|
// 60% somewhat arbitrarily based on feeling.
|
|
// - If we started our click left of xboundary, backwards selections
|
|
// can NEVER select the current char.
|
|
// - If we started our click right of xboundary, backwards selections
|
|
// ALWAYS selected the current char, but we must move the cursor
|
|
// left of the xboundary.
|
|
// - Inverted logic for forwards selections.
|
|
//
|
|
|
|
// the boundary point at which we consider selection or non-selection
|
|
const cell_xboundary = win.cell_size.width * 0.6;
|
|
|
|
// first xpos of the clicked cell
|
|
const cell_xstart = @intToFloat(f32, win.mouse.left_click_point.x) * win.cell_size.width;
|
|
const cell_start_xpos = win.mouse.left_click_xpos - cell_xstart;
|
|
|
|
// If this is the same cell, then we only start the selection if weve
|
|
// moved past the boundary point the opposite direction from where we
|
|
// started.
|
|
if (std.meta.eql(screen_point, win.mouse.left_click_point)) {
|
|
const cell_xpos = xpos - cell_xstart;
|
|
const selected: bool = if (cell_start_xpos < cell_xboundary)
|
|
cell_xpos >= cell_xboundary
|
|
else
|
|
cell_xpos < cell_xboundary;
|
|
|
|
win.io.terminal.selection = if (selected) .{
|
|
.start = screen_point,
|
|
.end = screen_point,
|
|
} else null;
|
|
|
|
return;
|
|
}
|
|
|
|
// If this is a different cell and we haven't started selection,
|
|
// we determine the starting cell first.
|
|
if (win.io.terminal.selection == null) {
|
|
// - If we're moving to a point before the start, then we select
|
|
// the starting cell if we started after the boundary, else
|
|
// we start selection of the prior cell.
|
|
// - Inverse logic for a point after the start.
|
|
const click_point = win.mouse.left_click_point;
|
|
const start: terminal.point.ScreenPoint = if (screen_point.before(click_point)) start: {
|
|
if (win.mouse.left_click_xpos > cell_xboundary) {
|
|
break :start click_point;
|
|
} else {
|
|
break :start if (click_point.x > 0) terminal.point.ScreenPoint{
|
|
.y = click_point.y,
|
|
.x = click_point.x - 1,
|
|
} else terminal.point.ScreenPoint{
|
|
.x = win.io.terminal.screen.cols - 1,
|
|
.y = click_point.y -| 1,
|
|
};
|
|
}
|
|
} else start: {
|
|
if (win.mouse.left_click_xpos < cell_xboundary) {
|
|
break :start click_point;
|
|
} else {
|
|
break :start if (click_point.x < win.io.terminal.screen.cols - 1) terminal.point.ScreenPoint{
|
|
.y = click_point.y,
|
|
.x = click_point.x + 1,
|
|
} else terminal.point.ScreenPoint{
|
|
.y = click_point.y + 1,
|
|
.x = 0,
|
|
};
|
|
}
|
|
};
|
|
|
|
win.io.terminal.selection = .{ .start = start, .end = screen_point };
|
|
return;
|
|
}
|
|
|
|
// TODO: detect if selection point is passed the point where we've
|
|
// actually written data before and disallow it.
|
|
|
|
// We moved! Set the selection end point. The start point should be
|
|
// set earlier.
|
|
assert(win.io.terminal.selection != null);
|
|
win.io.terminal.selection.?.end = screen_point;
|
|
}
|
|
|
|
fn posToViewport(self: Window, xpos: f64, ypos: f64) terminal.point.Viewport {
|
|
// xpos and ypos can be negative if while dragging, the user moves the
|
|
// mouse off the window. Likewise, they can be larger than our window
|
|
// width if the user drags out of the window positively.
|
|
return .{
|
|
.x = if (xpos < 0) 0 else x: {
|
|
// Our cell is the mouse divided by cell width
|
|
const cell_width = @floatCast(f64, self.cell_size.width);
|
|
const x = @floatToInt(usize, xpos / cell_width);
|
|
|
|
// Can be off the screen if the user drags it out, so max
|
|
// it out on our available columns
|
|
break :x @min(x, self.io.terminal.cols - 1);
|
|
},
|
|
|
|
.y = if (ypos < 0) 0 else y: {
|
|
const cell_height = @floatCast(f64, self.cell_size.height);
|
|
const y = @floatToInt(usize, ypos / cell_height);
|
|
break :y @min(y, self.io.terminal.rows - 1);
|
|
},
|
|
};
|
|
}
|
|
|
|
const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
|
|
const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
|
|
const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");
|
|
const face_emoji_text_ttf = @embedFile("font/res/NotoEmoji-Regular.ttf");
|