diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9a03f9d..25c746d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -74,8 +74,8 @@ import { type CollectorWorkspaceFilter } from "./views/collector/collector-model"; import { - CollectorContent, CollectorInputDialog, + MemoizedCollectorContent, CollectorSidebar, CollectorToolbar, type CollectorViewActions @@ -1120,6 +1120,15 @@ export function readDownloadSpeedSparklinePalette( }; } +export function getIdleSparklineStroke(cssHeight: number, devicePixelRatio: number): { y: number; lineWidth: number } { + const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + const baseline = Math.max(0, cssHeight - 2); + return { + y: (Math.floor(baseline * dpr) + 0.5) / dpr, + lineWidth: 1 / dpr + }; +} + export function appendBandwidthSample( history: { time: number; speed: number }[], speed: number, @@ -1337,16 +1346,29 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, const rootStyle = getComputedStyle(document.documentElement); const palette = readDownloadSpeedSparklinePalette((property) => rootStyle.getPropertyValue(property)); - let maxV = 0; - for (const v of hist) if (v > maxV) maxV = v; - maxV = Math.max(maxV, 1024 * 1024); + let maxV = 0; + for (const v of hist) if (v > maxV) maxV = v; + const idle = maxV <= 0; + maxV = Math.max(maxV, 1024 * 1024); const pad = 2; const h = cssH - pad * 2; const step = cssW / (DOWNLOAD_SPEED_MAX_SAMPLES - 1); const startIdx = DOWNLOAD_SPEED_MAX_SAMPLES - hist.length; - const px = (i: number): number => (startIdx + i) * step; - const py = (v: number): number => pad + h - (v / maxV) * h; + const px = (i: number): number => (startIdx + i) * step; + const py = (v: number): number => pad + h - (v / maxV) * h; + + if (idle) { + const stroke = getIdleSparklineStroke(cssH, dpr); + ctx.beginPath(); + ctx.moveTo(px(0), stroke.y); + ctx.lineTo(px(hist.length - 1), stroke.y); + ctx.strokeStyle = palette.accent; + ctx.lineWidth = stroke.lineWidth; + ctx.lineCap = "butt"; + ctx.stroke(); + return; + } ctx.beginPath(); ctx.moveTo(px(0), py(hist[0])); @@ -6245,7 +6267,7 @@ export function App(): ReactElement { >
{tab === "collector" && ( - + )} {tab === "downloads" && } diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index b134457..470b2c5 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent, ReactElement } from "react"; +import { memo, useEffect, useState, type ChangeEvent, type ReactElement } from "react"; import { formatDateTime, formatHosterLabel, humanSize } from "../../download-format"; import { DataTable, DataTableBody, DataTableEmpty, DataTableHeader } from "../../ui/DataTable"; import { Dialog } from "../../ui/Dialog"; @@ -165,7 +165,19 @@ function CollectorPackageGroup({ row, model, actions, selected }: { const allSelected = row.selectedCount === row.totalCount; const partiallySelected = row.selectedCount > 0 && !allSelected; const animateItems = model.animationsEnabled && row.allLinks.length <= 64; - const renderItems = !row.collapsed || animateItems; + const [renderItems, setRenderItems] = useState(!row.collapsed); + useEffect(() => { + if (!row.collapsed) { + setRenderItems(true); + return; + } + if (!animateItems) { + setRenderItems(false); + return; + } + const timer = window.setTimeout(() => setRenderItems(false), 300); + return () => window.clearTimeout(timer); + }, [animateItems, row.collapsed]); return (
0 ? " is-selected" : ""}`} role="row"> @@ -256,16 +268,21 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE ); } +export const MemoizedCollectorContent = memo( + CollectorContent, + (previous, next) => previous.model === next.model +); + export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement { if (region === "sidebar") return ; if (region === "toolbar") return ; - if (region === "content") return ; + if (region === "content") return ; return (
- +
); diff --git a/src/renderer/views/collector/collector.css b/src/renderer/views/collector/collector.css index eaac2d8..3517817 100644 --- a/src/renderer/views/collector/collector.css +++ b/src/renderer/views/collector/collector.css @@ -251,6 +251,7 @@ } .collector-package-items-frame.is-animated { + animation: collector-items-expand 300ms cubic-bezier(0.2, 0.8, 0.2, 1); transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms ease; } @@ -265,6 +266,17 @@ overflow: hidden; } +@keyframes collector-items-expand { + from { + grid-template-rows: 0fr; + opacity: 0; + } + to { + grid-template-rows: 1fr; + opacity: 1; + } +} + .collector-column-select { display: grid; place-items: center; diff --git a/tests/collector-view.test.tsx b/tests/collector-view.test.tsx index 2328f76..5ef70c5 100644 --- a/tests/collector-view.test.tsx +++ b/tests/collector-view.test.tsx @@ -14,6 +14,7 @@ import { import { CollectorContent, CollectorInputDialog, + MemoizedCollectorContent, CollectorSidebar, CollectorToolbar, CollectorView, @@ -323,10 +324,20 @@ describe("CollectorView", () => { expect(html).not.toContain("SBS14HD.part01.rar"); }); - it("keeps the animated disclosure frame mounted for compact packages", () => { - const html = renderToStaticMarkup(); - expect(html).toContain("collector-package-items-frame is-collapsed is-animated"); - expect(html).toContain("SBS14HD.part01.rar"); + it("removes collapsed child rows from the DOM even when animations are enabled", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain("collector-package-items-frame"); + expect(html).not.toContain("SBS14HD.part01.rar"); + }); + + it("reuses the collector content when app snapshots keep the same collector model", () => { + const model = buildCollectorWorkspaceViewModel(packages, "all", "", false, [], [], "", true); + const compare = (MemoizedCollectorContent as unknown as { + compare: (previous: { model: typeof model; actions: CollectorViewActions }, next: { model: typeof model; actions: CollectorViewActions }) => boolean; + }).compare; + + expect(compare({ model, actions: createActions() }, { model, actions: createActions() })).toBe(true); + expect(compare({ model, actions: createActions() }, { model: { ...model, query: "neu" }, actions: createActions() })).toBe(false); }); it("offers selected and all transfer actions", () => { diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 548d9b0..1f7a2d5 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -3,7 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types"; -import { appendBandwidthSample, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App"; +import { appendBandwidthSample, getIdleSparklineStroke, readBandwidthChartPalette, readDownloadSpeedSparklinePalette } from "../src/renderer/App"; import { buildStatisticsViewModel, type StatisticsMetric @@ -593,6 +593,12 @@ describe("bandwidth chart palette", () => { expect(palette).toEqual({ accent: "rgb(74, 222, 128)" }); }); + it("aligns the idle speed line to one physical pixel at every display scale", () => { + expect(getIdleSparklineStroke(22, 1)).toEqual({ y: 20.5, lineWidth: 1 }); + expect(getIdleSparklineStroke(22, 1.25)).toEqual({ y: 20.4, lineWidth: 0.8 }); + expect(getIdleSparklineStroke(22, 2)).toEqual({ y: 20.25, lineWidth: 0.5 }); + }); + it("labels the live chart and slows redraws when reduced motion is requested", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));