From d1d1817f86c44e20e85d12ce7c103da7e6d35eed Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Fri, 14 Aug 2026 23:59:51 +0200 Subject: [PATCH] feat: make package disclosure motion optional Add a persisted general setting that keeps package expand and collapse motion enabled by default while allowing remote and lower-performance systems to switch it off. Route the setting through normalized application state and renderer settings, bypass transition grouping when disabled, and retain the existing virtualized package list behavior. Cover legacy defaults, explicit opt-out persistence, settings presentation, and the immediate disclosure path with focused regression tests. --- src/main/constants.ts | 1 + src/main/renderer-state.ts | 1 + src/main/storage.ts | 1 + src/renderer/App.tsx | 5 +++-- src/renderer/i18n.ts | 2 +- src/renderer/views/downloads/DownloadsView.tsx | 1 + .../downloads/VirtualizedDownloadsBody.tsx | 8 ++++++-- .../download-disclosure-transition.ts | 5 +++-- src/renderer/views/settings/settings-model.ts | 1 + src/shared/types.ts | 2 ++ tests/downloads-view.test.tsx | 14 ++++++++++++++ tests/settings-view.test.tsx | 18 ++++++++++++++++++ tests/storage.test.ts | 8 ++++++++ tests/visual/fixtures.ts | 1 + 14 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/main/constants.ts b/src/main/constants.ts index 8369a4f..c89a948 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -105,6 +105,7 @@ export function defaultSettings(): AppSettings { theme: "dark" as const, logStorageLocation: "appdata", collapseNewPackages: true, + animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 232d9ba..25ce8c4 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -181,6 +181,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings theme: settings.theme, logStorageLocation: settings.logStorageLocation, collapseNewPackages: settings.collapseNewPackages, + animatePackageDisclosure: settings.animatePackageDisclosure, historyRetentionMode: settings.historyRetentionMode, historyMaxEntries: settings.historyMaxEntries, historyMaxAgeDays: settings.historyMaxAgeDays, diff --git a/src/main/storage.ts b/src/main/storage.ts index 7f62366..e329a53 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -527,6 +527,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { ? settings.logStorageLocation : defaults.logStorageLocation, collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, + animatePackageDisclosure: settings.animatePackageDisclosure !== undefined ? Boolean(settings.animatePackageDisclosure) : defaults.animatePackageDisclosure, historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode) ? settings.historyRetentionMode : defaults.historyRetentionMode, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fa0c438..9a3556c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -821,7 +821,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, + theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, @@ -4597,6 +4597,7 @@ export function App(): ReactElement { sortColumn: downloadsSortColumn, sortDirection: downloadsSortDescending ? "desc" : "asc", disclosureRevision: downloadDisclosureRevision, + animatePackageDisclosure: snapshot.settings.animatePackageDisclosure, status: { packages: snapshot.stats.totalPackages, links: getPendingDownloadItemCount(Object.values(snapshot.session.items)), @@ -4610,7 +4611,7 @@ export function App(): ReactElement { speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s", eta: snapshot.etaText } - }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); + }), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.animatePackageDisclosure, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]); const downloadsActions: DownloadsViewActions = { onDisplayModeChange: setDownloadDisplayMode, diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 52f7b4a..bd55832 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -12,7 +12,7 @@ const pairs = [ ["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"], ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], - ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], + ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Paket-Ein-/Ausklappen animieren", "Animate package expand/collapse"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], ["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"], diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 930856a..86c65a0 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -48,6 +48,7 @@ export interface DownloadsViewModel extends DownloadsViewModelCore { sortColumn?: DownloadSortColumn; sortDirection?: "asc" | "desc"; disclosureRevision: number; + animatePackageDisclosure: boolean; status: DownloadsStatusModel; } diff --git a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx index 47ae459..45dc711 100644 --- a/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx +++ b/src/renderer/views/downloads/VirtualizedDownloadsBody.tsx @@ -117,7 +117,11 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D }, [desiredRows]); useRendererLayoutEffect(() => { - const prepared = prepareDownloadDisclosureTransition(transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current); + const prepared = prepareDownloadDisclosureTransition( + transitionRowsRef.current ?? previousRowsRef.current, + desiredRowsRef.current, + model.animatePackageDisclosure + ); if (!prepared.animated) { transitionRowsRef.current = null; transitionPinnedIdsRef.current = []; @@ -148,7 +152,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D cancelActivation(); if (settleTimer) window.clearTimeout(settleTimer); }; - }, [model.disclosureRevision, model.displayMode]); + }, [model.animatePackageDisclosure, model.disclosureRevision, model.displayMode]); const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : stableDownloadDisclosureRows(desiredRows), [desiredRows, transitionRows]); const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, { diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index ab5b27f..715e89f 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -92,9 +92,10 @@ function groupRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map row.type === "package")) { + if (!enabled || !desired.some((row) => row.type === "package")) { return { animated: false, rows: stableDownloadDisclosureRows(desired) }; } diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index cb9ba5b..5ff2fc8 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -519,6 +519,7 @@ export function buildSettingsFormViewModel({ title: "Oberfläche und Bedienung", fields: [ { id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages }, + { id: "animatePackageDisclosure", kind: "switch", label: "Paket-Ein-/Ausklappen animieren", value: settings.animatePackageDisclosure }, { id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray }, { id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection }, { id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 6376d2e..b2e7c84 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -131,6 +131,7 @@ export interface AppSettings { theme: AppTheme; logStorageLocation: LogStorageLocation; collapseNewPackages: boolean; + animatePackageDisclosure: boolean; historyRetentionMode: HistoryRetentionMode; historyMaxEntries: number; historyMaxAgeDays: number; @@ -250,6 +251,7 @@ export interface RendererSettings { theme: AppTheme; logStorageLocation: LogStorageLocation; collapseNewPackages: boolean; + animatePackageDisclosure: boolean; historyRetentionMode: HistoryRetentionMode; historyMaxEntries: number; historyMaxAgeDays: number; diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index b278b0a..205c563 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -227,6 +227,19 @@ describe("virtualisierte Paketanimation", () => { renderLimit: 100 })); + it("überspringt die Paketbewegung, wenn sie in den Einstellungen deaktiviert ist", () => { + const expanded = logicalRows([]); + const prepared = prepareDownloadDisclosureTransition( + stableDownloadDisclosureRows(logicalRows([packageA.id])), + expanded, + false + ); + + expect(prepared.animated).toBe(false); + expect(prepared.rows).toEqual(stableDownloadDisclosureRows(expanded)); + expect(prepared.rows.some((row) => row.type === "item-group")).toBe(false); + }); + it("zeichnet den Startzustand in einem eigenen Frame vor der Aktivierung", () => { const frames: FrameRequestCallback[] = []; const cancelled: number[] = []; @@ -659,6 +672,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record { expect(settingsCss).toMatch(/\.settings-select\.is-open\s+\.settings-select-options\s*\{[^}]*opacity:\s*1[^}]*transform:\s*translateY\(0\)/s); }); + it("offers a general switch for package expand and collapse motion", () => { + const form = buildSettingsFormViewModel({ + settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" }, + section: "allgemein", + speedLimitInput: "0", + scheduleSpeedInputs: {} + }); + const animation = form.groups.flatMap((group) => group.fields) + .find((field) => field.id === "animatePackageDisclosure"); + + expect(animation).toEqual({ + id: "animatePackageDisclosure", + kind: "switch", + label: "Paket-Ein-/Ausklappen animieren", + value: true + }); + }); + it("supports keyboard navigation in animated settings selects", () => { expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2); expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 65272b7..1fd8b3f 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -36,6 +36,14 @@ afterEach(() => { }); describe("settings storage", () => { + it("enables package disclosure motion by default and preserves an explicit opt-out", () => { + const legacy = { ...defaultSettings() } as Partial; + delete legacy.animatePackageDisclosure; + + expect(normalizeSettings(legacy as AppSettings).animatePackageDisclosure).toBe(true); + expect(normalizeSettings({ ...defaultSettings(), animatePackageDisclosure: false }).animatePackageDisclosure).toBe(false); + }); + it("repairs a persisted version-2 column order that lost availability during default merging", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); tempDirs.push(dir); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 425b966..1d61e4e 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -117,6 +117,7 @@ function createSettings(): AppSettings { theme: "dark", logStorageLocation: "appdata", collapseNewPackages: false, + animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0,