Add a web browser tab.
This commit is contained in:
+360
@@ -0,0 +1,360 @@
|
||||
//! The other thing a pane can hold: a WebKit web view with a small navigation
|
||||
//! bar above it.
|
||||
//!
|
||||
//! The nav bar lives here rather than in the pane header because the header is
|
||||
//! the pane's drag handle — a text entry there would swallow the drags that
|
||||
//! rearrange the view. Keeping it inside the content also means `Pane` needs to
|
||||
//! know nothing about browsing beyond which kind of content it holds.
|
||||
//!
|
||||
//! The callback shape matches `Terminal`'s exactly, so a pane can drive either
|
||||
//! one through the same three signals.
|
||||
|
||||
const std = @import("std");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Pane = @import("Pane.zig");
|
||||
const webkit = @import("webkit.zig");
|
||||
|
||||
const Browser = @This();
|
||||
|
||||
/// Where a bare search term goes. Anything that parses as a host is loaded
|
||||
/// directly instead, so this only catches input that could not be an address.
|
||||
const search_prefix = "https://duckduckgo.com/?q=";
|
||||
|
||||
/// Upper bound on a resolved address. Long enough for real URLs, and for a
|
||||
/// search query that triples in length under percent-encoding.
|
||||
const url_max = 4096;
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
|
||||
/// Vertical box: nav bar on top, web view filling the rest.
|
||||
box: *gtk.Box,
|
||||
|
||||
view: *webkit.WebView,
|
||||
|
||||
entry: *gtk.Entry,
|
||||
back: *gtk.Button,
|
||||
forward: *gtk.Button,
|
||||
|
||||
/// Toggles between reload and stop depending on whether a load is running.
|
||||
reload: *gtk.Button,
|
||||
|
||||
/// Scratch for building the URI handed to WebKit. Held here rather than on the
|
||||
/// stack because it is larger than a callback frame wants to carry.
|
||||
url_buf: [url_max]u8 = undefined,
|
||||
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Browser {
|
||||
const self = try alloc.create(Browser);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.box = gtk.Box.new(.vertical, 0),
|
||||
.view = .new(),
|
||||
.entry = gtk.Entry.new(),
|
||||
.back = gtk.Button.newFromIconName("go-previous-symbolic"),
|
||||
.forward = gtk.Button.newFromIconName("go-next-symbolic"),
|
||||
.reload = gtk.Button.newFromIconName("view-refresh-symbolic"),
|
||||
.on_title = cbs.on_title,
|
||||
.on_exit = cbs.on_exit,
|
||||
.on_focus = cbs.on_focus,
|
||||
.ctx = cbs.ctx,
|
||||
};
|
||||
|
||||
self.box.append(self.buildNav());
|
||||
|
||||
const view_widget = self.view.as(gtk.Widget);
|
||||
view_widget.setHexpand(1);
|
||||
view_widget.setVexpand(1);
|
||||
self.box.append(view_widget);
|
||||
|
||||
// Everything interesting about a web view arrives as a property change:
|
||||
// the title for the tab label, the URI for the address bar, and the load
|
||||
// state for the progress indicator and the reload/stop button.
|
||||
self.watch("title", &onNotifyTitle);
|
||||
self.watch("uri", &onNotifyUri);
|
||||
self.watch("estimated-load-progress", &onNotifyProgress);
|
||||
self.watch("is-loading", &onNotifyLoading);
|
||||
|
||||
// A page calling window.close() should close its pane, the same way a
|
||||
// shell exiting closes a terminal's.
|
||||
self.view.connectSignal("close", *Browser, &onClose, self);
|
||||
|
||||
// Fires for focus landing anywhere inside, so clicking the address bar
|
||||
// marks the pane active just as clicking the page does.
|
||||
const focus = gtk.EventControllerFocus.new();
|
||||
_ = gtk.EventControllerFocus.signals.enter.connect(
|
||||
focus,
|
||||
*Browser,
|
||||
&onFocusEnter,
|
||||
self,
|
||||
.{},
|
||||
);
|
||||
self.box.as(gtk.Widget).addController(focus.as(gtk.EventController));
|
||||
|
||||
self.syncNav();
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Browser) void {
|
||||
// The widget tree outlives this struct by a moment: the pane frees its
|
||||
// content first and drops the widgets' last reference afterwards. Tearing
|
||||
// down a page makes WebKit emit property changes on the way out, so every
|
||||
// handler bound to `self` has to go before `self` does, or those changes
|
||||
// land on freed memory.
|
||||
_ = gobject.signalHandlersDisconnectMatched(
|
||||
self.view.as(gobject.Object),
|
||||
.{ .data = true },
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
self,
|
||||
);
|
||||
|
||||
self.alloc.destroy(self);
|
||||
}
|
||||
|
||||
pub fn widget(self: *Browser) *gtk.Widget {
|
||||
return self.box.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Focus the address bar until there is a page to interact with, then the
|
||||
/// page itself. A fresh web pane opens with nowhere to go, so the useful
|
||||
/// thing to type into is the address bar.
|
||||
pub fn grabFocus(self: *Browser) void {
|
||||
const target = if (self.view.getUri() == null)
|
||||
self.entry.as(gtk.Widget)
|
||||
else
|
||||
self.view.as(gtk.Widget);
|
||||
_ = target.grabFocus();
|
||||
}
|
||||
|
||||
/// Current page title for the pane header, falling back to the address while
|
||||
/// a page is still loading and to a placeholder before anything is loaded.
|
||||
pub fn title(self: *Browser) []const u8 {
|
||||
if (self.view.getTitle()) |t| {
|
||||
const span = std.mem.span(t);
|
||||
if (span.len > 0) return span;
|
||||
}
|
||||
if (self.view.getUri()) |u| return std.mem.span(u);
|
||||
return "web";
|
||||
}
|
||||
|
||||
/// Load an address, applying the same interpretation the address bar does.
|
||||
pub fn navigate(self: *Browser, input: []const u8) void {
|
||||
const uri = self.resolve(input) orelse return;
|
||||
self.view.loadUri(uri);
|
||||
}
|
||||
|
||||
fn buildNav(self: *Browser) *gtk.Widget {
|
||||
const nav = gtk.Box.new(.horizontal, 2);
|
||||
nav.as(gtk.Widget).addCssClass("vtabs-nav");
|
||||
|
||||
for ([_]*gtk.Button{ self.back, self.forward, self.reload }) |button| {
|
||||
button.as(gtk.Widget).addCssClass("flat");
|
||||
button.as(gtk.Widget).addCssClass("vtabs-nav-button");
|
||||
nav.append(button.as(gtk.Widget));
|
||||
}
|
||||
|
||||
_ = gtk.Button.signals.clicked.connect(self.back, *Browser, &onBack, self, .{});
|
||||
_ = gtk.Button.signals.clicked.connect(self.forward, *Browser, &onForward, self, .{});
|
||||
_ = gtk.Button.signals.clicked.connect(self.reload, *Browser, &onReload, self, .{});
|
||||
|
||||
self.entry.setPlaceholderText("Enter address or search");
|
||||
self.entry.as(gtk.Widget).addCssClass("vtabs-nav-entry");
|
||||
self.entry.as(gtk.Widget).setHexpand(1);
|
||||
_ = gtk.Entry.signals.activate.connect(self.entry, *Browser, &onEntryActivate, self, .{});
|
||||
nav.append(self.entry.as(gtk.Widget));
|
||||
|
||||
return nav.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Subscribe to one of the web view's properties.
|
||||
fn watch(
|
||||
self: *Browser,
|
||||
comptime property: [:0]const u8,
|
||||
handler: *const fn (*gobject.Object, *gobject.ParamSpec, *Browser) callconv(.c) void,
|
||||
) void {
|
||||
_ = gobject.Object.signals.notify.connect(
|
||||
self.view.as(gobject.Object),
|
||||
*Browser,
|
||||
handler,
|
||||
self,
|
||||
.{ .detail = property },
|
||||
);
|
||||
}
|
||||
|
||||
/// Bring the nav bar in line with the web view's current state.
|
||||
fn syncNav(self: *Browser) void {
|
||||
self.back.as(gtk.Widget).setSensitive(@intFromBool(self.view.canGoBack()));
|
||||
self.forward.as(gtk.Widget).setSensitive(@intFromBool(self.view.canGoForward()));
|
||||
|
||||
const loading = self.view.isLoading();
|
||||
self.reload.setIconName(if (loading) "process-stop-symbolic" else "view-refresh-symbolic");
|
||||
self.reload.as(gtk.Widget).setTooltipText(if (loading) "Stop" else "Reload");
|
||||
|
||||
// The entry's own progress bar doubles as the loading indicator, so the
|
||||
// nav bar doesn't need a separate widget that is empty most of the time.
|
||||
self.entry.setProgressFraction(if (loading)
|
||||
self.view.getEstimatedLoadProgress()
|
||||
else
|
||||
0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Address interpretation
|
||||
//
|
||||
// Three cases, in order: something that already names a scheme is an address
|
||||
// as it stands; a bare word with a dot in it (or `localhost`) is a host we can
|
||||
// put a scheme on; anything else is what the user meant to search for.
|
||||
|
||||
fn resolve(self: *Browser, raw: []const u8) ?[:0]const u8 {
|
||||
const input = std.mem.trim(u8, raw, &std.ascii.whitespace);
|
||||
if (input.len == 0) return null;
|
||||
|
||||
if (hasScheme(input)) {
|
||||
return copyZ(&self.url_buf, input);
|
||||
}
|
||||
if (looksLikeHost(input)) {
|
||||
return std.fmt.bufPrintZ(&self.url_buf, "https://{s}", .{input}) catch null;
|
||||
}
|
||||
return searchUrl(&self.url_buf, input);
|
||||
}
|
||||
|
||||
/// True if the input already names a scheme. Checking for a scheme rather than
|
||||
/// for `://` is what keeps `data:`, `about:` and `mailto:` working, since none
|
||||
/// of those has an authority.
|
||||
fn hasScheme(input: []const u8) bool {
|
||||
// Whitespace rules the whole thing out: `note: buy milk` is a search, not
|
||||
// a `note` URI.
|
||||
if (std.mem.indexOfAny(u8, input, " \t") != null) return false;
|
||||
|
||||
const colon = std.mem.indexOfScalar(u8, input, ':') orelse return false;
|
||||
if (colon == 0 or !std.ascii.isAlphabetic(input[0])) return false;
|
||||
for (input[1..colon]) |c| {
|
||||
const valid = std.ascii.isAlphanumeric(c) or c == '+' or c == '-' or c == '.';
|
||||
if (!valid) return false;
|
||||
}
|
||||
|
||||
// Digits all the way to the next separator make this a port rather than a
|
||||
// scheme, so `localhost:8080` still gets `https://` put on the front.
|
||||
const rest = input[colon + 1 ..];
|
||||
const end = std.mem.indexOfAny(u8, rest, "/?#") orelse rest.len;
|
||||
if (end == 0) return true;
|
||||
for (rest[0..end]) |c| {
|
||||
if (!std.ascii.isDigit(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn looksLikeHost(input: []const u8) bool {
|
||||
if (std.mem.indexOfAny(u8, input, " \t") != null) return false;
|
||||
|
||||
// The host stops at the first path, query or fragment separator, so
|
||||
// `example.com/a.b` isn't judged by its path.
|
||||
const host = input[0 .. std.mem.indexOfAny(u8, input, "/?#") orelse input.len];
|
||||
if (std.mem.eql(u8, host, "localhost")) return true;
|
||||
if (std.mem.startsWith(u8, host, "localhost:")) return true;
|
||||
|
||||
// A dot with something either side is the cheapest thing that separates
|
||||
// `example.com` from a one-word search.
|
||||
const dot = std.mem.lastIndexOfScalar(u8, host, '.') orelse return false;
|
||||
return dot > 0 and dot < host.len - 1;
|
||||
}
|
||||
|
||||
fn copyZ(buf: []u8, text: []const u8) ?[:0]const u8 {
|
||||
if (text.len >= buf.len) return null;
|
||||
@memcpy(buf[0..text.len], text);
|
||||
buf[text.len] = 0;
|
||||
return buf[0..text.len :0];
|
||||
}
|
||||
|
||||
/// Percent-encode `query` into a search URL. Only unreserved characters
|
||||
/// survive as themselves, which is more conservative than necessary but
|
||||
/// cannot produce an invalid URL.
|
||||
fn searchUrl(buf: []u8, query: []const u8) ?[:0]const u8 {
|
||||
const hex = "0123456789ABCDEF";
|
||||
var w: usize = 0;
|
||||
|
||||
if (search_prefix.len >= buf.len) return null;
|
||||
@memcpy(buf[0..search_prefix.len], search_prefix);
|
||||
w = search_prefix.len;
|
||||
|
||||
for (query) |c| {
|
||||
const unreserved = std.ascii.isAlphanumeric(c) or
|
||||
c == '-' or c == '_' or c == '.' or c == '~';
|
||||
if (unreserved) {
|
||||
if (w + 1 >= buf.len) return null;
|
||||
buf[w] = c;
|
||||
w += 1;
|
||||
} else {
|
||||
if (w + 3 >= buf.len) return null;
|
||||
buf[w] = '%';
|
||||
buf[w + 1] = hex[c >> 4];
|
||||
buf[w + 2] = hex[c & 0x0f];
|
||||
w += 3;
|
||||
}
|
||||
}
|
||||
|
||||
buf[w] = 0;
|
||||
return buf[0..w :0];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Callbacks
|
||||
|
||||
fn onEntryActivate(entry: *gtk.Entry, self: *Browser) callconv(.c) void {
|
||||
const text = entry.as(gtk.Editable).getText();
|
||||
self.navigate(std.mem.span(text));
|
||||
// Hand focus to the page so the address bar isn't still capturing keys
|
||||
// once the load starts.
|
||||
_ = self.view.as(gtk.Widget).grabFocus();
|
||||
}
|
||||
|
||||
fn onBack(_: *gtk.Button, self: *Browser) callconv(.c) void {
|
||||
self.view.goBack();
|
||||
}
|
||||
|
||||
fn onForward(_: *gtk.Button, self: *Browser) callconv(.c) void {
|
||||
self.view.goForward();
|
||||
}
|
||||
|
||||
fn onReload(_: *gtk.Button, self: *Browser) callconv(.c) void {
|
||||
if (self.view.isLoading()) self.view.stopLoading() else self.view.reload();
|
||||
}
|
||||
|
||||
fn onNotifyTitle(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
self.on_title(self.ctx, self.title());
|
||||
}
|
||||
|
||||
fn onNotifyUri(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
if (self.view.getUri()) |uri| self.entry.as(gtk.Editable).setText(uri);
|
||||
self.syncNav();
|
||||
|
||||
// A redirect can change the address without ever changing the title, and
|
||||
// the title falls back to the address, so the pane header needs telling.
|
||||
self.on_title(self.ctx, self.title());
|
||||
}
|
||||
|
||||
fn onNotifyProgress(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
self.syncNav();
|
||||
}
|
||||
|
||||
fn onNotifyLoading(_: *gobject.Object, _: *gobject.ParamSpec, self: *Browser) callconv(.c) void {
|
||||
self.syncNav();
|
||||
}
|
||||
|
||||
fn onClose(_: *webkit.WebView, self: *Browser) callconv(.c) void {
|
||||
self.on_exit(self.ctx);
|
||||
}
|
||||
|
||||
fn onFocusEnter(_: *gtk.EventControllerFocus, self: *Browser) callconv(.c) void {
|
||||
self.on_focus(self.ctx);
|
||||
}
|
||||
+133
-30
@@ -1,18 +1,23 @@
|
||||
//! One terminal pane inside a view.
|
||||
//!
|
||||
//! A pane is a terminal plus the chrome needed to tell panes apart and
|
||||
//! rearrange them: a header strip showing the terminal's title, which doubles
|
||||
//! as the drag handle, and a drop target covering the whole pane.
|
||||
//! One pane inside a view: either a terminal or a web view, plus the chrome
|
||||
//! needed to tell panes apart and rearrange them — a header strip showing the
|
||||
//! content's title, which doubles as the drag handle, and a drop target
|
||||
//! covering the whole pane.
|
||||
//!
|
||||
//! The header exists mainly so dragging a pane never competes with the
|
||||
//! terminal's own mouse handling. Grabbing anywhere in the terminal body
|
||||
//! would collide with text selection as soon as that lands.
|
||||
//! content's own mouse handling. Grabbing anywhere in the terminal body would
|
||||
//! collide with text selection as soon as that lands, and anywhere in a web
|
||||
//! page would collide with the page itself.
|
||||
//!
|
||||
//! Everything below the header is behind `Content`, so the layout, drag and
|
||||
//! drop, and focus tracking are all written once and neither kind of content
|
||||
//! is special-cased.
|
||||
|
||||
const std = @import("std");
|
||||
const gdk = @import("gdk");
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
const Browser = @import("Browser.zig");
|
||||
const Layout = @import("Layout.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
@@ -21,6 +26,64 @@ const Pane = @This();
|
||||
|
||||
const Side = Layout.Side;
|
||||
|
||||
/// What a pane can hold. Chosen when the pane is created and fixed for its
|
||||
/// lifetime; changing kinds means closing the pane and opening another.
|
||||
pub const Kind = enum {
|
||||
terminal,
|
||||
web,
|
||||
|
||||
/// Icon standing in for this kind in the pane header and the tab row.
|
||||
pub fn iconName(self: Kind) [:0]const u8 {
|
||||
return switch (self) {
|
||||
.terminal => "utilities-terminal-symbolic",
|
||||
.web => "web-browser-symbolic",
|
||||
};
|
||||
}
|
||||
|
||||
/// Title a pane of this kind starts with, before its content reports one.
|
||||
pub fn initialTitle(self: Kind) []const u8 {
|
||||
return switch (self) {
|
||||
.terminal => "shell",
|
||||
.web => "web",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// What a content kind reports back to its pane. Shared by both kinds so the
|
||||
/// pane can wire either one up with the same handlers.
|
||||
pub const Callbacks = struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
};
|
||||
|
||||
/// The two things a pane can hold. Both expose the same three operations and
|
||||
/// report back through the same three callbacks, which is the whole reason a
|
||||
/// pane can stay ignorant of which one it has.
|
||||
pub const Content = union(Kind) {
|
||||
terminal: *Terminal,
|
||||
web: *Browser,
|
||||
|
||||
pub fn widget(self: Content) *gtk.Widget {
|
||||
return switch (self) {
|
||||
inline else => |c| c.widget(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn grabFocus(self: Content) void {
|
||||
switch (self) {
|
||||
inline else => |c| c.grabFocus(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy(self: Content) void {
|
||||
switch (self) {
|
||||
inline else => |c| c.destroy(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const all_sides = [_]Side{ .left, .right, .top, .bottom };
|
||||
|
||||
/// How close to the view's outer border a drop must be, in pixels, to place
|
||||
@@ -31,39 +94,48 @@ const span_margin: f64 = 48;
|
||||
|
||||
alloc: std.mem.Allocator,
|
||||
view: *View,
|
||||
term: *Terminal,
|
||||
|
||||
/// Vertical box: header strip on top, terminal filling the rest.
|
||||
kind: Kind,
|
||||
content: Content,
|
||||
|
||||
/// Vertical box: header strip on top, content filling the rest.
|
||||
box: *gtk.Box,
|
||||
|
||||
/// The header doubles as the drag handle, so it's kept for the drag source.
|
||||
header: *gtk.Box,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// Latest terminal title, kept NUL-terminated for GTK.
|
||||
/// Latest title reported by the content, kept NUL-terminated for GTK.
|
||||
title: [128:0]u8 = @splat(0),
|
||||
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
pub fn create(alloc: std.mem.Allocator, view: *View, kind: Kind) !*Pane {
|
||||
const self = try alloc.create(Pane);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.alloc = alloc,
|
||||
.view = view,
|
||||
.term = undefined,
|
||||
.kind = kind,
|
||||
.content = undefined,
|
||||
.box = gtk.Box.new(.vertical, 0),
|
||||
.header = gtk.Box.new(.horizontal, 4),
|
||||
.label = gtk.Label.new("shell"),
|
||||
.label = gtk.Label.new(""),
|
||||
};
|
||||
setTitle(self, "shell");
|
||||
setTitle(self, kind.initialTitle());
|
||||
|
||||
self.term = try .create(alloc, .{
|
||||
.on_title = &onTermTitle,
|
||||
.on_exit = &onTermExit,
|
||||
.on_focus = &onTermFocus,
|
||||
// Both constructors take the same callbacks, so the only thing that
|
||||
// varies between kinds is which one is called.
|
||||
const callbacks: Callbacks = .{
|
||||
.on_title = &onContentTitle,
|
||||
.on_exit = &onContentExit,
|
||||
.on_focus = &onContentFocus,
|
||||
.ctx = self,
|
||||
});
|
||||
errdefer self.term.destroy();
|
||||
};
|
||||
self.content = switch (kind) {
|
||||
.terminal => .{ .terminal = try .create(alloc, callbacks) },
|
||||
.web => .{ .web = try .create(alloc, callbacks) },
|
||||
};
|
||||
errdefer self.content.destroy();
|
||||
|
||||
// Own a strong reference to our own widget. Rearranging the view detaches
|
||||
// panes from their parent before reattaching them elsewhere, and without a
|
||||
@@ -75,13 +147,14 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
box_widget.addCssClass("vtabs-pane");
|
||||
box_widget.setHexpand(1);
|
||||
box_widget.setVexpand(1);
|
||||
// Clip the terminal to the pane's rounded corners. The terminal paints a
|
||||
// plain rectangle and has no idea it's inside a rounded frame.
|
||||
// Clip the content to the pane's rounded corners. A terminal paints a
|
||||
// plain rectangle and a web page paints whatever it likes; neither has any
|
||||
// idea it's inside a rounded frame.
|
||||
box_widget.setOverflow(.hidden);
|
||||
|
||||
self.buildHeader();
|
||||
self.box.append(self.header.as(gtk.Widget));
|
||||
self.box.append(self.term.widget());
|
||||
self.box.append(self.content.widget());
|
||||
|
||||
self.installDragSource();
|
||||
self.installDropTarget();
|
||||
@@ -90,7 +163,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View) !*Pane {
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Pane) void {
|
||||
self.term.destroy();
|
||||
self.content.destroy();
|
||||
// Releases the reference taken in `create`, finalizing the widget tree.
|
||||
self.box.as(gobject.Object).unref();
|
||||
self.alloc.destroy(self);
|
||||
@@ -101,7 +174,17 @@ pub fn widget(self: *Pane) *gtk.Widget {
|
||||
}
|
||||
|
||||
pub fn grabFocus(self: *Pane) void {
|
||||
self.term.grabFocus();
|
||||
self.content.grabFocus();
|
||||
}
|
||||
|
||||
/// The terminal this pane holds, or null if it holds something else. Callers
|
||||
/// that only make sense for a terminal — pasting a VT sequence, say — use this
|
||||
/// to opt out on a web pane.
|
||||
pub fn terminal(self: *Pane) ?*Terminal {
|
||||
return switch (self.content) {
|
||||
.terminal => |t| t,
|
||||
.web => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn titleSlice(self: *const Pane) [:0]const u8 {
|
||||
@@ -123,6 +206,12 @@ fn buildHeader(self: *Pane) void {
|
||||
// The whole strip is the drag handle, so advertise that with the cursor.
|
||||
header.as(gtk.Widget).setCursorFromName("grab");
|
||||
|
||||
// With two kinds of pane in a view, the title alone no longer says what
|
||||
// you're looking at, so the header leads with the kind.
|
||||
const icon = gtk.Image.newFromIconName(self.kind.iconName());
|
||||
icon.as(gtk.Widget).addCssClass("vtabs-pane-icon");
|
||||
header.append(icon.as(gtk.Widget));
|
||||
|
||||
self.label.setXalign(0);
|
||||
self.label.setEllipsize(.end);
|
||||
self.label.as(gtk.Widget).setHexpand(1);
|
||||
@@ -136,10 +225,17 @@ fn buildHeader(self: *Pane) void {
|
||||
_ = gtk.Button.signals.clicked.connect(split, *Pane, &onSplitClicked, self, .{});
|
||||
header.append(split.as(gtk.Widget));
|
||||
|
||||
const web = gtk.Button.newFromIconName("web-browser-symbolic");
|
||||
web.as(gtk.Widget).addCssClass("flat");
|
||||
web.as(gtk.Widget).addCssClass("vtabs-pane-button");
|
||||
web.as(gtk.Widget).setTooltipText("New web view in this view (Ctrl+Shift+B)");
|
||||
_ = gtk.Button.signals.clicked.connect(web, *Pane, &onWebClicked, self, .{});
|
||||
header.append(web.as(gtk.Widget));
|
||||
|
||||
const close = gtk.Button.newFromIconName("window-close-symbolic");
|
||||
close.as(gtk.Widget).addCssClass("flat");
|
||||
close.as(gtk.Widget).addCssClass("vtabs-pane-button");
|
||||
close.as(gtk.Widget).setTooltipText("Close terminal (Ctrl+Shift+W)");
|
||||
close.as(gtk.Widget).setTooltipText("Close pane (Ctrl+Shift+W)");
|
||||
_ = gtk.Button.signals.clicked.connect(close, *Pane, &onCloseClicked, self, .{});
|
||||
header.append(close.as(gtk.Widget));
|
||||
}
|
||||
@@ -282,28 +378,35 @@ fn setTitle(self: *Pane, text: []const u8) void {
|
||||
self.label.as(gtk.Widget).setTooltipText(self.titleSlice());
|
||||
}
|
||||
|
||||
fn onTermTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
fn onContentTitle(ctx: ?*anyopaque, title: []const u8) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.setTitle(title);
|
||||
self.view.paneTitleChanged(self);
|
||||
}
|
||||
|
||||
fn onTermExit(ctx: ?*anyopaque) void {
|
||||
/// The shell exited, or a page called window.close().
|
||||
fn onContentExit(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.view.closePane(self);
|
||||
}
|
||||
|
||||
fn onTermFocus(ctx: ?*anyopaque) void {
|
||||
fn onContentFocus(ctx: ?*anyopaque) void {
|
||||
const self: *Pane = @ptrCast(@alignCast(ctx.?));
|
||||
self.view.setFocused(self);
|
||||
}
|
||||
|
||||
fn onSplitClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane() catch |err| {
|
||||
self.view.addPane(.terminal) catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onWebClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.addPane(.web) catch |err| {
|
||||
std.log.err("failed to open web view: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
fn onCloseClicked(_: *gtk.Button, self: *Pane) callconv(.c) void {
|
||||
self.view.closePane(self);
|
||||
}
|
||||
|
||||
+2
-9
@@ -17,6 +17,7 @@ const vt = @import("ghostty-vt");
|
||||
|
||||
const keymap = @import("key.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const Pane = @import("Pane.zig");
|
||||
const Session = @import("Session.zig");
|
||||
|
||||
const Terminal = @This();
|
||||
@@ -53,15 +54,7 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
|
||||
ctx: ?*anyopaque = null,
|
||||
|
||||
pub fn create(
|
||||
alloc: std.mem.Allocator,
|
||||
cbs: struct {
|
||||
on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void,
|
||||
on_exit: *const fn (ctx: ?*anyopaque) void,
|
||||
on_focus: *const fn (ctx: ?*anyopaque) void,
|
||||
ctx: ?*anyopaque,
|
||||
},
|
||||
) !*Terminal {
|
||||
pub fn create(alloc: std.mem.Allocator, cbs: Pane.Callbacks) !*Terminal {
|
||||
const self = try alloc.create(Terminal);
|
||||
errdefer alloc.destroy(self);
|
||||
|
||||
|
||||
+18
-9
@@ -1,5 +1,5 @@
|
||||
//! A view: the content of one tab, holding one or more terminal panes
|
||||
//! arranged in a split tree.
|
||||
//! A view: the content of one tab, holding one or more panes — terminals, web
|
||||
//! views, or a mix — arranged in a split tree.
|
||||
//!
|
||||
//! Dragging a pane rearranges the view live rather than on release. Each time
|
||||
//! the drop target changes, the move is applied for real, so what you see
|
||||
@@ -18,6 +18,7 @@ const Terminal = @import("Terminal.zig");
|
||||
const View = @This();
|
||||
|
||||
pub const Side = Layout.Side;
|
||||
pub const Kind = Pane.Kind;
|
||||
|
||||
/// Where a dragged pane would land.
|
||||
pub const Target = struct {
|
||||
@@ -113,8 +114,8 @@ pub fn widget(self: *View) *gtk.Widget {
|
||||
return self.box.as(gtk.Widget);
|
||||
}
|
||||
|
||||
/// Text for the tab label: the focused terminal's title, prefixed with the
|
||||
/// pane count once there is more than one.
|
||||
/// Text for the tab label: the focused pane's title, prefixed with the pane
|
||||
/// count once there is more than one.
|
||||
pub fn label(self: *View, buf: []u8) []const u8 {
|
||||
const pane = self.focusedPane() orelse return "";
|
||||
const title = pane.titleSlice();
|
||||
@@ -132,19 +133,27 @@ pub fn focusedPane(self: *View) ?*Pane {
|
||||
return self.focused orelse (if (self.panes.items.len > 0) self.panes.items[0] else null);
|
||||
}
|
||||
|
||||
/// The focused pane's terminal, or null when the focused pane holds a web
|
||||
/// view instead.
|
||||
pub fn focusedTerminal(self: *View) ?*Terminal {
|
||||
const pane = self.focusedPane() orelse return null;
|
||||
return pane.term;
|
||||
return pane.terminal();
|
||||
}
|
||||
|
||||
/// Icon for the tab row: whatever the focused pane is showing.
|
||||
pub fn iconName(self: *View) [:0]const u8 {
|
||||
const pane = self.focusedPane() orelse return Kind.terminal.iconName();
|
||||
return pane.kind.iconName();
|
||||
}
|
||||
|
||||
pub fn focus(self: *View) void {
|
||||
if (self.focusedPane()) |pane| pane.grabFocus();
|
||||
}
|
||||
|
||||
/// Add a terminal, splitting the focused pane so the new one appears beside
|
||||
/// the terminal you were working in.
|
||||
pub fn addPane(self: *View) !void {
|
||||
const pane = try Pane.create(self.alloc, self);
|
||||
/// Add a pane, splitting the focused one so the new pane appears beside
|
||||
/// whatever you were working in.
|
||||
pub fn addPane(self: *View, kind: Kind) !void {
|
||||
const pane = try Pane.create(self.alloc, self, kind);
|
||||
errdefer pane.destroy();
|
||||
|
||||
const node = try self.layout.newLeaf(pane);
|
||||
|
||||
+38
-14
@@ -14,6 +14,7 @@ const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
const vt = @import("ghostty-vt");
|
||||
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const View = @import("View.zig");
|
||||
|
||||
const Window = @This();
|
||||
@@ -42,13 +43,18 @@ updating: bool = false,
|
||||
/// doesn't try to close a tab we're already destroying.
|
||||
closing: bool = false,
|
||||
|
||||
/// A single tab: a view of one or more terminals, plus the sidebar row that
|
||||
/// A single tab: a view of one or more panes, plus the sidebar row that
|
||||
/// selects it.
|
||||
const Tab = struct {
|
||||
window: *Window,
|
||||
view: *View,
|
||||
row: *gtk.ListBoxRow,
|
||||
label: *gtk.Label,
|
||||
|
||||
/// Shows what the tab's focused pane is, so a web view is recognisable in
|
||||
/// the sidebar without reading the title.
|
||||
icon: *gtk.Image,
|
||||
|
||||
name: [16]u8,
|
||||
name_len: usize,
|
||||
|
||||
@@ -185,6 +191,7 @@ pub fn newTab(self: *Window) !void {
|
||||
.view = view,
|
||||
.row = gtk.ListBoxRow.new(),
|
||||
.label = gtk.Label.new("shell"),
|
||||
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
|
||||
.name = undefined,
|
||||
.name_len = 0,
|
||||
};
|
||||
@@ -195,8 +202,7 @@ pub fn newTab(self: *Window) !void {
|
||||
const row_box = gtk.Box.new(.horizontal, 6);
|
||||
row_box.as(gtk.Widget).addCssClass("vtabs-row");
|
||||
|
||||
const icon = gtk.Image.newFromIconName("utilities-terminal-symbolic");
|
||||
row_box.append(icon.as(gtk.Widget));
|
||||
row_box.append(tab.icon.as(gtk.Widget));
|
||||
|
||||
tab.label.setXalign(0);
|
||||
tab.label.setEllipsize(.end);
|
||||
@@ -217,8 +223,8 @@ pub fn newTab(self: *Window) !void {
|
||||
try self.tabs.append(self.alloc, tab);
|
||||
|
||||
// Only now is `tab` complete enough for the view's callbacks to use, so
|
||||
// this is the first safe moment to give the view its terminal.
|
||||
try view.addPane();
|
||||
// this is the first safe moment to give the view its first pane.
|
||||
try view.addPane(.terminal);
|
||||
|
||||
self.refreshLabel(tab);
|
||||
self.select(tab);
|
||||
@@ -301,6 +307,7 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
|
||||
|
||||
tab.label.setText(buf[0..text.len :0]);
|
||||
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
|
||||
tab.icon.setFromIconName(tab.view.iconName());
|
||||
}
|
||||
|
||||
fn onViewTitle(ctx: ?*anyopaque) void {
|
||||
@@ -342,6 +349,20 @@ fn activeTab(self: *Window) ?*Tab {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The focused terminal of the visible tab, or null when a web pane has focus.
|
||||
fn focusedTerminal(self: *Window) ?*Terminal {
|
||||
const tab = self.activeTab() orelse return null;
|
||||
return tab.view.focusedTerminal();
|
||||
}
|
||||
|
||||
/// Split the visible tab's focused pane, adding a pane of the given kind.
|
||||
fn addPane(self: *Window, kind: View.Kind) void {
|
||||
const tab = self.activeTab() orelse return;
|
||||
tab.view.addPane(kind) catch |err| {
|
||||
std.log.err("failed to open {s} pane: {s}", .{ @tagName(kind), @errorName(err) });
|
||||
};
|
||||
}
|
||||
|
||||
fn selectIndex(self: *Window, index: usize) void {
|
||||
if (index >= self.tabs.items.len) return;
|
||||
self.select(self.tabs.items[index]);
|
||||
@@ -376,22 +397,26 @@ fn onShortcut(
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_W, gdk.KEY_w => {
|
||||
// Closes the focused terminal. The view raises on_empty when
|
||||
// its last pane goes, which is what closes the tab.
|
||||
// Closes the focused pane. The view raises on_empty when its
|
||||
// last pane goes, which is what closes the tab.
|
||||
if (self.activeTab()) |tab| {
|
||||
if (tab.view.focusedPane()) |pane| tab.view.closePane(pane);
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_E, gdk.KEY_e => {
|
||||
if (self.activeTab()) |tab| {
|
||||
tab.view.addPane() catch |err| {
|
||||
std.log.err("failed to open terminal: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
self.addPane(.terminal);
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_B, gdk.KEY_b => {
|
||||
self.addPane(.web);
|
||||
return 1;
|
||||
},
|
||||
gdk.KEY_V, gdk.KEY_v => {
|
||||
// Only a terminal needs us to encode a paste for it. A web
|
||||
// pane has its own clipboard handling, so the key is left
|
||||
// alone rather than swallowed here.
|
||||
if (self.focusedTerminal() == null) return 0;
|
||||
self.paste();
|
||||
return 1;
|
||||
},
|
||||
@@ -478,8 +503,7 @@ fn onPasteReady(
|
||||
};
|
||||
defer glib.free(text);
|
||||
|
||||
const tab = self.activeTab() orelse return;
|
||||
const terminal = tab.view.focusedTerminal() orelse return;
|
||||
const terminal = self.focusedTerminal() orelse return;
|
||||
const session = terminal.session;
|
||||
// Coerce to a plain slice: encodePaste dispatches on the exact type.
|
||||
const span: []const u8 = std.mem.span(text);
|
||||
|
||||
@@ -120,6 +120,50 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vtabs-pane-icon {
|
||||
color: #6f6784;
|
||||
-gtk-icon-size: 14px;
|
||||
}
|
||||
|
||||
.vtabs-pane.active .vtabs-pane-icon {
|
||||
color: #b29df5;
|
||||
}
|
||||
|
||||
/* A web pane's navigation bar, below the pane header. Kept visually quieter
|
||||
than the header so the two rows don't compete. */
|
||||
.vtabs-nav {
|
||||
padding: 4px 6px;
|
||||
background-color: #16141c;
|
||||
border-bottom: 1px solid #262133;
|
||||
}
|
||||
|
||||
.vtabs-nav-button {
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
color: #b6afc7;
|
||||
}
|
||||
|
||||
.vtabs-nav-button:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.vtabs-nav-entry {
|
||||
min-height: 24px;
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85em;
|
||||
background-color: #0f0d14;
|
||||
color: #ded7ef;
|
||||
border: 1px solid #2a2536;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vtabs-nav-entry:focus-within {
|
||||
border-color: #5b4d80;
|
||||
}
|
||||
|
||||
/* The pane being dragged. There is no separate drop indicator: the layout
|
||||
rearranges live during the drag, so the view itself is the preview. */
|
||||
.vtabs-pane.dragging {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
//! Hand-written bindings for the slice of WebKitGTK 6.0 the web panes use.
|
||||
//!
|
||||
//! The zig-gobject binding set we share with Ghostty is generated from a fixed
|
||||
//! list of GIR files that doesn't include WebKit, and adding a second
|
||||
//! generated set would bring a second, incompatible `gtk.Widget` type with it —
|
||||
//! every widget handed across the boundary would need laundering. Declaring
|
||||
//! the dozen C entry points we actually call is smaller than that problem.
|
||||
//!
|
||||
//! `WebKitWebView` is an ordinary `GtkWidget` subclass, so a pointer cast is
|
||||
//! all it takes to hand one to GTK or to connect to its GObject signals; the
|
||||
//! bindings only have to cover the WebKit-specific calls.
|
||||
|
||||
const gobject = @import("gobject");
|
||||
const gtk = @import("gtk");
|
||||
|
||||
/// Boolean returns are C `gboolean`, which is an int, not a `bool`.
|
||||
fn truthy(v: c_int) bool {
|
||||
return v != 0;
|
||||
}
|
||||
|
||||
pub const WebView = opaque {
|
||||
extern fn webkit_web_view_new() *WebView;
|
||||
/// Returns a floating reference, like every other GTK widget constructor,
|
||||
/// so appending it to a container is what claims ownership.
|
||||
pub const new = webkit_web_view_new;
|
||||
|
||||
/// Upcast to the GTK/GObject types this really is underneath. Restricted
|
||||
/// to the two ancestors we need, since nothing here checks the hierarchy
|
||||
/// the way the generated bindings' `as` does.
|
||||
pub fn as(self: *WebView, comptime T: type) *T {
|
||||
comptime if (T != gtk.Widget and T != gobject.Object) @compileError(
|
||||
"WebKitWebView can only be cast to gtk.Widget or gobject.Object",
|
||||
);
|
||||
return @ptrCast(@alignCast(self));
|
||||
}
|
||||
|
||||
extern fn webkit_web_view_load_uri(*WebView, [*:0]const u8) void;
|
||||
pub const loadUri = webkit_web_view_load_uri;
|
||||
|
||||
/// The page's title, or null before one is known. Owned by WebKit.
|
||||
extern fn webkit_web_view_get_title(*WebView) ?[*:0]const u8;
|
||||
pub const getTitle = webkit_web_view_get_title;
|
||||
|
||||
/// The current URI, or null if nothing has been loaded. Owned by WebKit.
|
||||
extern fn webkit_web_view_get_uri(*WebView) ?[*:0]const u8;
|
||||
pub const getUri = webkit_web_view_get_uri;
|
||||
|
||||
extern fn webkit_web_view_can_go_back(*WebView) c_int;
|
||||
pub fn canGoBack(self: *WebView) bool {
|
||||
return truthy(webkit_web_view_can_go_back(self));
|
||||
}
|
||||
|
||||
extern fn webkit_web_view_can_go_forward(*WebView) c_int;
|
||||
pub fn canGoForward(self: *WebView) bool {
|
||||
return truthy(webkit_web_view_can_go_forward(self));
|
||||
}
|
||||
|
||||
extern fn webkit_web_view_go_back(*WebView) void;
|
||||
pub const goBack = webkit_web_view_go_back;
|
||||
|
||||
extern fn webkit_web_view_go_forward(*WebView) void;
|
||||
pub const goForward = webkit_web_view_go_forward;
|
||||
|
||||
extern fn webkit_web_view_reload(*WebView) void;
|
||||
pub const reload = webkit_web_view_reload;
|
||||
|
||||
extern fn webkit_web_view_stop_loading(*WebView) void;
|
||||
pub const stopLoading = webkit_web_view_stop_loading;
|
||||
|
||||
extern fn webkit_web_view_is_loading(*WebView) c_int;
|
||||
pub fn isLoading(self: *WebView) bool {
|
||||
return truthy(webkit_web_view_is_loading(self));
|
||||
}
|
||||
|
||||
/// 0..1, and meaningful only while `isLoading` is true.
|
||||
extern fn webkit_web_view_get_estimated_load_progress(*WebView) f64;
|
||||
pub const getEstimatedLoadProgress = webkit_web_view_get_estimated_load_progress;
|
||||
|
||||
/// Connect to a WebKit-specific signal.
|
||||
///
|
||||
/// The generated bindings' typed `connect` helpers only exist for types
|
||||
/// they know about, so WebKit's own signals go through the untyped
|
||||
/// GObject entry point. Property changes don't need this: `notify` is
|
||||
/// declared on `gobject.Object`, which this can be cast to.
|
||||
pub fn connectSignal(
|
||||
self: *WebView,
|
||||
comptime signal: [:0]const u8,
|
||||
comptime Data: type,
|
||||
handler: *const fn (*WebView, Data) callconv(.c) void,
|
||||
data: Data,
|
||||
) void {
|
||||
_ = gobject.signalConnectData(
|
||||
self.as(gobject.Object),
|
||||
signal,
|
||||
@ptrCast(handler),
|
||||
@ptrCast(data),
|
||||
null,
|
||||
.{},
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user