fix icon for clip drag

This commit is contained in:
Boof2015
2026-08-18 02:23:14 -04:00
parent eda0fbef51
commit 8d6916f203
6 changed files with 296 additions and 2 deletions
+5
View File
@@ -10,6 +10,7 @@
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"build:icons": "node scripts/build/generate-prism-icons.mjs",
"build:clip-drag-icons": "node scripts/build/generate-audio-clip-drag-icons.mjs",
"test": "node scripts/run-tests.mjs",
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
"test:audio-clips": "node scripts/run-audio-clip-tests.mjs",
@@ -107,6 +108,10 @@
"from": "resources/icon.png",
"to": "icon.png"
},
{
"from": "resources/drag/",
"to": "drag/"
},
{
"from": "resources/tray/",
"to": "tray/"
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

@@ -0,0 +1,236 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { deflateSync } from 'node:zlib'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(scriptDir, '../..')
const outputDir = join(repoRoot, 'resources', 'drag')
const logicalSize = 32
const supersample = 4
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
const colors = {
outline: [232, 241, 247, 255],
page: [9, 16, 23, 255],
fold: [26, 43, 55, 255],
waveform: [48, 197, 255, 255],
}
const documentOutline = [
[8.25, 2],
[18.75, 2],
[26, 9.25],
[26, 27],
[25.7, 28.25],
[24.7, 29.5],
[8.25, 29.5],
[7, 29.2],
[6, 28.2],
[6, 4.75],
[6.3, 3.5],
[7.05, 2.55],
]
const documentPage = [
[8.4, 3.7],
[18.05, 3.7],
[24.3, 9.95],
[24.3, 27.1],
[23.75, 27.8],
[8.25, 27.8],
[7.7, 27.15],
[7.7, 4.55],
]
const documentFold = [
[19.1, 4.75],
[23.25, 8.9],
[19.1, 8.9],
]
const waveformBars = [
{ x: 10.5, y: 16.5, height: 5 },
{ x: 13.25, y: 13.5, height: 11 },
{ x: 16, y: 15, height: 8 },
{ x: 18.75, y: 12.5, height: 13 },
{ x: 21.5, y: 16, height: 6 },
]
function makeCrc32Table() {
const table = new Uint32Array(256)
for (let i = 0; i < table.length; i += 1) {
let value = i
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) !== 0
? 0xedb88320 ^ (value >>> 1)
: value >>> 1
}
table[i] = value >>> 0
}
return table
}
const crc32Table = makeCrc32Table()
function crc32(buffer) {
let crc = 0xffffffff
for (const byte of buffer) {
crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return (crc ^ 0xffffffff) >>> 0
}
function pngChunk(type, data) {
const typeBuffer = Buffer.from(type, 'ascii')
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32BE(data.length, 0)
const crcBuffer = Buffer.alloc(4)
crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0)
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer])
}
function writePng(filePath, width, height, pixels) {
const stride = width * 4
const raw = Buffer.alloc((stride + 1) * height)
for (let y = 0; y < height; y += 1) {
const rowOffset = y * (stride + 1)
raw[rowOffset] = 0
pixels.copy(raw, rowOffset + 1, y * stride, (y + 1) * stride)
}
const header = Buffer.alloc(13)
header.writeUInt32BE(width, 0)
header.writeUInt32BE(height, 4)
header.writeUInt8(8, 8)
header.writeUInt8(6, 9)
writeFileSync(filePath, Buffer.concat([
pngSignature,
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0)),
]))
}
function pointInPolygon(x, y, points) {
let inside = false
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
const [xi, yi] = points[i]
const [xj, yj] = points[j]
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi) / (yj - yi)) + xi) {
inside = !inside
}
}
return inside
}
function setPixel(pixels, width, x, y, color) {
const offset = ((y * width) + x) * 4
pixels[offset] = color[0]
pixels[offset + 1] = color[1]
pixels[offset + 2] = color[2]
pixels[offset + 3] = color[3]
}
function fillPolygon(pixels, width, height, points, color, scale) {
const scaledPoints = points.map(([x, y]) => [x * scale, y * scale])
const xs = scaledPoints.map(([x]) => x)
const ys = scaledPoints.map(([, y]) => y)
const minX = Math.max(0, Math.floor(Math.min(...xs)))
const maxX = Math.min(width - 1, Math.ceil(Math.max(...xs)))
const minY = Math.max(0, Math.floor(Math.min(...ys)))
const maxY = Math.min(height - 1, Math.ceil(Math.max(...ys)))
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
if (pointInPolygon(x + 0.5, y + 0.5, scaledPoints)) {
setPixel(pixels, width, x, y, color)
}
}
}
}
function fillRoundedRect(pixels, width, height, rect, color, scale) {
const left = (rect.x - (rect.width / 2)) * scale
const top = rect.y * scale
const rectWidth = rect.width * scale
const rectHeight = rect.height * scale
const radius = Math.min(rectWidth / 2, rectHeight / 2)
const right = left + rectWidth
const bottom = top + rectHeight
for (let y = Math.max(0, Math.floor(top)); y < Math.min(height, Math.ceil(bottom)); y += 1) {
for (let x = Math.max(0, Math.floor(left)); x < Math.min(width, Math.ceil(right)); x += 1) {
const sampleX = x + 0.5
const sampleY = y + 0.5
const nearestX = Math.max(left + radius, Math.min(right - radius, sampleX))
const nearestY = Math.max(top + radius, Math.min(bottom - radius, sampleY))
const dx = sampleX - nearestX
const dy = sampleY - nearestY
if ((dx * dx) + (dy * dy) <= radius * radius) {
setPixel(pixels, width, x, y, color)
}
}
}
}
function downsample(source, sourceSize, targetSize) {
const ratio = sourceSize / targetSize
const target = Buffer.alloc(targetSize * targetSize * 4)
for (let y = 0; y < targetSize; y += 1) {
for (let x = 0; x < targetSize; x += 1) {
let alpha = 0
let red = 0
let green = 0
let blue = 0
for (let sampleY = 0; sampleY < ratio; sampleY += 1) {
for (let sampleX = 0; sampleX < ratio; sampleX += 1) {
const sourceOffset = ((((y * ratio) + sampleY) * sourceSize) + (x * ratio) + sampleX) * 4
const sampleAlpha = source[sourceOffset + 3]
alpha += sampleAlpha
red += source[sourceOffset] * sampleAlpha
green += source[sourceOffset + 1] * sampleAlpha
blue += source[sourceOffset + 2] * sampleAlpha
}
}
const targetOffset = ((y * targetSize) + x) * 4
const sampleCount = ratio * ratio
target[targetOffset + 3] = Math.round(alpha / sampleCount)
if (alpha > 0) {
target[targetOffset] = Math.round(red / alpha)
target[targetOffset + 1] = Math.round(green / alpha)
target[targetOffset + 2] = Math.round(blue / alpha)
}
}
}
return target
}
function renderIcon(size) {
const sourceSize = size * supersample
const scale = sourceSize / logicalSize
const source = Buffer.alloc(sourceSize * sourceSize * 4)
fillPolygon(source, sourceSize, sourceSize, documentOutline, colors.outline, scale)
fillPolygon(source, sourceSize, sourceSize, documentPage, colors.page, scale)
fillPolygon(source, sourceSize, sourceSize, documentFold, colors.fold, scale)
for (const bar of waveformBars) {
fillRoundedRect(source, sourceSize, sourceSize, {
...bar,
width: 1.7,
}, colors.waveform, scale)
}
return downsample(source, sourceSize, size)
}
mkdirSync(outputDir, { recursive: true })
for (const [filename, size] of [['audio-clip.png', 32], ['audio-clip@2x.png', 64]]) {
writePng(join(outputDir, filename), size, size, renderIcon(size))
}
+25 -2
View File
@@ -153,6 +153,8 @@ const NOW_PLAYING_CONFIG_DEFAULTS = {
}
const STATIC_APP_ICON_FILENAME = 'icon.png'
const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip.png'
const AUDIO_CLIP_DRAG_ICON_SIZE = 32
const DESKTOP_INTEGRATION_PREFS_FILENAME = 'desktop-integration.json'
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
@@ -377,11 +379,32 @@ function getStaticWindowIconOptions(): Pick<BrowserWindowConstructorOptions, 'ic
return icon ? { icon } : {}
}
function getAudioClipDragIconPath(): string | undefined {
const candidates = app.isPackaged
? [join(process.resourcesPath, 'drag', AUDIO_CLIP_DRAG_ICON_FILENAME)]
: [
join(process.cwd(), 'resources', 'drag', AUDIO_CLIP_DRAG_ICON_FILENAME),
join(__dirname, '../../resources/drag', AUDIO_CLIP_DRAG_ICON_FILENAME),
]
return candidates.find((candidate) => existsSync(candidate))
}
function getAudioClipDragIcon() {
const iconPath = getStaticAppIconPath()
const iconPath = getAudioClipDragIconPath()
if (iconPath) {
const icon = nativeImage.createFromPath(iconPath)
if (!icon.isEmpty()) return icon
if (!icon.isEmpty()) {
const size = icon.getSize()
if (size.width > AUDIO_CLIP_DRAG_ICON_SIZE || size.height > AUDIO_CLIP_DRAG_ICON_SIZE) {
return icon.resize({
width: AUDIO_CLIP_DRAG_ICON_SIZE,
height: AUDIO_CLIP_DRAG_ICON_SIZE,
quality: 'best',
})
}
return icon
}
}
const fallback = nativeImage.createFromDataURL(
+30
View File
@@ -4492,6 +4492,36 @@ test('rolling capture exposes persisted duration controls and a native toolbar d
assert.match(trayBridgeSource, /rollingCaptureSeconds,/)
})
test('rolling capture uses a dedicated bounded audio-file drag preview', async () => {
const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8')
const packageSource = await readFile(join(process.cwd(), 'package.json'), 'utf8')
const icon1x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip.png'))
const icon2x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip@2x.png'))
const packageJson = JSON.parse(packageSource) as {
build: {
extraResources: Array<{ from: string; to: string }>
}
}
const pngDimensions = (buffer: Buffer): [number, number] => {
assert.deepEqual([...buffer.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10])
return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)]
}
assert.deepEqual(pngDimensions(icon1x), [32, 32])
assert.deepEqual(pngDimensions(icon2x), [64, 64])
assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip\.png'/)
assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_SIZE = 32/)
assert.match(mainSource, /function getAudioClipDragIconPath\(\)/)
assert.match(mainSource, /const iconPath = getAudioClipDragIconPath\(\)/)
assert.match(mainSource, /icon\.resize\(\{[\s\S]*width: AUDIO_CLIP_DRAG_ICON_SIZE,[\s\S]*height: AUDIO_CLIP_DRAG_ICON_SIZE/)
assert.doesNotMatch(mainSource, /function getAudioClipDragIcon\(\) \{\s*const iconPath = getStaticAppIconPath\(\)/)
assert.match(mainSource, /event\.sender\.startDrag\(\{\s*file: filePath,\s*icon: getAudioClipDragIcon\(\),\s*\}\)/)
assert.ok(packageJson.build.extraResources.some((entry) => {
return entry.from === 'resources/drag/' && entry.to === 'drag/'
}))
})
test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => {
assert.deepEqual(
resolveWindowCapabilities({