- {status.kind === 'failed' ? (
-
-
- {status.text}
-
- ) : status.kind === 'running' ? (
-
-
-
- {status.pct}
- {status.eta}
-
-
- ) : status.kind === 'done' ? (
-
-
- Done
-
- ) : status.kind === 'queued' ? (
-
-
- Queued
-
- ) : status.kind === 'canceled' ? (
-
-
- Canceled
-
- ) : null}
+
+ ) : (
+
+ {kind.slice(0, 3)}
+
+ )
+ return (
+
+ {src ? : }
+ {kind}
+
+ )
+}
diff --git a/src/renderer/src/components/queue/TotalsRow.tsx b/src/renderer/src/components/queue/TotalsRow.tsx
index 6c51050..6530d89 100644
--- a/src/renderer/src/components/queue/TotalsRow.tsx
+++ b/src/renderer/src/components/queue/TotalsRow.tsx
@@ -3,33 +3,58 @@ import { formatBytes } from '@shared/compress'
import { Icon } from '../icons/Icon'
import type { Totals } from './rowModel'
-export function TotalsRow({ totals: t }: { totals: Totals }): JSX.Element {
+/** Column-aligned under Details; one flat line under the other sizes. */
+export function TotalsRow({
+ totals: t,
+ flat = false
+}: {
+ totals: Totals
+ flat?: boolean
+}): JSX.Element {
+ const files = `${t.files} file${t.files === 1 ? '' : 's'}`
+ const soFar =
+ t.doneSrc > 0 ? (
+ <>
+
{formatBytes(t.doneSrc)}
+
+
{formatBytes(t.doneOut)}
+
so far
+ >
+ ) : null
+ const done = t.files > 0 && (
+
+ {t.done} of {t.files} done
+
+ )
+ if (flat)
+ return (
+
+
+ {files}, {formatBytes(t.bytes)}
+
+ {soFar && (
+
+ {soFar}
+
+ )}
+ {done}
+
+ )
return (
- {t.files} file{t.files === 1 ? '' : 's'}
+ {files}
{formatBytes(t.bytes)}
- {t.doneSrc > 0 && (
- <>
- {formatBytes(t.doneSrc)}
-
- {formatBytes(t.doneOut)}
- so far
- >
- )}
+ {soFar}
- {t.files > 0 && (
-
- {t.done} of {t.files} done
-
- )}
+ {done}
)
diff --git a/src/renderer/src/components/queue/ViewMenu.tsx b/src/renderer/src/components/queue/ViewMenu.tsx
new file mode 100644
index 0000000..16a79e9
--- /dev/null
+++ b/src/renderer/src/components/queue/ViewMenu.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useLayoutEffect, useRef, useState, type JSX, type KeyboardEvent } from 'react'
+import { Icon } from '../icons/Icon'
+import { DEFAULT_VIEW, VIEW_SIZES, viewDef, type ViewSize } from './viewSize'
+
+/** The files toolbar's View button and its menu (spec 2). */
+export function ViewMenu({
+ size,
+ flash,
+ onPick
+}: {
+ size: ViewSize
+ flash: boolean
+ onPick: (s: ViewSize) => void
+}): JSX.Element {
+ const [open, setOpen] = useState(false)
+ const btn = useRef
(null)
+ const pop = useRef(null)
+ const def = viewDef(size)
+
+ // Anchor under the button, right-aligned, before paint; focus the checked entry.
+ useLayoutEffect(() => {
+ const el = pop.current
+ const b = btn.current
+ if (!open || !el || !b) return
+ const r = b.getBoundingClientRect()
+ el.style.top = `${r.bottom}px`
+ el.style.left = `${Math.max(8, r.right - el.offsetWidth)}px`
+ el.style.visibility = 'visible'
+ el.querySelector('[aria-checked="true"]')?.focus()
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ const outside = (e: Event): void => {
+ const t = e.target as Node
+ if (!pop.current?.contains(t) && !btn.current?.contains(t)) setOpen(false)
+ }
+ const away = (): void => setOpen(false)
+ window.addEventListener('mousedown', outside)
+ window.addEventListener('blur', away)
+ window.addEventListener('resize', away)
+ return () => {
+ window.removeEventListener('mousedown', outside)
+ window.removeEventListener('blur', away)
+ window.removeEventListener('resize', away)
+ }
+ }, [open])
+
+ function pick(s: ViewSize): void {
+ onPick(s)
+ setOpen(false)
+ btn.current?.focus()
+ }
+
+ function onKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ setOpen(false)
+ btn.current?.focus()
+ return
+ }
+ if (e.key === 'Tab') {
+ setOpen(false)
+ return
+ }
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return
+ e.preventDefault()
+ const items = Array.from(pop.current?.querySelectorAll('.menu-item') ?? [])
+ if (!items.length) return
+ const i = items.indexOf(document.activeElement as HTMLButtonElement)
+ const n =
+ e.key === 'Home'
+ ? 0
+ : e.key === 'End'
+ ? items.length - 1
+ : e.key === 'ArrowDown'
+ ? (i + 1) % items.length
+ : (i - 1 + items.length) % items.length
+ items[n].focus()
+ }
+
+ return (
+ <>
+ setOpen((o) => !o)}
+ >
+
+ {def.label}
+
+
+
+ {`View: ${def.label}`}
+
+ {open && (
+ e.preventDefault()}
+ >
+ {VIEW_SIZES.map((d) => (
+
pick(d.id)}
+ >
+
+
+ {d.label}
+ Ctrl+Shift+{d.digit}
+
+ ))}
+
+
pick(DEFAULT_VIEW)}
+ >
+
+
+ Reset to Details
+ Ctrl+0
+
+
+
+
+ Ctrl + wheel over the list
+
+
+ Bigger, smaller
+ Ctrl+= / Ctrl+-
+
+
+ )}
+ >
+ )
+}
diff --git a/src/renderer/src/components/queue/cardModel.ts b/src/renderer/src/components/queue/cardModel.ts
new file mode 100644
index 0000000..fd1e040
--- /dev/null
+++ b/src/renderer/src/components/queue/cardModel.ts
@@ -0,0 +1,70 @@
+import type { IconName } from '@shared/icons'
+import type { FileKind } from '@shared/types'
+import type { ItemStatus } from '../../state'
+import type { RowView } from './rowModel'
+import type { ViewSize } from './viewSize'
+
+// The compact status line under an icon-grid card (spec 4, table). Larger
+// cards add detail: the change %, the estimate, the source size.
+
+export interface CardLine {
+ kind: ItemStatus
+ icon: IconName | null
+ /** Bold lead: the result size, or the running percentage. */
+ strong?: string
+ /** Plain text: Queued, Canceled, Failed / the short error, or the source size. */
+ text?: string
+ eta?: string
+ pct?: string
+ /** Right-aligned fg3 extra: the estimate or the source size. */
+ extra?: string
+ title?: string
+}
+
+export function cardLine(v: RowView, size: ViewSize): CardLine {
+ const big = size === 'large' || size === 'xl'
+ const src = big ? v.size : undefined
+ const s = v.status
+ switch (s.kind) {
+ case 'done':
+ return {
+ kind: 'done',
+ icon: 'check',
+ strong: v.result?.text ?? 'Done',
+ pct: big ? (v.result?.pct ?? undefined) : undefined,
+ extra: size === 'xl' ? v.size : undefined
+ }
+ case 'running':
+ return {
+ kind: 'running',
+ icon: 'sync',
+ strong: s.pct ?? '…',
+ eta: s.eta,
+ extra: big ? v.result?.text : undefined
+ }
+ case 'queued':
+ return { kind: 'queued', icon: 'clock', text: 'Queued', extra: src }
+ case 'failed':
+ return { kind: 'failed', icon: 'warning', text: big ? s.text : 'Failed', title: s.title }
+ case 'canceled':
+ return { kind: 'canceled', icon: 'close', text: 'Canceled', extra: src }
+ default:
+ return { kind: 'ready', icon: null, text: v.size }
+ }
+}
+
+const KIND_ICON: Record = {
+ image: 'image',
+ video: 'video',
+ audio: 'audio',
+ pdf: 'pdf',
+ document: 'doc',
+ text: 'text',
+ archive: 'archive',
+ other: 'doc'
+}
+
+/** The glyph a card shows when there is no thumbnail. */
+export function kindIcon(k: FileKind): IconName {
+ return KIND_ICON[k]
+}
diff --git a/src/renderer/src/components/queue/gridKeys.ts b/src/renderer/src/components/queue/gridKeys.ts
new file mode 100644
index 0000000..2a2a508
--- /dev/null
+++ b/src/renderer/src/components/queue/gridKeys.ts
@@ -0,0 +1,53 @@
+import type { TableKey } from './tableKeys'
+
+// Keyboard movement over the files view (spec 4): Details is one column, Tiles
+// two, the icon grids as many as fit. `groups` holds each convert group's ids in
+// display order; each group starts a new visual row under its header.
+
+export type GridDir = 'up' | 'down' | 'left' | 'right'
+
+const MOVES: Partial> = {
+ up: { dir: 'up', extend: false },
+ down: { dir: 'down', extend: false },
+ left: { dir: 'left', extend: false },
+ right: { dir: 'right', extend: false },
+ extendUp: { dir: 'up', extend: true },
+ extendDown: { dir: 'down', extend: true },
+ extendLeft: { dir: 'left', extend: true },
+ extendRight: { dir: 'right', extend: true }
+}
+
+export function moveFor(k: TableKey): { dir: GridDir; extend: boolean } | null {
+ return MOVES[k] ?? null
+}
+
+export function gridNeighbor(
+ groups: string[][],
+ id: string,
+ dir: GridDir,
+ cols: number
+): string | undefined {
+ const g = groups.findIndex((list) => list.includes(id))
+ if (g < 0) return undefined
+ const c = Math.max(1, Math.floor(cols))
+ if (dir === 'left' || dir === 'right') {
+ if (c === 1) return undefined
+ const flat = groups.flat()
+ return flat[flat.indexOf(id) + (dir === 'left' ? -1 : 1)]
+ }
+ const list = groups[g]
+ const i = list.indexOf(id)
+ const col = i % c
+ if (dir === 'down') {
+ if (i + c < list.length) return list[i + c]
+ // A short last row below: land on its last item, like Explorer.
+ if (Math.floor(i / c) < Math.floor((list.length - 1) / c)) return list[list.length - 1]
+ const next = groups[g + 1]
+ return next ? next[Math.min(col, next.length - 1)] : undefined
+ }
+ if (i - c >= 0) return list[i - c]
+ const prev = groups[g - 1]
+ if (!prev) return undefined
+ const lastRowStart = Math.floor((prev.length - 1) / c) * c
+ return prev[Math.min(lastRowStart + col, prev.length - 1)]
+}
diff --git a/src/renderer/src/components/queue/rowProps.ts b/src/renderer/src/components/queue/rowProps.ts
new file mode 100644
index 0000000..ceff49d
--- /dev/null
+++ b/src/renderer/src/components/queue/rowProps.ts
@@ -0,0 +1,19 @@
+import type { KeyboardEvent, MouseEvent } from 'react'
+import type { QueueItem } from '../../state'
+import type { RowView } from './rowModel'
+
+/** What every row renderer (Details row, tile, card) gets from QueueTable, so
+ * selection, the menu, open and keyboard behave the same in every size. */
+export interface RowProps {
+ item: QueueItem
+ view: RowView
+ selected: boolean
+ /** A row of a different convert group than the selection (spec 2.6). */
+ dim: boolean
+ focusable: boolean
+ onClick: (e: MouseEvent) => void
+ onToggle: () => void
+ onOpen: () => void
+ onMenu: (x: number, y: number) => void
+ onKeyDown: (e: KeyboardEvent) => void
+}
diff --git a/src/renderer/src/components/queue/tableKeys.ts b/src/renderer/src/components/queue/tableKeys.ts
index 13ac8e6..979708b 100644
--- a/src/renderer/src/components/queue/tableKeys.ts
+++ b/src/renderer/src/components/queue/tableKeys.ts
@@ -1,5 +1,17 @@
export type TableKey =
- 'up' | 'down' | 'extendUp' | 'extendDown' | 'toggle' | 'selectAll' | 'remove' | 'open' | 'menu'
+ | 'up'
+ | 'down'
+ | 'left'
+ | 'right'
+ | 'extendUp'
+ | 'extendDown'
+ | 'extendLeft'
+ | 'extendRight'
+ | 'toggle'
+ | 'selectAll'
+ | 'remove'
+ | 'open'
+ | 'menu'
export interface KeyLike {
key: string
@@ -16,6 +28,8 @@ export function tableKey(e: KeyLike): TableKey | null {
if (mod) return e.key.toLowerCase() === 'a' && !e.shiftKey ? 'selectAll' : null
if (e.key === 'ArrowUp') return e.shiftKey ? 'extendUp' : 'up'
if (e.key === 'ArrowDown') return e.shiftKey ? 'extendDown' : 'down'
+ if (e.key === 'ArrowLeft') return e.shiftKey ? 'extendLeft' : 'left'
+ if (e.key === 'ArrowRight') return e.shiftKey ? 'extendRight' : 'right'
if (e.key === ' ') return 'toggle'
if (e.key === 'Delete') return 'remove'
if (e.key === 'Enter') return 'open'
diff --git a/src/renderer/src/components/queue/thumbSize.ts b/src/renderer/src/components/queue/thumbSize.ts
new file mode 100644
index 0000000..1efeb59
--- /dev/null
+++ b/src/renderer/src/components/queue/thumbSize.ts
@@ -0,0 +1,50 @@
+import type { ViewSize } from './viewSize'
+
+// Which thumbnail resolution each view size needs on this screen (spec 5).
+// Pure, so Vitest covers it; the renderer feeds it window.devicePixelRatio.
+
+/** Thumbnail resolutions the renderer asks main for. 128 is the one every item gets. */
+export const THUMB_BUCKETS = [128, 256, 512, 768, 1024] as const
+export type ThumbBucket = (typeof THUMB_BUCKETS)[number]
+export const BASE_THUMB: ThumbBucket = 128
+
+/** Card width in CSS px per size (Details: the row thumb, Tiles: the tile thumb).
+ * Cards use their full --cw: the thumb is a little narrower (8px padding a
+ * side), so this errs on the sharp side. */
+export const THUMB_CSS_PX: Record = {
+ details: 18,
+ 'details-l': 32,
+ tiles: 48,
+ medium: 132,
+ large: 196,
+ xl: 274
+}
+
+/** The smallest bucket that covers the size's thumb in device pixels, keeping
+ * 128 whenever it already suffices. Capped at the largest bucket. */
+export function thumbPxFor(size: ViewSize, dpr: number): ThumbBucket {
+ const ratio = Number.isFinite(dpr) && dpr > 0 ? dpr : 1
+ const need = Math.ceil(THUMB_CSS_PX[size] * ratio)
+ return THUMB_BUCKETS.find((b) => b >= need) ?? THUMB_BUCKETS[THUMB_BUCKETS.length - 1]
+}
+
+/** Cache key of a big thumbnail: one per source path and bucket. */
+export const thumbKey = (path: string, px: number): string => `${path}@${px}`
+
+/** A thumbnail cache by thumbKey; null marks a request that came back empty. */
+export type ThumbCache = Readonly>
+
+/** The best cached big thumbnail for a path: the wanted bucket, else the
+ * smallest larger one (going back down reuses it), else the largest smaller
+ * one above 128. Undefined when none is cached. */
+export function bestThumb(cache: ThumbCache, path: string, px: number): string | undefined {
+ const have = THUMB_BUCKETS.filter((b) => b > BASE_THUMB && cache[thumbKey(path, b)])
+ const pick = have.find((b) => b >= px) ?? have[have.length - 1]
+ return pick === undefined ? undefined : (cache[thumbKey(path, pick)] ?? undefined)
+}
+
+/** Whether a request for this bucket is still worth making: nothing at or above it is cached. */
+export function needsThumb(cache: ThumbCache, path: string, px: number): boolean {
+ if (px <= BASE_THUMB) return false
+ return !THUMB_BUCKETS.some((b) => b >= px && cache[thumbKey(path, b)])
+}
diff --git a/src/renderer/src/components/queue/useDevicePixelRatio.ts b/src/renderer/src/components/queue/useDevicePixelRatio.ts
new file mode 100644
index 0000000..8c978c0
--- /dev/null
+++ b/src/renderer/src/components/queue/useDevicePixelRatio.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from 'react'
+
+/** window.devicePixelRatio, kept current when the window moves to a monitor
+ * with another scale (or the page zoom changes): a resolution media query for
+ * the current ratio fires once it stops matching, then re-arms for the new one. */
+export function useDevicePixelRatio(): number {
+ const [dpr, setDpr] = useState(() => window.devicePixelRatio || 1)
+ useEffect(() => {
+ const mq = window.matchMedia(`(resolution: ${dpr}dppx)`)
+ const update = (): void => setDpr(window.devicePixelRatio || 1)
+ mq.addEventListener('change', update)
+ // Belt and braces: some moves between monitors only show up as a resize.
+ window.addEventListener('resize', update)
+ return () => {
+ mq.removeEventListener('change', update)
+ window.removeEventListener('resize', update)
+ }
+ }, [dpr])
+ return dpr
+}
diff --git a/src/renderer/src/components/queue/useViewSize.ts b/src/renderer/src/components/queue/useViewSize.ts
new file mode 100644
index 0000000..70f89de
--- /dev/null
+++ b/src/renderer/src/components/queue/useViewSize.ts
@@ -0,0 +1,49 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { DEFAULT_VIEW, parseViewSize, stepSize, VIEW_KEY, type ViewSize } from './viewSize'
+
+function read(): ViewSize {
+ try {
+ return parseViewSize(localStorage.getItem(VIEW_KEY))
+ } catch {
+ return DEFAULT_VIEW
+ }
+}
+
+/** The files view size, one per app (spec 3), persisted like the sidebar. */
+export function useViewSize(): {
+ size: ViewSize
+ flash: boolean
+ setSize: (s: ViewSize, opts?: { flash?: boolean }) => boolean
+ step: (delta: number) => boolean
+} {
+ const [size, setState] = useState(read)
+ const [flash, setFlash] = useState(false)
+ // Mirrors `size` for the stable callbacks; written only inside them.
+ const cur = useRef(size)
+ const timer = useRef(undefined)
+ useEffect(() => () => window.clearTimeout(timer.current), [])
+
+ const setSize = useCallback((next: ViewSize, opts: { flash?: boolean } = {}): boolean => {
+ if (next === cur.current) return false
+ cur.current = next
+ setState(next)
+ try {
+ localStorage.setItem(VIEW_KEY, next)
+ } catch {
+ /* storage blocked: the size still applies for this session */
+ }
+ if (opts.flash) {
+ setFlash(true)
+ window.clearTimeout(timer.current)
+ timer.current = window.setTimeout(() => setFlash(false), 450)
+ }
+ return true
+ }, [])
+
+ const step = useCallback(
+ (delta: number): boolean => setSize(stepSize(cur.current, delta), { flash: true }),
+ [setSize]
+ )
+
+ return { size, flash, setSize, step }
+}
diff --git a/src/renderer/src/components/queue/viewSize.ts b/src/renderer/src/components/queue/viewSize.ts
new file mode 100644
index 0000000..46544b6
--- /dev/null
+++ b/src/renderer/src/components/queue/viewSize.ts
@@ -0,0 +1,98 @@
+import type { KeyLike } from './tableKeys'
+
+// Pure model for the files view sizes (spec 1-3), named like Windows File
+// Explorer. Kept out of the .tsx files so Vitest and Fast Refresh both work.
+
+export type ViewSize = 'details' | 'details-l' | 'tiles' | 'medium' | 'large' | 'xl'
+export type ViewIcon =
+ 'view-details' | 'view-details-l' | 'view-tiles' | 'view-medium' | 'view-large' | 'view-xl'
+
+export interface ViewSizeDef {
+ id: ViewSize
+ label: string
+ icon: ViewIcon
+ /** Ctrl+Shift+ jumps to this size. */
+ digit: number
+}
+
+export const VIEW_SIZES: readonly ViewSizeDef[] = [
+ { id: 'details', label: 'Details', icon: 'view-details', digit: 1 },
+ { id: 'details-l', label: 'Large details', icon: 'view-details-l', digit: 2 },
+ { id: 'tiles', label: 'Tiles', icon: 'view-tiles', digit: 3 },
+ { id: 'medium', label: 'Medium icons', icon: 'view-medium', digit: 4 },
+ { id: 'large', label: 'Large icons', icon: 'view-large', digit: 5 },
+ { id: 'xl', label: 'Extra large icons', icon: 'view-xl', digit: 6 }
+]
+
+/** The two table sizes: head, columns, column-aligned totals. */
+export function isListSize(s: ViewSize): boolean {
+ return s === 'details' || s === 'details-l'
+}
+
+export const DEFAULT_VIEW: ViewSize = 'details'
+export const VIEW_KEY = 'filesmith.viewSize'
+
+const indexOf = (s: ViewSize): number => VIEW_SIZES.findIndex((d) => d.id === s)
+
+export function viewDef(s: ViewSize): ViewSizeDef {
+ return VIEW_SIZES[Math.max(0, indexOf(s))]
+}
+
+export function stepSize(s: ViewSize, delta: number): ViewSize {
+ const i = Math.max(0, Math.min(VIEW_SIZES.length - 1, indexOf(s) + delta))
+ return VIEW_SIZES[i].id
+}
+
+export function parseViewSize(v: unknown): ViewSize {
+ return VIEW_SIZES.find((d) => d.id === v)?.id ?? DEFAULT_VIEW
+}
+
+export type ViewKey = { kind: 'step'; delta: 1 | -1 } | { kind: 'set'; size: ViewSize }
+export interface ViewKeyLike extends KeyLike {
+ code: string
+}
+
+/** Ctrl+= / Ctrl++ bigger, Ctrl+- smaller, Ctrl+0 Details, Ctrl+Shift+1..6 a size.
+ * Digits match on `code`, so Shift+1 = `!` on Nordic layouts still works. */
+export function viewKeyFor(e: ViewKeyLike): ViewKey | null {
+ if (!(e.ctrlKey || e.metaKey) || e.altKey) return null
+ if (e.shiftKey) {
+ const m = /^Digit([1-9])$/.exec(e.code)
+ const def = m ? VIEW_SIZES[Number(m[1]) - 1] : undefined
+ if (def) return { kind: 'set', size: def.id }
+ return e.key === '+' ? { kind: 'step', delta: 1 } : null
+ }
+ if (e.key === '=' || e.key === '+') return { kind: 'step', delta: 1 }
+ if (e.key === '-') return { kind: 'step', delta: -1 }
+ if (e.key === '0') return { kind: 'set', size: DEFAULT_VIEW }
+ return null
+}
+
+export interface WheelState {
+ acc: number
+ /** Time of the last wheel event. */
+ at: number
+ /** Time of the last step. */
+ last: number
+}
+export const WHEEL_IDLE: WheelState = { acc: 0, at: -Infinity, last: -Infinity }
+
+const THRESHOLD = 40
+const MIN_GAP_MS = 90
+const STALE_MS = 250
+
+export function wheelDelta(deltaY: number, deltaMode: number): number {
+ return deltaMode === 1 ? deltaY * 40 : deltaMode === 2 ? deltaY * 400 : deltaY
+}
+
+/** One step per notch; trackpad deltas accumulate. Wheel up (negative) = bigger. */
+export function wheelStep(
+ s: WheelState,
+ delta: number,
+ now: number
+): { state: WheelState; step: -1 | 0 | 1 } {
+ const acc = (now - s.at > STALE_MS ? 0 : s.acc) + delta
+ if (Math.abs(acc) >= THRESHOLD && now - s.last > MIN_GAP_MS)
+ return { state: { acc: 0, at: now, last: now }, step: acc < 0 ? 1 : -1 }
+ return { state: { acc, at: now, last: s.last }, step: 0 }
+}
diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css
index f06d75c..d523aec 100644
--- a/src/renderer/src/index.css
+++ b/src/renderer/src/index.css
@@ -4,6 +4,7 @@
@import './theme/workbench.css';
@import './theme/controls.css';
@import './theme/views.css';
+@import './theme/viewsizes.css';
/* Mirror the tokens into Tailwind so utilities (bg-bg-0, text-fg2,
border-line) resolve to the variables; a future theme redefines only the
diff --git a/src/renderer/src/theme/viewsizes.css b/src/renderer/src/theme/viewsizes.css
new file mode 100644
index 0000000..0aba483
--- /dev/null
+++ b/src/renderer/src/theme/viewsizes.css
@@ -0,0 +1,356 @@
+/* View sizes (spec docs/superpowers/specs/2026-10-05-view-sizes-design.md),
+ values from docs/mockups/view-zoom/04-explorer-style.html. */
+
+/* toolbar View button */
+.viewbtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 10px 0 12px;
+ border-left: 1px solid var(--line);
+ color: var(--fg2);
+ font-size: 12px;
+ line-height: 1;
+ transition:
+ background-color 0.2s,
+ color 0.2s;
+}
+.viewbtn .vl {
+ color: var(--fg1);
+ min-width: 96px;
+ text-align: left;
+}
+.viewbtn .k {
+ color: var(--fg3);
+}
+.viewbtn:hover,
+.viewbtn[aria-expanded='true'] {
+ background: var(--hover);
+ color: var(--fg1);
+}
+.viewbtn:active {
+ background: var(--selected);
+}
+.viewbtn.flash {
+ background: var(--inv-bg);
+ color: var(--fg1);
+ transition: none;
+}
+
+/* its menu (shares .menu / .menu-item / .menu-sep with the context menu) */
+.viewmenu {
+ min-width: 272px;
+}
+.viewmenu .menu-item {
+ white-space: nowrap;
+}
+.viewmenu .ml {
+ flex: 1;
+}
+.viewmenu .sc {
+ font: 11px var(--mono);
+ color: var(--fg3);
+}
+.viewmenu .tick {
+ width: 12px;
+ height: 12px;
+ flex: none;
+ visibility: hidden;
+}
+.viewmenu .menu-item[aria-checked='true'] {
+ color: var(--fg1);
+ font-weight: 600;
+}
+.viewmenu .menu-item[aria-checked='true'] .tick {
+ visibility: visible;
+}
+.menu-hint {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 28px;
+ padding: 0 12px;
+ font-size: 12px;
+ color: var(--fg3);
+}
+.menu-hint.indent {
+ padding-left: 38px;
+}
+.menu-hint .sc {
+ margin-left: auto;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .viewbtn {
+ transition: none;
+ }
+ .viewbtn.flash {
+ background: transparent;
+ color: var(--fg2);
+ }
+}
+
+/* Large details: the Details table with 44px rows, a 32px thumb and 14px names */
+.qtable[data-size='details-l'] .tr {
+ height: 44px;
+}
+.qtable[data-size='details-l'] .tr .thumb {
+ width: 32px;
+ height: 32px;
+ font-size: 10px;
+}
+.qtable[data-size='details-l'] .tr .name {
+ font-size: 14px;
+}
+
+/* Tiles: two columns of 64px two-line tiles, 48px thumb */
+.tiles {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.tile {
+ position: relative;
+ display: grid;
+ grid-template-columns: 22px 48px 1fr;
+ align-items: center;
+ column-gap: 10px;
+ height: 64px;
+ padding: 0 12px 0 8px;
+ min-width: 0;
+ border-bottom: 1px solid var(--line);
+ cursor: default;
+}
+.tile:nth-child(odd) {
+ border-right: 1px solid var(--line);
+}
+.tile:hover {
+ background: var(--hover);
+}
+.tile.sel {
+ background: var(--selected);
+}
+.tile:focus-visible,
+.card:focus-visible {
+ outline: 1px solid var(--focus);
+ outline-offset: -1px;
+}
+.tile .tck {
+ display: grid;
+ place-items: center;
+}
+.tile .thumb {
+ width: 48px;
+ height: 48px;
+}
+.tile .lines {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ min-width: 0;
+}
+.tile .l1 {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ min-width: 0;
+ white-space: nowrap;
+}
+.tile .l1 .meta {
+ margin-left: auto;
+ flex: none;
+}
+.tile .l2 {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+ height: 22px;
+ white-space: nowrap;
+}
+.tile .l2 .res {
+ gap: 6px;
+}
+.tile .l2 .st {
+ margin-left: auto;
+}
+.tile .l2 .st.fail {
+ margin-left: 0;
+}
+.tile:hover .meta,
+.tile.sel .meta,
+.tile:hover .res .pct,
+.tile.sel .res .pct {
+ color: var(--fg2);
+}
+
+/* Icon grids: Medium 132px, Large 196px, Extra large 274px cards */
+.icons {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, var(--cw));
+ gap: 8px;
+ padding: 10px 12px 14px;
+}
+.icons.medium {
+ --cw: 132px;
+}
+.icons.large {
+ --cw: 196px;
+}
+.icons.xl {
+ --cw: 274px;
+}
+.card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px;
+ min-width: 0;
+ cursor: default;
+}
+.card:hover {
+ background: var(--hover);
+}
+.card.sel {
+ background: var(--selected);
+}
+.card .cck {
+ position: absolute;
+ left: 13px;
+ top: 13px;
+ z-index: 1;
+ display: grid;
+ opacity: 0;
+ transition: opacity 0.1s;
+}
+.card:hover .cck,
+.card:focus-within .cck,
+.card.sel .cck,
+.icons.any .cck {
+ opacity: 1;
+}
+.card .box {
+ background: var(--bg-0);
+}
+.card .box.on {
+ background: var(--inv-bg);
+}
+.thumb.big {
+ position: relative;
+ display: block;
+ width: 100%;
+ height: auto;
+ aspect-ratio: 1;
+}
+.thumb.big img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ image-rendering: auto;
+}
+.thumb.big .ph {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 32%;
+ height: 32%;
+ transform: translate(-50%, -50%);
+ color: var(--fg3);
+}
+.thumb .kt {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ padding: 2px 5px;
+ background: var(--bg-0);
+ font: 10px/1.2 var(--mono);
+ color: var(--fg2);
+ letter-spacing: 0.02em;
+}
+.card .cname {
+ font: 12px/1.35 var(--mono);
+ color: var(--fg1);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow-wrap: anywhere;
+}
+.icons.large .cname,
+.icons.xl .cname {
+ font-size: 13px;
+}
+.card .cst {
+ margin-top: auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 20px;
+ min-width: 0;
+ font: 12px var(--mono);
+ font-variant-numeric: tabular-nums;
+ color: var(--fg2);
+ white-space: nowrap;
+}
+.card .cst svg {
+ flex: none;
+}
+.card .cst b {
+ font-weight: 600;
+ color: var(--fg1);
+}
+.card .cst .pct,
+.card .cst .eta {
+ color: var(--fg3);
+}
+.card .cst .xtra {
+ margin-left: auto;
+ color: var(--fg3);
+}
+.card .cst.queued,
+.card .cst.canceled,
+.card .cst.ready {
+ color: var(--fg3);
+}
+.card .cst.failed {
+ align-self: flex-start;
+ max-width: 100%;
+ padding: 0 6px 0 4px;
+ color: var(--inv-fg);
+ background: var(--inv-bg);
+ font-weight: 600;
+}
+.card .cst.failed .ct {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* cross-group rows, as .tr.dim in views.css */
+.tile.dim .name,
+.tile.dim .meta,
+.tile.dim .size,
+.card.dim .cname {
+ color: var(--fg3);
+}
+.tile.dim .thumb,
+.card.dim .thumb {
+ opacity: 0.4;
+}
+
+/* totals as one line under the non-Details sizes */
+.totals.flat {
+ display: flex;
+ align-items: center;
+ gap: 22px;
+ padding: 0 16px;
+}
+.totals.flat .res {
+ gap: 8px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .card .cck {
+ transition: none;
+ }
+}
diff --git a/src/shared/icons.ts b/src/shared/icons.ts
index 70bbdcc..933f44b 100644
--- a/src/shared/icons.ts
+++ b/src/shared/icons.ts
@@ -45,7 +45,14 @@ export const ICON_NAMES = [
'tocbz',
'edit',
'grip',
- 'dots'
+ 'dots',
+ 'view-details',
+ 'view-details-l',
+ 'view-tiles',
+ 'view-medium',
+ 'view-large',
+ 'view-xl',
+ 'mouse'
] as const
export type IconName = (typeof ICON_NAMES)[number]
diff --git a/test/card-model.test.ts b/test/card-model.test.ts
new file mode 100644
index 0000000..eebe0df
--- /dev/null
+++ b/test/card-model.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest'
+import { cardLine, kindIcon } from '../src/renderer/src/components/queue/cardModel'
+import type { RowView } from '../src/renderer/src/components/queue/rowModel'
+
+const base: RowView = {
+ kind: 'PNG',
+ size: '1.8 MB',
+ result: null,
+ status: { kind: 'ready', text: '' },
+ action: { kind: 'remove', icon: 'close', label: 'Remove', ghost: true }
+}
+const done: RowView = {
+ ...base,
+ result: { text: '486 KB', pct: '-77%', estimate: false, grew: false },
+ status: { kind: 'done', text: 'Done' }
+}
+const running: RowView = {
+ ...base,
+ result: { text: '~410 KB', pct: '-77%', estimate: true, grew: false },
+ status: { kind: 'running', text: '', pct: '62%', eta: '(4s)' }
+}
+const failed: RowView = {
+ ...base,
+ status: {
+ kind: 'failed',
+ text: 'Unsupported compression',
+ title: 'Unsupported compression in TIFF'
+ }
+}
+
+describe('cardLine', () => {
+ it('done: result, then %, then the source size as the cards grow', () => {
+ expect(cardLine(done, 'medium')).toEqual({ kind: 'done', icon: 'check', strong: '486 KB' })
+ expect(cardLine(done, 'large')).toEqual({
+ kind: 'done',
+ icon: 'check',
+ strong: '486 KB',
+ pct: '-77%'
+ })
+ expect(cardLine(done, 'xl')).toEqual({
+ kind: 'done',
+ icon: 'check',
+ strong: '486 KB',
+ pct: '-77%',
+ extra: '1.8 MB'
+ })
+ expect(cardLine({ ...done, result: null }, 'medium').strong).toBe('Done')
+ })
+ it('running: 62%(4s), plus the estimate from Large up', () => {
+ expect(cardLine(running, 'medium')).toEqual({
+ kind: 'running',
+ icon: 'sync',
+ strong: '62%',
+ eta: '(4s)'
+ })
+ expect(cardLine(running, 'large').extra).toBe('~410 KB')
+ })
+ it('queued and canceled add the source size from Large up', () => {
+ const q: RowView = { ...base, status: { kind: 'queued', text: 'Queued' } }
+ expect(cardLine(q, 'medium')).toEqual({ kind: 'queued', icon: 'clock', text: 'Queued' })
+ expect(cardLine(q, 'large').extra).toBe('1.8 MB')
+ const c: RowView = { ...base, status: { kind: 'canceled', text: 'Canceled' } }
+ expect(cardLine(c, 'medium')).toEqual({ kind: 'canceled', icon: 'close', text: 'Canceled' })
+ expect(cardLine(c, 'xl').extra).toBe('1.8 MB')
+ })
+ it('failed: "Failed" on Medium, the short error from Large up, full error as title', () => {
+ expect(cardLine(failed, 'medium')).toEqual({
+ kind: 'failed',
+ icon: 'warning',
+ text: 'Failed',
+ title: 'Unsupported compression in TIFF'
+ })
+ expect(cardLine(failed, 'large').text).toBe('Unsupported compression')
+ })
+ it('ready: just the source size', () => {
+ expect(cardLine(base, 'medium')).toEqual({ kind: 'ready', icon: null, text: '1.8 MB' })
+ })
+})
+
+describe('kindIcon', () => {
+ it('maps each file kind to a glyph', () => {
+ expect(kindIcon('image')).toBe('image')
+ expect(kindIcon('video')).toBe('video')
+ expect(kindIcon('audio')).toBe('audio')
+ expect(kindIcon('pdf')).toBe('pdf')
+ expect(kindIcon('document')).toBe('doc')
+ expect(kindIcon('text')).toBe('text')
+ expect(kindIcon('archive')).toBe('archive')
+ expect(kindIcon('other')).toBe('doc')
+ })
+})
diff --git a/test/grid-keys.test.ts b/test/grid-keys.test.ts
new file mode 100644
index 0000000..52aae81
--- /dev/null
+++ b/test/grid-keys.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { gridNeighbor, moveFor } from '../src/renderer/src/components/queue/gridKeys'
+
+// Two groups in a 4-column grid:
+// a b c d (group 1)
+// e f g
+// h i (group 2)
+const G = [
+ ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
+ ['h', 'i']
+]
+
+describe('gridNeighbor', () => {
+ it('walks left and right in visual order, across groups', () => {
+ expect(gridNeighbor(G, 'd', 'right', 4)).toBe('e')
+ expect(gridNeighbor(G, 'g', 'right', 4)).toBe('h')
+ expect(gridNeighbor(G, 'h', 'left', 4)).toBe('g')
+ expect(gridNeighbor(G, 'a', 'left', 4)).toBeUndefined()
+ expect(gridNeighbor(G, 'i', 'right', 4)).toBeUndefined()
+ })
+ it('moves one visual row in the same column', () => {
+ expect(gridNeighbor(G, 'a', 'down', 4)).toBe('e')
+ expect(gridNeighbor(G, 'c', 'down', 4)).toBe('g')
+ expect(gridNeighbor(G, 'f', 'up', 4)).toBe('b')
+ })
+ it('reaches a short last row from the column above it', () => {
+ expect(gridNeighbor(G, 'd', 'down', 4)).toBe('g')
+ })
+ it('crosses group headers at the nearest column', () => {
+ expect(gridNeighbor(G, 'e', 'down', 4)).toBe('h')
+ expect(gridNeighbor(G, 'g', 'down', 4)).toBe('i')
+ expect(gridNeighbor(G, 'h', 'up', 4)).toBe('e')
+ expect(gridNeighbor(G, 'i', 'up', 4)).toBe('f')
+ expect(gridNeighbor(G, 'a', 'up', 4)).toBeUndefined()
+ expect(gridNeighbor(G, 'i', 'down', 4)).toBeUndefined()
+ })
+ it('is the plain list order with one column (Details), where Left/Right do nothing', () => {
+ expect(gridNeighbor(G, 'g', 'down', 1)).toBe('h')
+ expect(gridNeighbor(G, 'h', 'up', 1)).toBe('g')
+ expect(gridNeighbor(G, 'b', 'up', 1)).toBe('a')
+ expect(gridNeighbor(G, 'b', 'right', 1)).toBeUndefined()
+ })
+ it('returns nothing for an unknown id', () => {
+ expect(gridNeighbor(G, 'zz', 'down', 4)).toBeUndefined()
+ })
+})
+
+describe('moveFor', () => {
+ it('maps arrow keys to a direction and whether Shift extends', () => {
+ expect(moveFor('up')).toEqual({ dir: 'up', extend: false })
+ expect(moveFor('extendDown')).toEqual({ dir: 'down', extend: true })
+ expect(moveFor('left')).toEqual({ dir: 'left', extend: false })
+ expect(moveFor('extendRight')).toEqual({ dir: 'right', extend: true })
+ expect(moveFor('toggle')).toBeNull()
+ expect(moveFor('menu')).toBeNull()
+ })
+})
diff --git a/test/icons.test.ts b/test/icons.test.ts
index 5d58394..4128c1b 100644
--- a/test/icons.test.ts
+++ b/test/icons.test.ts
@@ -39,6 +39,10 @@ describe('icon registry', () => {
]
for (const n of mockup) expect(ICON_NAMES as readonly string[]).toContain(n)
})
+ it('draws the view-size glyphs and the mouse hint', () => {
+ for (const n of ['view-details', 'view-tiles', 'view-medium', 'view-large', 'view-xl', 'mouse'])
+ expect(ICON_NAMES as readonly string[]).toContain(n)
+ })
it('stays inside the 16px grid', () => {
for (const [name, prims] of Object.entries(ICON_SHAPES))
for (const p of prims)
diff --git a/test/no-em-dash.test.ts b/test/no-em-dash.test.ts
index c10802b..d4ae375 100644
--- a/test/no-em-dash.test.ts
+++ b/test/no-em-dash.test.ts
@@ -22,7 +22,12 @@ const TARGETS = [
'resources/skill',
'build/installer/path.nsh',
'docs/cli.md',
- 'docs/superpowers/plans/2026-10-04-cli-and-skill.md'
+ 'docs/superpowers/plans/2026-10-04-cli-and-skill.md',
+ 'src/renderer/src/components/queue',
+ 'src/renderer/src/theme/viewsizes.css',
+ 'e2e/viewsizes.spec.ts',
+ 'docs/superpowers/specs/2026-10-05-view-sizes-design.md',
+ 'docs/superpowers/plans/2026-10-05-view-sizes.md'
]
function files(p: string): string[] {
diff --git a/test/table-keys.test.ts b/test/table-keys.test.ts
index 88ff4ab..a2ad418 100644
--- a/test/table-keys.test.ts
+++ b/test/table-keys.test.ts
@@ -32,4 +32,10 @@ describe('tableKey', () => {
expect(tableKey(k('a'))).toBeNull()
expect(tableKey(k('ArrowUp', { altKey: true }))).toBeNull()
})
+ it('maps Left/Right for the grid sizes, Shift extends', () => {
+ expect(tableKey(k('ArrowLeft'))).toBe('left')
+ expect(tableKey(k('ArrowRight'))).toBe('right')
+ expect(tableKey(k('ArrowLeft', { shiftKey: true }))).toBe('extendLeft')
+ expect(tableKey(k('ArrowRight', { shiftKey: true }))).toBe('extendRight')
+ })
})
diff --git a/test/thumb-size.test.ts b/test/thumb-size.test.ts
new file mode 100644
index 0000000..07eaa4a
--- /dev/null
+++ b/test/thumb-size.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest'
+import {
+ bestThumb,
+ needsThumb,
+ thumbKey,
+ thumbPxFor,
+ THUMB_BUCKETS
+} from '../src/renderer/src/components/queue/thumbSize'
+import { VIEW_SIZES } from '../src/renderer/src/components/queue/viewSize'
+import { magickGeometry, osTooSmall, scale } from '../src/main/thumbnail'
+
+const per = (dpr: number): number[] => VIEW_SIZES.map((s) => thumbPxFor(s.id, dpr))
+
+describe('thumbnail bucket per size and screen', () => {
+ // details, details-l, tiles, medium, large, xl
+ it('DPR 1: 128 for the lists and tiles, then 256, 256, 512', () => {
+ expect(per(1)).toEqual([128, 128, 128, 256, 256, 512])
+ })
+ it('DPR 1.5', () => {
+ expect(per(1.5)).toEqual([128, 128, 128, 256, 512, 512])
+ })
+ it('DPR 2.25 (a 4K screen at 225%): Extra large icons get 768', () => {
+ expect(per(2.25)).toEqual([128, 128, 128, 512, 512, 768])
+ })
+ it('caps at the largest bucket and treats a bad ratio as 1', () => {
+ expect(thumbPxFor('xl', 8)).toBe(1024)
+ expect(thumbPxFor('xl', 0)).toBe(512)
+ expect(thumbPxFor('xl', Number.NaN)).toBe(512)
+ expect(THUMB_BUCKETS).toEqual([128, 256, 512, 768, 1024])
+ })
+})
+
+describe('big thumbnail cache', () => {
+ const p = 'C:\\x\\a.png'
+ it('keys by path and bucket', () => {
+ expect(thumbKey(p, 512)).toBe('C:\\x\\a.png@512')
+ })
+ it('shows the wanted bucket, else a bigger one, else the best smaller one', () => {
+ expect(bestThumb({}, p, 512)).toBeUndefined()
+ expect(bestThumb({ [thumbKey(p, 256)]: 's' }, p, 512)).toBe('s')
+ const both = { [thumbKey(p, 256)]: 's', [thumbKey(p, 768)]: 'l' }
+ expect(bestThumb(both, p, 768)).toBe('l')
+ expect(bestThumb(both, p, 512)).toBe('l')
+ expect(bestThumb(both, p, 256)).toBe('s')
+ expect(bestThumb({ [thumbKey(p, 512)]: null }, p, 512)).toBeUndefined()
+ })
+ it('asks only when nothing at or above the bucket is cached', () => {
+ expect(needsThumb({}, p, 128)).toBe(false)
+ expect(needsThumb({}, p, 512)).toBe(true)
+ expect(needsThumb({ [thumbKey(p, 256)]: 's' }, p, 512)).toBe(true)
+ expect(needsThumb({ [thumbKey(p, 768)]: 'l' }, p, 512)).toBe(false)
+ expect(needsThumb({ [thumbKey('other', 768)]: 'l' }, p, 512)).toBe(true)
+ })
+})
+
+describe('OS thumbnail too small -> tool fallback', () => {
+ const d = (width: number, height: number) => ({ width, height })
+ it('falls back when the shell caps a big request at 256', () => {
+ expect(osTooSmall(d(256, 171), 768, 'image')).toBe(true)
+ expect(osTooSmall(d(256, 144), 512, 'video')).toBe(true)
+ expect(osTooSmall(d(256, 256), 512, 'audio')).toBe(true)
+ })
+ it('keeps a result that meets the request, give or take rounding', () => {
+ expect(osTooSmall(d(768, 512), 768, 'image')).toBe(false)
+ expect(osTooSmall(d(767, 511), 768, 'image')).toBe(false)
+ })
+ it('cover measures the short side, so a fitted 16:9 frame is too small', () => {
+ expect(osTooSmall(d(768, 432), 768, 'video', 'cover')).toBe(true)
+ expect(osTooSmall(d(1365, 768), 768, 'video', 'cover')).toBe(false)
+ })
+ it('never expects more than the source has', () => {
+ expect(osTooSmall(d(300, 200), 768, 'image', 'contain', d(300, 200))).toBe(false)
+ expect(osTooSmall(d(300, 200), 768, 'image', 'cover', d(300, 200))).toBe(false)
+ expect(osTooSmall(d(256, 171), 768, 'image', 'contain', d(3000, 2000))).toBe(true)
+ })
+ it('leaves the 128px request and tool-less kinds alone', () => {
+ expect(osTooSmall(d(96, 64), 128, 'image')).toBe(false)
+ expect(osTooSmall(d(256, 256), 768, 'pdf')).toBe(false)
+ expect(osTooSmall(d(256, 256), 768, 'other')).toBe(false)
+ })
+ it('the tool sizes fit or fill without upscaling', () => {
+ expect(scale(768)).toBe(
+ "scale='min(768,iw)':'min(768,ih)':force_original_aspect_ratio=decrease"
+ )
+ expect(scale(768, 'cover')).toBe(
+ "scale='min(768,iw)':'min(768,ih)':force_original_aspect_ratio=increase"
+ )
+ expect(magickGeometry(128)).toBe('128x128>')
+ expect(magickGeometry(768, 'cover')).toBe('768x768^>')
+ })
+})
diff --git a/test/view-size.test.ts b/test/view-size.test.ts
new file mode 100644
index 0000000..66d02f2
--- /dev/null
+++ b/test/view-size.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from 'vitest'
+import {
+ DEFAULT_VIEW,
+ parseViewSize,
+ isListSize,
+ stepSize,
+ VIEW_KEY,
+ VIEW_SIZES,
+ viewDef,
+ viewKeyFor,
+ wheelDelta,
+ WHEEL_IDLE,
+ wheelStep,
+ type ViewKeyLike
+} from '../src/renderer/src/components/queue/viewSize'
+
+const k = (
+ key: string,
+ code: string,
+ o: Partial> = {}
+): ViewKeyLike => ({
+ key,
+ code,
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ ...o
+})
+
+describe('view sizes', () => {
+ it('lists the six sizes in order with their Ctrl+Shift digit', () => {
+ expect(VIEW_SIZES.map((s) => [s.id, s.label, s.digit])).toEqual([
+ ['details', 'Details', 1],
+ ['details-l', 'Large details', 2],
+ ['tiles', 'Tiles', 3],
+ ['medium', 'Medium icons', 4],
+ ['large', 'Large icons', 5],
+ ['xl', 'Extra large icons', 6]
+ ])
+ expect(viewDef('details-l').icon).toBe('view-details-l')
+ expect(DEFAULT_VIEW).toBe('details')
+ expect(VIEW_KEY).toBe('filesmith.viewSize')
+ expect(viewDef('large').icon).toBe('view-large')
+ })
+ it('steps one size and clamps at both ends', () => {
+ expect(stepSize('details', 1)).toBe('details-l')
+ expect(stepSize('details-l', 1)).toBe('tiles')
+ expect(stepSize('tiles', -1)).toBe('details-l')
+ expect(stepSize('medium', -1)).toBe('tiles')
+ expect(stepSize('xl', 1)).toBe('xl')
+ expect(stepSize('details', -1)).toBe('details')
+ })
+ it('reads a stored value, falling back to Details for anything unknown', () => {
+ expect(parseViewSize('large')).toBe('large')
+ expect(parseViewSize('details-l')).toBe('details-l')
+ for (const bad of [null, undefined, '', 'huge', '3', 3, {}, 'details-xl'])
+ expect(parseViewSize(bad)).toBe('details')
+ })
+ it('treats the two Details sizes as the table', () => {
+ expect(VIEW_SIZES.filter((s) => isListSize(s.id)).map((s) => s.id)).toEqual([
+ 'details',
+ 'details-l'
+ ])
+ })
+})
+
+describe('view keys', () => {
+ it('maps Ctrl+= / Ctrl++ / numpad + / Ctrl+Shift+= to bigger', () => {
+ expect(viewKeyFor(k('=', 'Equal'))).toEqual({ kind: 'step', delta: 1 })
+ expect(viewKeyFor(k('+', 'NumpadAdd'))).toEqual({ kind: 'step', delta: 1 })
+ expect(viewKeyFor(k('+', 'Equal', { shiftKey: true }))).toEqual({ kind: 'step', delta: 1 })
+ // Nordic layouts: + is its own unshifted key (code Minus)
+ expect(viewKeyFor(k('+', 'Minus'))).toEqual({ kind: 'step', delta: 1 })
+ })
+ it('maps Ctrl+- (any layout, numpad) to smaller and Ctrl+0 to Details', () => {
+ expect(viewKeyFor(k('-', 'Minus'))).toEqual({ kind: 'step', delta: -1 })
+ expect(viewKeyFor(k('-', 'Slash'))).toEqual({ kind: 'step', delta: -1 })
+ expect(viewKeyFor(k('-', 'NumpadSubtract'))).toEqual({ kind: 'step', delta: -1 })
+ expect(viewKeyFor(k('0', 'Digit0'))).toEqual({ kind: 'set', size: 'details' })
+ expect(viewKeyFor(k('0', 'Numpad0'))).toEqual({ kind: 'set', size: 'details' })
+ })
+ it('maps Ctrl+Shift+1..6 by code, whatever character the layout makes', () => {
+ expect(viewKeyFor(k('!', 'Digit1', { shiftKey: true }))).toEqual({
+ kind: 'set',
+ size: 'details'
+ })
+ expect(viewKeyFor(k('@', 'Digit2', { shiftKey: true }))).toEqual({
+ kind: 'set',
+ size: 'details-l'
+ })
+ expect(viewKeyFor(k('$', 'Digit4', { shiftKey: true }))).toEqual({
+ kind: 'set',
+ size: 'medium'
+ })
+ expect(viewKeyFor(k('&', 'Digit6', { shiftKey: true }))).toEqual({ kind: 'set', size: 'xl' })
+ expect(viewKeyFor(k('/', 'Digit7', { shiftKey: true }))).toBeNull()
+ expect(viewKeyFor(k('=', 'Digit0', { shiftKey: true }))).toBeNull()
+ })
+ it('works with Cmd, ignores Alt, plain keys and other Ctrl keys', () => {
+ expect(viewKeyFor(k('=', 'Equal', { ctrlKey: false, metaKey: true }))).toEqual({
+ kind: 'step',
+ delta: 1
+ })
+ expect(viewKeyFor(k('=', 'Equal', { ctrlKey: false }))).toBeNull()
+ expect(viewKeyFor(k('=', 'Equal', { altKey: true }))).toBeNull()
+ expect(viewKeyFor(k('a', 'KeyA'))).toBeNull()
+ expect(viewKeyFor(k('3', 'Digit3'))).toBeNull()
+ })
+})
+
+describe('Ctrl+wheel accumulation', () => {
+ it('scales line and page deltas to pixels', () => {
+ expect(wheelDelta(100, 0)).toBe(100)
+ expect(wheelDelta(3, 1)).toBe(120)
+ expect(wheelDelta(1, 2)).toBe(400)
+ })
+ it('steps once per mouse notch, wheel up = bigger, at most once per 90ms', () => {
+ let r = wheelStep(WHEEL_IDLE, -100, 1000)
+ expect(r.step).toBe(1)
+ r = wheelStep(r.state, -100, 1050)
+ expect(r.step).toBe(0)
+ r = wheelStep(r.state, -100, 1200)
+ expect(r.step).toBe(1)
+ expect(wheelStep(WHEEL_IDLE, 100, 1000).step).toBe(-1)
+ })
+ it('accumulates small trackpad deltas and drops a stale accumulation', () => {
+ let r = wheelStep(WHEEL_IDLE, -10, 1000)
+ r = wheelStep(r.state, -10, 1010)
+ r = wheelStep(r.state, -10, 1020)
+ expect(r.step).toBe(0)
+ r = wheelStep(r.state, -10, 1030)
+ expect(r.step).toBe(1)
+ const stale = wheelStep(WHEEL_IDLE, -30, 1000)
+ expect(wheelStep(stale.state, -30, 1400).step).toBe(0)
+ })
+})