summaryrefslogtreecommitdiff
path: root/src/Messages.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/Messages.zig')
-rw-r--r--src/Messages.zig105
1 files changed, 97 insertions, 8 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index b7153e5c..ea62041f 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -181,10 +181,12 @@ fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void {
pane.pushOlderMessage();
if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot();
pane.msg_announcement = false;
+ pane.msg_sent = false;
pane.msg_status = !stack;
const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving;
for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c;
pane.msg_len = @intCast(n);
+ pane.msg_said_ms = nowMs();
p.needs_frame = true;
if (arrives) {
pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown };
@@ -198,6 +200,35 @@ fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void {
}
}
+fn nowMs() i64 {
+ if (comptime !pardes.hosted) return 0;
+ var ts: std.c.timespec = undefined;
+ if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
+}
+
+/// With no frontend attached no input dismisses a message, and a detached
+/// session's /screen and messages kept what was said an hour ago: each
+/// pane's lines go once its newest has been up `message_linger_ms`, on the
+/// clock, checked as the core is stepped and by the detached session's loop
+/// between steps -- not as a 9P request is served, since a read is answered
+/// while a step is out and must change nothing.
+/// ponytail: a pane's older lines go with its newest, not each on its own
+/// clock; the render pipeline's clock will give every line its own.
+pub fn expireUnattended(p: *Pardes) void {
+ if (!p.unattended) return;
+ const now = nowMs();
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.msg_len == 0 and pane.msg_older_len == 0) continue;
+ if (now - pane.msg_said_ms < p.settings.message_linger_ms) continue;
+ pane.msg_len = 0;
+ pane.msg_life = .{};
+ pane.msg_older_len = 0;
+ p.needs_frame = true;
+ }
+}
+
/// The key or click that used to clear the message row now starts each
/// line's exit: it stays `message_linger_ms`, then dissolves. A line
/// already on its way out keeps its own timing.
@@ -410,23 +441,51 @@ pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage {
return &m.log[(first + i) % limits.message_log];
}
+/// `<operation>: <the error in words>`: FileNotFound reads "file not found".
pub fn reportError(p: *Pardes, id: usize, operation: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
- const text = std.fmt.bufPrint(&buf, "{s}: {s}", .{ operation, @errorName(err) }) catch operation;
- reportFailure(p, id, text);
+ var w = std.Io.Writer.fixed(&buf);
+ w.print("{s}: ", .{operation}) catch {};
+ for (@errorName(err), 0..) |c, i| {
+ if (std.ascii.isUpper(c) and i > 0) w.writeByte(' ') catch break;
+ w.writeByte(std.ascii.toLower(c)) catch break;
+ }
+ reportFailure(p, id, w.buffered());
+}
+
+/// The longest start of `text` that fits `max` bytes and ends between
+/// words (or, with no space to cut at, between characters).
+pub fn clip(text: []const u8, max: usize) []const u8 {
+ if (text.len <= max) return text;
+ if (std.mem.lastIndexOfScalar(u8, text[0 .. max + 1], ' ')) |sp| return text[0..sp];
+ var n = max;
+ while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
+ return text[0..n];
}
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
- // A builtin a ctl write runs: its first error is also the write's.
+ // A builtin a ctl write runs: its first error is also the write's, cut
+ // between words.
if (p.fs.no_prompt and p.fs.failure_len == 0) {
- const n = @min(text.len, p.fs.failure.len);
- @memcpy(p.fs.failure[0..n], text[0..n]);
+ const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
+ @memcpy(p.fs.failure[0..kept.len], kept);
+ var n = kept.len;
+ if (kept.len < text.len) {
+ @memcpy(p.fs.failure[n..][0..3], "...");
+ n += 3;
+ }
p.fs.failure_len = @intCast(n);
}
setMessage(p, id, text);
}
+test "an error reads as words and a failure is cut between them" {
+ try std.testing.expectEqualStrings("go", clip("go haskell", 4));
+ try std.testing.expectEqualStrings("go haskell", clip("go haskell", 10));
+ try std.testing.expectEqualStrings("\u{e9}", clip("\u{e9}\u{e9}", 3));
+}
+
/// The one place that decides which transient lines a pane is showing and
/// in what order. Everything downstream reads the answer: the body layer
/// so it does not paint under them, and the paint pass so it can stack
@@ -528,14 +587,14 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it
// ...on the pane that ran it, and nowhere else
try std.testing.expectEqualStrings("", row(p, 0));
- // Bare, it reports ITSELF through reportError — the `<operation>: <Error>`
+ // Bare, it reports ITSELF through reportError — the `<operation>: <error in words>`
// every failed save and refused Look arrives in.
try std.testing.expect(p.executeBuiltinLine(2, "Msg"));
- try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
+ try std.testing.expectEqualStrings("Msg: no message", row(p, 2));
// Input starts its exit: it lingers, dissolves, and is gone.
p.update(.{ .key = .{ .cp = Key.escape } });
- try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
+ try std.testing.expectEqualStrings("Msg: no message", row(p, 2));
for (0..200) |_| p.update(.tick);
try std.testing.expectEqualStrings("", row(p, 2));
// ...and with neither linger nor animation it is as old as your last input.
@@ -554,6 +613,31 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it
try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
}
+test "with no frontend attached a message goes once it has lingered, on the clock" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = p.panes[p.active].?;
+ p.setMessage(p.active, "said");
+ p.setMessage(p.active, "said again");
+ // Attached: it waits for input, however long.
+ pane.msg_said_ms -= 60_000;
+ p.update(.tick);
+ try std.testing.expect(pane.msg_len > 0);
+ // Detached with nobody attached: past its linger it is gone, and the
+ // line above it with it.
+ p.unattended = true;
+ // A read served meanwhile changes nothing; the session's loop expires.
+ _ = p.serveFs(.{ .tag = 1, .op = .read, .node = @intFromEnum(ctlfs.TopFile.index), .size = 64 });
+ try std.testing.expect(pane.msg_len > 0);
+ expireUnattended(p);
+ try std.testing.expectEqual(@as(u16, 0), pane.msg_len);
+ try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len);
+ // A fresh one stays until its linger is up.
+ p.setMessage(p.active, "fresh");
+ p.update(.tick);
+ try std.testing.expect(pane.msg_len > 0);
+}
+
test "a message falls in, lingers past the input that dismisses it, and dissolves" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
defer p.deinit();
@@ -733,6 +817,11 @@ test "a builtin announces itself on the message row, and Verbose silences it" {
// for its announcement, which is correct and not what this is testing.
try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap"));
try std.testing.expectEqualStrings("Wrap", pane.msg[0..pane.msg_len]);
+ // A value it does not take is said, in place of the announcement.
+ const wrapped = p.settings.wrap;
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap maybe"));
+ try std.testing.expectEqualStrings("Wrap: takes on or off", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqual(wrapped, p.settings.wrap);
// The notice list is what the paint pass reads, so the announcement has to
// reach it, not just the buffer.