diff --git a/app/layout/SPLITS.md b/app/layout/SPLITS.md index de0797705..85920bbdc 100644 --- a/app/layout/SPLITS.md +++ b/app/layout/SPLITS.md @@ -149,6 +149,13 @@ by again, the drop zones, splits, the picker. A place of several views keeps the document (its place is the slot its plugin made for it), and a view already alone in a float nobody split — floating it again would only move it, which its header already does. For those the middle stays bare glass. +- **A document floats in a view of its own,** where the application gives documents one + (`State.documents`: fizzy's workspaces, behind `FIZZY_WORKSPACES=1`). A tab dropped on the + middle of its own pane, or let go over no window of the app's where floats are OS windows, + floats a new workspace holding just it; a workspace's only tab carries the workspace, which + floats itself. Its float closes when that workspace empties, and closed from its header gives + its documents to the workspace the main window shows (`Documents.fold`) rather than going home + as a view. - **Where it opens.** At 60% of the place it left, never under 360×240 nor over 90% of the window, centred on where the view was and held 8pt inside the window; out of another float, a step down and right of that one (`float_rules.zig`, unit-tested). diff --git a/app/layout/State.zig b/app/layout/State.zig index cc6acba3f..e0d87e53a 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -48,6 +48,10 @@ pub const Documents = struct { /// The view holding document `id` and nothing else, if one does: carried alone, the /// document carries that view, which lands as itself rather than inside a new one. alone: *const fn (ctx: *anyopaque, id: []const u8) ?[]const u8, + /// View `id`, out of a float that is closing, going back into the main window: a view holding + /// documents gives them to the one the main window shows, and goes as an emptied one does. + /// True when it did; false for any other view, or with none in the main window to take them. + fold: *const fn (ctx: *anyopaque, id: []const u8) bool, }; pub const StoreCatalog = struct { diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 2848ee76b..85d6dfa36 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1310,6 +1310,8 @@ pub fn drawZones(l: *Layout, name: []const u8, key: dvui.Id) void { const d = &l.state.view_drag; const center: DropZones.Center = if (std.mem.eql(u8, d.name, name)) (if (canFloat(l, name)) .float else .none) + else if (floatsDocument(l, name)) + .float else if (joins(l, d.name, name)) .join else if (regionNamed(l.state, name)) |r| (if (r.shows == .many) .add else .replace) else .replace; @@ -2330,6 +2332,9 @@ pub fn place(l: *Layout, source: []const u8, dest: []const u8, kind: Drop.Kind) // opened empty on the far side — a tab dragged to the top of its own strip's place moved the // tabs left behind to the bottom, as though another view had been carried. const same = std.mem.eql(u8, source, dest); + // A document dropped on its own slot's middle floats, in a view holding it (`floatsDocument`). + if (kind == .swap and std.mem.eql(u8, source, loose_source) and floatsDocument(l, dest)) + return floatDocument(l, ownId(l.arena, l.state.view_drag.moved_id) orelse return, null); // An empty place carried: it is the place that moves, not a view (`placeEmpty`). if (!std.mem.eql(u8, source, loose_source) and movedFrom(l, source) == null) return placeEmpty(l, source, dest, kind); const stays = same and (kind != .split or holding(l, source).len <= 1); @@ -2493,6 +2498,8 @@ fn floatAway(l: *Layout, source: []const u8, mouse: dvui.Point.Physical) void { const carried = if (from_picker) l.state.view_drag.moved_id else movedFrom(l, source) orelse return; const moved = ownId(l.arena, carried) orelse return; const s = l.host.surfaceById(moved) orelse return; + // A document: in a view holding it, where the application gives it one (`floatDocument`). + if (from_picker and l.slotted(s)) return floatDocument(l, moved, mouse); if (!float_rules.canFloat(.{ .slotted = l.slotted(s), .alone_in_float = false })) return; const left = if (from_picker) holderOf(l, moved) else source; floatOut(l, source, moved, mouse); @@ -2509,6 +2516,36 @@ fn canFloat(l: *Layout, source: []const u8) bool { return float_rules.canFloat(.{ .slotted = l.slotted(s), .alone_in_float = alone }); } +/// Whether the document carried loose floats when it is dropped on the middle of `dest`: the +/// slot it is a tab of — its own workspace's, in fizzy — where the application gives documents +/// views of their own (`State.documents`), as a view dropped on its own place's middle floats. +/// Not when the view it would float in is already alone in a float nobody split. +fn floatsDocument(l: *Layout, dest: []const u8) bool { + if (l.state.documents == null) return false; + const d = &l.state.view_drag; + if (!d.active() or !d.loose() or d.with_n > 1) return false; + const r = regionNamed(l.state, dest) orelse return false; + if (!r.kind_slot or !containsId(holding(l, dest), d.moved_id)) return false; + const c = carriedView(l, d.moved_id) orelse return true; + return !(l.state.floats.find(c.from) != null and l.state.splits.root(c.from) == null and holding(l, c.from).len <= 1); +} + +/// Float the document `moved`, carried loose, in a view of the application's holding it +/// (`State.documents`): the view it carries alone (`carriedView`) out of the place showing that, +/// or a new one holding just it (`houseView`), out of the tab strip it leaves. Let go at `at` +/// (over no window of the app's), the float opens there; else over the pane it came out of. +fn floatDocument(l: *Layout, moved: []const u8, at: ?dvui.Point.Physical) void { + if (l.state.documents == null) return; + if (carriedView(l, moved)) |c| { + floatOut(l, c.from, c.view, at); + shutIfEmptied(l, c.from); + } else { + floatOut(l, loose_source, houseView(l, moved) orelse return, at); + } + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + /// Float `moved` out of `source`: a new float (`Floats`) holding just it, where `float_rules` /// opens one — out of another float, a step down and right of that one — growing out of the glass /// it was carried in, with the photograph the drag took of it. The source loses the view; a place @@ -2523,16 +2560,20 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P // Carried out of the picker (a loose drag), it floats out of whichever place holds it now — // its home when the float closes — or, held nowhere, out of none: its keywords take it home. const holder: ?[]const u8 = if (std.mem.eql(u8, source, loose_source)) holderOf(l, moved) else source; - const src = (if (holder) |h| placeBounds(state, h) else null) orelse dvui.windowRectPixels(); + const d = &state.view_drag; + // Held nowhere — a document's new view (`floatDocument`) — it comes out of the pane its + // photograph was taken in. + const photo_rect: ?dvui.Rect.Physical = if (d.active() and d.texture != null) d.texture_rect else null; + const src = (if (holder) |h| placeBounds(state, h) else null) orelse photo_rect orelse dvui.windowRectPixels(); const out_of: ?usize = if (holder) |h| (if (state.floats.rootOf(h)) |root| state.floats.find(root) else null) else null; var rect = if (out_of) |i| float_rules.nudged(Floats.toRules(state.floats.items.items[i].rect), window) else float_rules.initialRect(Floats.toRules(src.toNatural()), window); - // The glass it was carried in, when a drag let go of it here; the place itself, when nothing - // was carried (the picker, a test). - const d = &state.view_drag; - const carried = d.active() and std.mem.eql(u8, d.name, source) and d.shape_rect.w > 0; + // The glass it was carried in, when a drag let go of it here — a loose one too, the document + // in it floating in a view it carried (`floatDocument`); the place itself, when nothing was + // carried (the picker, a test). + const carried = d.active() and (std.mem.eql(u8, d.name, source) or d.loose()) and d.shape_rect.w > 0; // Carried with its picture, it opens as its place was, there (`float_rules.asTaken`): the // picture growing with it out of the drop lands on the view as the float shows it. // The window the place's size, its view inside it below its header (the user: a window no @@ -2613,6 +2654,8 @@ pub fn sendHome(l: *Layout, leaves: []const []const u8, home: []const u8) void { state.unassign(l.gpa, leaf); } for (ids.items) |id| { + // A view of the application's holding documents gives them to the main window's own. + if (state.documents) |docs| if (docs.fold(docs.ctx, id)) continue; const held = holding(l, home); var landed: ?[]const u8 = if (r != null) home else null; switch (float_rules.goHome(.{ diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig index b8da92f82..40c581063 100644 --- a/src/editor/Workspaces.zig +++ b/src/editor/Workspaces.zig @@ -48,7 +48,7 @@ pub fn wanted(gpa: std.mem.Allocator) bool { pub fn install(self: *Workspaces, wb: *Workbench) void { wb.places = .{ .ctx = self, .beside = beside, .placed = placed, .close = close, .open = open }; // A document carried beside a view, rather than into a pane, opens a workspace there. - self.editorOf().app.layout.documents = .{ .ctx = self, .house = house, .alone = alone }; + self.editorOf().app.layout.documents = .{ .ctx = self, .house = house, .alone = alone, .fold = fold }; } pub fn deinit(self: *Workspaces, gpa: std.mem.Allocator) void { @@ -367,6 +367,59 @@ fn alone(ctx: *anyopaque, id: []const u8) ?[]const u8 { return null; } +/// A closing float's workspace, going back into the main window (`Layout.State.documents`): its +/// documents go into the workspace the main window shows — the one the person was in, when that +/// is there — after its own, in their order, the one the float showed shown. Emptied, it goes as +/// any emptied workspace does. False for a view that is no workspace, or with no workspace in the +/// main window to take them, when it goes home as itself. +fn fold(ctx: *anyopaque, id: []const u8) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + if (!std.mem.startsWith(u8, id, id_prefix)) return false; + const g = std.fmt.parseInt(u64, id[id_prefix.len..], 10) catch return false; + const editor = self.editorOf(); + const wb = &editor.workbench; + const host = &editor.app.host; + const from = wb.workspaces.getPtr(g) orelse return false; + const into_g = self.inMainWindow(g) orelse return false; + // Copied, ids and all: emptying this workspace, and each tab going into the other (which evicts + // it from every list), frees the strings the assignment held. + var buf: [32]u8 = undefined; + const held = host.assignedSurfaces(Editor.Workspace.name(&buf, g)) orelse &.{}; + const tabs = host.arena().alloc([]const u8, held.len) catch return false; + for (held, tabs) |t, *copy| copy.* = host.arena().dupe(u8, t) catch return false; + const shown: ?[]const u8 = if (from.active) |doc| + sdk.document.surfaceId(host.arena(), doc.owner.id, doc.owner.documentPath(doc)) catch null + else + null; + from.setTabs(&.{}, null); + from.expecting = false; + const into = wb.workspaces.getPtr(into_g) orelse return false; + for (tabs) |t| into.addTab(t, false); + if (shown) |t| into.addTab(t, true); + return true; +} + +/// The workspace the main window shows, other than `except`: the one the person was in when it +/// is there, else the first in a place of the main window's — or in none, where the keywords +/// show it in Main. +fn inMainWindow(self: *Workspaces, except: u64) ?u64 { + const editor = self.editorOf(); + const wb = &editor.workbench; + var l = layoutOf(editor); + const open_g = wb.open_workspace_grouping; + var first: ?u64 = null; + for (wb.workspaces.keys()) |g| { + if (g == except) continue; + var buf: [64]u8 = undefined; + if (Layout.ViewDrag.holderOf(&l, viewId(&buf, g))) |place| { + if (l.state.floatRoot(place) != null) continue; + } + if (g == open_g) return g; + if (first == null) first = g; + } + return first; +} + fn placed(ctx: *anyopaque, grouping: u64) bool { const self: *Workspaces = @ptrCast(@alignCast(ctx)); if (self.assigned(grouping)) return true; diff --git a/tests/integration.zig b/tests/integration.zig index 2637d5397..b6ff01bff 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -4508,6 +4508,113 @@ test "drag: a document not open yet goes only to a document's slot, whose drop g try std.testing.expect(state.assignment("Panel") == null); } +/// An application's views holding documents (`State.documents`), for the layout to float: a new +/// one is always `test.workspace.2`; the view a document carries alone is `lone`'s. +const DocViews = struct { + var housed: [64]u8 = undefined; + var housed_len: usize = 0; + var lone: ?[]const u8 = null; + var folded: usize = 0; + + fn house(_: *anyopaque, ids: []const []const u8) ?[]const u8 { + housed_len = @min(ids[0].len, housed.len); + @memcpy(housed[0..housed_len], ids[0][0..housed_len]); + return "test.workspace.2"; + } + + fn alone(_: *anyopaque, _: []const u8) ?[]const u8 { + return lone; + } + + fn fold(_: *anyopaque, id: []const u8) bool { + if (!std.mem.startsWith(u8, id, "test.workspace.")) return false; + folded += 1; + return true; + } +}; + +test "float: a document tab dropped on its own pane's middle floats in a view holding it" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.view_drag.discard(); + + const state = &editor.app.layout; + const gpa = editor.app.gpa; + const host = &editor.app.host; + const doc_kw: []const []const u8 = &.{"main.document"}; + const regions = struct { + fn publish(st: *@TypeOf(editor.app.layout), g: std.mem.Allocator, float: bool) void { + st.registerRegion(g, .{ .name = "Main", .keywords = fizzy.sdk.keywords.ide.main, .bounds = .{ .x = 0, .y = 0, .w = 800, .h = 400 }, .size = .{ .w = 800, .h = 400 } }); + st.registerRegion(g, .{ .name = "Pane 0", .keywords = &.{"main.document"}, .by_name = true, .kind_slot = true, .shows = .many, .bounds = .{ .x = 100, .y = 40, .w = 600, .h = 320 }, .on_drop = DropProbe.onDrop }); + if (float) st.registerRegion(g, .{ .name = "Float 2", .keywords = fizzy.sdk.keywords.ide.main, .bounds = .{ .x = 200, .y = 100, .w = 400, .h = 240 } }); + st.publishRegions(); + } + }; + regions.publish(state, gpa, false); + try host.registerSurface(.{ .id = "test.workspace.1", .title = "Workspace", .keywords = fizzy.sdk.keywords.ide.main, .draw = ManyPanelFrame.draw }); + try host.registerSurface(.{ .id = "test.workspace.2", .title = "Workspace", .keywords = fizzy.sdk.keywords.ide.main, .draw = ManyPanelFrame.draw }); + try host.registerSurface(.{ .id = "text.doc:/project/a.md", .title = "a.md", .keywords = doc_kw, .draw = ManyPanelFrame.draw }); + try host.registerSurface(.{ .id = "text.doc:/project/b.md", .title = "b.md", .keywords = doc_kw, .draw = ManyPanelFrame.draw }); + try state.assign(gpa, "Main", &.{"test.workspace.1"}); + try state.assign(gpa, "Pane 0", &.{ "text.doc:/project/a.md", "text.doc:/project/b.md" }); + var docs_ctx: u8 = 0; + state.documents = .{ .ctx = &docs_ctx, .house = DocViews.house, .alone = DocViews.alone, .fold = DocViews.fold }; + defer state.documents = null; + DocViews.lone = null; + DocViews.folded = 0; + + var layout = fizzy.Editor.Layout.init(host, state, gpa, dvui.currentWindow().arena()); + const VD = fizzy.Editor.Layout.ViewDrag; + + // One tab of two: it floats in a new view holding just it, and the pane is never asked. + DropProbe.last = null; + VD.beginLoose(&layout, "text.doc:/project/a.md", .{ .x = 110, .y = 44, .w = 120, .h = 24 }, null); + VD.place(&layout, VD.loose_source, "Pane 0", .swap); + state.view_drag.discard(); + try std.testing.expect(DropProbe.last == null); + try std.testing.expectEqualStrings("text.doc:/project/a.md", DocViews.housed[0..DocViews.housed_len]); + try std.testing.expectEqual(@as(usize, 1), state.floats.items.items.len); + const first = state.floats.items.items[0]; + try std.testing.expectEqualStrings("Float 1", first.name); + const in_first = state.assignment("Float 1") orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 1), in_first.len); + try std.testing.expectEqualStrings("test.workspace.2", in_first[0]); + // The workspace it came out of stays where it was. + try std.testing.expectEqualStrings("test.workspace.1", (state.assignment("Main") orelse return error.TestExpectedEqual)[0]); + + // A workspace's only tab carries the workspace: that floats, out of the place showing it. + try state.assign(gpa, "Pane 0", &.{"text.doc:/project/b.md"}); + DocViews.lone = "test.workspace.1"; + VD.beginLoose(&layout, "text.doc:/project/b.md", .{ .x = 110, .y = 44, .w = 120, .h = 24 }, null); + VD.place(&layout, VD.loose_source, "Pane 0", .swap); + state.view_drag.discard(); + try std.testing.expectEqual(@as(usize, 2), state.floats.items.items.len); + try std.testing.expectEqualStrings("Main", state.floats.items.items[1].home); + try std.testing.expectEqualStrings("test.workspace.1", (state.assignment("Float 2") orelse return error.TestExpectedEqual)[0]); + try std.testing.expectEqual(@as(usize, 0), (state.assignment("Main") orelse return error.TestExpectedEqual).len); + + // Alone in a float nobody split, it does not float again: the pane takes the drop. + regions.publish(state, gpa, true); + VD.beginLoose(&layout, "text.doc:/project/b.md", .{ .x = 110, .y = 44, .w = 120, .h = 24 }, null); + VD.place(&layout, VD.loose_source, "Pane 0", .swap); + state.view_drag.discard(); + try std.testing.expectEqual(@as(usize, 2), state.floats.items.items.len); + try std.testing.expect(DropProbe.last != null); + + // Closed, a float's workspace gives its documents to the main window's (`fold`), and is + // not sent home as itself. + layout.closeFloat("Float 2"); + try std.testing.expectEqual(@as(usize, 1), DocViews.folded); + try std.testing.expectEqual(@as(usize, 0), (state.assignment("Main") orelse return error.TestExpectedEqual).len); +} + test "a region with no drop handler takes the middle by the app's default" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator);