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
7 changes: 7 additions & 0 deletions app/layout/SPLITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions app/layout/State.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
53 changes: 48 additions & 5 deletions app/layout/ViewDrag.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(.{
Expand Down
55 changes: 54 additions & 1 deletion src/editor/Workspaces.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions tests/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading