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([ + [Style.yellow, "#ffee92"], + [Style.pink, "#ffc5e1"], + [Style.green, "#d1ebcb"], + [Style.purple, "#e2d0f5"], + [Style.blue, "#cce7ff"], + [Style.gray, "#dddad8"], + [Style.charcoal, "#3f3d3c"], + [Style.window, "#deddda"], +]); + +const SWATCH_SIZE = 16; +const SWATCH_BORDER = 1; + +// hosts cache menu items by id, so ids must be stable across rebuilds +const SEPARATOR_ID = 1; +const OPEN_ID = 2; +const FIRST_NOTE_ID = 3; + +interface TrayMenuItem { + id: number; + properties: Record; + activate?: () => void; +} + +// in dbusmenu labels, a single underscore marks the mnemonic character +const escape_label = (label: string) => label.replace(/_/g, "__"); + +export class TrayIcon { + private app: Application; + private item_dbus_object: Gio.DBusExportedObject | null = null; + private menu_dbus_object: Gio.DBusExportedObject | null = null; + private watcher_id: number | null = null; + private items: TrayMenuItem[] = []; + private note_order: string[] = []; + private note_ids = new Map(); + private next_note_id = FIRST_NOTE_ID; + private revision = 0; + private rebuild_source: number | null = null; + private connected_notes = new WeakSet(); + private swatches = new Map(); + + registered = false; + registered_changed: ((registered: boolean) => void) | null = null; + + constructor(app: Application) { + this.app = app; + + this.rebuild_menu(); + + this.app.notes_list.connect("items-changed", () => { + this.watch_notes(); + this.invalidate_menu(); + }); + this.watch_notes(); + } + + start() { + try { + this.export(); + } catch (error) { + console.error("Failed to export tray icon on D-Bus:", error as any); + return; + } + + this.watcher_id = Gio.bus_watch_name( + Gio.BusType.SESSION, + WATCHER_BUS_NAME, + Gio.BusNameWatcherFlags.NONE, + () => this.register(), + () => this.set_registered(false), + ); + } + + private export() { + this.item_dbus_object = Gio.DBusExportedObject.wrapJSObject( + StatusNotifierItemInterface, + this.make_item_implementation(), + ); + this.item_dbus_object.export(Gio.DBus.session, ITEM_OBJECT_PATH); + + this.menu_dbus_object = Gio.DBusExportedObject.wrapJSObject( + DBusMenuInterface, + this.make_menu_implementation(), + ); + this.menu_dbus_object.export(Gio.DBus.session, MENU_OBJECT_PATH); + } + + private register() { + Gio.DBus.session.call( + WATCHER_BUS_NAME, + WATCHER_OBJECT_PATH, + WATCHER_BUS_NAME, + "RegisterStatusNotifierItem", + // registering the object path (instead of a bus name) works with all + // modern hosts and doesn't require owning a second name on the bus + new GLib.Variant("(s)", [ITEM_OBJECT_PATH]), + null, + Gio.DBusCallFlags.NONE, + -1, + null, + (connection, result) => { + try { + connection!.call_finish(result); + this.set_registered(true); + } catch (error) { + console.error( + "Failed to register with the status notifier watcher:", + error as any, + ); + this.set_registered(false); + } + }, + ); + } + + private set_registered(registered: boolean) { + if (this.registered === registered) return; + this.registered = registered; + this.registered_changed?.(registered); + } + + private make_item_implementation() { + return { + Category: "ApplicationStatus", + Id: pkg.name, + Title: _("Sticky Notes"), + Status: "Active", + WindowId: 0, + IconName: pkg.name, + OverlayIconName: "", + AttentionIconName: "", + AttentionMovieName: "", + ItemIsMenu: false, + Menu: MENU_OBJECT_PATH, + Activate: (_x: number, _y: number) => { + this.app.all_notes(); + }, + SecondaryActivate: (_x: number, _y: number) => { + this.app.all_notes(); + }, + ContextMenu: (_x: number, _y: number) => { + // the host displays the Menu itself + }, + Scroll: (_delta: number, _orientation: string) => {}, + }; + } + + private make_menu_implementation() { + return { + Version: 3, + TextDirection: "ltr", + Status: "normal", + IconThemePath: [] as string[], + GetLayout: ( + parent_id: number, + recursion_depth: number, + _property_names: string[], + ) => { + return GLib.Variant.new_tuple([ + GLib.Variant.new_uint32(this.revision), + this.layout_variant(parent_id, recursion_depth), + ]); + }, + GetGroupProperties: (ids: number[], _property_names: string[]) => { + const rows = this.items + .filter((item) => ids.length === 0 || ids.includes(item.id)) + .map((item) => [item.id, item.properties]); + + if (ids.length === 0 || ids.includes(0)) { + rows.unshift([0, this.root_properties()]); + } + + return new GLib.Variant("(a(ia{sv}))", [rows]); + }, + GetProperty: (id: number, name: string) => { + const value = id === 0 + ? this.root_properties()[name] + : this.items.find((item) => item.id === id)?.properties[name]; + + return value ?? new GLib.Variant("s", ""); + }, + Event: ( + id: number, + event_id: string, + _data: GLib.Variant, + _timestamp: number, + ) => { + if (event_id !== "clicked") return; + this.items.find((item) => item.id === id)?.activate?.(); + }, + EventGroup: (events: [number, string, GLib.Variant, number][]) => { + const id_errors: number[] = []; + + for (const [id, event_id] of events) { + const item = this.items.find((item) => item.id === id); + if (!item && id !== 0) { + id_errors.push(id); + continue; + } + if (event_id === "clicked") item?.activate?.(); + } + + return id_errors; + }, + AboutToShow: (_id: number) => true, + AboutToShowGroup: (ids: number[]) => { + return new GLib.Variant("(aiai)", [ids, []]); + }, + }; + } + + private root_properties(): Record { + return { + "children-display": new GLib.Variant("s", "submenu"), + }; + } + + private item_variant(item: TrayMenuItem) { + return new GLib.Variant("(ia{sv}av)", [item.id, item.properties, []]); + } + + private layout_variant(parent_id: number, recursion_depth: number) { + if (parent_id !== 0) { + const item = this.items.find((item) => item.id === parent_id); + + return item + ? this.item_variant(item) + : new GLib.Variant("(ia{sv}av)", [parent_id, {}, []]); + } + + const children = recursion_depth === 0 + ? [] + : this.items.map((item) => this.item_variant(item)); + + return new GLib.Variant("(ia{sv}av)", [ + 0, + this.root_properties(), + children, + ]); + } + + private swatch(style: Style) { + const cached = this.swatches.get(style); + if (cached) return cached; + + const hex = STYLE_COLORS.get(style) ?? STYLE_COLORS.get(Style.yellow)!; + const rgb = parseInt(hex.slice(1), 16); + const darken = (channel: number) => Math.round(channel * 0.6); + const border = (darken((rgb >> 16) & 0xff) << 16) | + (darken((rgb >> 8) & 0xff) << 8) | + darken(rgb & 0xff); + + const pixbuf = GdkPixbuf.Pixbuf.new( + GdkPixbuf.Colorspace.RGB, + true, + 8, + SWATCH_SIZE, + SWATCH_SIZE, + )!; + // pixel is RGBA as an unsigned 32-bit value; avoid << which yields + // negative numbers for bright colors + pixbuf.fill(border * 0x100 + 0xff); + + const inner_size = SWATCH_SIZE - 2 * SWATCH_BORDER; + const inner = GdkPixbuf.Pixbuf.new( + GdkPixbuf.Colorspace.RGB, + true, + 8, + inner_size, + inner_size, + )!; + inner.fill(rgb * 0x100 + 0xff); + inner.copy_area( + 0, + 0, + inner_size, + inner_size, + pixbuf, + SWATCH_BORDER, + SWATCH_BORDER, + ); + + const [success, buffer] = pixbuf.save_to_bufferv("png", [], []); + if (!success) return new GLib.Variant("ay", []); + + const variant = new GLib.Variant("ay", buffer); + this.swatches.set(style, variant); + return variant; + } + + private watch_notes() { + this.app.foreach_note((note) => { + if (this.connected_notes.has(note)) return; + this.connected_notes.add(note); + + // "modified" changes on any edit and also reorders the list; + // "style" changes the swatch color + note.connect("notify::modified", () => this.invalidate_menu()); + note.connect("notify::style", () => this.invalidate_menu()); + }); + } + + private invalidate_menu() { + if (this.rebuild_source !== null) return; + + this.rebuild_source = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + this.rebuild_source = null; + + const previous = new Map( + this.items.map((item) => [item.id, item.properties]), + ); + this.rebuild_menu(); + this.emit_menu_updates(previous); + + return GLib.SOURCE_REMOVE; + }); + } + + private emit_menu_updates( + previous: Map>, + ) { + const updated: [number, Record][] = []; + const removed: [number, string[]][] = []; + + for (const item of this.items) { + const old = previous.get(item.id); + // new items are picked up through LayoutUpdated + if (!old) continue; + + const changed: Record = {}; + for (const [name, value] of Object.entries(item.properties)) { + if (!old[name]?.equal(value)) changed[name] = value; + } + if (Object.keys(changed).length > 0) updated.push([item.id, changed]); + + const dropped = Object.keys(old).filter( + (name) => !(name in item.properties), + ); + if (dropped.length > 0) removed.push([item.id, dropped]); + } + + if (updated.length > 0 || removed.length > 0) { + this.menu_dbus_object?.emit_signal( + "ItemsPropertiesUpdated", + new GLib.Variant("(a(ia{sv})a(ias))", [updated, removed]), + ); + } + + this.menu_dbus_object?.emit_signal( + "LayoutUpdated", + new GLib.Variant("(ui)", [this.revision, 0]), + ); + } + + private note_id(uuid: string) { + let id = this.note_ids.get(uuid); + + if (id === undefined) { + id = this.next_note_id++; + this.note_ids.set(uuid, id); + } + + return id; + } + + private rebuild_menu() { + const items: TrayMenuItem[] = []; + + const notes = this.app.notes_array().sort(compare_notes_newest_first); + + const uuids = notes.map((note) => note.uuid); + const existing = new Set(uuids); + + for (const uuid of this.note_ids.keys()) { + if (!existing.has(uuid)) this.note_ids.delete(uuid); + } + + // hosts may not support moving items, so a reorder gets fresh ids + const known = new Set(this.note_order); + const kept = this.note_order.filter((uuid) => existing.has(uuid)); + const kept_now = uuids.filter((uuid) => known.has(uuid)); + if (kept.some((uuid, index) => uuid !== kept_now[index])) { + this.note_ids.clear(); + } + this.note_order = uuids; + + for (const note of notes) { + const uuid = note.uuid; + const title = note.title.trim(); + + items.push({ + id: this.note_id(uuid), + properties: { + label: new GLib.Variant( + "s", + title ? escape_label(title) : `(${_("Empty Note")})`, + ), + "icon-data": this.swatch(note.style), + }, + activate: () => this.app.show_note(uuid), + }); + } + + items.push({ + id: SEPARATOR_ID, + properties: { type: new GLib.Variant("s", "separator") }, + }); + + items.push({ + id: OPEN_ID, + properties: { + label: new GLib.Variant("s", escape_label(_("Open Sticky Notes"))), + "icon-name": new GLib.Variant("s", "edit-find-symbolic"), + }, + activate: () => this.app.search_notes(), + }); + + this.items = items; + this.revision++; + } +} diff --git a/src/util.ts b/src/util.ts index 66a36c2..2aac3c7 100644 --- a/src/util.ts +++ b/src/util.ts @@ -302,6 +302,10 @@ export class Note extends GObject.Object { } } +// newest first; the order shared by the all notes view and the tray menu +export const compare_notes_newest_first = (note1: Note, note2: Note) => + note2.modified.compare(note1.modified); + export const confirm_delete = (window: Gtk.Window, cb: () => void) => { if (SETTINGS.CONFIRM_DELETE) { const dialog = Adw.MessageDialog.new(