summaryrefslogtreecommitdiff
path: root/src/ninep/cols.zig
blob: b6dc7d26db2356602b5c9d62b09331604bb50748 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
//! The layout and the header tags: /layout, /tag (the workspace's) and
//! /col/<serial>/tag (each column's; a column's serial stays while it lives,
//! as a pane's does). acme serves no column files -- its columns
//! are only a window's place -- so these go past it: a script can see where
//! the panes are and edit the tags a person clicks in.
const std = @import("std");
const pardes = @import("../pardes.zig");
const Pardes = pardes.Pardes;
const tree = @import("tree.zig");
const tagline = pardes.tagline;
const layout = pardes.layout;
const Text = pardes.panes.Text;
const Req = tree.Req;
const Reply = tree.Reply;
const E = tree.E;

/// The column with the keyboard: the header's, when a column tag has it,
/// else the active pane's. Null when the workspace tag has it and no pane
/// is active.
pub fn activeColumn(p: *const Pardes) ?usize {
    if (p.header_focus) if (p.header_column) |c| return if (c < p.ncol) c else null;
    const at = layout.findPane(p, p.active) orelse return null;
    return at.col;
}

/// One line a column: its serial (the name /col/<serial> and the log give
/// it, which stays while it lives), its index left to right, x and width in
/// cells, `current` or `notcurrent` (rio's words, for the column with the
/// keyboard), `empty` or `full`, and its panes' serials, top to bottom;
/// then `active <serial>`: the active column, where pane/new and a look
/// place a pane next (acme's activecol), not always the keyboard's.
pub fn writeLayout(p: *Pardes, w: *std.Io.Writer) !void {
    const active = activeColumn(p);
    for (0..p.ncol) |c| {
        try w.print("{d} {d} {d} {d} {s} {s}", .{
            layout.columnSerial(p, c),                    c,                                          p.col_x[c], p.col_w[c],
            if (active == c) "current" else "notcurrent", if (p.col_n[c] == 0) "empty" else "full",
        });
        for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| try w.print(" {d}", .{pane.serial});
        try w.writeByte('\n');
    }
    if (p.activeColumn() orelse active) |c| try w.print("active {d}\n", .{layout.columnSerial(p, c)}) else try w.writeAll("active -\n");
}

pub fn layoutLen(p: *Pardes) u64 {
    var buf: [256]u8 = undefined;
    var counting: std.Io.Writer.Discarding = .init(&buf);
    writeLayout(p, &counting.writer) catch return 0;
    return counting.fullCount();
}

/// A header by its node: the workspace's (`serial` null) or the column's
/// with that serial; null when that column has closed.
const Header = struct { text: *Text, col: ?usize };

fn header(p: *Pardes, serial: ?u32) ?Header {
    const s = serial orelse return .{ .text = &p.global_tag, .col = null };
    const c = layout.columnBySerial(p, s) orelse return null;
    return .{ .text = &p.col_tags[c], .col = c };
}

pub fn headerText(p: *Pardes, serial: ?u32) ?[]const u8 {
    const h = header(p, serial) orelse return null;
    return if (h.col) |c| tagline.columnTag(p, c) else tagline.topbar(p);
}

pub fn read(p: *Pardes, req: Req, serial: ?u32) Reply {
    const text = headerText(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
    const out = p.fs.stage(p.gpa);
    out.appendSlice(p.gpa, text) catch return Reply.fail(req.tag, E.NOMEM);
    out.append(p.gpa, '\n') catch return Reply.fail(req.tag, E.NOMEM);
    return tree.stagedReply(p, req);
}

/// A write appends, as a pane tag's does; the tag is one line, so a newline
/// in it becomes a space (tagline.flattenHeaders), but after a truncation
/// the newline that ends the write is dropped, as a pane tag drops it.
pub fn write(p: *Pardes, req: Req, serial: ?u32) Reply {
    const t = (header(p, serial) orelse return Reply.fail(req.tag, E.NOENT)).text;
    if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
    const was = headerText(p, serial).?;
    var data = req.data;
    const rewriting = p.fs.header_rewrite == rewriteKey(serial);
    // A newline held from the write before goes in once more text follows.
    const held: []const u8 = if (rewriting and p.fs.header_held) " " else "";
    p.fs.header_held = false;
    if (rewriting and data[data.len - 1] == '\n') {
        data = data[0 .. data.len - 1];
        p.fs.header_held = true;
    }
    const next = std.mem.concat(p.gpa, u8, &.{ was, held, data }) catch return Reply.fail(req.tag, E.NOMEM);
    std.mem.replaceScalar(u8, next, '\n', ' ');
    if (t.own) |own| p.gpa.free(own);
    t.own = next;
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

/// Truncating clears it, as a pane tag's `cleartag`: its default words go
/// too, and `u` in it brings them back.
pub fn truncate(p: *Pardes, serial: ?u32) tree.Status {
    const t = (header(p, serial) orelse return .err).text;
    const empty = p.gpa.alloc(u8, 0) catch return .err;
    t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null);
    if (t.own) |own| p.gpa.free(own);
    t.own = empty;
    t.cur_row = 0;
    t.cur_col = 0;
    p.fs.header_rewrite = rewriteKey(serial);
    p.fs.header_held = false;
    return .ok;
}

/// Which header a truncation was of: the workspace's is 0 (no column's
/// serial is), a column's its serial.
fn rewriteKey(serial: ?u32) u32 {
    return serial orelse 0;
}

pub fn released(p: *Pardes, serial: ?u32) void {
    if (p.fs.header_rewrite != rewriteKey(serial)) return;
    p.fs.header_rewrite = null;
    p.fs.header_held = false;
}

/// A column's ctl: the words that act on a column, as its tag's clicks do --
/// Delcol, Joincol, New, Tty -- one a line, each run as from that tag.
pub fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply {
    var lines = std.mem.splitScalar(u8, req.data, '\n');
    while (lines.next()) |raw| {
        const line = std.mem.trim(u8, raw, " \t\r");
        if (line.len == 0) continue;
        const word = line[0 .. std.mem.indexOfAny(u8, line, " \t") orelse line.len];
        const takes = for ([_][]const u8{ "Delcol", "Joincol", "New", "Tty" }) |w| {
            if (std.mem.eql(u8, w, word)) break true;
        } else false;
        if (!takes) return tree.failText(req.tag, E.INVAL, "a column's ctl takes Delcol, Joincol, New or Tty");
        const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
        p.exec_column = col;
        defer p.exec_column = null;
        if (pardes.ctlfs.ctl.runBuiltin(p, req, p.active, line)) |refusal| return refusal;
    }
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

/// A column's exec: each line run as a middle click on it in that column's
/// tag, acting on that column.
pub fn writeExec(p: *Pardes, req: Req, serial: u32) Reply {
    var lines = std.mem.splitScalar(u8, req.data, '\n');
    while (lines.next()) |raw| {
        const line = std.mem.trim(u8, raw, " \t\r");
        if (line.len == 0) continue;
        for (line) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
        const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
        p.exec_column = col;
        defer p.exec_column = null;
        pardes.ctlfs.ctl.run(p, p.active, true, line);
    }
    return .{ .tag = req.tag, .written = @intCast(req.data.len) };
}

/// rmdir of a column closes it when it is empty; one with panes is
/// refused, saying so (close them, or Delcol on its ctl, which asks first).
pub fn remove(p: *Pardes, req: Req, serial: u32, file: tree.ColFile) Reply {
    if (file != .dir) return Reply.fail(req.tag, E.PERM);
    const col = layout.columnBySerial(p, serial) orelse return Reply.fail(req.tag, E.NOENT);
    if (p.col_n[col] > 0) return tree.failText(req.tag, E.NOTEMPTY, "column not empty: close its panes, or write Delcol to its ctl");
    p.removeColumn(col) catch return Reply.fail(req.tag, E.IO);
    return .{ .tag = req.tag };
}

// ---- tests ----

const testing = std.testing;
const th = @import("testing.zig");
const Node = tree.Node;

test "layout lists each column's place, panes and state, and the active one" {
    const p = try th.withFile(testing.allocator, "x\n");
    defer p.deinit();
    p.newColumn(p.active);
    p.sync();
    const first_serial = layout.columnSerial(p, 0);
    const new_serial = layout.columnSerial(p, 1);
    try testing.expect(first_serial != new_serial);
    const layout_node = @intFromEnum(tree.TopFile.layout);
    const text = th.rd(p, layout_node, 0, 4096).bytes;
    var want: [256]u8 = undefined;
    const first = try std.fmt.bufPrint(&want, "{d} 0 {d} {d} notcurrent full {d}\n", .{ first_serial, p.col_x[0], p.col_w[0], p.panes[0].?.serial });
    try testing.expect(std.mem.startsWith(u8, text, first));
    // Newcol gives the new, empty column's tag the keyboard.
    try testing.expect(std.mem.indexOf(u8, text, "current empty\n") != null);
    try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "active {d}\n", .{new_serial})));
    try testing.expectEqual(@as(u64, text.len), th.call(p, .{ .tag = 1, .op = .getattr, .node = layout_node }).reply.attr.size);
    // /index says which column each pane is in, by its serial, last.
    try testing.expect(std.mem.endsWith(u8, th.rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, try std.fmt.bufPrint(&want, " {d}\n", .{first_serial})));
    try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "newcol {d}\n", .{new_serial})));
    // A column that closes takes its name with it; the others keep theirs.
    try p.removeColumn(1);
    try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "delcol {d}\n", .{new_serial})));
    try testing.expectEqual(E.NOENT, th.look_up(p, @intFromEnum(tree.TopFile.col), try std.fmt.bufPrint(&want, "{d}", .{new_serial})).errno());
    try testing.expectEqual(first_serial, layout.columnSerial(p, 0));
}

test "a column's tag and the workspace's read, replace and append as a pane tag does, on one line" {
    const p = try th.withFile(testing.allocator, "x\n");
    defer p.deinit();
    const col_dir = @intFromEnum(tree.TopFile.col);
    const serial = layout.columnSerial(p, 0);
    var name: [16]u8 = undefined;
    try testing.expectEqual(tree.Status.ok, th.look_up(p, col_dir, try std.fmt.bufPrint(&name, "{d}", .{serial})).reply.status);
    try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "0").errno());
    try testing.expectEqual(E.NOENT, th.look_up(p, col_dir, "777").errno());
    const tag = Node.ofCol(serial, .tag);
    try testing.expectEqual(tree.Status.ok, th.look_up(p, Node.ofCol(serial, .dir), "tag").reply.status);
    try testing.expectEqualStrings("New Tty Find Grep Joincol Delcol\n", th.rd(p, tag, 0, 4096).bytes);
    // `echo Make > tag`: cleared, then the newline ending the write dropped.
    _ = th.call(p, .{ .tag = 1, .op = .setattr, .node = tag, .truncate = true });
    _ = th.wr(p, tag, "Make\n");
    try testing.expectEqualStrings("Make\n", th.rd(p, tag, 0, 4096).bytes);
    _ = th.call(p, .{ .tag = 2, .op = .release, .node = tag });
    // `>>` appends; a newline in a one-line tag is a space.
    _ = th.wr(p, tag, " Lint\nNow");
    try testing.expectEqualStrings("Make Lint Now\n", th.rd(p, tag, 0, 4096).bytes);
    const top = @intFromEnum(tree.TopFile.tag);
    _ = th.call(p, .{ .tag = 3, .op = .setattr, .node = top, .truncate = true });
    _ = th.wr(p, top, "Newcol Exit\n");
    try testing.expectEqualStrings("Newcol Exit", tagline.topbar(p));
}

test "a focus write makes its pane's column the active one: layout says so and pane/new goes there" {
    for ([_][]const u8{ "Placement acme", "Placement pardes" }) |placement| {
        const p = try th.withFile(testing.allocator, "x\n");
        defer p.deinit();
        try testing.expect(p.executeBuiltinLine(p.active, placement));
        const left = p.panes[0].?.serial;
        p.newColumn(p.active);
        p.newScratchFor(p.active, .fs); // fills the new, right column
        p.sync();
        try testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.col);
        var digits: [16]u8 = undefined;
        _ = th.wr(p, @intFromEnum(tree.TopFile.focus), try std.fmt.bufPrint(&digits, "{d}\n", .{left}));
        var want: [32]u8 = undefined;
        const text = th.rd(p, @intFromEnum(tree.TopFile.layout), 0, 4096).bytes;
        try testing.expect(std.mem.endsWith(u8, text, try std.fmt.bufPrint(&want, "active {d}\n", .{layout.columnSerial(p, 0)})));
        const made = th.call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(tree.TopFile.new) }).reply.handle;
        try testing.expectEqual(@as(usize, 0), layout.findPane(p, p.paneBySerial(made).?).?.col);
    }
}

test "a column's ctl and exec act on it as its tag would, and rmdir closes it only empty" {
    const p = try th.withFile(testing.allocator, "x\n");
    defer p.deinit();
    const first = layout.columnSerial(p, 0);
    p.newColumn(p.active);
    const empty = layout.columnSerial(p, 1);
    // New on the empty column's ctl fills it, wherever the keyboard is.
    try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .ctl), "New\n").reply.status);
    try testing.expectEqual(@as(usize, 1), p.col_n[1]);
    try testing.expectEqual(E.INVAL, th.wr(p, Node.ofCol(empty, .ctl), "Exit\n").errno());
    // rmdir refuses a column with a pane, and says why.
    const full = th.call(p, .{ .tag = 1, .op = .release, .node = Node.ofCol(empty, .dir), .remove = true });
    try testing.expectEqual(E.NOTEMPTY, full.errno());
    try testing.expect(th.logHas(p, "column not empty"));
    // exec runs a word as a click in that column's tag: Delcol closes it.
    try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(empty, .exec), "Delcol\n").reply.status);
    try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, empty));
    // An empty column goes on rmdir.
    p.newColumn(p.active);
    const made = layout.columnSerial(p, 1);
    try testing.expectEqual(tree.Status.ok, th.call(p, .{ .tag = 2, .op = .release, .node = Node.ofCol(made, .dir), .remove = true }).reply.status);
    try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, made));
    try testing.expectEqual(first, layout.columnSerial(p, 0));
    // Joincol folds a column into the one on its right, which keeps its own
    // serial and tag.
    p.newColumn(p.active);
    const right = layout.columnSerial(p, 1);
    _ = th.wr(p, Node.ofCol(right, .tag), " Mine");
    try testing.expectEqual(tree.Status.ok, th.wr(p, Node.ofCol(first, .ctl), "Joincol\n").reply.status);
    try testing.expectEqual(@as(?usize, null), layout.columnBySerial(p, first));
    try testing.expectEqual(@as(?usize, 0), layout.columnBySerial(p, right));
    try testing.expect(std.mem.endsWith(u8, headerText(p, right).?, " Mine"));
    // The root's ctl points a column word at the column's own.
    try testing.expect(std.mem.endsWith(u8, th.wr(p, @intFromEnum(tree.TopFile.ctl), "Delcol\n").reply.ename, "write it to col/<serial>/ctl"));
}