diff --git a/.gitignore b/.gitignore index 9deed33..d539389 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,16 @@ node_modules/ /mac-app/dist/index.html .DS_Store + +# Python bytecode and virtualenvs. build/build.py imports build/icons.py, so +# a __pycache__ appears next to it on every build. +__pycache__/ +*.py[cod] +venv/ +.venv/ + +# Generic build output, alongside the specific paths above. +dist/ + +# Local-only settings: machine-specific, and not the project's business. +.claude/settings.local.json diff --git a/build/__pycache__/icons.cpython-310.pyc b/build/__pycache__/icons.cpython-310.pyc deleted file mode 100644 index d1ef4bc..0000000 Binary files a/build/__pycache__/icons.cpython-310.pyc and /dev/null differ diff --git a/build/__pycache__/icons.cpython-312.pyc b/build/__pycache__/icons.cpython-312.pyc deleted file mode 100644 index 50b2917..0000000 Binary files a/build/__pycache__/icons.cpython-312.pyc and /dev/null differ diff --git a/build/body.html b/build/body.html index 6b55fd3..c57ea69 100644 --- a/build/body.html +++ b/build/body.html @@ -165,7 +165,6 @@ - @@ -178,9 +177,9 @@ aria-label="Close">×

Spectrogram

-

+

@@ -274,9 +273,6 @@
- diff --git a/build/build.py b/build/build.py index 5223746..0465ae1 100644 --- a/build/build.py +++ b/build/build.py @@ -1566,6 +1566,9 @@ SPECTRO_PATCHES = [ \t\tvar spectroNote = container.querySelector( "[data-bwfa-spectro-note]" ); \t\tvar spectroTitle = container.querySelector( "[data-bwfa-spectro-title]" ); \t\tvar spectroScale = container.querySelector( "[data-bwfa-spectro-scale]" ); +\t\t// Kept so the exported image can label its own frequency axis. On +\t\t// screen the axis is HTML beside the canvas; a PNG has to carry it. +\t\tvar spectroPicture = null; \t\t/** \t\t * Viridis, sampled. Perceptually even, so a bright patch means a loud @@ -1602,6 +1605,7 @@ SPECTRO_PATCHES = [ \t\t\tif ( ! ctx ) { \t\t\t\treturn; \t\t\t} +\t\t\tspectroPicture = picture; \t\t\tspectroCanvas.width = picture.columns; \t\t\tspectroCanvas.height = picture.bins; \t\t\tvar image = ctx.createImageData( picture.columns, picture.bins ); @@ -1636,6 +1640,81 @@ SPECTRO_PATCHES = [ \t\t\t} ); \t\t} +\t\t/** +\t\t * The picture as something worth keeping: the file it came from, the +\t\t * reading underneath it, and the frequency axis, drawn into the image +\t\t * rather than sitting beside it in HTML. +\t\t * +\t\t * A PNG leaves the app and is looked at somewhere else, where none of +\t\t * that context survives — a bare spectrogram is a pretty picture of an +\t\t * unknown file at an unknown scale. On screen it is all still there in +\t\t * the modal, so the viewer keeps exactly the layout it has. +\t\t * +\t\t * Black on white regardless of the app's own theme: this is a figure to +\t\t * be sent, printed or dropped into a report. +\t\t */ +\t\tfunction buildSpectroExport( row ) { +\t\t\tif ( ! spectroPicture || ! spectroCanvas.width ) { +\t\t\t\treturn null; +\t\t\t} +\t\t\tvar gutter = 74; +\t\t\tvar header = 54; +\t\t\tvar pad = 14; +\t\t\tvar plotWidth = spectroCanvas.width; +\t\t\t// The bins are far shorter than the columns, and the modal stretches +\t\t\t// them to fit. Do the same rather than exporting a 1200×512 sliver. +\t\t\tvar plotHeight = 460; + +\t\t\tvar out = document.createElement( "canvas" ); +\t\t\tout.width = gutter + plotWidth + pad; +\t\t\tout.height = header + plotHeight + pad; +\t\t\tvar ctx = out.getContext( "2d" ); +\t\t\tif ( ! ctx ) { +\t\t\t\treturn null; +\t\t\t} +\t\t\tvar sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif'; + +\t\t\tctx.fillStyle = "#ffffff"; +\t\t\tctx.fillRect( 0, 0, out.width, out.height ); + +\t\t\t// The folder as well as the file: a take number on its own names +\t\t\t// nothing once the picture is out of the app. +\t\t\tvar folder = window.BWFA_FOLDER_NAME || ""; +\t\t\tvar fileName = ( row && row.parsed && row.parsed.fileName ) || ""; +\t\t\tctx.fillStyle = "#111111"; +\t\t\tctx.font = "600 19px " + sans; +\t\t\tctx.textBaseline = "alphabetic"; +\t\t\tctx.fillText( folder ? ( folder + " / " + fileName ) : fileName, gutter, 26 ); +\t\t\tctx.fillStyle = "#555555"; +\t\t\tctx.font = "13px " + sans; +\t\t\tctx.fillText( spectroNote.textContent || "", gutter, 45 ); + +\t\t\t// The picture itself, unsmoothed for the same reason the modal +\t\t\t// doesn't smooth it: it would invent detail the file doesn't have. +\t\t\tctx.imageSmoothingEnabled = false; +\t\t\tctx.drawImage( spectroCanvas, gutter, header, plotWidth, plotHeight ); + +\t\t\t// And the axis it is read against, on the same five marks the modal +\t\t\t// shows: Nyquist at the top, DC at the bottom. +\t\t\tvar top = spectroPicture.sampleRate / 2; +\t\t\tctx.fillStyle = "#555555"; +\t\t\tctx.font = "12px " + sans; +\t\t\tctx.textAlign = "right"; +\t\t\t[ 1, 0.75, 0.5, 0.25, 0 ].forEach( function ( part ) { +\t\t\t\tvar hz = top * part; +\t\t\t\tvar label = hz >= 1000 +\t\t\t\t\t? ( Math.round( hz / 100 ) / 10 ) + " kHz" +\t\t\t\t\t: Math.round( hz ) + " Hz"; +\t\t\t\tvar y = header + ( 1 - part ) * plotHeight; +\t\t\t\t// Nudged inside the plot at the ends so neither mark is clipped. +\t\t\t\tvar baseline = Math.max( header + 10, Math.min( header + plotHeight, y + 4 ) ); +\t\t\t\tctx.fillText( label, gutter - 10, baseline ); +\t\t\t} ); +\t\t\tctx.textAlign = "left"; + +\t\t\treturn out; +\t\t} + \t\tfunction openSpectro() { \t\t\tvar row = playerRow(); \t\t\tif ( ! row || ! row.file ) { @@ -1682,7 +1761,7 @@ SPECTRO_PATCHES = [ \t\t\t\tvar row = playerRow(); \t\t\t\tvar stem = row && row.parsed \t\t\t\t\t? row.parsed.fileName.replace( /\\.[^.]*$/, "" ) : "spectrogram"; -\t\t\t\tspectroCanvas.toBlob( function ( blob ) { +\t\t\t\t( buildSpectroExport( row ) || spectroCanvas ).toBlob( function ( blob ) { \t\t\t\t\tif ( ! blob ) { \t\t\t\t\t\treturn; \t\t\t\t\t} @@ -2584,7 +2663,448 @@ PLAYER_PATCHES = [ ), ] +# ---- the order of the columns belongs to whoever is looking ------------ +# +# Drag a heading and the column moves. Applied last, so its anchors are the +# markup as every other patch leaves it. +# +# Three things have to agree: the table, the Settings list and what is on +# disk. All three read one array of keys — every column, shown or hidden — +# so there is no second copy to fall out of step. +COLUMN_ORDER_PATCHES = [ + ( + """\tfunction getDefaultVisibleColumnKeys( columns ) {""", + """\tvar COLUMN_ORDER_STORAGE_KEY = "bwfa_column_order_v1"; + +\t/** +\t * The order the columns are shown in, as a list of keys. +\t * +\t * Kept for every column, hidden ones included. Switching a column off and +\t * on again should put it back where it was rather than at the end, and +\t * dragging past a hidden column should not shunt it elsewhere. +\t */ +\tfunction loadColumnOrder( columns, storage ) { +\t\tvar defaults = columns.map( function ( col ) { return col.key; } ); +\t\tif ( ! storage ) { +\t\t\treturn defaults; +\t\t} +\t\ttry { +\t\t\tvar parsed = JSON.parse( storage.getItem( COLUMN_ORDER_STORAGE_KEY ) || "null" ); +\t\t\tif ( ! Array.isArray( parsed ) ) { +\t\t\t\treturn defaults; +\t\t\t} +\t\t\t// A key that is no longer a column is dropped; a column added to +\t\t\t// the app since this was saved is slotted in where it ships, rather +\t\t\t// than appearing at the far end of somebody's saved order. +\t\t\tvar kept = parsed.filter( function ( key ) { +\t\t\t\treturn defaults.indexOf( key ) !== -1; +\t\t\t} ); +\t\t\tdefaults.forEach( function ( key, index ) { +\t\t\t\tif ( kept.indexOf( key ) === -1 ) { +\t\t\t\t\tkept.splice( Math.min( index, kept.length ), 0, key ); +\t\t\t\t} +\t\t\t} ); +\t\t\treturn kept; +\t\t} catch ( e ) { +\t\t\treturn defaults; +\t\t} +\t} + +\tfunction saveColumnOrder( order, storage ) { +\t\tif ( ! storage ) { +\t\t\treturn; +\t\t} +\t\ttry { +\t\t\tstorage.setItem( COLUMN_ORDER_STORAGE_KEY, JSON.stringify( order ) ); +\t\t} catch ( e ) { +\t\t\t// As with the visible-column picker: it works for this session and +\t\t\t// simply isn't remembered. Not fatal. +\t\t} +\t} + +\tfunction getDefaultVisibleColumnKeys( columns ) {""", + ), + ( + """\t\t\tvisibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ), +\t\t\texportFields: loadExportFields( storage ),""", + """\t\t\tvisibleColumns: loadVisibleColumns( TABLE_COLUMNS, storage ), +\t\t\tcolumnOrder: loadColumnOrder( TABLE_COLUMNS, storage ), +\t\t\texportFields: loadExportFields( storage ),""", + ), + ( + """\t\tfunction visibleColumnList() { +\t\t\treturn TABLE_COLUMNS.filter( function ( col ) { return state.visibleColumns.has( col.key ); } ); +\t\t}""", + """\t\t/** Every column there is, in the order they are shown. */ +\t\tfunction orderedColumnList() { +\t\t\tvar byKey = {}; +\t\t\tTABLE_COLUMNS.forEach( function ( col ) { byKey[ col.key ] = col; } ); +\t\t\treturn ( state.columnOrder || [] ).map( function ( key ) { +\t\t\t\treturn byKey[ key ]; +\t\t\t} ).filter( Boolean ); +\t\t} + +\t\tfunction visibleColumnList() { +\t\t\treturn orderedColumnList().filter( function ( col ) { +\t\t\t\treturn state.visibleColumns.has( col.key ); +\t\t\t} ); +\t\t} + +\t\t/** +\t\t * Moves a column so it sits immediately before another, or to the end +\t\t * when there is nothing to sit before. +\t\t * +\t\t * Both are keys, not positions, and the list being reordered is the +\t\t * full one. A hidden column keeps whichever neighbour it already had, +\t\t * so switching it back on puts it somewhere recognisable. +\t\t */ +\t\tfunction moveColumn( key, beforeKey ) { +\t\t\tvar order = ( state.columnOrder || [] ).slice(); +\t\t\tvar from = order.indexOf( key ); +\t\t\tif ( from === -1 ) { +\t\t\t\treturn; +\t\t\t} +\t\t\torder.splice( from, 1 ); +\t\t\tvar at = beforeKey ? order.indexOf( beforeKey ) : -1; +\t\t\tif ( at === -1 ) { +\t\t\t\torder.push( key ); +\t\t\t} else { +\t\t\t\torder.splice( at, 0, key ); +\t\t\t} +\t\t\tstate.columnOrder = order; +\t\t\tsaveColumnOrder( order, storage ); +\t\t}""", + ), + ( + """\t\tfunction renderTableHead() {""", + """\t\t/* ---- dragging a heading ---------------------------------------- +\t\t Pointer events, not HTML5 drag-and-drop. The window has Tauri's +\t\t native drag-drop switched on so a folder can be dropped onto it, +\t\t and on macOS that intercepts the webview's own drag events. + +\t\t It suits the table anyway: no drag image, no insertion line, no +\t\t placeholder, nothing added to the page at any point. The columns +\t\t themselves move as the pointer passes them, which is the only +\t\t feedback there is and the only kind that costs the design nothing. +\t\t -------------------------------------------------------------- */ + +\t\tvar columnDrag = null; +\t\tvar sortClickSuppressed = false; + +\t\t/** The key of the heading under this x position, if any. */ +\t\tfunction columnKeyAt( clientX ) { +\t\t\tvar found = null; +\t\t\ttableHead.querySelectorAll( "th[data-bwfa-col]" ).forEach( function ( th ) { +\t\t\t\tvar box = th.getBoundingClientRect(); +\t\t\t\tif ( clientX >= box.left && clientX <= box.right ) { +\t\t\t\t\tfound = th.getAttribute( "data-bwfa-col" ); +\t\t\t\t} +\t\t\t} ); +\t\t\treturn found; +\t\t} + +\t\tfunction startColumnDrag( e, key, label ) { +\t\t\tif ( e.button !== 0 ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tcolumnDrag = { key: key, label: label, x: e.clientX, moved: false, ghost: null }; +\t\t} + +\t\t/** The heading you are holding, following the pointer. */ +\t\tfunction showColumnGhost( e ) { +\t\t\tif ( ! columnDrag.ghost ) { +\t\t\t\tvar ghost = document.createElement( "div" ); +\t\t\t\tghost.className = "bwfa-col-ghost"; +\t\t\t\tghost.setAttribute( "data-bwfa-col-ghost", columnDrag.key ); +\t\t\t\tghost.textContent = columnDrag.label; +\t\t\t\tcontainer.appendChild( ghost ); +\t\t\t\tcolumnDrag.ghost = ghost; +\t\t\t} +\t\t\tcolumnDrag.ghost.style.left = ( e.clientX + 12 ) + "px"; +\t\t\tcolumnDrag.ghost.style.top = ( e.clientY + 12 ) + "px"; +\t\t\t// Re-applied after every reorder, because the heading it marks is a +\t\t\t// new element by then. +\t\t\ttableHead.querySelectorAll( "th[data-bwfa-col]" ).forEach( function ( th ) { +\t\t\t\tth.classList.toggle( "is-dragging", +\t\t\t\t\tth.getAttribute( "data-bwfa-col" ) === columnDrag.key ); +\t\t\t} ); +\t\t} + +\t\tfunction endColumnGhost() { +\t\t\tif ( columnDrag && columnDrag.ghost && columnDrag.ghost.parentNode ) { +\t\t\t\tcolumnDrag.ghost.parentNode.removeChild( columnDrag.ghost ); +\t\t\t} +\t\t\ttableHead.querySelectorAll( "th.is-dragging" ).forEach( function ( th ) { +\t\t\t\tth.classList.remove( "is-dragging" ); +\t\t\t} ); +\t\t} + +\t\tdocument.addEventListener( "mousemove", function ( e ) { +\t\t\tif ( ! columnDrag ) { +\t\t\t\treturn; +\t\t\t} +\t\t\t// A few pixels of slack, so a click with a tremor in it still sorts. +\t\t\tif ( ! columnDrag.moved && Math.abs( e.clientX - columnDrag.x ) < 4 ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tcolumnDrag.moved = true; +\t\t\tshowColumnGhost( e ); +\t\t\tvar over = columnKeyAt( e.clientX ); +\t\t\tif ( ! over || over === columnDrag.key ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tvar visible = visibleColumnList().map( function ( col ) { return col.key; } ); +\t\t\tvar from = visible.indexOf( columnDrag.key ); +\t\t\tvar to = visible.indexOf( over ); +\t\t\tif ( from === -1 || to === -1 ) { +\t\t\t\treturn; +\t\t\t} +\t\t\t// Rightwards it lands after the heading it passed, leftwards before +\t\t\t// it — so the one under the pointer stays under the pointer. +\t\t\tmoveColumn( columnDrag.key, to > from ? ( visible[ to + 1 ] || null ) : over ); +\t\t\trenderTableHead(); +\t\t\trenderTableBody(); +\t\t\trenderColumnsMenu(); +\t\t\t// The head was just rebuilt, so the heading being dragged has to be +\t\t\t// marked again on its new element. +\t\t\tshowColumnGhost( e ); +\t\t} ); + +\t\tdocument.addEventListener( "mouseup", function () { +\t\t\tif ( ! columnDrag ) { +\t\t\t\treturn; +\t\t\t} +\t\t\tendColumnGhost(); +\t\t\t// The click that ends a drag is the end of the drag, not a request +\t\t\t// to sort by whatever the pointer came to rest on. +\t\t\tsortClickSuppressed = columnDrag.moved; +\t\t\tcolumnDrag = null; +\t\t\tif ( sortClickSuppressed ) { +\t\t\t\twindow.setTimeout( function () { sortClickSuppressed = false; }, 0 ); +\t\t\t} +\t\t} ); + +\t\tfunction renderTableHead() {""", + ), + ( + """\t\t\t\tvar th = document.createElement( "th" ); +\t\t\t\tth.textContent = col.label; +\t\t\t\tif ( col.sortable ) { +\t\t\t\t\tth.setAttribute( "data-bwfa-sort", col.key ); +\t\t\t\t\tvar indicator = el( "span", "bwfa-sort-indicator", "\\u25B2" ); +\t\t\t\t\tth.appendChild( indicator ); +\t\t\t\t\tth.addEventListener( "click", function () { +\t\t\t\t\t\tif ( state.sortKey === col.key ) {""", + """\t\t\t\tvar th = document.createElement( "th" ); +\t\t\t\tth.textContent = col.label; +\t\t\t\t// Invisible: somewhere for the drag to take hold, and how the +\t\t\t\t// pointer is matched to a heading. Every column can be dragged, +\t\t\t\t// including the two that cannot be sorted. +\t\t\t\tth.setAttribute( "data-bwfa-col", col.key ); +\t\t\t\tth.addEventListener( "mousedown", function ( e ) { +\t\t\t\t\tstartColumnDrag( e, col.key, col.label ); +\t\t\t\t} ); +\t\t\t\tif ( col.sortable ) { +\t\t\t\t\tth.setAttribute( "data-bwfa-sort", col.key ); +\t\t\t\t\tvar indicator = el( "span", "bwfa-sort-indicator", "\\u25B2" ); +\t\t\t\t\tth.appendChild( indicator ); +\t\t\t\t\tth.addEventListener( "click", function () { +\t\t\t\t\t\tif ( sortClickSuppressed ) { +\t\t\t\t\t\t\treturn; +\t\t\t\t\t\t} +\t\t\t\t\t\tif ( state.sortKey === col.key ) {""", + ), + ( + """\t\t\tcolumnsMenu.textContent = ""; +\t\t\tTABLE_COLUMNS.forEach( function ( col ) {""", + """\t\t\tcolumnsMenu.textContent = ""; +\t\t\t// In the order the table shows them, so the list reads like the +\t\t\t// table rather than like the source file. +\t\t\torderedColumnList().forEach( function ( col ) {""", + ), +] + + +# ---- a folder that opens has its first file on the transport ----------- +FIRST_FILE_PATCHES = [ + # playRow gains a paused mode: everything it does to load a file, minus + # the two lines that make a sound. Loading is the expensive, fiddly part + # — the read, the peaks, the graph, the channel chips — and a second + # copy of it that drifted from this one is exactly the bug that would + # not show up until someone pressed Play on a primed file. + ( + """\t\tfunction playRow( row ) {""", + """\t\tfunction playRow( row, startPaused ) {""", + ), + ( + """\t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" ); +\t\t\tif ( playingBadge ) { +\t\t\t\tplayingBadge.textContent = t( "playerPlaying" ); +\t\t\t}""", + """\t\t\tvar playingBadge = container.querySelector( "[data-bwfa-player-badge]" ); +\t\t\tif ( playingBadge ) { +\t\t\t\tplayingBadge.textContent = startPaused ? t( "playerReady" ) : t( "playerPlaying" ); +\t\t\t}""", + ), + ( + """\t\t\t\t\tisPlaying: true,""", + """\t\t\t\t\tisPlaying: ! startPaused,""", + ), + # Nobody asked the primed file to play, so a file that won't load must + # not shout about it. handlePlaybackError writes to the status line, and + # the status line has just said the folder opened — replacing that with + # "this file could not be played back" reads as the folder having failed. + ( + """\t\t\t} ).catch( function () { +\t\t\t\tplayerDecoding.hidden = true; +\t\t\t\thandlePlaybackError(); +\t\t\t\tshowIdlePlayer( null ); +\t\t\t} );""", + """\t\t\t} ).catch( function () { +\t\t\t\tplayerDecoding.hidden = true; +\t\t\t\tif ( ! startPaused ) { +\t\t\t\t\thandlePlaybackError(); +\t\t\t\t} +\t\t\t\tshowIdlePlayer( null ); +\t\t\t} );""", + ), + # The badge was written once, when a file was started, and never again — + # so a file paused from the transport still read "Now playing". Loading a + # file up front made that plain: it announced itself as playing the + # moment you pressed Play on it, because the word never changed. It + # belongs with everything else the transport keeps in step. + ( + """\t\tfunction syncPlaybackUI() { +\t\t\tsyncMixerTransport(); +\t\t\tsyncMixerStates();""", + """\t\tfunction syncPlaybackUI() { +\t\t\tsyncMixerTransport(); +\t\t\tsyncMixerStates(); +\t\t\tvar transportBadge = container.querySelector( "[data-bwfa-player-badge]" ); +\t\t\tif ( transportBadge && state.playback ) { +\t\t\t\ttransportBadge.textContent = state.playback.isPlaying +\t\t\t\t\t? t( "playerPlaying" ) : t( "playerReady" ); +\t\t\t}""", + ), + ( + """\t\t\tstate.playback.elapsed = 0; +\t\t\t\tplayer( "bwf_play", { +\t\t\t\t\tpath: row.file.path, +\t\t\t\t\toffset: startAt, +\t\t\t\t\tgains: state.playback.channelGains || [] +\t\t\t\t} ).catch( function () { handlePlaybackError(); } ); +\t\t\t\trenderChannelChips( row ); +\t\t\t\tfitCanvasResolution( playerWaveformCanvas ); +\t\t\t\tstartTransportLoop(); +\t\t\t\tsyncPlaybackUI();""", + """\t\t\tstate.playback.elapsed = 0; +\t\t\t\tif ( ! startPaused ) { +\t\t\t\t\tplayer( "bwf_play", { +\t\t\t\t\t\tpath: row.file.path, +\t\t\t\t\t\toffset: startAt, +\t\t\t\t\t\tgains: state.playback.channelGains || [] +\t\t\t\t\t} ).catch( function () { handlePlaybackError(); } ); +\t\t\t\t} +\t\t\t\trenderChannelChips( row ); +\t\t\t\tfitCanvasResolution( playerWaveformCanvas ); +\t\t\t\tif ( startPaused ) { +\t\t\t\t\t// No loop to draw the first frame, so draw it: the waveform and +\t\t\t\t\t// the length are the point of loading it up front. +\t\t\t\t\tupdateTransportDisplay(); +\t\t\t\t} else { +\t\t\t\t\tstartTransportLoop(); +\t\t\t\t} +\t\t\t\tsyncPlaybackUI();""", + ), + ( + """\t\tfunction setEditableMode( editable ) {""", + """\t\t/** +\t\t * Puts the first file of the folder on the transport, ready to play. +\t\t * +\t\t * Ready, not playing: opening a folder is not a request to make a +\t\t * noise. The transport's idle path already knows how to start what it +\t\t * is holding, so Play and the spacebar both work from here without +\t\t * anything else being wired up. +\t\t * +\t\t * Runs on every folder load — the first of the day, the one reopened +\t\t * at launch, and any folder chosen after that — so the transport names +\t\t * the folder on screen rather than sitting on a file from the last one +\t\t * that is no longer in the table. +\t\t */ +\t\tfunction primePlayerWithFirstRow() { +\t\t\tvar first = state.filteredRows[ 0 ] || null; +\t\t\tif ( ! first ) { +\t\t\t\tstate.lastPlayed = null; +\t\t\t\tplayerWrap.hidden = true; +\t\t\t\treturn; +\t\t\t} +\t\t\t// The whole load, not a label: the file is read, the waveform drawn +\t\t\t// and the channels listed, exactly as pressing Play would — and then +\t\t\t// it sits there paused at the start instead of making a sound. +\t\t\t// +\t\t\t// Contained, because this is a convenience on top of opening a +\t\t\t// folder and must never be able to stop one opening. A file that +\t\t\t// won't load — a corrupt header, an engine that isn't answering — +\t\t\t// leaves the table exactly as it is and the transport merely idle. +\t\t\ttry { +\t\t\t\tplayRow( first, true ); +\t\t\t} catch ( e ) { +\t\t\t\tconsole.error( "BWF Analyser: the folder's first file could not be" + +\t\t\t\t\t" loaded into the transport — " + ( ( e && e.message ) || e ) ); +\t\t\t\tstate.lastPlayed = { row: first, peaks: null, duration: 0 }; +\t\t\t\tplayerWrap.hidden = false; +\t\t\t\tshowIdlePlayer( state.lastPlayed ); +\t\t\t} +\t\t} + +\t\tfunction setEditableMode( editable ) {""", + ), + # The scan itself starts from nothing on the transport. Without this the + # previous folder's file sits there, named, for as long as the new folder + # takes to read — which on a card is long enough to look like the answer. + ( + """\t\t\tstate.rows = []; +\t\t\tstopPlayback(); +\t\t\tstate.decodedBufferCache.clear();""", + """\t\t\tstate.rows = []; +\t\t\tstopPlayback(); +\t\t\tstate.lastPlayed = null; +\t\t\tstate.decodedBufferCache.clear();""", + ), + ( + """\t\t\t\tstate.rows = results; +\t\t\t\tsetProgress( 0, 0 ); +\t\t\t\tsetStatus( format( t( "statusDone" ), results.length, skipped ) ); +\t\t\t\tapplyFilterAndSort();""", + """\t\t\t\tstate.rows = results; +\t\t\t\tsetProgress( 0, 0 ); +\t\t\t\tsetStatus( format( t( "statusDone" ), results.length, skipped ) ); +\t\t\t\tapplyFilterAndSort(); +\t\t\t\tprimePlayerWithFirstRow();""", + ), +] + + APP_JS_PATCHES = [ + # ---- what the run just rewrote is on the transport too -------------- + # + # The run re-reads every file it touches, so the rows are current — but + # the transport and the mixer drew their names when the file was loaded + # and nothing tells them to look again. Rename the boom across a card + # and the table says Boom Left while the mixer, showing the very file + # that was rewritten, still says Boom. + ( + """\t\t\t\t\tsetStatus( format( t( "bulkEditDone" ), succeeded, failed ) ); +\t\t\t\t\trenderTableBody();""", + """\t\t\t\t\tsetStatus( format( t( "bulkEditDone" ), succeeded, failed ) ); +\t\t\t\t\trenderTableBody(); +\t\t\t\t\tvar onAir = state.playerRow && state.playerRow(); +\t\t\t\t\tif ( onAir ) { +\t\t\t\t\t\tplayerFilename.textContent = onAir.parsed.fileName; +\t\t\t\t\t\trenderChannelChips( onAir ); +\t\t\t\t\t} +\t\t\t\t\trenderMixer();""", + ), # ---- a failed file has to say why ----------------------------------- # # This swallowed the reason entirely, and a bulk run that writes nothing @@ -3031,6 +3551,8 @@ app_js = apply_patches(app_js, TRANSPORT_KEY_PATCHES, "transport key patch") app_js = apply_patches(app_js, APP_JS_PATCHES, "app js patch") app_js = apply_patches(app_js, PLAYER_PATCHES, "player patch") app_js = apply_patches(app_js, SPECTRO_PATCHES, "spectrogram patch") +app_js = apply_patches(app_js, COLUMN_ORDER_PATCHES, "column order patch") +app_js = apply_patches(app_js, FIRST_FILE_PATCHES, "first file patch") shim_js = guard(read(BUILD / "tauri-bridge.js"), "shim js", "script") replacements = [ diff --git a/build/overrides.css b/build/overrides.css index cdf7ecb..9a1d55d 100644 --- a/build/overrides.css +++ b/build/overrides.css @@ -1038,21 +1038,10 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) { transform: translateY(0.15em); } -/* Save Image sits with the title, on the right, where the modal's own - heading block already reserves the room. */ -.bwfa-scope .bwfa-spectro-head { - display: flex; - align-items: flex-end; - gap: var(--space-4); -} - -.bwfa-scope .bwfa-spectro-head h4, -.bwfa-scope .bwfa-spectro-head p { - flex: none; -} - +/* The layout of this block is further down, after the shared modal header + rule that sets every dialog's heading to display: block — anything about + it written here is overwritten by that. */ .bwfa-scope .bwfa-spectro-save { - margin-left: auto; flex: none; } @@ -1265,6 +1254,36 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) { color: var(--color-text-muted); } +/* ---- Save Image belongs on the file name's line -------------------- + The spectrogram's heading holds three things where the other dialogs + hold two, and the rule above lays every heading out as a plain block — + so the button, being inline, dropped to a line of its own under the + name. Laid out as a wrapping flex row instead: the name and the button + share the first line, the reading takes the second. + + This has to sit after that shared rule rather than with the rest of the + spectrogram's styling further up. An earlier attempt lived up there and + was overwritten by it, which is why nothing moved. + ------------------------------------------------------------------ */ +.bwfa-scope .bwfa-spectro-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 var(--space-4); +} + +.bwfa-scope .bwfa-spectro-head h4 { + flex: none; +} + +.bwfa-scope .bwfa-spectro-head p { + flex: 1 0 100%; +} + +.bwfa-scope .bwfa-spectro-save { + margin-left: auto; +} + /* ---- 3. The body ---- */ .bwfa-scope .modal-body, .bwfa-scope .bwfa-sheet-dialog .bwfa-export-fields, @@ -1412,11 +1431,27 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) { min-width: 0; } +/* A track name reads the same here as it does in the player's row: the same + pill, the same size, the same colours, so the two places that list the + tracks of a file look like the same thing. + + It takes the look and not the behaviour — in the player the pill is the + mute button, here it is a label and mute has its own M — so no pointer + cursor and none of the muted/solo states. inline-block rather than the + chip's inline-flex, because the name has to be able to ellipsis inside a + 9em column and text-overflow doesn't reach the children of a flex box. */ .bwfa-scope .bwfa-mixer-name { + display: inline-block; + max-width: 100%; + padding: 0.3rem 0.5rem 0.3rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + background-color: var(--color-surface-alt); + font-size: var(--fs-sm); + font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-weight: 500; } .bwfa-scope .bwfa-mixer-fader { @@ -1474,6 +1509,73 @@ html:has(.bwfa-scope[data-bwfa-theme="dark"]) { min-width: 6em; } +/* ---- Dragging a column heading --------------------------------------- + The heading you are holding, following the pointer, so the gesture says + what it is moving. Everything here is the app's own surface, border and + text — the only new thing is the shadow, which is what lifts it off the + table and tells you it is in your hand rather than in the row. + + It exists only while a drag is in progress, and it never takes a click: + the pointer has to reach the headings underneath to know where it is. + ------------------------------------------------------------------- */ +.bwfa-scope .bwfa-col-ghost { + position: fixed; + z-index: 60; + pointer-events: none; + padding: 0.3rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font-size: var(--fs-sm); + font-weight: var(--fw-semibold); + white-space: nowrap; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); +} + +/* And the column it came from steps back while it is out of place. */ +.bwfa-scope .bwfa-table thead th.is-dragging { + opacity: 0.45; +} + + +/* The waveform thumbnails sat on their own grey panel, which drew a row of + boxes down the table and cut across the striping and the hover. The + drawing itself only ever clears the canvas, so taking the fill off is + enough for the row's own background to show through — the wave keeps its + blue, and the column stops being a column of tiles. */ +.bwfa-scope .bwfa-waveform-thumb { + background-color: transparent; +} + + +/* ---- One button size -------------------------------------------------- + The transport's buttons set the standard, and the standard is what the + framework calls .btn-sm. Everything else was a size larger, so a modal's + Close and the toolbar's buttons stood a good 8px taller than the Play + button right beneath them. + + Done here, on .btn itself, rather than by adding .btn-sm to every button + in the markup: buttons get built in JavaScript too, and a rule covers the + ones written later without anybody having to remember. .btn-sm keeps + working and is now simply the same thing. + + Not included, and deliberately: .bwfa-round-btn, .modal-close and .chip + are their own shapes and carry no .btn at all, so none of this reaches + them. + + This sits after the framework's own .btn-sm / .btn-lg / .btn-icon and + matches their specificity, so it outranks all three. .btn-sm is now the + same declaration twice over; .btn-lg and .btn-icon are unused, and a + size modifier would have to be written to beat this rule to work again. + The one button it does not size is the Choose beside the export + destination, which takes its height from the field it stands next to. + --------------------------------------------------------------------- */ +.bwfa-scope .btn { + padding: 0.35rem 0.75rem; + font-size: var(--fs-sm); +} + /* The word inside the transport button is not a target. It is rewritten as playback runs, and a node that changes under the pointer between mousedown and mouseup takes the click with it. */ diff --git a/build/tauri-shell.css b/build/tauri-shell.css index 46c26a4..9bddb4a 100644 --- a/build/tauri-shell.css +++ b/build/tauri-shell.css @@ -78,10 +78,9 @@ body { background: var(--color-info-bg); } -.bwfa-scope .bwfa-edit-entry .btn { - font-size: 15px; - padding: 9px 20px; -} +/* Open Folder used to be sized up here, and the one on the launch screen + sized up again. Both are gone: a button is a button, and the transport's + size is the size. */ .bwfa-scope .bwfa-edit-entry .bwfa-dropzone-subnote { max-width: 62ch; @@ -253,10 +252,6 @@ body { display: none; } -.bwfa-scope .bwfa-edit-entry.is-folder-open .btn { - font-size: var(--fs-sm); - padding: 6px 14px; -} .bwfa-scope .bwfa-current-folder { font-size: 15px; @@ -576,10 +571,16 @@ body { text-overflow: ellipsis; } +/* The one button in the app that isn't the standard height, because it is + not standing among buttons: it is paired with the path field beside it and + takes that field's 38px, so the two read as one control. The row stretches + its items, so this height only spells out what the field would impose + anyway. Side padding matches every other button; the vertical padding has + to be 0 for a fixed height to mean anything. */ .bwfa-scope .bwfa-export-dest-row > .btn { flex: none; height: 38px; - padding: 0 14px; + padding: 0 0.75rem; white-space: nowrap; } diff --git a/build/test-tauri.js b/build/test-tauri.js index 1216819..f961713 100644 --- a/build/test-tauri.js +++ b/build/test-tauri.js @@ -639,6 +639,14 @@ function press(el, key, opts) { await waitFor(() => /Done/i.test(status.textContent), "folder open + parse"); const rowsOf = () => Array.from(doc.querySelectorAll("[data-bwfa-table-body] tr")); + + /** A row's cell for one column, found by key so it survives reordering. */ + const cellText = (tr, key) => { + const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")); + const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key); + assert(at !== -1, "no " + key + " column in the head"); + return tr.children[at].textContent.trim(); + }; const headers = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")) .map((th) => th.textContent.replace(/[▲▼\s]+$/, "").trim()); const col = (rowIndex, header) => { @@ -654,10 +662,49 @@ function press(el, key, opts) { // appears on the first Play and vanishes when the file ends. const player = doc.querySelector("[data-bwfa-player]"); assert.strictEqual(player.hidden, false, "the player is hidden with a folder open"); - assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, true, - "Play is offered with nothing loaded"); - assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, true, - "the channel hint is up with no channels to mute"); + // And it is already holding the folder's first file, ready to play: + // a full table over a transport reading "nothing playing" was a step + // nobody wanted to take before hearing the first take of the day. + assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false, + "Play is dead, so the first file wasn't loaded"); + assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(), + cellText(rowsOf()[0], "fileName"), + "the transport is holding something other than the first row"); + // Loaded means loaded: the channels are listed and mutable, and the + // waveform is drawn, without a note being played. + assert.strictEqual(doc.querySelector("[data-bwfa-channel-row]").hidden, false, + "no channels to mute, so the file wasn't really loaded"); + assert(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip").length > 0, + "the channel row is empty"); + assert.notStrictEqual( + doc.querySelector("[data-bwfa-player-duration]").textContent.trim(), "00:00:00", + "no length, so the file was named but not read"); + // Precisely: a real transport, holding peaks, standing still at the + // start. Paused rather than idle is what makes Play a resume. + const held = window.BWFA_STATE.playback; + assert(held, "there is no transport, only a label"); + assert(held.peaks && held.peaks.min && held.peaks.min.length, + "loaded without the waveform data, so there is nothing to draw"); + assert.strictEqual(held.isPlaying, false, "it started playing on its own"); + assert.strictEqual(held.offset, 0, "it loaded part way in"); + }); + + await checkAsync("ready is not playing, and Play starts what it is holding", async () => { + // Opening a folder should not make a noise on its own. + assert.strictEqual(engine.state, null, + "opening a folder started playback by itself"); + const playpause = doc.querySelector("[data-bwfa-player-playpause]"); + const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim(); + click(playpause); + await waitFor(() => engine.state !== null, "Play to start the loaded file", 5000); + assert.strictEqual(doc.querySelector("[data-bwfa-player-filename]").textContent.trim(), + named, "Play started a different file from the one on the transport"); + click(playpause); + await waitFor(() => /play/i.test(playpause.textContent), "it to come back to rest", 4000); + // Put the stub back to untouched. The playback checks further down + // wait for the engine to be asked for a file, and would sail past + // that wait on the strength of this click rather than their own. + engine.state = null; }); check("the folder opens straight into edit mode", () => { @@ -1280,6 +1327,57 @@ function press(el, key, opts) { }); }); + check("every button in the app is the one size", () => { + // The transport's buttons set it. Everything used to be a size bigger + // than the Play button beneath it, and a button added later inherits + // whatever .btn says — so this reads the whole document rather than a + // list someone has to remember to extend. + // + // Round buttons, the traffic-light close and the chips are out of + // scope by construction: none of them carries .btn, so they cannot + // appear here at all. Asserted below, because that is the assumption + // this check rests on. + const sizeOf = (b) => { + const cs = window.getComputedStyle(b); + return { font: cs.fontSize, pad: cs.padding, height: cs.height }; + }; + // Round ones are out, by shape rather than by name: the eject button + // wears .btn as well as its own circle, so a list of exempt classes + // would need maintaining and this does not. + const round = (b) => /50%|9999px/.test(window.getComputedStyle(b).borderRadius || ""); + const buttons = Array.from(doc.querySelectorAll(".btn")).filter((b) => !round(b)); + assert(buttons.length > 20, "only " + buttons.length + " buttons found"); + assert(Array.from(doc.querySelectorAll(".btn")).some(round), + "nothing round left in the sweep — the filter is no longer testing anything"); + // The Choose beside the export destination is the documented + // exception: it takes the height of the path field it is paired with. + const chooser = doc.querySelector("[data-bwfa-export-choose]"); + assert(chooser && chooser.classList.contains("btn"), "no export chooser"); + const standard = sizeOf(doc.querySelector("[data-bwfa-player-playpause]")); + assert(standard.font && standard.pad, + "couldn't read the transport button's own size"); + + const odd = buttons.filter((b) => b !== chooser) + .filter((b) => { + const s = sizeOf(b); + return s.font !== standard.font || s.pad !== standard.pad; + }); + assert.strictEqual(odd.length, 0, odd.length + " buttons are a different size, e.g. '" + + (odd[0] && odd[0].className) + "' at " + JSON.stringify(odd[0] && sizeOf(odd[0])) + + " against the transport's " + JSON.stringify(standard)); + + // The exception stays one exception, and stays the size of its field. + assert.strictEqual(sizeOf(chooser).font, standard.font, + "even the paired button shares the font size"); + assert.strictEqual(sizeOf(chooser).height, "38px", + "the chooser no longer matches the field beside it: " + sizeOf(chooser).height); + + ["bwfa-round-btn", "modal-close", "chip"].forEach((cls) => { + assert.strictEqual(doc.querySelectorAll("." + cls + ".btn").length, 0, + "a ." + cls + " has picked up .btn, so this rule now resizes it"); + }); + }); + check("the file the arrow leads to sits by the buttons, not mid-footer", () => { // jsdom has no layout, so this reads the cascade instead. The trap is // specific and it has already bitten once: the framework gives @@ -1408,6 +1506,26 @@ function press(el, key, opts) { missed.length + " of " + carrying.length + " files still say Boom: " + missed.join(", ")); + // The transport is holding one of the files that was just rewritten, + // and it drew its track names when the file was loaded. Nothing tells + // it to look again, so it went on showing the old ones over a table + // already showing the new. + const onAir = window.BWFA_STATE.playerRow(); + assert(onAir, "nothing on the transport to check"); + const trackNames = ((onAir.parsed.ixml && onAir.parsed.ixml.trackList) || []) + .map((t) => String(t.name || "").trim()).filter(Boolean); + const chips = Array.from(doc.querySelectorAll("[data-bwfa-channel-row] .bwfa-channel-chip")) + .map((c) => c.textContent.trim()); + trackNames.forEach((name) => assert(chips.indexOf(name) !== -1, + "the player still lists the old names — has " + chips.join(", ") + + ", the file now says " + trackNames.join(", "))); + const strips = Array.from(doc.querySelectorAll("[data-bwfa-mixer-strips] .bwfa-mixer-name")) + .map((s) => s.textContent.trim()); + if (strips.length) { + trackNames.forEach((name) => assert(strips.indexOf(name) !== -1, + "the mixer still lists the old names: " + strips.join(", "))); + } + // Applying closes the panel, and the checks below expect it open. click(doc.querySelector("[data-bwfa-bulk-edit-toggle]")); await waitFor(() => bulkPanel.hidden === false, "the panel to come back", 5000); @@ -1648,6 +1766,65 @@ function press(el, key, opts) { assert(/save/i.test(save.textContent), "it doesn't say what it does"); }); + check("the exported picture carries the file name and the frequency scale", () => { + // A PNG leaves the app: on its own it is a pretty picture of an + // unknown file at an unknown scale. Recorded rather than rendered — + // there is no canvas here — so text that never reaches the image + // cannot pass for a caption. + const texts = []; + const drawn = []; + const realGetContext = window.HTMLCanvasElement.prototype.getContext; + const realToBlob = window.HTMLCanvasElement.prototype.toBlob; + const plot = doc.querySelector("[data-bwfa-spectro-canvas]"); + const plotWas = { width: plot.width, height: plot.height }; + let exported = null; + + window.HTMLCanvasElement.prototype.getContext = function () { + const base = realGetContext.call(this); + return new Proxy(base, { + get: (target, prop) => { + if (prop === "fillText") return (s) => texts.push(String(s)); + if (prop === "drawImage") return (img) => drawn.push(img); + return target[prop]; + }, + set: () => true, + }); + }; + window.HTMLCanvasElement.prototype.toBlob = function () { exported = this; }; + try { + click(doc.querySelector("[data-bwfa-spectro-save]")); + } finally { + window.HTMLCanvasElement.prototype.getContext = realGetContext; + window.HTMLCanvasElement.prototype.toBlob = realToBlob; + } + + assert(exported, "nothing was handed to the save panel"); + assert.notStrictEqual(exported, plot, + "it exported the bare plot, so the name and the scale are missing"); + assert(exported.width > plotWas.width, + "the image is no wider than the plot, so there is no room for the scale"); + assert(drawn.indexOf(plot) !== -1, "the picture itself isn't in the export"); + + const said = texts.join(" | "); + const named = doc.querySelector("[data-bwfa-spectro-title]").textContent.trim(); + assert(said.indexOf(named) !== -1, "the file isn't named in the image: " + said); + assert(said.indexOf(window.BWFA_FOLDER_NAME) !== -1, + "the folder isn't named, so a take number names nothing: " + said); + assert(/\bkHz\b/.test(said), "no frequency scale in the image: " + said); + // The top of the axis is Nyquist, and these are 48k files. + assert(texts.some((s) => /^24(\.0)? kHz$/.test(s.trim())), + "the axis doesn't reach 24 kHz on a 48k file: " + said); + assert(texts.some((s) => /^0 Hz$/.test(s.trim())), + "the axis doesn't start at DC: " + said); + + // And the viewer is left exactly as it was: the caption belongs to + // the export, not to the screen. + assert.strictEqual(plot.width, plotWas.width, "the export resized the plot on screen"); + assert.strictEqual(plot.height, plotWas.height, "the export resized the plot on screen"); + assert.strictEqual(doc.querySelectorAll("[data-bwfa-spectro-scale] span").length, 5, + "the on-screen scale was disturbed"); + }); + check("it follows the channel chips rather than always summing", () => { // Solo the boom and you see the boom. Looking at what you are hearing // is the entire reason to have it in the player. @@ -1685,6 +1862,26 @@ function press(el, key, opts) { "the mixer and the player disagree on the track names: " + names.join(", ")); }); + check("a track name in the mixer looks like a track name in the player", () => { + // The two places that list the tracks of a file should look like the + // same thing. The mixer's name is a label and the player's is the + // mute button, so it takes the look and not the behaviour. + const chip = doc.querySelector("[data-bwfa-channel-row] .bwfa-channel-chip"); + const name = doc.querySelector("[data-bwfa-mixer-strips] .bwfa-mixer-name"); + assert(chip && name, "need both a player chip and a mixer name on screen"); + const asChip = window.getComputedStyle(chip); + const asName = window.getComputedStyle(name); + ["fontSize", "padding", "borderRadius", "backgroundColor", + "borderTopWidth", "borderTopStyle", "borderTopColor"].forEach((prop) => { + assert.strictEqual(asName[prop], asChip[prop], + prop + " differs — mixer has " + JSON.stringify(asName[prop]) + + ", player has " + JSON.stringify(asChip[prop])); + }); + // Look, not behaviour: it is not offering itself as a button. + assert.notStrictEqual(asName.cursor, "pointer", + "the mixer's label looks clickable, and clicking it does nothing"); + }); + await checkAsync("pulling a fader down reaches the engine, that track only", async () => { const fader = doc.querySelector('[data-bwfa-mixer-fader="0"]'); assert(fader, "no fader on the first track"); @@ -2032,6 +2229,292 @@ function press(el, key, opts) { "the stop", 5000); }); + /* --- dragging a heading reorders the table --- */ + + // jsdom has no layout, so every getBoundingClientRect is a box of zeros + // and the drag has nothing to aim at. Give the headings a synthetic + // 100px each, on the prototype so it survives the re-render mid-drag, + // and put it back afterwards — the mixer's click-to-seek measures itself + // the same way and would read these boxes as its own. + const realRect = window.Element.prototype.getBoundingClientRect; + function withHeaderGeometry(fn) { + window.Element.prototype.getBoundingClientRect = function () { + if (this.tagName === "TH" && this.hasAttribute("data-bwfa-col")) { + const at = Array.from(this.parentNode.children).indexOf(this); + return { left: at * 100, right: at * 100 + 100, top: 0, bottom: 20, + width: 100, height: 20, x: at * 100, y: 0 }; + } + return realRect.call(this); + }; + try { return fn(); } finally { + window.Element.prototype.getBoundingClientRect = realRect; + } + } + const headKeys = () => Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")) + .map((th) => th.getAttribute("data-bwfa-col")).filter(Boolean); + const settingsKeys = () => Array.from( + doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch span")) + .map((s) => s.textContent.trim()); + const centreOf = (key) => { + const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")); + const at = heads.findIndex((th) => th.getAttribute("data-bwfa-col") === key); + return at * 100 + 50; + }; + function dragHeading(key, ontoKey) { + return withHeaderGeometry(() => { + const th = doc.querySelector('[data-bwfa-col="' + key + '"]'); + assert(th, "no heading for " + key); + const at = centreOf(key); + th.dispatchEvent(new window.MouseEvent("mousedown", + { bubbles: true, clientX: at, button: 0 })); + doc.dispatchEvent(new window.MouseEvent("mousemove", + { bubbles: true, clientX: centreOf(ontoKey) })); + doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true })); + }); + } + + check("you can see what you are dragging, and only while you drag it", () => { + const ghost = () => doc.querySelector("[data-bwfa-col-ghost]"); + assert(!ghost(), "something is following the pointer before a drag starts"); + withHeaderGeometry(() => { + const key = headKeys()[1]; + const label = doc.querySelector('[data-bwfa-col="' + key + '"]') + .firstChild.textContent.trim(); + doc.querySelector('[data-bwfa-col="' + key + '"]').dispatchEvent( + new window.MouseEvent("mousedown", { bubbles: true, clientX: centreOf(key), button: 0 })); + doc.dispatchEvent(new window.MouseEvent("mousemove", + { bubbles: true, clientX: centreOf(key) + 30, clientY: 40 })); + + const held = ghost(); + assert(held, "nothing shows what is being dragged"); + assert.strictEqual(held.textContent.trim(), label, + "it names " + held.textContent + " rather than the column being dragged"); + assert.strictEqual(held.getAttribute("data-bwfa-col-ghost"), key, "it names the wrong column"); + assert.strictEqual(window.getComputedStyle(held).pointerEvents, "none", + "the marker takes the pointer, so the drag can't see the headings under it"); + // And the column it came from shows that it is the one in hand. + assert(doc.querySelector('th[data-bwfa-col="' + key + '"]').classList.contains("is-dragging"), + "the heading it came from isn't marked"); + + doc.dispatchEvent(new window.MouseEvent("mouseup", { bubbles: true })); + assert(!ghost(), "it stayed on screen after the drag ended"); + assert.strictEqual(doc.querySelectorAll("th.is-dragging").length, 0, + "a heading is left marked as being dragged"); + }); + }); + + check("dragging a heading moves the column, and the row follows it", () => { + const before = headKeys(); + const moved = before[0], onto = before[3]; + const wasFirstCell = cellText(rowsOf()[0], moved); + dragHeading(moved, onto); + const after = headKeys(); + assert.notDeepStrictEqual(after, before, "nothing moved"); + assert.strictEqual(after.indexOf(moved), before.indexOf(onto), + "expected " + moved + " to land where " + onto + " was; got " + after.join(", ")); + assert.deepStrictEqual(after.slice().sort(), before.slice().sort(), + "a column was lost or duplicated: " + after.join(", ")); + // The body has to move with the head, or every value is under the + // wrong heading — which is worse than not reordering at all. + assert.strictEqual(cellText(rowsOf()[0], moved), wasFirstCell, + "the cells didn't follow their heading"); + }); + + await checkAsync("a drag is not also a request to sort", async () => { + // mousedown-move-mouseup on a heading ends in a click, and the click + // is the one the sort listens for. Reordering the table and resorting + // it in the same gesture is two surprises for the price of one. + const key = headKeys()[1]; + const sortedBy = () => { + const th = doc.querySelector("[data-bwfa-table-head] th.is-sorted"); + return th && th.getAttribute("data-bwfa-sort"); + }; + const before = sortedBy(); + const arrowOf = () => { + const el = doc.querySelector('[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator'); + return el && el.textContent; + }; + const startedAt = arrowOf(); + try { + dragHeading(key, headKeys()[3]); + // The click the browser sends after a drag, which is what gets past + // a naive implementation. + doc.querySelector('[data-bwfa-col="' + key + '"]') + .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + assert.strictEqual(sortedBy(), before, + "the drag re-sorted the table by " + sortedBy()); + + // And a plain click still sorts, or this could be "fixed" by + // breaking sorting altogether. Done on the column that is already + // sorted, and toggled back: the checks further down read the first + // row of the table and would be sorting a different day's work. + assert(before, "nothing is sorted, so there is no direction to flip"); + // The suppression lifts on the next tick, which is the tick after the + // browser has delivered the drag's own click. Wait for it, or this + // would be testing the suppression a second time over. + await new Promise((resolve) => setTimeout(resolve, 0)); + const arrow = () => doc.querySelector( + '[data-bwfa-sort="' + before + '"] .bwfa-sort-indicator').textContent; + const plainClick = () => doc.querySelector('[data-bwfa-sort="' + before + '"]') + .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + const wasArrow = arrow(); + plainClick(); + assert.notStrictEqual(arrow(), wasArrow, "a plain click stopped sorting"); + plainClick(); + assert.strictEqual(arrow(), wasArrow, "couldn't put the sort back as it was"); + assert.strictEqual(sortedBy(), before, "the sort column moved"); + } finally { + // Whatever happened above, hand the table back sorted the way it + // was found. A broken suppression re-sorts it, and the checks + // further down read the first row — they should report their own + // problem, not this one, and not by hanging. + for (let tries = 0; tries < 4; tries++) { + if (sortedBy() === before && arrowOf() === startedAt) break; + doc.querySelector('[data-bwfa-sort="' + before + '"]') + .dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + } + } + }); + + check("the waveform column takes the table's own background", () => { + const css = fs.readFileSync(INDEX, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " "); + const rules = css.match(/\.bwfa-scope \.bwfa-waveform-thumb \{[^}]*\}/g) || []; + assert(rules.length >= 1, "no rule for the waveform thumbnails"); + // The last one wins, and it has to hand the background back. + const last = rules[rules.length - 1]; + assert(/background-color:\s*transparent/.test(last), + "the thumbnails still paint their own panel: " + last); + }); + + check("the order is written down, so it comes back next launch", () => { + const saved = JSON.parse(window.localStorage.getItem("bwfa_column_order_v1")); + assert(Array.isArray(saved), "nothing was saved"); + assert.deepStrictEqual(saved.filter((k) => headKeys().indexOf(k) !== -1), headKeys(), + "what was saved isn't the order on screen"); + // Hidden columns are in there too, or switching one back on would + // send it to the end of the table. + assert(saved.length > headKeys().length, + "the hidden columns were dropped from the saved order"); + }); + + check("the settings list is in the same order as the table", () => { + click(doc.querySelector("[data-bwfa-columns-open]")); + const labels = settingsKeys(); + const heads = Array.from(doc.querySelectorAll("[data-bwfa-table-head] th")) + .filter((th) => th.getAttribute("data-bwfa-col")) + .map((th) => th.firstChild.textContent.trim()); + // The list carries hidden columns as well, so the visible headings + // should appear within it in exactly this order. + const positions = heads.map((label) => labels.indexOf(label)); + assert(positions.every((p) => p !== -1), + "a heading is missing from the settings list: " + heads.join(", ")); + assert.deepStrictEqual(positions.slice().sort((a, b) => a - b), positions, + "settings lists them in a different order from the table: " + labels.join(", ")); + click(doc.querySelector("[data-bwfa-columns-close]")); + }); + + check("a hidden column keeps its place rather than going to the end", () => { + const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1")); + const hiding = headKeys()[2]; + const neighbour = order()[order().indexOf(hiding) - 1]; + click(doc.querySelector("[data-bwfa-columns-open]")); + const toggles = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch")); + const label = doc.querySelector('[data-bwfa-col="' + hiding + '"]').firstChild.textContent.trim(); + const row = toggles.filter((r) => r.querySelector("span").textContent.trim() === label)[0]; + assert(row, "no toggle for " + label); + const box = row.querySelector("input"); + box.checked = false; + box.dispatchEvent(new window.Event("change", { bubbles: true })); + assert.strictEqual(headKeys().indexOf(hiding), -1, "it stayed in the table"); + // Still in the order, still next to what it was next to. + assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour, + "hiding it moved it in the order"); + box.checked = true; + box.dispatchEvent(new window.Event("change", { bubbles: true })); + assert.strictEqual(order()[order().indexOf(hiding) - 1], neighbour, + "it came back somewhere else"); + click(doc.querySelector("[data-bwfa-columns-close]")); + }); + + check("dragging over a hidden column doesn't disturb it", () => { + // The drag only ever sees what is on screen, but the order it edits + // holds everything — so a hidden column has to keep its neighbour. + const order = () => JSON.parse(window.localStorage.getItem("bwfa_column_order_v1")); + click(doc.querySelector("[data-bwfa-columns-open]")); + const label = doc.querySelector('[data-bwfa-col="folder"]').firstChild.textContent.trim(); + const row = Array.from(doc.querySelectorAll("[data-bwfa-columns-menu] .form-switch")) + .filter((r) => r.querySelector("span").textContent.trim() === label)[0]; + const box = row.querySelector("input"); + box.checked = false; + box.dispatchEvent(new window.Event("change", { bubbles: true })); + click(doc.querySelector("[data-bwfa-columns-close]")); + + const before = order(); + const visible = headKeys(); + dragHeading(visible[0], visible[2]); + const after = order(); + assert.deepStrictEqual(after.slice().sort(), before.slice().sort(), + "the hidden column was lost in the drag"); + assert(after.indexOf("folder") !== -1, "the hidden column fell out of the order"); + }); + + check("neither settings nor the mixer has a second way out", () => { + // Settings saves as you touch it and the mixer applies as you move a + // fader, so a Done or a Close at the bottom implied there was + // something waiting to be confirmed. The window's own close remains, + // and is now the only one. + const settings = doc.querySelector("[data-bwfa-columns-modal]"); + assert(settings, "no settings dialog"); + assert(!settings.querySelector(".modal-footer"), + "settings still carries a footer for a button to sit in"); + const settingsOuts = settings.querySelectorAll("[data-bwfa-columns-close]"); + assert.strictEqual(settingsOuts.length, 1, + "settings offers " + settingsOuts.length + " ways out"); + assert(settingsOuts[0].classList.contains("modal-close"), + "the one way out of settings isn't the window close"); + + const mixer = doc.querySelector("[data-bwfa-mixer]"); + const mixerOuts = mixer.querySelectorAll("[data-bwfa-mixer-close]"); + assert.strictEqual(mixerOuts.length, 1, + "the mixer offers " + mixerOuts.length + " ways out"); + assert(mixerOuts[0].classList.contains("modal-close"), + "the mixer's one way out isn't the window close"); + // Its footer stays, because stepping between files lives there. + assert(mixer.querySelector(".modal-footer [data-bwfa-mixer-next]"), + "stepping to the next file went with the button"); + }); + + check("Save Image shares the file name's line", () => { + // jsdom has no layout, so this reads the cascade and the order. The + // button used to share a flex row with the reading underneath, and + // that row carries the gap below the whole heading block — so it + // aligned to the bottom of the reading and sat under the name. + const css = fs.readFileSync(INDEX, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\n/g, " "); + // The last rule for each selector, because that is the one that wins + // and the plugin styles this block too. + const lastRule = (selector) => { + const all = css.match(new RegExp(selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + " \\{[^}]*\\}", "g")) || []; + return all[all.length - 1]; + }; + const head = lastRule(".bwfa-scope .bwfa-spectro-head"); + assert(head && /flex-wrap:\s*wrap/.test(head), + "the heading block can't wrap, so the three parts share one line: " + head); + const note = lastRule(".bwfa-scope .bwfa-spectro-head p"); + assert(note && /flex:\s*1 0 100%/.test(note), + "the reading doesn't take a line of its own: " + note); + // And it has to come after the button in the markup, or the button is + // what wraps to the second line instead. + const kids = Array.from(doc.querySelector(".bwfa-spectro-head").children); + const saveAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-save")); + const noteAt = kids.findIndex((k) => k.hasAttribute("data-bwfa-spectro-note")); + assert(saveAt !== -1 && noteAt !== -1, "the heading block is missing a part"); + assert(saveAt < noteAt, + "the button comes after the reading, so it wraps below the name"); + }); + check("the settings are folded away behind their own headings", () => { click(doc.querySelector("[data-bwfa-columns-open]")); const sections = Array.from(doc.querySelectorAll("[data-bwfa-settings]")); @@ -2356,6 +2839,18 @@ function press(el, key, opts) { "drop highlight not cleared"); }); + check("a new folder puts its own first file on the transport", () => { + // The one that bites: a folder had been played, then another folder + // was opened, and the transport went on naming a file that is no + // longer anywhere in the table. + const named = doc.querySelector("[data-bwfa-player-filename]").textContent.trim(); + assert.strictEqual(named, cellText(rowsOf()[0], "fileName"), + "the transport is still holding the previous folder's file: " + named); + assert(/A003_14B_T3/.test(named), "expected the dropped folder's own file, got " + named); + assert.strictEqual(doc.querySelector("[data-bwfa-player-playpause]").disabled, false, + "the new folder's first file isn't ready to play"); + }); + /* --- an empty folder clears the table rather than lying about it --- */ const emptyFolder = path.join(root, "Empty Card");