diff --git a/.gitignore b/.gitignore
index f96843d..ebc3c55 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,8 @@ node_modules/
# local flatpak build
/repo
-/.flatpak-builder/
\ No newline at end of file
+/.flatpak-builder/
+
+# local nix build
+/build/
+/result
\ No newline at end of file
diff --git a/README.md b/README.md
index 431c2b5..459b4a7 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,18 @@ written in GJS and uses GTK4.
+## Tray icon
+
+Sticky Notes provides a tray icon with quick access to all your notes. It uses
+the freedesktop [StatusNotifierItem][spec] specification, which KDE Plasma and
+most other desktop environments support out of the box. GNOME Shell does not:
+to see the tray icon on GNOME, install and enable the
+[AppIndicator and KStatusNotifierItem Support][extension] extension.
+Without it, the app still works normally — the tray icon just won't be shown.
+
+[spec]: https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/
+[extension]: https://extensions.gnome.org/extension/615/appindicator-support/
+
## Installation
### From Flathub
diff --git a/build-aux/flatpak/com.vixalien.sticky.json b/build-aux/flatpak/com.vixalien.sticky.json
index 298e3e6..6377eb5 100644
--- a/build-aux/flatpak/com.vixalien.sticky.json
+++ b/build-aux/flatpak/com.vixalien.sticky.json
@@ -18,6 +18,7 @@
"--device=dri",
"--socket=wayland",
"--socket=fallback-x11",
+ "--talk-name=org.kde.StatusNotifierWatcher",
"--env=GJS_DISABLE_JIT=1"
],
"cleanup": [
diff --git a/data/com.vixalien.sticky.appdata.xml.in.in b/data/com.vixalien.sticky.appdata.xml.in.in
index 14ef9ee..5de332c 100644
--- a/data/com.vixalien.sticky.appdata.xml.in.in
+++ b/data/com.vixalien.sticky.appdata.xml.in.in
@@ -16,7 +16,14 @@
notes are restored if they were open when the application was closed
changing color of notes
dark theme support
+ tray icon with quick access to all notes
+
+ The tray icon requires a status notifier host: it works out of the box on
+ KDE Plasma and most other desktop environments, but on GNOME you need to
+ install and enable the "AppIndicator and KStatusNotifierItem Support"
+ extension to see it.
+
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..b84cc16
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1782723713,
+ "narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..cb370b1
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,96 @@
+{
+ description = "Sticky Notes — GNOME sticky notes app (with tray icon)";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ };
+
+ outputs = { self, nixpkgs }:
+ let
+ systems = [ "x86_64-linux" "aarch64-linux" ];
+ forAllSystems = f: nixpkgs.lib.genAttrs systems (
+ system: f system nixpkgs.legacyPackages.${system}
+ );
+ in
+ {
+ packages = forAllSystems (system: pkgs: {
+ # reuses the nixpkgs sticky-notes derivation (same version, same
+ # yarn.lock, so the yarn offline cache hash still applies) but builds
+ # from this source tree
+ default = pkgs.sticky-notes.overrideAttrs (old: {
+ version = "${old.version}-dev";
+ src = self;
+ });
+ });
+
+ apps = forAllSystems (system: pkgs: {
+ # nix run — build and launch the app
+ default = {
+ type = "app";
+ program = "${self.packages.${system}.default}/bin/com.vixalien.sticky";
+ meta.description = "Run Sticky Notes built from this source tree";
+ };
+
+ # nix run .#install-appindicator — link the AppIndicator GNOME Shell
+ # extension (a StatusNotifierWatcher host) into the user profile, so
+ # the tray icon can be tested with and without it
+ install-appindicator = {
+ type = "app";
+ program = toString (pkgs.writeShellScript "install-appindicator" ''
+ set -eu
+ uuid=appindicatorsupport@rgcjonas.gmail.com
+ src=${pkgs.gnomeExtensions.appindicator}/share/gnome-shell/extensions/$uuid
+ dest="$HOME/.local/share/gnome-shell/extensions/$uuid"
+ mkdir -p "$(dirname "$dest")"
+ ln -sfT "$src" "$dest"
+ echo "AppIndicator extension linked into $dest"
+ echo
+ echo "GNOME Shell only picks up new extensions on login:"
+ echo "log out and back in, then run:"
+ echo
+ echo " gnome-extensions enable $uuid"
+ echo
+ echo "Toggle it off/on with gnome-extensions disable/enable (or the"
+ echo "Extensions app) to compare behavior; remove it again with:"
+ echo
+ echo " rm '$dest'"
+ '');
+ meta.description = "Install the AppIndicator extension for the current user";
+ };
+ });
+
+ devShells = forAllSystems (system: pkgs: {
+ default = pkgs.mkShell {
+ # meson/ninja/yarn/gjs toolchain and GTK/libadwaita libraries, with
+ # the GI and GSettings setup hooks, exactly as the package build
+ inputsFrom = [ self.packages.${system}.default ];
+
+ packages = [
+ pkgs.gjs # on PATH for running the app uninstalled
+ pkgs.nodejs # yarn typecheck
+ pkgs.libxml2 # xmllint, silences a gresource warning
+
+ # build and run the app from the working tree:
+ # sticky-run [args...]
+ # GJS only registers the compiled-in source gresource for
+ # installed apps, so overlay the resource path onto the build dir
+ (pkgs.writeShellScriptBin "sticky-run" ''
+ set -eu
+ [ -d build ] || meson setup build
+ ninja -C build
+ cd build
+ export GSETTINGS_SCHEMA_DIR="$PWD/data"
+ export G_RESOURCE_OVERLAYS="/com/vixalien/sticky/js=$PWD/src"
+ exec gjs -m src/com.vixalien.sticky "$@"
+ '')
+ ];
+
+ shellHook = ''
+ echo "Sticky Notes dev shell"
+ echo " sticky-run build and run from the working tree"
+ echo " yarn typecheck check TypeScript (needs the gi-types submodule)"
+ '';
+ };
+ });
+ };
+}
diff --git a/po/POTFILES b/po/POTFILES
index 4758d86..ee4d199 100644
--- a/po/POTFILES
+++ b/po/POTFILES
@@ -13,6 +13,7 @@ src/card.ts
src/main.ts
src/notes.ts
src/styleselector.ts
+src/trayicon.ts
src/util.ts
src/view.ts
src/window.ts
diff --git a/src/application.ts b/src/application.ts
index 8d29ddc..80c7010 100644
--- a/src/application.ts
+++ b/src/application.ts
@@ -31,7 +31,7 @@ import Gtk from "gi://Gtk?version=4.0";
import Gdk from "gi://Gdk?version=4.0";
import { StickyNotes } from "./notes.js";
-import { Note, settings } from "./util.js";
+import { compare_notes_newest_first, Note, settings } from "./util.js";
import {
delete_note,
load_notes,
@@ -40,10 +40,13 @@ import {
save_notes,
} from "./store.js";
import { Window } from "./window.js";
+import { TrayIcon } from "./trayicon.js";
export class Application extends Adw.Application {
private window: StickyNotes | null = null;
private note_windows: Window[] = [];
+ private tray: TrayIcon | null = null;
+ private tray_holding = false;
static {
GObject.registerClass(this);
@@ -195,7 +198,7 @@ export class Application extends Adw.Application {
if (!has_one_open) {
const last_open_note = this.notes_array()
- .sort((a, b) => b.modified.compare(a.modified))[0];
+ .sort(compare_notes_newest_first)[0];
if (has_one_open) {
this.show_note(last_open_note.uuid);
@@ -539,9 +542,30 @@ export class Application extends Adw.Application {
window.present();
}
+ search_notes() {
+ this.all_notes();
+ this.window?.focus_search();
+ }
+
vfunc_startup() {
super.vfunc_startup();
+ this.tray = new TrayIcon(this);
+ // keep the application running without windows while the tray icon is
+ // shown, so that it stays reachable from the tray
+ this.tray.registered_changed = (registered) => {
+ if (registered === this.tray_holding) return;
+
+ if (registered) {
+ this.hold();
+ } else {
+ this.release();
+ }
+
+ this.tray_holding = registered;
+ };
+ this.tray.start();
+
const style_manager = Adw.StyleManager.get_default();
function setColorScheme() {
const color_scheme = settings.get_int("color-scheme");
diff --git a/src/com.vixalien.sticky.src.gresource.xml b/src/com.vixalien.sticky.src.gresource.xml
index 8062fda..024f401 100644
--- a/src/com.vixalien.sticky.src.gresource.xml
+++ b/src/com.vixalien.sticky.src.gresource.xml
@@ -9,6 +9,7 @@
styleselector.js
store.js
themeselector.js
+ trayicon.js
view.js
window.js
util.js
diff --git a/src/meson.build b/src/meson.build
index c1ac862..0e23ec1 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -9,6 +9,7 @@ sources = [
'styleselector.ts',
'store.ts',
'themeselector.ts',
+ 'trayicon.ts',
'view.ts',
'window.ts',
'util.ts'
diff --git a/src/notes.ts b/src/notes.ts
index 5d21528..d59c050 100644
--- a/src/notes.ts
+++ b/src/notes.ts
@@ -29,7 +29,7 @@ import Adw from "gi://Adw";
import type { Application } from "./application.js";
import { StickyNoteCard } from "./card.js";
-import { Note, settings } from "./util.js";
+import { compare_notes_newest_first, Note, settings } from "./util.js";
import { ThemeSelector } from "./themeselector.js";
@@ -156,11 +156,9 @@ export class StickyNotes extends Adw.ApplicationWindow {
filter,
);
- this.sorter = Gtk.CustomSorter.new((note1, note2) => {
- const date1 = (note1 as Note).modified;
- const date2 = (note2 as Note).modified;
- return date2.compare(date1);
- });
+ this.sorter = Gtk.CustomSorter.new((note1, note2) =>
+ compare_notes_newest_first(note1 as Note, note2 as Note)
+ );
const sorter_model = Gtk.SortListModel.new(filter_model, this.sorter);
@@ -199,6 +197,10 @@ export class StickyNotes extends Adw.ApplicationWindow {
popover.add_child(new ThemeSelector(), "themeswitcher");
}
+ focus_search() {
+ this._search_entry.grab_focus();
+ }
+
set_status() {
if (this.last_model.get_n_items() > 0) {
this.set_visible_child(this._notes_box);
diff --git a/src/trayicon.ts b/src/trayicon.ts
new file mode 100644
index 0000000..52cdb10
--- /dev/null
+++ b/src/trayicon.ts
@@ -0,0 +1,560 @@
+/* MIT License
+ *
+ * Copyright (c) 2026 Sticky Notes contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+import Gio from "gi://Gio";
+import GLib from "gi://GLib";
+import GdkPixbuf from "gi://GdkPixbuf";
+
+import type { Application } from "./application.js";
+import { compare_notes_newest_first, Note, Style } from "./util.js";
+
+// GTK4 has no tray icon support, so the icon is provided over D-Bus using the
+// StatusNotifierItem specification, and its menu using the com.canonical.dbusmenu
+// specification. See:
+// https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/
+// https://github.com/AyatanaIndicators/libdbusmenu/blob/master/libdbusmenu-glib/dbus-menu.xml
+
+const WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher";
+const WATCHER_OBJECT_PATH = "/StatusNotifierWatcher";
+const ITEM_OBJECT_PATH = "/StatusNotifierItem";
+const MENU_OBJECT_PATH = "/com/vixalien/sticky/TrayMenu";
+
+const StatusNotifierItemInterface = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const DBusMenuInterface = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+// the "header" color of each note style, used to draw the menu swatches
+const STYLE_COLORS = new Map