Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/layout/Layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,10 @@ pub const splitNamed = Region.splitNamed;
pub const splitOn = Region.splitOn;
/// Mark the split that minted a place as fizzy's own, so it follows its space's shape (`core.placement`).
pub const markAdaptive = Region.markAdaptive;
/// Divide a named place the way its space's shape asks, the split adaptive — the menu's Split.
pub const splitAdaptive = Region.splitAdaptive;
/// Make the split a place is one half of follow its space's shape, or stop.
pub const setAdaptive = Region.setAdaptive;
/// Move the visible surface from one place to another — a view-drag drop.
pub const placeVisible = ViewDrag.place;

Expand Down
27 changes: 19 additions & 8 deletions app/layout/Picker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
//! Each card carries a **snapshot** of the surface, not the live surface.
//!
//! The header is icons only, each a command (`place_commands`): split right or
//! down, clear, reset to the layout's default, delete (in red). A **pinned**
//! down, split the way the room asks, the split following its room or not (lit
//! while it does), clear, reset to the layout's default, delete (in red). A **pinned**
//! place (Sidebar, Main, Panel, Center) can be cleared or reset to its keywords;
//! it cannot be deleted. A **created** place (`Main/r1`) can. A split keeps this
//! place's view beside the new empty one — see `SPLITS.md`.
Expand Down Expand Up @@ -71,6 +72,8 @@ list_ns: i128 = 0,
pub const Action = enum {
split_right,
split_down,
split,
adaptive,
clear,
reset,
delete,
Expand Down Expand Up @@ -122,7 +125,8 @@ fn applies(f: *Layout, region: *const Layout.Region, action: Action) bool {
pub fn allows(state: *State, name: []const u8, action: Action) bool {
const created = state.isMinted(name);
return switch (action) {
.split_right, .split_down => true,
.split_right, .split_down, .split => true,
.adaptive => state.adaptiveOf(name) != null,
.clear => !created,
.reset => !created and state.assignment(name) != null,
// On the tree, any leaf with a sibling can go — a declared place's pin moves to the
Expand Down Expand Up @@ -156,6 +160,11 @@ fn applyRequest(self: *Picker, f: *Layout, req: *const Request) void {
.split_right, .split_down => if (f.splitOn(name, if (req.action == .split_right) .right else .bottom) == null) {
return dvui.log.warn("{s}: '{s}' is too small to split", .{ place_commands.title(req.action), name });
},
.split => if (f.splitAdaptive(name) == null) {
return dvui.log.warn("{s}: '{s}' is too small to split", .{ place_commands.title(req.action), name });
},
// `applies` checked it is a split the person may turn.
.adaptive => f.setAdaptive(name, !f.state.adaptiveOf(name).?),
.clear => clearRegion(f, name),
.reset => {
f.state.unassign(f.gpa, name);
Expand All @@ -166,11 +175,11 @@ fn applyRequest(self: *Picker, f: *Layout, req: *const Request) void {
}
// A split or a deleted place leaves the menu with nothing that is still true to show.
switch (req.action) {
.split_right, .split_down, .delete => if (self.is_open and std.mem.eql(u8, self.region, name)) {
.split_right, .split_down, .split, .delete => if (self.is_open and std.mem.eql(u8, self.region, name)) {
self.close(f.gpa);
f.state.discardSnapshots(f.gpa);
},
.clear, .reset => {},
.adaptive, .clear, .reset => {},
}
}

Expand Down Expand Up @@ -274,7 +283,8 @@ pub fn draw(self: *Picker, f: *Layout) void {
defer actions.deinit();
inline for (place_commands.all, 0..) |c, i| {
const enabled = applies(f, &region, c.action);
if (controlButton(@src(), c, region.name, enabled, i)) {
const lit = c.action == .adaptive and (f.state.adaptiveOf(region.name) orelse false);
if (controlButton(@src(), c, region.name, enabled, lit, i)) {
f.host.runCommand(c.id) catch |err| dvui.log.err("{s}: {t}", .{ c.id, err });
dvui.refresh(null, @src(), null);
}
Expand Down Expand Up @@ -772,13 +782,14 @@ fn drawLifted(self: *Picker, f: *Layout) void {
/// One of the header's controls: an icon, a tooltip naming it, and its command run when pressed.
/// Published as `region.menu.<action>:<place>` while it can be pressed — a dimmed one swallows the
/// click, and a driver waiting for the name should wait for the action, not for the button.
/// Delete wears the theme's error colour. Disabled, it keeps its place (dimmed, no hover).
fn controlButton(src: std.lang.SourceLocation, c: place_commands.Command, place: []const u8, enabled: bool, id_extra: usize) bool {
/// Delete wears the theme's error colour, a toggle that is on (`lit`) the highlight's. Disabled,
/// it keeps its place (dimmed, no hover).
fn controlButton(src: std.lang.SourceLocation, c: place_commands.Command, place: []const u8, enabled: bool, lit: bool, id_extra: usize) bool {
const theme = dvui.themeGet();
const destructive = c.action == .delete;
// Destructive controls wear the error fill as it is (lightening it in dark darkened it);
// `.err`'s text colour is not red in every theme.
const tint = if (destructive) theme.color(.err, .fill) else theme.color(.control, .text);
const tint = if (destructive) theme.color(.err, .fill) else if (lit) theme.color(.highlight, .fill) else theme.color(.control, .text);
// Disabled keeps the place's slot but dimmer; delete stays red enough to read as delete.
const toward_fill: f32 = if (destructive) 0.4 else 0.65;
const fill = theme.color(.control, .fill);
Expand Down
70 changes: 67 additions & 3 deletions app/layout/Region.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,7 @@ fn initTree(

if (init_opts.resize) self.drawPendingSplit(id) else self.drawPendingSplit(null);

const branch = orientBranch(self, node.kind.branch, id);
const branch = orientBranch(self, &node.kind.branch, id);
var box_opts = opts;
var default_extent: f32 = 0;
// Forgotten this frame, the tree `branch` points into may be freed: its children wait for
Expand Down Expand Up @@ -1440,7 +1440,9 @@ fn initTree(
/// changed shape), and follows a drag while turned. That drag is shown and nothing more: turning
/// back takes the share from the saved one again (`_seen` is unread while turned, so dvui drops it
/// and the first frame back reads the saved share anew).
fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.Branch {
fn orientBranch(self: *Layout, branch: *SplitTree.Branch, id: dvui.Id) SplitTree.Branch {
if (branch.settle) settleTurn(self, branch, id);
const saved = branch.*;
if (!saved.adaptive) return saved;
const dims = dvui.dataGet(null, id, "_dims", dvui.Size) orelse return saved;
const leaf = idOf(self.state, saved.created);
Expand Down Expand Up @@ -1511,6 +1513,19 @@ fn orientBranch(self: *Layout, saved: SplitTree.Branch, id: dvui.Id) SplitTree.B
return drawn;
}

/// Drop what a turn left in the frame's data (`orientBranch`) for `branch`, whose box is `id`. Its
/// places read `_fold` and `_turned` every frame, adaptive or not, so a split that stopped being
/// adaptive mid-turn would stay shut, or never save a drag again. What the turn showed is already
/// in the branch (`setAdaptive`), or is not wanted (a split rebuilt from a saved arrangement).
fn settleTurn(self: *Layout, branch: *SplitTree.Branch, id: dvui.Id) void {
branch.settle = false;
for ([_][]const u8{ "_turned", "_fold", "_share", "_set", "_seen" }) |key| dvui.dataRemove(null, id, key);
if (idOf(self.state, branch.created)) |leaf| {
dvui.dataRemove(null, leaf, "_turned");
dvui.dataRemove(null, leaf, "_fold");
}
}

/// The share an adaptive split's new half has now: drawn through the turn (`_share`), and saved
/// with the split.
fn setShare(self: *Layout, created: []const u8, id: dvui.Id, share: f32) void {
Expand Down Expand Up @@ -1700,7 +1715,7 @@ fn drawTreeNode(
},
.branch => |*branch| {
if (self.depth >= Layout.max_nesting) return;
const b = orientBranch(self, branch.*, dvui.parentGet().extendId(src, nameExtra(branch.origin)));
const b = orientBranch(self, branch, dvui.parentGet().extendId(src, nameExtra(branch.origin)));
const parent_dir = if (self.depth > 0) self.containers[self.depth - 1].dir else .horizontal;
// Same extra as the leaf this branch replaced, so the parent sash
// keeps the widget it was already dragging.
Expand Down Expand Up @@ -1890,6 +1905,55 @@ pub fn markAdaptive(self: *Layout, new: []const u8) void {
if (self.state.splits.markAdaptive(new)) self.state.markDirty();
}

/// Divide `name` the way its space's shape asks (`core.placement.orient`), the new place on the
/// trailing side, and the split adaptive from then on: the menu's Split (`layout.place.split`),
/// two groups where the person asked for two and named no side. Returns the minted leaf, or null
/// as `splitOn` does. No size floor past the layout's own: the person asked.
pub fn splitAdaptive(self: *Layout, name: []const u8) ?[]const u8 {
const size = ViewDrag.placeSize(self.state, name) orelse return null;
const side: SplitTree.Side = switch (core.placement.orient(null, size.w, size.h)) {
.side_by_side => .right,
.stacked => .bottom,
};
const new = splitOn(self, name, side) orelse return null;
markAdaptive(self, new);
return new;
}

/// Make the split place `name` is one half of follow its room, or stop (`layout.place.adaptive`),
/// where `State.adaptiveOf` allows it. Nothing moves under the person either way. On, the split
/// keeps the size it is drawn at as its share (the seed tree's sizes by `ratio` already; the
/// forest's takes the share from the extent, `orientBranch`). Off, it keeps the direction it is
/// drawn in now, and a turn under way settles where it is (`settleTurn`).
pub fn setAdaptive(self: *Layout, name: []const u8, on: bool) void {
if (self.state.adaptiveOf(name) == null) return;
if (self.state.floatRoot(name) == null) if (self.state.dock) |*dock| {
const parent = dock.findParent(dock.findPanel(name).?).?;
const split = &dock.nodes.items[parent.idx].split;
if (!on) split.dir = split.axis();
split.adaptive = on;
split.drawn = null;
self.state.markDirty();
dvui.refresh(null, @src(), null);
return;
};
const branch = self.state.splits.halfOf(name).?;
if (!on) if (idOf(self.state, branch.created)) |leaf| {
if (dvui.dataGet(null, leaf, "_turned", bool) orelse false) {
SplitTree.turn(branch);
if (dvui.dataGet(null, leaf, "_size", f32)) |extent| {
if (self.state.setExtent(self.gpa, branch.created, extent)) self.extents_changed = true;
}
}
};
branch.adaptive = on;
// Taken anew from the extent it has now: a share from when it was last adaptive would jump.
branch.share = null;
if (!on) branch.settle = true;
self.state.markDirty();
dvui.refresh(null, @src(), null);
}

fn splitDock(
self: *Layout,
dock: *core.widgets.DockLayout,
Expand Down
48 changes: 48 additions & 0 deletions app/layout/SplitTree.zig
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ pub const Branch = struct {
/// the half's pixel extent is along the axis it was last chosen on, which a box of another
/// shape (a restart in portrait) no longer has. Null until the person has sized it.
share: ?f32 = null,
/// Transient: what a turn left in the frame's data for this split is to go on its next draw
/// (`Region.settleTurn`). Set when it stops being adaptive, and when it is rebuilt under the
/// name of one that was. Never saved.
settle: bool = false,
a: *Node,
b: *Node,
};
Expand Down Expand Up @@ -224,6 +228,25 @@ pub const Forest = struct {
return false;
}

/// The split `name` is one half of, as a single place; null when it is no split's half.
pub fn halfOf(self: *const Forest, name: []const u8) ?*Branch {
var it = self.roots.valueIterator();
while (it.next()) |n| if (halfIn(n.*, name)) |b| return b;
return null;
}

fn halfIn(node: *Node, name: []const u8) ?*Branch {
switch (node.kind) {
.leaf => return null,
.branch => |*b| {
for ([_]*Node{ b.a, b.b }) |half| {
if (leafName(half.*)) |n| if (std.mem.eql(u8, n, name)) return b;
}
return halfIn(b.a, name) orelse halfIn(b.b, name);
},
}
}

/// Record the share of the adaptive split that minted `created`. True when it changed.
pub fn setShare(self: *Forest, created: []const u8, share: f32) bool {
var it = self.roots.valueIterator();
Expand Down Expand Up @@ -737,3 +760,28 @@ test "an adaptive split turns a quarter, keeps its half and its name, and is sav
turn(b);
try std.testing.expectEqual(Side.right, b.side);
}

test "a place's own split is found from either half, and is saved as the toggle leaves it" {
const gpa = std.testing.allocator;
var f: Forest = .{};
defer f.deinit(gpa);
_ = f.split(gpa, internLiteral, "Main", .right, 80, "Main/r1").?;
_ = f.split(gpa, internLiteral, "Main/r1", .bottom, 40, "Main/r1/b1").?;
try std.testing.expect(f.halfOf("Nowhere") == null);

const outer = f.halfOf("Main").?;
try std.testing.expectEqualStrings("Main/r1", outer.created);
// The half split again is a half of its own split, not of the one that made it.
try std.testing.expectEqualStrings("Main/r1/b1", f.halfOf("Main/r1").?.created);
try std.testing.expectEqual(f.halfOf("Main/r1"), f.halfOf("Main/r1/b1"));

outer.adaptive = true;
turn(outer);
outer.adaptive = false;
const links = f.collectLinks(gpa);
defer gpa.free(links);
for (links) |l| {
try std.testing.expect(!l.adaptive);
if (std.mem.eql(u8, l.name, "Main/r1")) try std.testing.expectEqual(Side.bottom, l.side);
}
}
87 changes: 87 additions & 0 deletions app/layout/State.zig
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,55 @@ pub fn isMinted(self: *const State, name: []const u8) bool {
return self.splits.canForget(name);
}

/// Whether the split place `name` is one half of follows its room (`core.placement`), or null
/// when that split is not the person's to change so: `name` is no split's half, the shape declared
/// the split (a shape's own splits stay as it wrote them), or the split is sized to its content
/// or in points (`fit`, `fixed`), which a turn would not keep.
pub fn adaptiveOf(self: *const State, name: []const u8) ?bool {
if (self.floatRoot(name) == null) {
if (self.dock) |*d| {
const idx = d.findPanel(name) orelse return null;
const parent = d.findParent(idx) orelse return null;
const split = switch (d.nodes.items[parent.idx]) {
.split => |sp| sp,
else => return null,
};
if (split.fit != null or split.fixed != null) return null;
const made = mintedFrom(d, split.first, split.second) or mintedFrom(d, split.second, split.first);
return if (made) split.adaptive else null;
}
}
const branch = self.splits.halfOf(name) orelse return null;
return branch.adaptive;
}

/// Whether a place under `at` was minted from one under `origin`: so a split made at runtime
/// (`Region.splitDock`) has its halves, where a split the seed declared has places of its own.
fn mintedFrom(d: *const core.widgets.DockLayout, at: core.widgets.DockLayout.NodeIndex, origin: core.widgets.DockLayout.NodeIndex) bool {
switch (d.nodes.items[at]) {
.split => |sp| return mintedFrom(d, sp.first, origin) or mintedFrom(d, sp.second, origin),
.leaf => |l| {
if (l.pinned) return false;
for (l.tabs.items) |panel| {
const slash = std.mem.findScalarLast(u8, panel, '/') orelse continue;
if (holds(d, origin, panel[0..slash])) return true;
}
return false;
},
.free => return false,
}
}

fn holds(d: *const core.widgets.DockLayout, at: core.widgets.DockLayout.NodeIndex, name: []const u8) bool {
return switch (d.nodes.items[at]) {
.split => |sp| holds(d, sp.first, name) or holds(d, sp.second, name),
.leaf => |l| for (l.tabs.items) |panel| {
if (std.mem.eql(u8, panel, name)) break true;
} else false,
.free => false,
};
}

/// The other half of the split place `name` is one half of, when that half is a single place:
/// the place it would merge with. Null when `name` is no split's half, or its other half is
/// split again.
Expand Down Expand Up @@ -1106,3 +1155,41 @@ test "a split nested in a split rounds only the outer corners" {
try std.testing.expectEqual([4]bool{ false, false, false, false }, testCorners(&s, bl));
try std.testing.expectEqual([4]bool{ false, false, true, false }, testCorners(&s, br));
}

test "only a split made at runtime may follow its room, never one the seed declared" {
const gpa = std.testing.allocator;
const side: Seed.Tree = .{ .leaf = .{ .name = "Sidebar", .keywords = &.{"sidebar"}, .pinned = true } };
const main: Seed.Tree = .{ .leaf = .{ .name = "Main", .keywords = &.{"main"}, .pinned = true } };
const panel: Seed.Tree = .{ .leaf = .{ .name = "Panel", .keywords = &.{"panel"}, .pinned = true } };
const column: Seed.Tree = .{ .split = .{ .dir = .vertical, .first = &main, .second = &panel } };
const seed: Seed.Tree = .{ .split = .{
.dir = .horizontal,
.fixed = .{ .child = .first, .points = 260 },
.first = &side,
.second = &column,
} };
var s: State = .{};
s.dock = try seed.toDockLayout(gpa);
defer s.dock.?.deinit();
const dock = &s.dock.?;

// The seed's own: one in points, one plain. Neither is the person's to turn.
try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Sidebar"));
try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Main"));
try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Nowhere"));

// Main split at runtime (`Region.splitDock`), then its new half split again.
try dock.splitLeaf(dock.findPanel("Main").?, .right, try gpa.dupe(u8, "Main/r1"));
try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main"));
try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main/r1"));
try dock.splitLeaf(dock.findPanel("Main/r1").?, .bottom, try gpa.dupe(u8, "Main/r1/b1"));
try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main/r1/b1"));
// Main's split holds the other split whole: still the runtime one.
try std.testing.expectEqual(@as(?bool, false), s.adaptiveOf("Main"));
// The column the seed declared, Main's split now inside it, stays the seed's.
try std.testing.expectEqual(@as(?bool, null), s.adaptiveOf("Panel"));

const parent = dock.findParent(dock.findPanel("Main/r1/b1").?).?;
dock.nodes.items[parent.idx].split.adaptive = true;
try std.testing.expectEqual(@as(?bool, true), s.adaptiveOf("Main/r1"));
}
2 changes: 2 additions & 0 deletions app/layout/place_commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub const Command = struct {
pub const all = [_]Command{
.{ .id = "layout.place.splitRight", .title = "Place: Split Right", .action = .split_right, .icon = icons.tvg.lucide.@"columns-2" },
.{ .id = "layout.place.splitDown", .title = "Place: Split Down", .action = .split_down, .icon = icons.tvg.lucide.@"rows-2" },
.{ .id = "layout.place.split", .title = "Place: Split", .action = .split, .icon = icons.tvg.lucide.@"square-split-horizontal" },
.{ .id = "layout.place.adaptive", .title = "Place: Split Follows the Room", .action = .adaptive, .icon = icons.tvg.lucide.@"rotate-cw-square" },
.{ .id = "layout.place.clear", .title = "Place: Clear", .action = .clear, .icon = icons.tvg.lucide.eraser },
.{ .id = "layout.place.reset", .title = "Place: Reset to the Layout's Default", .action = .reset, .icon = icons.tvg.lucide.@"rotate-ccw" },
.{ .id = "layout.place.delete", .title = "Place: Delete", .action = .delete, .icon = icons.tvg.lucide.@"trash-2" },
Expand Down
Loading
Loading