diff options
Diffstat (limited to 'src/Messages.zig')
| -rw-r--r-- | src/Messages.zig | 105 |
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. |
