diff options
Diffstat (limited to 'src/ninep/pane.zig')
| -rw-r--r-- | src/ninep/pane.zig | 146 |
1 files changed, 126 insertions, 20 deletions
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index c594c7a3..d8329ccc 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -41,6 +41,9 @@ pub const State = struct { /// rather than act at the address before it, which acme would do. addr_failed: bool = false, limit: ?Range = null, + /// The limit a truncation cleared, kept until the write that follows it + /// on that open (a shell's `>`): a write that fails puts it back. + limit_truncated: ?Range = null, /// Opens of `event`, which hold the pane scripted. readers: u16 = 0, /// One of `readers` reads `event`; a second reading open is refused. @@ -74,10 +77,23 @@ pub const State = struct { /// Installed during this update; /log hears about it once the update ends /// and the pane has its file, name and place. unannounced: bool = false, + /// A chorded exec's argument an event reader was told of (flag 8), kept + /// for the record's write-back at `chord_at`, which runs with it. + chord_arg: ?[]u8 = null, + chord_at: Range = .{}, + /// A chorded record written back: how many of its two follow-up records + /// (argument, location) are still to be consumed rather than run. + chord_follow: u8 = 0, + /// A chorded record written back alone with no argument kept: the + /// command, run when its argument record comes. + chord_pending: ?[]u8 = null, + chord_pending_tag: bool = false, pub const Range = struct { q0: u32 = 0, q1: u32 = 0 }; pub fn deinit(pf: *State, gpa: std.mem.Allocator) void { + if (pf.chord_arg) |a| gpa.free(a); + if (pf.chord_pending) |c| gpa.free(c); pf.events.deinit(gpa); pf.pty_out.deinit(gpa); pf.tag_snap.deinit(gpa); @@ -128,11 +144,12 @@ pub fn clip(n: usize) u32 { pub fn dotOf(pane: *Pane) State.Range { const text = bodyOf(pane); - const head = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); + // In runes, as every address is (modal.runeStart). + const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); + const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); var hi = @max(head, anchor); - if (hi < text.len) hi = modal.nextGrapheme(text, hi); + if (hi < text.len) hi = modal.nextRune(text, hi); return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; } @@ -140,9 +157,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void { const text = bodyOf(pane); const q0 = @min(@as(usize, r.q0), text.len); const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.positionAt(text, q0); + const a = modal.runePositionAt(text, q0); pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.positionAt(text, if (q1 > q0) modal.prevGrapheme(text, q1) else q0); + const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0); pane.body.cur_row = @intCast(h.row); pane.body.cur_col = @intCast(h.col); pane.body.cur_pinned = true; @@ -246,10 +263,11 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const d = dotOf(pane); break :sel @max(d.q0, d.q1) - d.q0; }, - .addr, .dot => range_len, + .addr => if (pf.addr_failed) 0 else range_len, + .dot => range_len, .limit => if (pf.limit == null) 0 else range_len, .dirty, .mark, .scroll => 2, - .look, .exec => ctl.resultsLen(p), + .look, .exec, .tagexec => ctl.resultsLen(p), // What the next read would answer, which a client can see without // parking on it. .event => if (pf.events.peek()) |record| record.len else 0, @@ -304,11 +322,12 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply break :tag tree.stagedReply(p, req); }, .ctl => ctl.readPane(p, req, pane), - .addr => addr: { - if (pf.addr_failed) break :addr tree.failText(req.tag, E.INVAL, e_addr_failed); + // A failed address reads empty, as an unset limit does: there is + // none. data and xdata are what refuse. + .addr => readRange(p, req, if (pf.addr_failed) null else addr: { clampAddr(pf, bodyOf(pane).len); - break :addr readRange(p, req, pf.addr); - }, + break :addr pf.addr; + }), .dot => readRange(p, req, dotOf(pane)), .limit => readRange(p, req, pf.limit), .data => readData(req, id, pane, pf, false), @@ -317,7 +336,7 @@ pub fn read(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .dirty => readFlag(p, req, dirtyOf(pane)), .mark => readFlag(p, req, !pf.nomark), .scroll => readFlag(p, req, !pf.noscroll), - .look, .exec => ctl.readResults(p, req), + .look, .exec, .tagexec => ctl.readResults(p, req), .event => events.readQueue(p, req, &pf.events), .pty_status => pty.readStatus(p, req, id, pane), .pty_data => pty.readData(p, req, pf), @@ -369,7 +388,7 @@ fn readData(req: Req, id: usize, pane: *Pane, pf: *State, stop_at_end: bool) Rep const q0: usize = pf.addr.q0; const hi: usize = if (stop_at_end) @max(q0, @as(usize, pf.addr.q1)) else text.len; var end = @min(hi, q0 +| req.size); - end = @max(q0, modal.graphemeStart(text, end)); + end = @max(q0, modal.runeStart(text, end)); pf.addr.q0 = clip(end); if (!stop_at_end) pf.addr.q1 = clip(end); if (pane.file == null) return .{ .tag = req.tag }; @@ -409,7 +428,9 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply .data, .xdata => writeData(p, req, pane), .sel => writeSel(p, req, pane), .dirty, .mark, .scroll => writeFlag(p, req, pane, file), - .look, .exec => ctl.command(p, req, pane.serial, file == .exec), + .look, .exec => ctl.command(p, req, pane.serial, file == .exec, false), + // A click in its tag: never an event reader's to hear (cols.writeExec). + .tagexec => ctl.command(p, req, pane.serial, true, true), .event => events.writeEvent(p, req, id), .errors => if (appendErrors(p, id, req.data)) |took| .{ .tag = req.tag, .written = @intCast(took) } @@ -522,8 +543,13 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply { }; const range = r orelse { if (file == .addr) pf.addr_failed = true; + if (file == .limit) if (pf.limit_truncated) |kept| { + pf.limit = kept; + pf.limit_truncated = null; + }; return tree.failText(req.tag, E.INVAL, a.err); }; + if (file == .limit) pf.limit_truncated = null; switch (file) { .addr => { pf.addr = range; @@ -565,10 +591,18 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply { /// Writing `name` renames the buffer: a relative name resolves against the /// pane's directory, as the tag's name field does. +/// acme's refusal of a name with a blank or a control character in it +/// (editors/acme/xfid.c:650-652). A blank inside a name is taken here, as +/// pardes names files with spaces; one at either end is refused, not +/// quietly cut off, so the name a script wrote is the name it gets. +const e_name_char = "bad character in file name"; + fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { - const name = std.mem.trim(u8, req.data, " \t\r\n"); + // One line: its newline ends it, as `echo` writes it. + const name = if (std.mem.endsWith(u8, req.data, "\n")) req.data[0 .. req.data.len - 1] else req.data; if (name.len == 0) return Reply.fail(req.tag, E.INVAL); - for (name) |c| if (c < ' ') return Reply.fail(req.tag, E.INVAL); + for (name) |c| if (c < ' ') return tree.failText(req.tag, E.INVAL, e_name_char); + if (name[0] == ' ' or name[name.len - 1] == ' ') return tree.failText(req.tag, E.INVAL, e_name_char); if (fileOf(pane) == null) return tree.failText(req.tag, E.PERM, if (pane.isTerminal()) "rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another" else @@ -638,6 +672,12 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { return null; }; exec.placeNew(p, id, id, free, .errors); + // No column had room for it (it closes at the step's end): the text is + // logged instead, a msg a line, so it is not lost and nothing fails. + if (p.unplaced.isSet(free)) { + var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n'); + while (lines.next()) |line| events.noteMessage(p, pane.serial, line[0..@min(line.len, pardes.Messages.LoggedMessage.cap)]); + } return text.len; } @@ -683,7 +723,10 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status { // is evaluated from where the last one left off, as with `>>`. // `0` (or `,`) is how to start over. See State.addr. .addr => {}, - .limit => pf.limit = null, + .limit => { + pf.limit_truncated = pf.limit; + pf.limit = null; + }, .dot => if (fileOf(pane) != null) setDot(pane, .{}), else => {}, } @@ -858,7 +901,7 @@ test "data and xdata read from addr, move it, and write through it" { try testing.expectEqual(@as(u32, 4), p.panes[0].?.fs.addr.q0); } -test "data never splits a grapheme, in either direction" { +test "data never splits a rune, in either direction" { const gpa = testing.allocator; const p = try withFile(gpa, "\u{00e9}x\n"); defer p.deinit(); @@ -1060,6 +1103,9 @@ test "name reads the file name and writing it promotes a scratch without touchin try testing.expectEqualStrings("existing target\n", target); for ([_][]const u8{ "", "\n", "bad\x01name\n" }) |bad| try testing.expectEqual(E.INVAL, wr(p, name, bad).errno()); + // A blank at either end is refused as acme refuses one, not cut off. + for ([_][]const u8{ "trailing.zig \n", " leading.zig\n", "tab\t.zig\n" }) |bad| + try testing.expectEqualStrings("bad character in file name", wr(p, name, bad).reply.ename); try testing.expectEqualStrings(path, pane.file.?.path); try testing.expectEqual(Status.ok, wr(p, name, "two words.zig\n").reply.status); const spaced = try std.fmt.bufPrint(&path_buffer, "{s}/two words.zig", .{directory}); @@ -1108,11 +1154,15 @@ test "relative names use the file directory and can name a new target" { try testing.expect(dirtyOf(pane)); } -test "relative names follow inherited scratch and virtual directories" { +test "relative names follow a pane/new scratch's session directory and virtual directories" { const p = try withFile(testing.allocator, "source body\n"); defer p.deinit(); const source = p.panes[0].?; - try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/project/src/source.zig\n").reply.status); + try testing.expectEqual(Status.ok, wr(p, Node.of(source.serial, .name), "/elsewhere/source.zig\n").reply.status); + // pane/new's scratch is the session's, not the active pane's. + const session = "/project/src"; + @memcpy(p.session_dir[0..session.len], session); + p.session_dir_len = session.len; const serial = try th.newPane(p); const scratch = p.panes[p.paneBySerial(serial).?].?; const name = Node.of(serial, .name); @@ -1205,3 +1255,59 @@ test "setattr truncation empties the body and answers fresh attributes" { const noop = call(p, .{ .tag = 8, .op = .setattr, .node = Node.of(serial, .body) }); try testing.expectEqual(@as(u64, 9), noop.reply.attr.size); } + +test "addresses are in runes: a combining mark and a lone \\r are places of their own" { + const gpa = testing.allocator; + // e, U+0301 (two bytes, 1-2), x, \r, \n + const p = try withFile(gpa, "e\u{0301}x\r\n"); + defer p.deinit(); + const serial = serialOf(p); + const addr_node = Node.of(serial, .addr); + const dot = Node.of(serial, .dot); + const Case = struct { expr: []const u8, q0: u32, q1: u32 }; + for ([_]Case{ + .{ .expr = "#1,#3", .q0 = 1, .q1 = 3 }, // the mark alone + .{ .expr = "#2", .q0 = 1, .q1 = 1 }, // inside it: back to its start + .{ .expr = "#4,#5", .q0 = 4, .q1 = 5 }, // the \r alone + .{ .expr = "1:2", .q0 = 1, .q1 = 1 }, // line:col the same + .{ .expr = "1:3", .q0 = 1, .q1 = 1 }, + .{ .expr = "0/\xCC/", .q0 = 1, .q1 = 3 }, // a match inside a rune covers it + }) |c| { + try testing.expectEqual(Status.ok, wr(p, addr_node, c.expr).reply.status); + try testing.expectEqual(c.q0, p.panes[0].?.fs.addr.q0); + try testing.expectEqual(c.q1, p.panes[0].?.fs.addr.q1); + } + // Copied to dot, it reads back the same. + _ = wr(p, addr_node, "#1,#3"); + try testing.expectEqual(Status.ok, wr(p, dot, " 1 3 ").reply.status); + p.sync(); + try testing.expectEqualStrings(" 1 3 ", rd(p, dot, 0, 100).bytes); + _ = wr(p, addr_node, "#4,#5"); + try testing.expectEqualStrings("\r", rd(p, Node.of(serial, .xdata), 0, 100).bytes); +} + +test "a limit write that fails keeps the limit a truncation cleared before it" { + const gpa = testing.allocator; + const p = try withFile(gpa, "one two\n"); + defer p.deinit(); + const limit = Node.of(serialOf(p), .limit); + _ = wr(p, limit, "#0,#3"); + _ = call(p, .{ .tag = 1, .op = .setattr, .node = limit, .truncate = true }); + try testing.expectEqual(Status.err, wr(p, limit, "/nomatch/").reply.status); + try testing.expectEqualStrings(" 0 3 ", rd(p, limit, 0, 100).bytes); + // A truncation with no write after it clears it. + _ = call(p, .{ .tag = 2, .op = .setattr, .node = limit, .truncate = true }); + _ = call(p, .{ .tag = 3, .op = .release, .node = limit }); + try testing.expectEqualStrings("", rd(p, limit, 0, 100).bytes); +} + +test "a pane's tagexec runs a word as a click in its tag, and reads back what it touched" { + const gpa = testing.allocator; + const p = try withFile(gpa, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .tagexec), "Msg from the tag\n").reply.status); + try testing.expect(th.logHas(p, "from the tag")); + var want: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{d}\n", .{serial}), rd(p, Node.of(serial, .tagexec), 0, 64).bytes); +} |
