Add notifications.

This commit is contained in:
Greyson Parrelli
2026-08-26 23:24:14 -04:00
parent afad6a2636
commit 7414c89fe6
33 changed files with 1026 additions and 381 deletions
+127 -21
View File
@@ -28,6 +28,7 @@ const View = @import("View.zig");
const appearance = @import("appearance.zig");
const emoji = @import("emoji.zig");
const key = @import("key.zig");
const notify = @import("notify.zig");
const review = @import("review.zig");
const shortcuts = @import("shortcuts.zig");
@@ -132,7 +133,7 @@ layout_popover: *gtk.Popover,
/// they belong to are on screen.
layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty,
/// What a sidebar row is signalling. Defined with the dots themselves, since
/// What a sidebar row is signaling. Defined with the dots themselves, since
/// a row and a pane header show the same five states for the same reasons.
const Attention = Pane.Attention;
@@ -155,17 +156,17 @@ const Source = struct {
///
/// Shaped like the pane drag in `View`, and for the same reason: the reorder is
/// applied as the pointer moves rather than on the drop, so the sidebar under
/// the cursor is always the order you will get. That means a cancelled drag has
/// the cursor is always the order you will get. That means a canceled drag has
/// something to undo, which is what `origin` is for.
const Drag = struct {
tab: *Tab,
/// Where the tab sat in `tabs` when the drag began, so a cancelled drag can
/// Where the tab sat in `tabs` when the drag began, so a canceled drag can
/// put it back.
origin: usize,
/// Set once a drop has been accepted; a drag that ends without this was
/// cancelled, and the preview has to be undone.
/// canceled, and the preview has to be undone.
committed: bool = false,
};
@@ -177,7 +178,7 @@ const Tab = struct {
row: *gtk.ListBoxRow,
label: *gtk.Label,
/// Shows what the tab's focused pane is, so a web view is recognisable in
/// Shows what the tab's focused pane is, so a web view is recognizable in
/// the sidebar without reading the title.
icon: *gtk.Image,
@@ -228,6 +229,13 @@ const Tab = struct {
/// with me?", which only you can answer.
finished_since_visit: bool = false,
/// Whether this tab's finishes are posting notifications, and until when.
///
/// Session state rather than a setting: a mute is set from the row menu to
/// get through the next hour, and a tab does not survive the app anyway, so
/// there is nothing here worth writing to a file. See `notify.Mute`.
mute: notify.Mute = .off,
name: [16]u8,
name_len: usize,
@@ -334,7 +342,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
// One drop target for the whole list rather than one per row, so that the
// gaps between rows and the empty space under the last one are part of it:
// a drag to the bottom of the sidebar should land there, not be cancelled
// a drag to the bottom of the sidebar should land there, not be canceled
// for having missed every row by a few pixels.
self.installRowDropTarget();
@@ -605,18 +613,15 @@ fn bindReview(self: *Window, tab: *Tab) void {
//
// It does not select the row it belongs to. Renaming a tab, or giving it an
// emoji, is not a reason to go and look at it — often it is the opposite, since
// the tab you are labelling is the one you are about to leave alone for a while.
// the tab you are labeling is the one you are about to leave alone for a while.
/// Attach the row's context menu and the right-click that opens it.
///
/// The menu's contents are not built here — see `fillRowMenu`, which builds them
/// as it opens.
fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
_ = self;
const box = gtk.Box.new(.vertical, 2);
box.as(gtk.Widget).addCssClass("playpen-row-menu");
box.append(menuItem("Rename", &onMenuRename, tab));
box.append(menuItem("Settings…", &onMenuSettings, tab));
tab.menu_popover.setChild(box.as(gtk.Widget));
tab.menu_popover.setHasArrow(0);
tab.menu_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget));
@@ -632,6 +637,53 @@ fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController));
}
/// Build the menu's contents, immediately before it opens.
///
/// Rebuilt per opening rather than built once and switched about, because half
/// of what it says is a deadline that has been quietly passing while the popover
/// sat there unopened. There is no moment other than "now" at which "muted for
/// another forty minutes" can be made true.
fn fillRowMenu(tab: *Tab) void {
const box = gtk.Box.new(.vertical, 2);
box.as(gtk.Widget).addCssClass("playpen-row-menu");
box.append(menuItem("Rename", &onMenuRename, tab));
box.append(menuItem("Settings…", &onMenuSettings, tab));
box.append(gtk.Separator.new(.horizontal).as(gtk.Widget));
// Cleared as the menu opens as well as when something wants to post, so a
// mute that ran out an hour ago isn't still described as one.
const now = notify.nowMs();
if (tab.mute.expired(now)) tab.mute = .off;
var buf: [80]u8 = undefined;
if (tab.mute.describe(now, &buf)) |state| {
// A label rather than an insensitive menu item: this is the answer to
// "why has this tab gone quiet", and there is nothing to click.
const label = gtk.Label.new(state);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("playpen-row-menu-state");
box.append(label.as(gtk.Widget));
box.append(menuItem("Unmute", &onMenuUnmute, tab));
}
// Offered whether or not the tab is already muted: re-picking is how you
// change your mind about how long, and having to unmute first to mute again
// for longer would be a menu arguing with you.
inline for (std.enums.values(notify.Duration)) |duration| {
const Item = struct {
fn clicked(_: *gtk.Button, clicked_tab: *Tab) callconv(.c) void {
clicked_tab.menu_popover.popdown();
clicked_tab.mute = duration.mute(notify.nowMs());
}
};
box.append(menuItem(duration.label(), &Item.clicked, tab));
}
tab.menu_popover.setChild(box.as(gtk.Widget));
}
/// One line of the row menu, styled like the layout menu's rows so the two
/// popovers read as the same kind of thing.
fn menuItem(
@@ -647,7 +699,7 @@ fn menuItem(
return button.as(gtk.Widget);
}
/// Open the menu where the pointer is, rather than centred on the row: with one
/// Open the menu where the pointer is, rather than centered on the row: with one
/// popover per row anchored to the whole row, a fixed position would put the
/// menu somewhere you weren't pointing.
fn onRowSecondary(
@@ -663,6 +715,7 @@ fn onRowSecondary(
.f_width = 1,
.f_height = 1,
};
fillRowMenu(tab);
tab.menu_popover.setPointingTo(&at);
tab.menu_popover.popup();
}
@@ -677,6 +730,11 @@ fn onMenuSettings(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.window.openTabSettings(tab);
}
fn onMenuUnmute(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.menu_popover.popdown();
tab.mute = .off;
}
// -------------------------------------------------------------------------
// Per-tab settings
@@ -1290,12 +1348,16 @@ fn select(self: *Window, tab: *Tab) void {
self.list.selectRow(tab.row);
tab.view.focus();
// Opening the tab is the acknowledgement the row was asking for, and the
// Opening the tab is the acknowledgment the row was asking for, and the
// pane you land in counts as answered along with it. Any other pane in a
// split keeps its own dot until you go to it.
tab.finished_since_visit = false;
tab.view.answerFocused();
self.refreshStatus(tab);
// And it answers the popup too, which would otherwise sit in the tray
// telling you about a tab you are now looking at.
if (self.application()) |app| notify.withdraw(app, tab.pageName());
}
fn indexOf(self: *Window, tab: *Tab) ?usize {
@@ -1416,7 +1478,7 @@ fn onRowDragPrepare(
// A lone tab has nothing to be reordered against, so there is no preview to
// show and no drop that could change anything. Refusing the drag is better
// than starting one that can only ever be cancelled.
// than starting one that can only ever be canceled.
if (self.tabs.items.len < 2) return null;
self.drag = .{ .tab = tab, .origin = self.indexOf(tab) orelse return null };
@@ -1694,7 +1756,7 @@ fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
self.openSettings();
}
/// The colour scheme changed. Everything styled by CSS restyles itself; the
/// The color scheme changed. Everything styled by CSS restyles itself; the
/// terminal grids do not, because Cairo draws them from `theme.zig` and GTK
/// has no idea that widget's contents depend on the palette at all. Without
/// this, switching to light leaves every terminal a dark rectangle until
@@ -1702,7 +1764,7 @@ fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
///
/// Sessions already open are re-palletted rather than left on the one they
/// started in: a shell you have had running all day is exactly the one you are
/// looking at when you switch, and leaving it in the old scheme's colours
/// looking at when you switch, and leaving it in the old scheme's colors
/// would make the setting look like it only applies to new tabs.
fn onAppearanceChanged(ctx: ?*anyopaque) void {
const self: *Window = @ptrCast(@alignCast(ctx.?));
@@ -1810,10 +1872,54 @@ fn onViewStatus(ctx: ?*anyopaque) void {
/// Recorded even when the tab is the one on screen: you may well have watched
/// it stop and then gone somewhere else, and the next time you open this tab is
/// when that stops being news.
fn onViewFinished(ctx: ?*anyopaque) void {
fn onViewFinished(ctx: ?*anyopaque, task: []const u8) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.finished_since_visit = true;
// The row first, always, and the popup second, maybe: the dot is the half
// that is never wrong and never unwanted.
tab.window.refreshStatus(tab);
tab.window.notifyFinished(tab, task);
}
/// Tell the desktop that a pane finished, unless something says not to.
///
/// See `notify.wanted` for the three things that can say not to.
fn notifyFinished(self: *Window, tab: *Tab, task: []const u8) void {
// A timed mute is cleared here rather than on a timer. Nothing needs to
// know it has run out until something wants to post, and a GLib timeout per
// muted tab would be a great deal of machinery for a deadline nobody is
// watching.
const now = notify.nowMs();
if (tab.mute.expired(now)) tab.mute = .off;
// "You watched it happen" is the window having the focus and this being the
// tab it is showing. Everything the decision rests on is gathered here and
// weighed in `notify.wanted`, which is where it can be tested.
const watching = self.window.as(gtk.Window).isActive() != 0 and self.activeTab() == tab;
if (!notify.wanted(.{
.enabled = Settings.get().notifications,
.mute = tab.mute,
.watching = watching,
}, now)) return;
const app = self.application() orelse return;
var buf: [192]u8 = undefined;
notify.post(app, tab.pageName(), self.tabName(tab, &buf), task);
}
/// The `GApplication` this window belongs to, which is what carries a
/// notification to the session.
///
/// Fetched each time rather than held. It is the same object for the life of the
/// process, but a window part-way through teardown has already been unparented
/// from it, and a session exiting during teardown is exactly when a finish can
/// still arrive.
fn application(self: *Window) ?*gio.Application {
const app = self.window.as(gtk.Window).getApplication() orelse return null;
return app.as(gio.Application);
}
/// The view lost its last pane, so the tab goes with it.
@@ -2145,7 +2251,7 @@ fn addReviewPane(self: *Window, tab: *Tab) void {
/// repositories reviews the one you are looking at.
///
/// Falls back to playpen's own working directory, which at least gives the
/// server something to fail on that the user can recognise in the message.
/// server something to fail on that the user can recognize in the message.
fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
_ = self;
@@ -2160,7 +2266,7 @@ fn tabDirectory(self: *Window, tab: *Tab, buf: []u8) []const u8 {
}
// Playpen's own directory, which at least gives the server something to
// fail on that the user can recognise in the message. `std.c` rather than
// fail on that the user can recognize in the message. `std.c` rather than
// `std.posix`, matching `Pty.zig`: the latter has been churning across Zig
// releases and this is one call.
if (std.c.getcwd(buf.ptr, buf.len) != null) {