diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index bcb8b86f6..78fa33e27 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -6,8 +6,10 @@ //! picker fills them. Closing an empty leaf collapses the branch. const std = @import("std"); const dvui = @import("dvui"); +const core = @import("core"); -pub const Side = enum { left, right, top, bottom }; +/// The docking widget's side: a pane tree and a place tree name an edge the same way. +pub const Side = core.widgets.DockLayout.Side; pub fn axisOf(side: Side) dvui.enums.Direction { return switch (side) { diff --git a/app/layout/State.zig b/app/layout/State.zig index 46aa109cf..243029465 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -38,6 +38,14 @@ pub const StoreOffer = struct { title: []const u8, }; +/// An app's answer to where an open beside splits a place (`beside`). +pub const Beside = struct { + ctx: *anyopaque, + /// The edge of place `name` an open beside splits, or null where it joins a place, becomes a + /// card, or `name` holds no documents. + edge: *const fn (ctx: *anyopaque, name: []const u8) ?SplitTree.Side, +}; + /// How an application gives a document a home outside the slots its plugins made: a view of its /// own that holds documents (fizzy's workspaces). With one, a document carried in the view drag /// lands in any place as a view would, inside a new such view (`ViewDrag.place`). @@ -169,10 +177,11 @@ tree_cleared: bool = false, slide_open: []const u8 = "", /// A place's view being dragged to another place. Empty `name` when idle. view_drag: ViewDrag = .{}, -/// The edge an open beside splits the documents' place on right now, set each frame by the app -/// while a demo plays: that edge's drop zone is also published as the anchor `drop:beside`, so a -/// tape aims at the place's answer, not at an edge it chose when it was written. -beside: ?SplitTree.Side = null, +/// Set each frame by the app while a demo plays: asked for each place a drag may land in, it +/// names the edge an open beside splits that place on. That edge's drop zone is also published as +/// the anchor `drop:beside`, so a tape aims at the target place's own answer, not at an edge it +/// chose when it was written. +beside: ?Beside = null, /// Floats are OS windows of their own (the application says so each frame, where it has them): a /// view let go over no window of the app's opens one there (`ViewDrag.apply`). floats_windowed: bool = false, diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index ce83ef85d..73e449dc8 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1323,18 +1323,21 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void { // is the target, so a demo aims at the bubble rather than an offset from the place's middle. // Marked only while a tape asks for anchors, and ahead of the offer queue's limit (which has // nothing to do with the bubble being there). - if (target and !covered and core.anchor.wanted()) for (DropZones.all) |z| { - if (z == .remove and !zones.remove) continue; - const b = zones.bubble(z); - const r: dvui.Rect.Physical = .{ .x = b.c.x - b.r, .y = b.c.y - b.r, .w = 2 * b.r, .h = 2 * b.r }; - switch (z) { - .edge => |side| { - core.anchor.markRect(key, r, true, "drop:{t}", .{side}); - if (l.state.beside) |aimed_side| if (std.mem.eql(u8, @tagName(aimed_side), @tagName(side))) core.anchor.markRect(key, r, true, "drop:beside", .{}); - }, - else => core.anchor.markRect(key, r, true, "drop:{t}", .{z}), + if (target and !covered and core.anchor.wanted()) { + const beside = if (l.state.beside) |aim_at| aim_at.edge(aim_at.ctx, name) else null; + for (DropZones.all) |z| { + if (z == .remove and !zones.remove) continue; + const b = zones.bubble(z); + const r: dvui.Rect.Physical = .{ .x = b.c.x - b.r, .y = b.c.y - b.r, .w = 2 * b.r, .h = 2 * b.r }; + switch (z) { + .edge => |side| { + core.anchor.markRect(key, r, true, "drop:{t}", .{side}); + if (beside == side) core.anchor.markRect(key, r, true, "drop:beside", .{}); + }, + else => core.anchor.markRect(key, r, true, "drop:{t}", .{z}), + } } - }; + } // Queued, not drawn: over everything, once every place has drawn (`drawOverlay`). const now = dvui.currentWindow().frame_time_ns; if (d.pending_frame != now) { diff --git a/core/widgets/DropZones.zig b/core/widgets/DropZones.zig index 0db908e0e..a66ecc084 100644 --- a/core/widgets/DropZones.zig +++ b/core/widgets/DropZones.zig @@ -48,7 +48,7 @@ const motion = @import("glass").motion; const liquid_glass = @import("glass").liquid_glass; const LiquidField = @import("glass").LiquidField; -pub const Side = enum { left, right, top, bottom }; +pub const Side = @import("DockingWidget/Layout.zig").Side; pub const Zone = union(enum) { /// Into the place itself: a trade for a place that shows one view, a new tab for one that diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index ea3ce2a27..e9848c5a6 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -233,27 +233,26 @@ pub fn sideBeside(self: *const Workbench, anchor: u64) Panes.Side { /// What opening beside the person's pane asks of `core.placement.place`: the other pane that is /// there, if any, and the answer over the person's room and that pane's. Null before the person's /// pane has drawn, with no room to judge. -const Beside = struct { other: ?u64, answer: ?core.placement.Result }; +const Beside = struct { other: ?u64, room: ?core.placement.Size, answer: ?core.placement.Result }; -fn besideAnswer(self: *Workbench) Beside { - const here = self.open_workspace_grouping; +fn besideAnswer(self: *Workbench, here: u64) Beside { const other = for (self.workspaces.values()) |ws| { if (ws.grouping != here and !self.paneClosing(ws.grouping)) break ws.grouping; } else null; - const room = self.paneSize(here) orelse return .{ .other = other, .answer = null }; + const room = self.paneSize(here) orelse return .{ .other = other, .room = null, .answer = null }; var others: [1]core.placement.Size = undefined; const places: []const core.placement.Size = if (other) |g| blk: { others[0] = self.paneSize(g) orelse .{ .w = 0, .h = 0 }; break :blk &others; } else &.{}; - return .{ .other = other, .answer = core.placement.place(room, places) }; + return .{ .other = other, .room = room, .answer = core.placement.place(room, places) }; } /// The grouping a document opened beside the person's goes in (`core.placement.place`): another /// pane that is there, else a new one when the person's has room for two, else the person's own, /// where it becomes a card. The caller makes it the front card, as a plain open does. pub fn besideGrouping(self: *Workbench) u64 { - const asked = self.besideAnswer(); + const asked = self.besideAnswer(self.open_workspace_grouping); // Not drawn yet, so no room to judge: beside as before, in the other pane if there is one. const answer = asked.answer orelse return asked.other orelse self.newGroupingID(); return switch (answer) { @@ -263,11 +262,12 @@ pub fn besideGrouping(self: *Workbench) u64 { }; } -/// The edge of the person's pane an open beside splits it on, or null where it joins a pane or -/// becomes a card. What a demo's `drop:beside` aims at. -pub fn besideEdge(self: *Workbench) ?Panes.Side { - const room = self.paneSize(self.open_workspace_grouping) orelse return null; - return switch (self.besideAnswer().answer orelse return null) { +/// The edge of `grouping`'s pane an open beside it splits on, or null where it joins a pane or +/// becomes a card. What a demo's `drop:beside` aims at, over the pane it is aimed at. +pub fn besideEdge(self: *Workbench, grouping: u64) ?Panes.Side { + const asked = self.besideAnswer(grouping); + const room = asked.room orelse return null; + return switch (asked.answer orelse return null) { .split => sideFor(room.w, room.h), .into, .tab => null, }; diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index b9ae713ac..a4eef97db 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -161,7 +161,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { // The person's pane, when it has the room for an open beside to split it: a tape that drops a // tab on the edge asks for this first, or skips the drop (`core.placement`). if (core.anchor.wanted() and runtime.workbench().open_workspace_grouping == self.grouping and - runtime.workbench().besideEdge() != null) core.anchor.mark(pane_box, "workbench.beside-room", .{}); + runtime.workbench().besideEdge(self.grouping) != null) core.anchor.mark(pane_box, "workbench.beside-room", .{}); if (tabs.len > 0) { self.dropSnapshot(); diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 2813e46c0..c83a31c1e 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -285,11 +285,8 @@ pub fn frame(self: *Demo) void { .tape => |bytes| self.playTape(bytes) catch {}, } } - // A drop beside is aimed at where the workbench would split, as the room is now. - self.editor.app.layout.beside = if (self.active()) - if (self.editor.workbench.besideEdge()) |side| std.meta.stringToEnum(SplitTree.Side, @tagName(side)) else null - else - null; + // A drop beside is aimed at where the workbench would split the target place, as its room is now. + self.editor.app.layout.beside = if (self.active()) .{ .ctx = self, .edge = besideEdge } else null; self.snapshot.beginFrame(); self.live.frame(); self.player.frame(); @@ -872,6 +869,26 @@ fn commandTitle(ctx: *anyopaque, id: []const u8) ?[]const u8 { return c.title; } +/// The edge a drop beside aims at over place `name`: where the workbench would split the pane +/// shown there (a pane of its own tree is named for its grouping). A pane the layout's keywords +/// show is in no list, so Main is the person's pane. +pub fn besideEdge(ctx: *anyopaque, name: []const u8) ?SplitTree.Side { + const editor = from(ctx).editor; + const wb = &editor.workbench; + const grouping = Editor.Workspace.groupingOfName(name) orelse editor.workspaces.groupingIn(name) orelse blk: { + const main = editor.regionFor(sdk.keywords.ide.main) orelse return null; + if (!std.mem.eql(u8, main.name, name)) return null; + var layout = Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.host.arena()); + var buf: [64]u8 = undefined; + const id = std.mem.print(&buf, Editor.Workspaces.id_prefix ++ "{d}", .{wb.open_workspace_grouping}) catch return null; + if (Editor.Layout.ViewDrag.holderOf(&layout, id)) |holder| { + if (!std.mem.eql(u8, holder, name)) return null; + } + break :blk wb.open_workspace_grouping; + }; + return wb.besideEdge(grouping); +} + /// The place a role names, by what it is rather than what the person's layout calls it. fn resolveRole(ctx: *anyopaque, role: []const u8, buf: []u8) ?[]const u8 { const keywords = if (std.mem.eql(u8, role, "documents")) diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig index 1c1bfda8a..0e31bfa65 100644 --- a/src/editor/Workspaces.zig +++ b/src/editor/Workspaces.zig @@ -289,18 +289,29 @@ fn layoutOf(editor: *Editor) Layout { return Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.host.arena()); } +/// The pane shown in place `name`: the person's own when it is there, else the first held. +/// Null where the place holds no pane (a pane the keywords show is in no list: the caller's). +pub fn groupingIn(self: *Workspaces, name: []const u8) ?u64 { + const editor = self.editorOf(); + var l = layoutOf(editor); + var buf: [64]u8 = undefined; + var first: ?u64 = null; + for (self.views.keys()) |g| { + const holder = Layout.ViewDrag.holderOf(&l, viewId(&buf, g)) orelse continue; + if (!std.mem.eql(u8, holder, name)) continue; + if (g == editor.workbench.open_workspace_grouping) return g; + first = first orelse g; + } + return first; +} + fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Side, adaptive: bool) bool { const self: *Workspaces = @ptrCast(@alignCast(ctx)); const editor = self.editorOf(); var l = layoutOf(editor); var abuf: [64]u8 = undefined; const place = Layout.ViewDrag.holderOf(&l, viewId(&abuf, anchor)) orelse return false; - const new = Layout.splitOn(&l, place, switch (side) { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - }) orelse return false; + const new = Layout.splitOn(&l, place, side) orelse return false; var buf: [64]u8 = undefined; const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return false; l.state.assign(l.gpa, new, &.{id}) catch return false; @@ -354,12 +365,7 @@ fn open(ctx: *anyopaque, grouping: u64, side: Workbench.Panes.Side) bool { var l = layoutOf(self.editorOf()); var buf: [64]u8 = undefined; const place = Layout.ViewDrag.holderOf(&l, viewId(&buf, grouping)) orelse return false; - return Layout.splitOn(&l, place, switch (side) { - .left => .left, - .right => .right, - .top => .top, - .bottom => .bottom, - }) != null; + return Layout.splitOn(&l, place, side) != null; } /// The view of the workspace whose only tab is document `id`, if one is. diff --git a/tests/integration.zig b/tests/integration.zig index a2340a78a..dc4cba703 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -5547,6 +5547,71 @@ test "dragging a view over its own place shows that place's drop zones" { try std.testing.expect(fizzy.core.widgets.DropZones.showing(center.id)); } +test "drop: beside marks the tall target's bottom rather than the wide source's right" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + const gpa = std.testing.allocator; + editor.app.gpa = gpa; + const state = &editor.app.layout; + defer state.regions.deinit(gpa); + defer state.regions_building.deinit(gpa); + defer state.deinitExtents(gpa); + defer state.deinitAssignments(gpa); + defer state.deinitQualified(gpa); + defer state.view_drag.discard(gpa); + const source: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 1200, .h = 800 }; + const target: dvui.Rect.Physical = .{ .x = 1200, .y = 0, .w = 820, .h = 1180 }; + state.registerRegion(gpa, .{ .name = "Source", .keywords = fizzy.sdk.keywords.ide.main, .bounds = source, .size = .{ .w = source.w, .h = source.h } }); + state.registerRegion(gpa, .{ .name = "Target", .keywords = &.{"slot"}, .bounds = target, .size = .{ .w = target.w, .h = target.h } }); + state.publishRegions(); + const Probe = struct { + calls: usize = 0, + fn edge(raw: *anyopaque, name: []const u8) ?fizzy.Editor.Layout.State.SplitTree.Side { + const self: *@This() = @ptrCast(@alignCast(raw)); + self.calls += 1; + const room = if (std.mem.eql(u8, name, "Target")) target else source; + return workbench.Workbench.sideFor(room.w, room.h); + } + }; + var probe: Probe = .{}; + state.beside = .{ .ctx = &probe, .edge = Probe.edge }; + defer state.beside = null; + try editor.app.host.registerSurface(.{ .id = "test.view", .title = "View", .keywords = &.{"slot"}, .draw = ManyPanelFrame.draw }); + try state.assign(gpa, "Source", &.{"test.view"}); + var layout = fizzy.Editor.Layout.init(&editor.app.host, state, gpa, dvui.currentWindow().arena()); + const VD = fizzy.Editor.Layout.ViewDrag; + const cw = dvui.currentWindow(); + const mouse = cw.mouse_pt; + defer cw.mouse_pt = mouse; + cw.mouse_pt = source.center(); + VD.begin(&layout, "Source", source, source); + cw.mouse_pt = target.center(); + fizzy.core.anchor.want(); + VD.drawZones(&layout, "Target", dvui.parentGet().extendId(@src(), 0)); + const beside = dvui.tagGet("drop:beside") orelse return error.TestUnexpectedResult; + const bottom = dvui.tagGet("drop:bottom") orelse return error.TestUnexpectedResult; + const right = dvui.tagGet("drop:right") orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(bottom.rect, beside.rect); + try std.testing.expect(!std.meta.eql(right.rect, beside.rect)); + try std.testing.expectEqual(@as(usize, 1), probe.calls); + + // Main's implicit pane must not borrow the shape of a workspace held elsewhere. + const wb = &editor.workbench; + const grouping = wb.open_workspace_grouping; + var workspace = workbench.Workspace.init(grouping); + workspace.pane_rect_physical = source; + try wb.workspaces.put(gpa, grouping, workspace); + defer wb.workspaces.deinit(gpa); + var id_buf: [64]u8 = undefined; + const id = try std.mem.print(&id_buf, fizzy.Editor.Workspaces.id_prefix ++ "{d}", .{grouping}); + try editor.app.host.registerSurface(.{ .id = id, .title = "Workspace", .keywords = &.{"slot"}, .draw = ManyPanelFrame.draw }); + try state.assign(gpa, "Target", &.{id}); + editor.demo.editor = editor; + editor.app.host.installFizzyApi(.{ .ctx = editor, .vtable = &fizzy.Editor.fizzy_api_vtable }); + try std.testing.expectEqual(@as(?fizzy.Editor.Layout.State.SplitTree.Side, null), fizzy.Editor.Demo.besideEdge(&editor.demo, "Source")); +} + // `core` is not a test root, so its widgets' pure rules are tested here. const DZ = fizzy.core.widgets.DropZones; @@ -11030,6 +11095,26 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes // With another pane there, it takes the view; without, the person's own does. const taken = wb.besideGrouping(); if (others > 0) try std.testing.expect(taken != here) else try std.testing.expectEqual(here, taken); + // A demo's `drop:beside` is the answer for the place it is aimed at, not the person's pane's + // for every place: Main's is the pane's edge in its own room, which turns with the room, + // and a place that holds no documents has none. + if (others == 0) { + const Side = fizzy.core.widgets.DockLayout.Side; + const main = (editor.regionFor(fizzy.sdk.keywords.ide.main) orelse return error.TestUnexpectedResult).name; + const sidebar = (editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return error.TestUnexpectedResult).name; + ws.pane_rect_physical = .{ .x = 0, .y = 0, .w = 1200 * s, .h = 800 * s }; + try std.testing.expectEqual(@as(?Side, .right), fizzy.Editor.Demo.besideEdge(&editor.demo, main)); + try std.testing.expectEqual(@as(?Side, null), fizzy.Editor.Demo.besideEdge(&editor.demo, sidebar)); + ws.pane_rect_physical = .{ .x = 0, .y = 0, .w = 820 * s, .h = 1180 * s }; + try std.testing.expectEqual(@as(?Side, .bottom), fizzy.Editor.Demo.besideEdge(&editor.demo, main)); + var pane_buf: [32]u8 = undefined; + const pane = fizzy.Editor.Workspace.name(&pane_buf, here); + try std.testing.expectEqual(@as(?Side, .bottom), fizzy.Editor.Demo.besideEdge(&editor.demo, pane)); + var other_buf: [32]u8 = undefined; + try std.testing.expectEqual(@as(?Side, null), fizzy.Editor.Demo.besideEdge(&editor.demo, fizzy.Editor.Workspace.name(&other_buf, here + 1000))); + try std.testing.expectEqual(@as(?Side, null), wb.besideEdge(here + 1000)); + ws.pane_rect_physical = .{ .x = 0, .y = 0, .w = 390 * s, .h = 844 * s }; + } try std.testing.expectEqual(fizzy.core.widgets.DockLayout.Side.right, workbench.Workbench.sideFor(1200, 800)); try std.testing.expectEqual(fizzy.core.widgets.DockLayout.Side.bottom, workbench.Workbench.sideFor(820, 1180)); }