From f69c412f7c0a86b0fd5029d66c3f064102aa0d4e Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:46:30 +0900 Subject: [PATCH 1/3] feat: add lazyLoader to app-launcher --- src/stores/application/appLauncher.ts | 10 +- src/stores/application/applicationCatalog.ts | 24 +++- .../app-launcher/AppLauncherContent.tsx | 104 ++++++++++++++ src/widget/app-launcher/index.tsx | 133 +++--------------- 4 files changed, 157 insertions(+), 114 deletions(-) create mode 100644 src/widget/app-launcher/AppLauncherContent.tsx diff --git a/src/stores/application/appLauncher.ts b/src/stores/application/appLauncher.ts index 170cfb9..e2b610c 100644 --- a/src/stores/application/appLauncher.ts +++ b/src/stores/application/appLauncher.ts @@ -5,23 +5,31 @@ import Apps from 'gi://AstalApps'; import {searchApps} from '@/stores/application/applicationCatalog'; export interface AppLauncherState { + contentLoaded: Accessor; text: Accessor; setText: Setter; selectedIndex: Accessor; setSelectedIndex: Setter; results: Accessor; + loadContent: () => void; } export function createAppLauncherState(): AppLauncherState { + const [contentLoaded, setContentLoaded] = createState(false); const [text, setText] = createState(''); const [selectedIndex, setSelectedIndex] = createState(0); - const results = createComputed(() => searchApps(text().trim().toLowerCase())); + const results = createComputed(() => { + if (!contentLoaded()) return []; + return searchApps(text().trim().toLowerCase()); + }); return { + contentLoaded, text, setText, selectedIndex, setSelectedIndex, results, + loadContent: () => setContentLoaded(true), }; } diff --git a/src/stores/application/applicationCatalog.ts b/src/stores/application/applicationCatalog.ts index f72008f..3b87324 100644 --- a/src/stores/application/applicationCatalog.ts +++ b/src/stores/application/applicationCatalog.ts @@ -3,8 +3,24 @@ import Apps from 'gi://AstalApps'; import {ApplicationHistory} from '@/stores/application/applicationHistory'; const MAX_APP_RESULTS = 30; -const applications = new Apps.Apps(); -const history = new ApplicationHistory(applications); + +interface ApplicationCatalog { + applications: Apps.Apps; + history: ApplicationHistory; +} + +let catalog: ApplicationCatalog | null = null; + +function getCatalog() { + if (catalog) return catalog; + + const applications = new Apps.Apps(); + catalog = { + applications, + history: new ApplicationHistory(applications), + }; + return catalog; +} function getResultKey(application: Apps.Application) { return application.name + (application.description || '') + (application.iconName || ''); @@ -27,6 +43,7 @@ function getUniqueResults(applicationList: Apps.Application[]) { } function getApplicationList() { + const {applications, history} = getCatalog(); return applications.get_list().sort((applicationA, applicationB) => { const scoreA = history.getScore(applicationA); const scoreB = history.getScore(applicationB); @@ -36,10 +53,11 @@ function getApplicationList() { } export function recordAppLaunch(application: Apps.Application) { - history.recordLaunch(application); + getCatalog().history.recordLaunch(application); } export function searchApps(query: string) { + const {history} = getCatalog(); const allApplications = getApplicationList(); if (query === '') return getUniqueResults(allApplications); const keywords = query.split(/\s+/); diff --git a/src/widget/app-launcher/AppLauncherContent.tsx b/src/widget/app-launcher/AppLauncherContent.tsx new file mode 100644 index 0000000..e75c6ca --- /dev/null +++ b/src/widget/app-launcher/AppLauncherContent.tsx @@ -0,0 +1,104 @@ +import {onCleanup} from 'ags'; +import {Gtk} from 'ags/gtk4'; + +import {scaleUiSize} from '@/lib/uiScale'; +import {type AppLauncherState} from '@/stores/application/appLauncher'; +import { + ensureLauncherBackground, + registerLauncherBackground, +} from '@/stores/application/launcherBackground'; +import {AppList} from '@/widget/app-launcher/widget/AppList'; +import {SearchInput} from '@/widget/app-launcher/widget/SearchInput'; + +export interface AppLauncherContentHandle { + focus: () => void; + reset: () => void; +} + +export interface AppLauncherContentProps { + monitorConnector: string | null; + state: AppLauncherState; + register: (content: AppLauncherContentHandle | null) => void; +} + +function createLauncherBackground() { + const picture = new Gtk.Picture({ + contentFit: Gtk.ContentFit.COVER, + canTarget: false, + canShrink: true, + hexpand: true, + vexpand: true, + halign: Gtk.Align.FILL, + valign: Gtk.Align.FILL, + widthRequest: scaleUiSize(1), + heightRequest: scaleUiSize(1), + }); + + const unregister = registerLauncherBackground(picture); + picture.connect('destroy', unregister); + return picture; +} + +export default function AppLauncherContent({ + monitorConnector, + state, + register, +}: AppLauncherContentProps) { + const launcherBackground = createLauncherBackground(); + const searchInput = SearchInput({...state, monitorConnector}); + const appList = AppList({...state, monitorConnector}); + + const handle: AppLauncherContentHandle = { + focus: () => { + ensureLauncherBackground(); + searchInput.grab_focus(); + appList.get_vadjustment()?.set_value(0); + }, + reset: () => { + searchInput.set_text(''); + state.setText(''); + state.setSelectedIndex(0); + }, + }; + register(handle); + onCleanup(() => register(null)); + + const launcherContent = ( + + + + {searchInput} + + + ) as Gtk.Box; + + return ( + + + + { + self.set_child(launcherBackground); + self.add_overlay(launcherContent); + }} + /> + + + {appList} + + + + ); +} diff --git a/src/widget/app-launcher/index.tsx b/src/widget/app-launcher/index.tsx index a298753..3b80117 100644 --- a/src/widget/app-launcher/index.tsx +++ b/src/widget/app-launcher/index.tsx @@ -1,53 +1,12 @@ -import {onCleanup} from 'ags'; +import {For, onCleanup} from 'ags'; import {Astal, Gdk, Gtk} from 'ags/gtk4'; import app from 'ags/gtk4/app'; import {type Timer, idle} from 'ags/time'; -import {scaleUiSize} from '@/lib/uiScale'; import {createAppLauncherState} from '@/stores/application/appLauncher'; -import { - ensureLauncherBackground, - registerLauncherBackground, -} from '@/stores/application/launcherBackground'; -import {AppList} from '@/widget/app-launcher/widget/AppList'; -import {SearchInput} from '@/widget/app-launcher/widget/SearchInput'; - -function createLauncherBackground() { - const picture = new Gtk.Picture({ - contentFit: Gtk.ContentFit.COVER, - canTarget: false, - canShrink: true, - hexpand: true, - vexpand: true, - halign: Gtk.Align.FILL, - valign: Gtk.Align.FILL, - widthRequest: scaleUiSize(1), - heightRequest: scaleUiSize(1), - }); - - const unregister = registerLauncherBackground(picture); - picture.connect('destroy', unregister); - - return picture; -} - -function resetLauncherState( - searchInput: Gtk.Entry, - setText: (text: string) => void, - setSelectedIndex: (index: number) => void -) { - searchInput.set_text(''); - setText(''); - setSelectedIndex(0); -} - -function focusLauncher(searchInput: Gtk.Entry, appList: Gtk.ScrolledWindow) { - ensureLauncherBackground(); - return idle(() => { - searchInput.grab_focus(); - appList.get_vadjustment()?.set_value(0); - }); -} +import AppLauncherContent, { + type AppLauncherContentHandle, +} from '@/widget/app-launcher/AppLauncherContent'; function addEscapeHandler(window: Astal.Window) { const controller = new Gtk.EventControllerKey(); @@ -65,37 +24,11 @@ export interface AppLauncherProps { } export default function AppLauncher({monitor}: AppLauncherProps) { - const {text, setText, selectedIndex, setSelectedIndex, results} = createAppLauncherState(); + const state = createAppLauncherState(); const monitorConnector = monitor.get_connector(); + let content: AppLauncherContentHandle | null = null; let focusTimer: Timer | null = null; - const launcherBackground = createLauncherBackground(); - - const searchInput = SearchInput({ - text, - setText, - selectedIndex, - setSelectedIndex, - results, - monitorConnector, - }); - - const launcherContent = ( - - - - {searchInput} - - - ) as Gtk.Box; - - const appList = AppList({ - text, - selectedIndex, - results, - monitorConnector, - }); - const win = ( { + focusTimer = null; + content?.focus(); + }); }} > - - - {/* Left Panel */} - - { - self.set_child(launcherBackground); - self.add_overlay(launcherContent); - }} - /> - - - {/* Right Panel */} - - {appList} - - - + (loaded ? [true] : []))}> + {() => ( + (content = handle)} + /> + )} + ) as Astal.Window; From 9a96a836dcea153935538bc904666bfd6d722274 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:17:58 +0900 Subject: [PATCH 2/3] test: add test for notifications --- debug/run-memory-scenarios.sh | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/debug/run-memory-scenarios.sh b/debug/run-memory-scenarios.sh index cac4493..849d9e6 100755 --- a/debug/run-memory-scenarios.sh +++ b/debug/run-memory-scenarios.sh @@ -13,7 +13,7 @@ usage() { Usage: run-memory-scenarios.sh [OPTIONS] Options: - --scenario NAME launcher, launcher-no-theme, wallpaper, wallpaper-no-theme, power-menu, theme-only, css-only, cc, cc-no-theme, date-weather, date-weather-no-theme, date-weather-css-only, notifications, notifications-date-weather, notifications-date-weather-repeat, notifications-date-weather-close-wait, or all (runs every scenario with AGS restart between each; default: all) + --scenario NAME launcher, launcher-no-theme, wallpaper, wallpaper-no-theme, power-menu, theme-only, css-only, cc, cc-no-theme, date-weather, date-weather-no-theme, date-weather-css-only, notifications, notifications-date-weather, notifications-date-weather-repeat, notifications-date-weather-close-wait, notifications-date-weather-hide-retained, or all (runs every scenario with AGS restart between each; default: all) --iterations N Panel open/theme-change/close repetitions (default: 30) --notifications N Number of random image notifications (default: 30) --settle-seconds N Delay after UI and wallpaper operations (default: 2) @@ -50,7 +50,7 @@ while (($#)); do esac done -case "$scenario" in launcher|launcher-no-theme|wallpaper|wallpaper-no-theme|power-menu|theme-only|css-only|cc|cc-no-theme|date-weather|date-weather-no-theme|date-weather-css-only|notifications|notifications-date-weather|notifications-date-weather-repeat|notifications-date-weather-close-wait|all) ;; *) printf 'Invalid scenario: %s\n' "$scenario" >&2; exit 2 ;; esac +case "$scenario" in launcher|launcher-no-theme|wallpaper|wallpaper-no-theme|power-menu|theme-only|css-only|cc|cc-no-theme|date-weather|date-weather-no-theme|date-weather-css-only|notifications|notifications-date-weather|notifications-date-weather-repeat|notifications-date-weather-close-wait|notifications-date-weather-hide-retained|all) ;; *) printf 'Invalid scenario: %s\n' "$scenario" >&2; exit 2 ;; esac [[ $iterations =~ ^[1-9][0-9]*$ ]] || { printf '%s\n' '--iterations must be a positive integer' >&2; exit 2; } [[ $notification_count =~ ^[1-9][0-9]*$ ]] || { printf '%s\n' '--notifications must be a positive integer' >&2; exit 2; } [[ $settle_seconds =~ ^[0-9]+$ && $gc_wait_seconds =~ ^[0-9]+$ ]] || { printf '%s\n' 'wait values must be non-negative integers' >&2; exit 2; } @@ -159,6 +159,7 @@ run_named_scenario() { notifications-date-weather) run_notification_scenario true ;; notifications-date-weather-repeat) run_notification_scenario true 2 ;; notifications-date-weather-close-wait) run_notification_scenario true 1 true ;; + notifications-date-weather-hide-retained) run_notification_scenario true 1 false true ;; *) printf 'Invalid scenario: %s\n' "$1" >&2; exit 2 ;; esac } @@ -182,6 +183,7 @@ run_all_scenarios() { notifications-date-weather notifications-date-weather-repeat notifications-date-weather-close-wait + notifications-date-weather-hide-retained ) for scenario_name in "${scenarios[@]}"; do @@ -265,6 +267,7 @@ run_notification_scenario() { local with_date_weather=${1:-false} local batch_count=${2:-1} local observe_close=${3:-false} + local hide_with_notifications=${4:-false} local scenario_name=notifications local -a images=() local image size i batch added_phase cleared_phase settled_phase @@ -276,6 +279,9 @@ run_notification_scenario() { if "$observe_close"; then scenario_name=notifications-date-weather-close-wait fi + if "$hide_with_notifications"; then + scenario_name=notifications-date-weather-hide-retained + fi image_count=$(find "$HOME/Pictures" -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.webp' \) -print | wc -l) if ((image_count < notification_count)); then @@ -319,6 +325,24 @@ run_notification_scenario() { fi wait_for_settle "$settle_seconds" snapshot "$scenario_name" "$batch" "$added_phase" + if "$hide_with_notifications"; then + ags_request toggle-notif + snapshot "$scenario_name" "$batch" date_weather_close_requested + wait_for_settle 1 + snapshot "$scenario_name" "$batch" date_weather_hidden_1s + wait_for_settle 4 + snapshot "$scenario_name" "$batch" date_weather_hidden_5s + wait_for_settle 10 + snapshot "$scenario_name" "$batch" date_weather_hidden_15s + wait_for_settle 15 + snapshot "$scenario_name" "$batch" date_weather_hidden_30s + ags_request clear-notifications + wait_for_settle "$settle_seconds" + snapshot "$scenario_name" "$batch" cleared_after_hide + wait_for_settle "$gc_wait_seconds" + snapshot "$scenario_name" "$batch" cleared_gc_settled + return + fi ags_request clear-notifications wait_for_settle "$settle_seconds" snapshot "$scenario_name" "$batch" "$cleared_phase" From 465a6e89600f90bf904f20d063a9066e3751ffd6 Mon Sep 17 00:00:00 2001 From: Ry2X <45420571+ry2x@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:19:32 +0900 Subject: [PATCH 3/3] perf: isolate notification image decoding --- src/stores/notification/notificationImage.ts | 177 +++++++++++++++ .../notification/notificationThumbnail.ts | 214 ++++++++++++++++++ src/widget/common/NotificationCard.tsx | 92 +------- 3 files changed, 401 insertions(+), 82 deletions(-) create mode 100644 src/stores/notification/notificationImage.ts create mode 100644 src/stores/notification/notificationThumbnail.ts diff --git a/src/stores/notification/notificationImage.ts b/src/stores/notification/notificationImage.ts new file mode 100644 index 0000000..ae1188f --- /dev/null +++ b/src/stores/notification/notificationImage.ts @@ -0,0 +1,177 @@ +import system from 'system'; + +import {Gtk} from 'ags/gtk4'; +import {type Timer, timeout} from 'ags/time'; + +import Notifd from 'gi://AstalNotifd'; + +import {type SharedTexture, acquireNotificationTexture} from '@/stores/notification/imageCache'; +import {subscribeNotificationThumbnail} from '@/stores/notification/notificationThumbnail'; + +const IMAGE_RELEASE_DELAY_MS = 300; +const GC_DELAY_MS = 100; + +interface PictureResource { + picture: Gtk.Picture; + uri: string; + logicalWidth: number; + logicalHeight: number; + mapHook: number; + destroyHook: number; + destroyed: boolean; + root: Gtk.Widget | null; + rootUnmapHook: number | null; + thumbnail: boolean; + thumbnailUnsubscribe: (() => void) | null; + texture: SharedTexture | null; +} + +let garbageCollectionTimer: Timer | null = null; + +function scheduleGarbageCollection() { + garbageCollectionTimer?.cancel(); + garbageCollectionTimer = timeout(GC_DELAY_MS, () => { + garbageCollectionTimer = null; + try { + system.gc(); + } catch (error) { + console.error(error); + } + }); +} + +export class NotificationImageResources { + private readonly pictures: PictureResource[] = []; + private releaseTimer: Timer | null = null; + private disposed = false; + private imagesEnabled = true; + private readonly resolvedHook: number; + + constructor(private readonly notification: Notifd.Notification) { + this.resolvedHook = notification.connect('resolved', () => this.scheduleRelease()); + } + + bindPicture( + picture: Gtk.Picture, + uri: string, + logicalWidth: number, + logicalHeight: number, + thumbnail = false + ) { + const resource: PictureResource = { + picture, + uri, + logicalWidth, + logicalHeight, + mapHook: 0, + destroyHook: 0, + destroyed: false, + root: null, + rootUnmapHook: null, + thumbnail, + thumbnailUnsubscribe: null, + texture: null, + }; + resource.mapHook = picture.connect('map', () => this.load(resource)); + resource.destroyHook = picture.connect('destroy', () => { + resource.destroyed = true; + this.release(resource); + this.disconnectRoot(resource); + }); + this.pictures.push(resource); + + if (picture.get_mapped()) this.load(resource); + } + + dispose() { + if (this.disposed) return; + this.disposed = true; + this.imagesEnabled = false; + this.releaseTimer?.cancel(); + this.releaseTimer = null; + + for (const resource of this.pictures) { + this.release(resource); + if (resource.destroyed) continue; + resource.picture.disconnect(resource.mapHook); + resource.picture.disconnect(resource.destroyHook); + this.disconnectRoot(resource); + } + this.pictures.length = 0; + this.notification.disconnect(this.resolvedHook); + scheduleGarbageCollection(); + } + + private load(resource: PictureResource) { + if (this.disposed || !this.imagesEnabled || resource.texture || resource.thumbnailUnsubscribe) + return; + + try { + this.connectRoot(resource); + const scaleFactor = Math.max(1, resource.picture.get_scale_factor()); + const width = resource.logicalWidth * scaleFactor; + const height = resource.logicalHeight * scaleFactor; + if (!resource.thumbnail) { + this.loadTexture(resource, resource.uri, width, height); + return; + } + + let completed = false; + const unsubscribe = subscribeNotificationThumbnail(resource.uri, width, height, path => { + completed = true; + resource.thumbnailUnsubscribe = null; + if (this.disposed || !this.imagesEnabled || resource.destroyed) return; + if (path) this.loadTexture(resource, `file://${path}`, width, height); + else this.loadTexture(resource, resource.uri, width, height); + }); + if (!completed) resource.thumbnailUnsubscribe = unsubscribe; + } catch (error) { + console.error(error); + } + } + + private loadTexture(resource: PictureResource, uri: string, width: number, height: number) { + resource.texture = acquireNotificationTexture(uri, width, height); + resource.picture.set_paintable(resource.texture.texture); + } + + private release(resource: PictureResource) { + resource.thumbnailUnsubscribe?.(); + resource.thumbnailUnsubscribe = null; + if (!resource.texture) return; + if (!resource.destroyed) resource.picture.set_paintable(null); + resource.texture.release(); + resource.texture = null; + scheduleGarbageCollection(); + } + + private releaseImages() { + for (const resource of this.pictures) this.release(resource); + } + + private connectRoot(resource: PictureResource) { + const root = resource.picture.get_root() as Gtk.Widget | null; + if (!root || resource.root === root) return; + + this.disconnectRoot(resource); + resource.root = root; + resource.rootUnmapHook = root.connect('unmap', () => this.release(resource)); + } + + private disconnectRoot(resource: PictureResource) { + if (resource.root && resource.rootUnmapHook !== null) { + resource.root.disconnect(resource.rootUnmapHook); + } + resource.root = null; + resource.rootUnmapHook = null; + } + + private scheduleRelease() { + this.imagesEnabled = false; + this.releaseTimer?.cancel(); + this.releaseTimer = timeout(IMAGE_RELEASE_DELAY_MS, () => { + this.releaseTimer = null; + this.releaseImages(); + }); + } +} diff --git a/src/stores/notification/notificationThumbnail.ts b/src/stores/notification/notificationThumbnail.ts new file mode 100644 index 0000000..1a93828 --- /dev/null +++ b/src/stores/notification/notificationThumbnail.ts @@ -0,0 +1,214 @@ +import {type Process, subprocess} from 'ags/process'; + +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +import {rystalShellCacheDir} from '@/lib/paths'; + +const THUMBNAIL_VERSION = 'v1'; +const MAX_THUMBNAIL_WORKERS = 2; +const MAX_CACHE_FILES = 128; +const CACHE_FILE_ATTRIBUTES = 'standard::name,time::modified'; +const cacheRoot = `${rystalShellCacheDir}/notifications/thumbnails`; +const textEncoder = new TextEncoder(); + +type ThumbnailSubscriber = (path: string | null) => void; + +interface ThumbnailJob { + key: string; + sourcePath: string; + outputPath: string; + width: number; + height: number; + subscribers: Set; +} + +let thumbnailQueue: ThumbnailJob[] = []; +const thumbnailJobs = new Map(); +const activeThumbnailJobs = new Set(); + +function getThumbnailJob(uri: string, width: number, height: number): ThumbnailJob { + const file = Gio.File.new_for_uri(uri); + const sourcePath = file.get_path(); + if (!sourcePath) throw new Error(`Notification image URI is not a local file: ${uri}`); + + const info = file.query_info( + 'standard::size,time::modified,time::modified-usec', + Gio.FileQueryInfoFlags.NONE, + null + ); + const payload = textEncoder.encode( + `${sourcePath}\0${info.get_size()}:${info.get_attribute_uint64('time::modified')}:` + + `${info.get_attribute_uint32('time::modified-usec')}\0${width}x${height}\0` + + THUMBNAIL_VERSION + ); + const key = GLib.compute_checksum_for_data(GLib.ChecksumType.SHA256, payload); + if (!key) throw new Error(`Failed to hash notification image: ${sourcePath}`); + return { + key, + sourcePath, + outputPath: `${cacheRoot}/${key}.webp`, + width, + height, + subscribers: new Set(), + }; +} + +function deleteFile(path: string) { + try { + Gio.File.new_for_path(path).delete(null); + } catch { + // The process may not have created the file, or it may already have been moved. + } +} + +function waitForProcess(process: Process) { + return new Promise<{code: number; signaled: boolean}>(resolve => { + let exitHook: number | null = null; + exitHook = process.connect('exit', (_, code, signaled) => { + if (exitHook !== null) process.disconnect(exitHook); + resolve({code, signaled}); + }); + }); +} + +function notifySubscribers(job: ThumbnailJob, path: string | null) { + const subscribers = [...job.subscribers]; + job.subscribers.clear(); + for (const subscriber of subscribers) { + try { + subscriber(path); + } catch (error) { + console.error('Failed to load generated notification thumbnail:', error); + } + } +} + +function pruneThumbnailCache() { + try { + const directory = Gio.File.new_for_path(cacheRoot); + const enumerator = directory.enumerate_children( + CACHE_FILE_ATTRIBUTES, + Gio.FileQueryInfoFlags.NONE, + null + ); + const cachedFiles: {path: string; modified: number}[] = []; + let info = enumerator.next_file(null); + while (info) { + const name = info.get_name(); + if (name.endsWith('.webp')) { + cachedFiles.push({ + path: `${cacheRoot}/${name}`, + modified: info.get_attribute_uint64('time::modified'), + }); + } + info = enumerator.next_file(null); + } + enumerator.close(null); + + cachedFiles + .sort((a, b) => b.modified - a.modified) + .slice(MAX_CACHE_FILES) + .forEach(file => deleteFile(file.path)); + } catch (error) { + console.error('Failed to prune notification thumbnail cache:', error); + } +} + +async function generateThumbnail(job: ThumbnailJob) { + GLib.mkdir_with_parents(cacheRoot, 0o755); + const temporaryPath = `${job.outputPath}.tmp-${GLib.uuid_string_random()}`; + const errors: string[] = []; + + try { + const process = subprocess({ + cmd: [ + 'magick', + '-limit', + 'thread', + '1', + `${job.sourcePath}[0]`, + '-auto-orient', + '-colorspace', + 'sRGB', + '-strip', + '-thumbnail', + `${job.width}x${job.height}`, + '-quality', + '80', + '-define', + 'webp:method=2', + `webp:${temporaryPath}`, + ], + err: line => errors.push(line), + }); + activeThumbnailJobs.add(job.key); + + const {code, signaled} = await waitForProcess(process); + if (job.subscribers.size === 0) return; + if (signaled || code !== 0) { + throw new Error(errors.join('\n') || `magick exited with status ${code}`); + } + + Gio.File.new_for_path(temporaryPath).move( + Gio.File.new_for_path(job.outputPath), + Gio.FileCopyFlags.OVERWRITE, + null, + null + ); + notifySubscribers(job, job.outputPath); + pruneThumbnailCache(); + } catch (error) { + if (job.subscribers.size > 0) { + console.error(`Failed to generate notification thumbnail for ${job.sourcePath}:`, error); + notifySubscribers(job, null); + } + } finally { + activeThumbnailJobs.delete(job.key); + thumbnailJobs.delete(job.key); + deleteFile(temporaryPath); + pumpThumbnailQueue(); + } +} + +function pumpThumbnailQueue() { + while (activeThumbnailJobs.size < MAX_THUMBNAIL_WORKERS) { + const job = thumbnailQueue.shift(); + if (!job) return; + if (job.subscribers.size > 0) void generateThumbnail(job); + else thumbnailJobs.delete(job.key); + } +} + +function cancelSubscription(job: ThumbnailJob, subscriber: ThumbnailSubscriber) { + job.subscribers.delete(subscriber); + if (job.subscribers.size > 0) return; + + if (activeThumbnailJobs.has(job.key)) return; + + thumbnailQueue = thumbnailQueue.filter(queuedJob => queuedJob !== job); + thumbnailJobs.delete(job.key); +} + +export function subscribeNotificationThumbnail( + uri: string, + width: number, + height: number, + subscriber: ThumbnailSubscriber +) { + const candidate = getThumbnailJob(uri, width, height); + if (GLib.file_test(candidate.outputPath, GLib.FileTest.IS_REGULAR)) { + subscriber(candidate.outputPath); + return () => {}; + } + + const job = thumbnailJobs.get(candidate.key) ?? candidate; + job.subscribers.add(subscriber); + if (!thumbnailJobs.has(job.key)) { + thumbnailJobs.set(job.key, job); + thumbnailQueue.push(job); + pumpThumbnailQueue(); + } + + return () => cancelSubscription(job, subscriber); +} diff --git a/src/widget/common/NotificationCard.tsx b/src/widget/common/NotificationCard.tsx index 8de2459..895f10d 100644 --- a/src/widget/common/NotificationCard.tsx +++ b/src/widget/common/NotificationCard.tsx @@ -1,14 +1,11 @@ -import system from 'system'; - import {onCleanup} from 'ags'; import {Gtk} from 'ags/gtk4'; -import {type Timer, timeout} from 'ags/time'; import Notifd from 'gi://AstalNotifd'; import Pango from 'gi://Pango'; import {scaleUiSize} from '@/lib/uiScale'; -import {type SharedTexture, acquireNotificationTexture} from '@/stores/notification/imageCache'; +import {NotificationImageResources} from '@/stores/notification/notificationImage'; import {LucideIcon} from '@/widget/common/lucide'; export interface NotificationCardProps { @@ -16,8 +13,6 @@ export interface NotificationCardProps { onDismiss?: () => void; } -const IMAGE_RELEASE_DELAY_MS = 300; - function resolveImage(image: string | null) { if (!image) return null; if (image.startsWith('file://')) return image; @@ -25,73 +20,8 @@ function resolveImage(image: string | null) { return null; } -function collectGarbage() { - try { - system.gc(); - } catch (error) { - console.error(error); - } -} - -class NotificationImageResources { - appIconPicture: Gtk.Picture | null = null; - imagePicture: Gtk.Picture | null = null; - private appIconTexture: SharedTexture | null = null; - private imageTexture: SharedTexture | null = null; - - private releaseTimer: Timer | null = null; - private disposed = false; - private readonly resolvedHook: number; - - constructor(private readonly notification: Notifd.Notification) { - this.resolvedHook = notification.connect('resolved', () => this.scheduleRelease()); - } - - private scheduleRelease() { - this.releaseTimer?.cancel(); - this.releaseTimer = timeout(IMAGE_RELEASE_DELAY_MS, () => { - this.releaseTimer = null; - this.releaseImages(); - collectGarbage(); - }); - } - - private releaseImages() { - this.appIconPicture?.set_paintable(null); - this.imagePicture?.set_paintable(null); - this.appIconPicture = null; - this.imagePicture = null; - this.appIconTexture?.release(); - this.imageTexture?.release(); - this.appIconTexture = null; - this.imageTexture = null; - } - - setAppIcon(picture: Gtk.Picture, uri: string) { - this.appIconPicture = picture; - this.appIconTexture = acquireNotificationTexture(uri, scaleUiSize(64), scaleUiSize(64)); - picture.set_paintable(this.appIconTexture.texture); - } - - setImage(picture: Gtk.Picture, uri: string) { - this.imagePicture = picture; - this.imageTexture = acquireNotificationTexture(uri, scaleUiSize(760), scaleUiSize(280)); - picture.set_paintable(this.imageTexture.texture); - } - - dispose() { - if (this.disposed) return; - this.disposed = true; - this.releaseTimer?.cancel(); - this.releaseTimer = null; - this.releaseImages(); - this.notification.disconnect(this.resolvedHook); - collectGarbage(); - } -} - export default function NotificationCard({notif, onDismiss}: NotificationCardProps) { - const appIcon = notif.app_icon || notif.desktop_entry || notif.image; + const appIcon = notif.app_icon || notif.desktop_entry; const appIconPath = resolveImage(appIcon); const imageToDisplay = resolveImage(notif.image); const time = new Date(notif.time * 1000).toLocaleTimeString([], { @@ -122,11 +52,7 @@ export default function NotificationCard({notif, onDismiss}: NotificationCardPro canShrink contentFit={Gtk.ContentFit.CONTAIN} $={picture => { - try { - resources.setAppIcon(picture, appIconPath); - } catch (error) { - console.error(error); - } + resources.bindPicture(picture, appIconPath, scaleUiSize(32), scaleUiSize(32)); }} /> @@ -196,11 +122,13 @@ export default function NotificationCard({notif, onDismiss}: NotificationCardPro canShrink contentFit={Gtk.ContentFit.COVER} $={picture => { - try { - resources.setImage(picture, imageToDisplay); - } catch (error) { - console.error(error); - } + resources.bindPicture( + picture, + imageToDisplay, + scaleUiSize(380), + scaleUiSize(140), + true + ); }} />