704 lines
24 KiB
Zig
704 lines
24 KiB
Zig
//! 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
|
|
//! during the drag is the layout you will get. If the drag is cancelled the
|
|
//! pane goes back where it came from, which is possible because only the
|
|
//! dragged pane ever moves: the rest of the tree is unchanged by a move, so
|
|
//! re-inserting it next to its original sibling restores the original shape.
|
|
|
|
const std = @import("std");
|
|
const gtk = @import("gtk");
|
|
|
|
const Layout = @import("Layout.zig");
|
|
const Layouts = @import("Layouts.zig");
|
|
const Pane = @import("Pane.zig");
|
|
const Terminal = @import("Terminal.zig");
|
|
|
|
const View = @This();
|
|
|
|
pub const Side = Layout.Side;
|
|
pub const Kind = Pane.Kind;
|
|
pub const Status = Pane.Status;
|
|
|
|
/// Where a dragged pane would land.
|
|
pub const Target = struct {
|
|
/// The pane being dropped on, or null to place against the view's own
|
|
/// edge so the pane spans the full width or height of the layout.
|
|
pane: ?*Pane,
|
|
side: Side,
|
|
|
|
pub fn eql(a: Target, b: Target) bool {
|
|
return a.pane == b.pane and a.side == b.side;
|
|
}
|
|
};
|
|
|
|
/// State tracked for the duration of a pane drag.
|
|
const Drag = struct {
|
|
pane: *Pane,
|
|
|
|
/// Where the pane sat when the drag began, so a cancelled drag can put
|
|
/// it back.
|
|
origin_sibling: *Layout.Node,
|
|
origin_side: Side,
|
|
origin_ratio: f64,
|
|
|
|
/// The last target previewed, so we only rearrange when it changes.
|
|
previewed: ?Target = null,
|
|
|
|
/// Set once a drop has been accepted; a drag that ends without this was
|
|
/// cancelled and must be undone.
|
|
committed: bool = false,
|
|
};
|
|
|
|
alloc: std.mem.Allocator,
|
|
|
|
/// Mount point for the tree's root widget.
|
|
box: *gtk.Box,
|
|
|
|
layout: Layout,
|
|
|
|
/// Every pane in the view, in creation order. The tree owns the arrangement;
|
|
/// this is just a flat set for iteration.
|
|
panes: std.ArrayListUnmanaged(*Pane) = .empty,
|
|
|
|
focused: ?*Pane = null,
|
|
|
|
/// The pane temporarily filling the view, if any.
|
|
///
|
|
/// This is a display state and nothing more — the split tree is untouched
|
|
/// while it is set, so leaving zoom restores the arrangement exactly rather
|
|
/// than reconstructing it.
|
|
zoomed: ?*Pane = null,
|
|
|
|
drag: ?Drag = null,
|
|
|
|
/// Set while the view is being torn down, so a pane's child exiting doesn't
|
|
/// try to remove it from a list we're already draining.
|
|
closing: bool = false,
|
|
|
|
on_empty: *const fn (ctx: ?*anyopaque) void,
|
|
on_title: *const fn (ctx: ?*anyopaque) void,
|
|
on_status: *const fn (ctx: ?*anyopaque) void,
|
|
|
|
/// A pane in here just finished work. Separate from `on_status` because the
|
|
/// tab needs the *edge*, not the state: "something finished since you were
|
|
/// last here" cannot be recovered by looking at the panes afterwards, since
|
|
/// one that finished before your last visit looks identical to one that
|
|
/// finished after it.
|
|
on_finished: *const fn (ctx: ?*anyopaque) void,
|
|
ctx: ?*anyopaque = null,
|
|
|
|
pub const Callbacks = struct {
|
|
on_empty: *const fn (ctx: ?*anyopaque) void,
|
|
on_title: *const fn (ctx: ?*anyopaque) void,
|
|
on_status: *const fn (ctx: ?*anyopaque) void,
|
|
on_finished: *const fn (ctx: ?*anyopaque) void,
|
|
ctx: ?*anyopaque,
|
|
};
|
|
|
|
pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
|
|
const self = try alloc.create(View);
|
|
errdefer alloc.destroy(self);
|
|
|
|
self.* = .{
|
|
.alloc = alloc,
|
|
.box = gtk.Box.new(.horizontal, 0),
|
|
.layout = .{ .alloc = alloc },
|
|
.on_empty = cbs.on_empty,
|
|
.on_title = cbs.on_title,
|
|
.on_status = cbs.on_status,
|
|
.on_finished = cbs.on_finished,
|
|
.ctx = cbs.ctx,
|
|
};
|
|
|
|
self.box.as(gtk.Widget).addCssClass("playpen-view");
|
|
self.box.as(gtk.Widget).setHexpand(1);
|
|
self.box.as(gtk.Widget).setVexpand(1);
|
|
|
|
// Deliberately created empty. Adding a pane fires on_title, and the
|
|
// caller's context object is not usable until it has finished wiring
|
|
// itself to this view.
|
|
return self;
|
|
}
|
|
|
|
pub fn destroy(self: *View) void {
|
|
self.closing = true;
|
|
self.layout.deinit();
|
|
for (self.panes.items) |pane| pane.destroy();
|
|
self.panes.deinit(self.alloc);
|
|
self.alloc.destroy(self);
|
|
}
|
|
|
|
pub fn widget(self: *View) *gtk.Widget {
|
|
return self.box.as(gtk.Widget);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Zoom
|
|
//
|
|
// One pane temporarily filling the tab, for when a split is too cramped to
|
|
// work in. Nothing closes and nothing moves: the tree is left exactly as it
|
|
// was and only the rendering changes, so leaving zoom restores the previous
|
|
// arrangement rather than rebuilding an approximation of it.
|
|
|
|
/// Put the view on screen. Every structural change goes through here rather
|
|
/// than calling the layout directly, so zoom is honoured from one place
|
|
/// instead of being re-checked at each call site.
|
|
fn render(self: *View) void {
|
|
if (self.zoomed) |pane| {
|
|
self.layout.materializeZoom(self.box, pane);
|
|
} else {
|
|
self.layout.materialize(self.box);
|
|
}
|
|
}
|
|
|
|
/// Toggle `pane` filling the view.
|
|
///
|
|
/// A single-pane view has nothing to hide, so zooming it would be an
|
|
/// invisible state change that leaves the button looking wrong; refuse
|
|
/// instead.
|
|
pub fn toggleZoom(self: *View, pane: *Pane) void {
|
|
if (self.zoomed == pane) {
|
|
self.zoomed = null;
|
|
} else {
|
|
if (self.panes.items.len < 2) return;
|
|
self.zoomed = pane;
|
|
}
|
|
|
|
self.render();
|
|
self.refreshZoomChrome();
|
|
|
|
// Re-rendering reparents the pane, which drops keyboard focus on the way
|
|
// through, so it has to be taken again afterwards.
|
|
pane.grabFocus();
|
|
self.on_title(self.ctx);
|
|
}
|
|
|
|
pub fn toggleZoomFocused(self: *View) void {
|
|
if (self.focusedPane()) |pane| self.toggleZoom(pane);
|
|
}
|
|
|
|
/// Leave zoom, if we are in it. Used by everything that changes the shape of
|
|
/// the view: the point of those actions is the arrangement, and staying zoomed
|
|
/// would hide the very thing the user just asked for.
|
|
fn clearZoom(self: *View) void {
|
|
if (self.zoomed == null) return;
|
|
self.zoomed = null;
|
|
self.refreshZoomChrome();
|
|
}
|
|
|
|
/// Point every pane's zoom button at the current state: which pane (if any) is
|
|
/// zoomed, and whether zooming means anything in a view this size.
|
|
fn refreshZoomChrome(self: *View) void {
|
|
const available = self.panes.items.len > 1;
|
|
for (self.panes.items) |pane| {
|
|
pane.setZoomAvailable(available);
|
|
pane.setZoomed(self.zoomed == pane);
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
|
|
if (self.panes.items.len < 2) {
|
|
const n = @min(title.len, buf.len);
|
|
@memcpy(buf[0..n], title[0..n]);
|
|
return buf[0..n];
|
|
}
|
|
|
|
return std.fmt.bufPrint(buf, "{d} · {s}", .{ self.panes.items.len, title }) catch title;
|
|
}
|
|
|
|
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.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 pane, splitting the focused one so the new pane appears beside
|
|
/// whatever you were working in.
|
|
pub fn addPane(self: *View, spec: Pane.Spec) !void {
|
|
const pane = try Pane.create(self.alloc, self, spec);
|
|
errdefer pane.destroy();
|
|
|
|
const node = try self.layout.newLeaf(pane);
|
|
errdefer self.alloc.destroy(node);
|
|
|
|
if (self.focusedPane()) |current| {
|
|
const target = self.layout.find(current) orelse return error.PaneNotInLayout;
|
|
try self.layout.insert(node, target, .right, 0.5);
|
|
} else {
|
|
self.layout.root = node;
|
|
}
|
|
|
|
try self.panes.append(self.alloc, pane);
|
|
|
|
// Splitting while zoomed would put the new pane somewhere you cannot see,
|
|
// so opening one leaves zoom.
|
|
self.zoomed = null;
|
|
self.render();
|
|
self.refreshZoomChrome();
|
|
|
|
self.setFocused(pane);
|
|
pane.grabFocus();
|
|
self.on_title(self.ctx);
|
|
}
|
|
|
|
pub fn closePane(self: *View, pane: *Pane) void {
|
|
if (self.closing) return;
|
|
|
|
const index = self.indexOf(pane) orelse return;
|
|
|
|
// A close during a drag invalidates the recorded origin: the node the
|
|
// dragged pane would be restored next to may be the one going away. Treat
|
|
// the drag as committed so it ends without trying to undo itself.
|
|
if (self.drag) |d| {
|
|
if (d.pane == pane) {
|
|
self.drag = null;
|
|
} else {
|
|
self.drag.?.committed = true;
|
|
}
|
|
}
|
|
|
|
if (self.layout.find(pane)) |node| {
|
|
self.layout.remove(node);
|
|
self.alloc.destroy(node);
|
|
}
|
|
|
|
_ = self.panes.orderedRemove(index);
|
|
if (self.focused == pane) self.focused = null;
|
|
|
|
// The zoomed pane going away takes the zoom with it. A different pane
|
|
// closing — a background shell exiting, say — leaves it standing, since
|
|
// what you are looking at is still there and still what you asked for.
|
|
if (self.zoomed == pane) self.zoomed = null;
|
|
|
|
// Detach the pane's widget before destroying it so materialize doesn't
|
|
// walk into a half-freed subtree.
|
|
if (pane.widget().getParent() != null) pane.widget().unparent();
|
|
pane.destroy();
|
|
|
|
if (self.panes.items.len == 0) {
|
|
self.on_empty(self.ctx);
|
|
return;
|
|
}
|
|
|
|
self.render();
|
|
self.refreshZoomChrome();
|
|
|
|
const next = @min(index, self.panes.items.len - 1);
|
|
self.setFocused(self.panes.items[next]);
|
|
self.panes.items[next].grabFocus();
|
|
self.on_title(self.ctx);
|
|
|
|
// Closing a busy pane changes what the tab as a whole is reporting, so
|
|
// the row has to be recomputed even though no pane changed its own state.
|
|
self.on_status(self.ctx);
|
|
}
|
|
|
|
pub fn setFocused(self: *View, pane: *Pane) void {
|
|
if (self.focused == pane) return;
|
|
if (self.focused) |old| old.setActive(false);
|
|
self.focused = pane;
|
|
pane.setActive(true);
|
|
self.on_title(self.ctx);
|
|
|
|
// Deliberately moving into a pane answers it, the same as typing would.
|
|
// The identity check above is what keeps this honest: work finishing in
|
|
// the pane you are already sitting in leaves the focus unchanged, so it
|
|
// stays flagged — which is the case the whole indicator exists for.
|
|
pane.markAnswered();
|
|
self.on_status(self.ctx);
|
|
}
|
|
|
|
pub fn paneFinished(self: *View, pane: *Pane) void {
|
|
_ = pane;
|
|
if (self.closing) return;
|
|
self.on_finished(self.ctx);
|
|
}
|
|
|
|
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
|
|
if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx);
|
|
}
|
|
|
|
pub fn paneStatusChanged(self: *View, pane: *Pane) void {
|
|
_ = pane;
|
|
if (self.closing) return;
|
|
self.on_status(self.ctx);
|
|
}
|
|
|
|
/// The view's status: the most urgent thing any of its panes is reporting.
|
|
///
|
|
/// A tab is one row however many panes it holds, so the row has to say
|
|
/// something about all of them at once. Urgency wins rather than, say, the
|
|
/// focused pane's status, because the whole point of the indicator is to be
|
|
/// read from another tab — a pane blocked on a permission prompt matters
|
|
/// even if it isn't the one you left focused.
|
|
pub fn status(self: *View) Status {
|
|
var worst: Status = .idle;
|
|
for (self.panes.items) |pane| {
|
|
worst = switch (pane.status) {
|
|
// Nothing outranks a pane waiting on the user, so this can
|
|
// return the moment one is found.
|
|
.needs_input => return .needs_input,
|
|
.failed => .failed,
|
|
.busy => if (worst == .failed) worst else .busy,
|
|
.idle => worst,
|
|
};
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
/// Whether any pane here has finished work that hasn't been answered.
|
|
///
|
|
/// Any one pane is enough, and deliberately so: the view's own status can't
|
|
/// answer this, because a tab holding three agents never goes idle as a whole
|
|
/// until the last of them stops, and "one of them is ready for you" is worth
|
|
/// hearing before then.
|
|
pub fn anyDoneUnanswered(self: *View) bool {
|
|
for (self.panes.items) |pane| if (pane.done_unanswered) return true;
|
|
return false;
|
|
}
|
|
|
|
/// Answer the pane the user has landed in, without touching its siblings.
|
|
///
|
|
/// Called when a tab is opened: the pane you arrive in is the one you are
|
|
/// looking at, while the others in a split are still holding news you have not
|
|
/// gone to yet, and their dots are the only thing that says which.
|
|
pub fn answerFocused(self: *View) void {
|
|
if (self.focusedPane()) |pane| pane.markAnswered();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Layouts
|
|
|
|
/// Fill an empty view from a saved layout, substituting `bindings` into every
|
|
/// directory, script and URL as the panes are created.
|
|
///
|
|
/// Only valid on a view that has no panes yet — a layout describes a whole
|
|
/// tab, not an addition to one.
|
|
pub fn applyLayout(
|
|
self: *View,
|
|
spec: *const Layouts.Node,
|
|
bindings: []const Layouts.Binding,
|
|
) !void {
|
|
std.debug.assert(self.panes.items.len == 0);
|
|
|
|
const root = try self.buildNode(spec, bindings);
|
|
self.layout.root = root;
|
|
root.parent = null;
|
|
self.render();
|
|
|
|
// The first pane in tree order is the top-left one, which is where you
|
|
// would start reading the tab and so where focus belongs.
|
|
if (self.panes.items.len > 0) {
|
|
self.setFocused(self.panes.items[0]);
|
|
self.panes.items[0].grabFocus();
|
|
}
|
|
|
|
// Panes are built one at a time and each starts assuming it is alone, so
|
|
// a multi-pane layout has to be told once it is fully assembled.
|
|
self.refreshZoomChrome();
|
|
self.on_title(self.ctx);
|
|
}
|
|
|
|
/// Build one subtree. Panes are appended to `self.panes` as they are created,
|
|
/// so a failure part-way leaves them owned by the view and torn down with it
|
|
/// rather than leaked.
|
|
fn buildNode(
|
|
self: *View,
|
|
spec: *const Layouts.Node,
|
|
bindings: []const Layouts.Binding,
|
|
) !*Layout.Node {
|
|
switch (spec.*) {
|
|
.pane => |p| {
|
|
const pane_spec = try self.paneSpec(p, bindings);
|
|
defer freePaneSpec(self.alloc, pane_spec);
|
|
|
|
const pane = try Pane.create(self.alloc, self, pane_spec);
|
|
errdefer pane.destroy();
|
|
|
|
const node = try self.layout.newLeaf(pane);
|
|
errdefer self.alloc.destroy(node);
|
|
|
|
try self.panes.append(self.alloc, pane);
|
|
return node;
|
|
},
|
|
.split => |s| {
|
|
const first = try self.buildNode(s.first, bindings);
|
|
const second = try self.buildNode(s.second, bindings);
|
|
return self.layout.newSplit(
|
|
switch (s.orientation) {
|
|
.horizontal => .horizontal,
|
|
.vertical => .vertical,
|
|
},
|
|
first,
|
|
second,
|
|
s.ratio,
|
|
);
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Turn a layout's pane description into the spec `Pane.create` wants, with
|
|
/// parameters substituted. The strings are owned by the caller.
|
|
fn paneSpec(
|
|
self: *View,
|
|
p: Layouts.Pane,
|
|
bindings: []const Layouts.Binding,
|
|
) !Pane.Spec {
|
|
return switch (p.kind) {
|
|
.terminal => blk: {
|
|
const cwd = try Layouts.expandPath(self.alloc, p.cwd, bindings);
|
|
errdefer self.alloc.free(cwd);
|
|
const command = try Layouts.expand(self.alloc, p.command, bindings);
|
|
break :blk .{ .terminal = .{ .cwd = cwd, .command = command } };
|
|
},
|
|
.web => .{ .web = .{
|
|
.url = try Layouts.expand(self.alloc, p.url, bindings),
|
|
} },
|
|
};
|
|
}
|
|
|
|
fn freePaneSpec(alloc: std.mem.Allocator, spec: Pane.Spec) void {
|
|
switch (spec) {
|
|
.terminal => |o| {
|
|
alloc.free(o.cwd);
|
|
alloc.free(o.command);
|
|
},
|
|
.web => |o| alloc.free(o.url),
|
|
}
|
|
}
|
|
|
|
/// Capture this view's arrangement as a layout tree, for "save tab as layout".
|
|
///
|
|
/// The shape, split orientations and divider ratios come across exactly as
|
|
/// they are on screen. What each pane should *run* can't be known from a live
|
|
/// pane, so terminals come back with their current directory and an empty
|
|
/// command for the user to fill in; web panes bring their current URL.
|
|
pub fn capture(self: *View, builder: Layouts.Builder) !?*Layouts.Node {
|
|
const root = self.layout.root orelse return null;
|
|
return try self.captureNode(root, builder);
|
|
}
|
|
|
|
fn captureNode(self: *View, node: *Layout.Node, builder: Layouts.Builder) !*Layouts.Node {
|
|
return switch (node.kind) {
|
|
.leaf => |pane| switch (pane.content) {
|
|
.terminal => |t| blk: {
|
|
var buf: [std.fs.max_path_bytes]u8 = undefined;
|
|
break :blk try builder.pane(.{
|
|
.kind = .terminal,
|
|
.cwd = t.session.pty.cwd(&buf) orelse "",
|
|
});
|
|
},
|
|
.web => |b| try builder.pane(.{
|
|
.kind = .web,
|
|
.url = b.currentUrl(),
|
|
}),
|
|
},
|
|
.split => |s| try builder.split(
|
|
switch (s.paned.as(gtk.Orientable).getOrientation()) {
|
|
.vertical => .vertical,
|
|
else => .horizontal,
|
|
},
|
|
s.ratio,
|
|
try self.captureNode(s.a, builder),
|
|
try self.captureNode(s.b, builder),
|
|
),
|
|
};
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Rearranging
|
|
|
|
/// Apply a move. A target pane of null places the moving pane against the
|
|
/// view's own edge, which is what produces a pane spanning the full width or
|
|
/// height across everything else.
|
|
pub fn moveTo(self: *View, moving: *Pane, target: Target, ratio: f64) void {
|
|
if (target.pane == moving) return;
|
|
if (self.panes.items.len < 2) return;
|
|
|
|
const node = self.layout.find(moving) orelse return;
|
|
|
|
// Removing first keeps the tree free of the moving pane while the
|
|
// destination is resolved, which matters when the destination is the
|
|
// root: the root changes as the pane's old parent collapses.
|
|
self.layout.remove(node);
|
|
|
|
const anchor: *Layout.Node = if (target.pane) |p|
|
|
(self.layout.find(p) orelse {
|
|
self.reattachAtRoot(node);
|
|
return;
|
|
})
|
|
else
|
|
(self.layout.root orelse {
|
|
self.layout.root = node;
|
|
node.parent = null;
|
|
self.render();
|
|
return;
|
|
});
|
|
|
|
self.layout.insert(node, anchor, target.side, ratio) catch {
|
|
self.reattachAtRoot(node);
|
|
return;
|
|
};
|
|
|
|
self.render();
|
|
}
|
|
|
|
/// Last-resort reattachment so a pane can never be orphaned by a failed move.
|
|
fn reattachAtRoot(self: *View, node: *Layout.Node) void {
|
|
if (self.layout.root) |root| {
|
|
self.layout.insert(node, root, .right, 0.5) catch {
|
|
// Nothing left to try, and leaving the node detached would lose a
|
|
// running terminal, so make it the whole layout.
|
|
self.layout.root = node;
|
|
node.parent = null;
|
|
};
|
|
} else {
|
|
self.layout.root = node;
|
|
node.parent = null;
|
|
}
|
|
self.render();
|
|
}
|
|
|
|
/// Move the focused pane one step in a direction: the keyboard equivalent of
|
|
/// dragging it onto the neighbouring pane.
|
|
pub fn moveFocused(self: *View, side: Side) void {
|
|
const pane = self.focusedPane() orelse return;
|
|
if (self.panes.items.len < 2) return;
|
|
|
|
// Rearranging is about where panes sit relative to each other, which is
|
|
// exactly what zoom is hiding. Leave it so the move can be seen — and so
|
|
// `neighbor` below has laid-out panes to probe for in the first place.
|
|
self.clearZoom();
|
|
self.render();
|
|
|
|
const target = self.neighbor(pane, side) orelse {
|
|
// Nothing that way, so place it against the view edge instead and let
|
|
// it span the layout on that side.
|
|
self.moveTo(pane, .{ .pane = null, .side = side }, 0.5);
|
|
return;
|
|
};
|
|
self.moveTo(pane, .{ .pane = target, .side = side }, 0.5);
|
|
}
|
|
|
|
/// The pane visually adjacent to `pane` on the given side, found by probing
|
|
/// just past the pane's edge and asking which pane covers that point.
|
|
fn neighbor(self: *View, pane: *Pane, side: Side) ?*Pane {
|
|
const w = pane.widget();
|
|
const width: f64 = @floatFromInt(w.getWidth());
|
|
const height: f64 = @floatFromInt(w.getHeight());
|
|
if (width <= 0 or height <= 0) return null;
|
|
|
|
const probe_gap: f64 = 12;
|
|
const local: [2]f64 = switch (side) {
|
|
.left => .{ -probe_gap, height / 2 },
|
|
.right => .{ width + probe_gap, height / 2 },
|
|
.top => .{ width / 2, -probe_gap },
|
|
.bottom => .{ width / 2, height + probe_gap },
|
|
};
|
|
|
|
var vx: f64 = 0;
|
|
var vy: f64 = 0;
|
|
if (w.translateCoordinates(self.widget(), local[0], local[1], &vx, &vy) == 0) return null;
|
|
|
|
for (self.panes.items) |candidate| {
|
|
if (candidate == pane) continue;
|
|
var cx: f64 = 0;
|
|
var cy: f64 = 0;
|
|
if (self.widget().translateCoordinates(candidate.widget(), vx, vy, &cx, &cy) == 0) continue;
|
|
const cw: f64 = @floatFromInt(candidate.widget().getWidth());
|
|
const ch: f64 = @floatFromInt(candidate.widget().getHeight());
|
|
if (cx >= 0 and cy >= 0 and cx < cw and cy < ch) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Drag lifecycle
|
|
|
|
/// Called when a pane's header starts a drag.
|
|
pub fn beginDrag(self: *View, pane: *Pane) bool {
|
|
if (self.panes.items.len < 2) return false;
|
|
|
|
// Only the zoomed pane is on screen, so there is nothing to drop against
|
|
// and no preview to show. Refusing the drag is better than starting one
|
|
// that can only ever be cancelled.
|
|
if (self.zoomed != null) return false;
|
|
|
|
const node = self.layout.find(pane) orelse return false;
|
|
const position = Layout.positionOf(node) orelse return false;
|
|
|
|
self.drag = .{
|
|
.pane = pane,
|
|
.origin_sibling = position.sibling,
|
|
.origin_side = position.side,
|
|
.origin_ratio = position.ratio,
|
|
};
|
|
pane.widget().addCssClass("dragging");
|
|
return true;
|
|
}
|
|
|
|
/// Preview a target by performing the move for real, but only when the target
|
|
/// has actually changed since the last preview.
|
|
pub fn previewDrag(self: *View, target: Target) void {
|
|
if (self.drag == null) return;
|
|
if (target.pane == self.drag.?.pane) return;
|
|
if (self.drag.?.previewed) |previous| {
|
|
if (previous.eql(target)) return;
|
|
}
|
|
|
|
self.drag.?.previewed = target;
|
|
self.moveTo(self.drag.?.pane, target, 0.5);
|
|
}
|
|
|
|
pub fn commitDrag(self: *View) void {
|
|
if (self.drag != null) self.drag.?.committed = true;
|
|
}
|
|
|
|
/// End of a drag. If no drop was accepted, undo whatever the preview did.
|
|
pub fn endDrag(self: *View) void {
|
|
const drag = self.drag orelse return;
|
|
self.drag = null;
|
|
drag.pane.widget().removeCssClass("dragging");
|
|
|
|
if (drag.committed or drag.previewed == null) return;
|
|
|
|
// Only the dragged pane ever moved, so the rest of the tree still has its
|
|
// original shape; putting the pane back beside its original sibling
|
|
// restores the arrangement the drag started from.
|
|
const node = self.layout.find(drag.pane) orelse return;
|
|
self.layout.remove(node);
|
|
self.layout.insert(node, drag.origin_sibling, drag.origin_side, drag.origin_ratio) catch {
|
|
self.reattachAtRoot(node);
|
|
return;
|
|
};
|
|
self.render();
|
|
}
|
|
|
|
fn indexOf(self: *View, pane: *Pane) ?usize {
|
|
for (self.panes.items, 0..) |p, i| if (p == pane) return i;
|
|
return null;
|
|
}
|