Add notifications.
This commit is contained in:
+127
-21
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user