From c454add5c89a767d47d90735030e25cc49456234 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 15 Oct 2026 04:11:58 +0200 Subject: [PATCH] fix(desktop): match KDE tray icon size to Breeze 22px grid (#3489) --- fluxer_desktop/src/main/DesktopTray.ts | 4 +- .../src/main/TrayIconImage.test.mjs | 47 +++++++++++++++---- fluxer_desktop/src/main/TrayIconImage.ts | 31 ++++++++++-- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/fluxer_desktop/src/main/DesktopTray.ts b/fluxer_desktop/src/main/DesktopTray.ts index a7b32342f..a7e2ef620 100644 --- a/fluxer_desktop/src/main/DesktopTray.ts +++ b/fluxer_desktop/src/main/DesktopTray.ts @@ -15,6 +15,7 @@ import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/D import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt'; import {shouldDisableModuleUpdatesForLaunch} from '@electron/main/LaunchOptions'; import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath'; +import {getLinuxDesktop} from '@electron/main/LinuxSession'; import {onLocaleChange, t} from '@electron/main/MainI18n'; import {renderTrayIcon, resolveTrayIconAppearance} from '@electron/main/TrayIconImage'; import {describeUnreadBadge} from '@electron/main/UnreadBadge'; @@ -258,7 +259,8 @@ function resolveTrayIcon(): {key: string; image: Electron.NativeImage} | null { const key = `${appearance}:${indicator}`; let image = trayIconCache.get(key); if (!image) { - image = renderTrayIcon(trayBaseIcon, appearance, indicator); + const padded = process.platform === 'linux' && getLinuxDesktop() === 'kde'; + image = renderTrayIcon(trayBaseIcon, appearance, indicator, padded); trayIconCache.set(key, image); } return {key, image}; diff --git a/fluxer_desktop/src/main/TrayIconImage.test.mjs b/fluxer_desktop/src/main/TrayIconImage.test.mjs index b82a16cb4..3fd6d21c8 100644 --- a/fluxer_desktop/src/main/TrayIconImage.test.mjs +++ b/fluxer_desktop/src/main/TrayIconImage.test.mjs @@ -7,9 +7,8 @@ import {BLUE_BGRA, discBitmap, FakeNativeImage, fakeNativeImageModule, pixel} fr installElectronStub({nativeImage: fakeNativeImageModule}); -const {paintMonochromeGlyph, paintTrayIndicatorDot, renderTrayIcon, resolveTrayIconAppearance} = await import( - './TrayIconImage.ts' -); +const {padTrayIconBitmap, paintMonochromeGlyph, paintTrayIndicatorDot, renderTrayIcon, resolveTrayIconAppearance} = + await import('./TrayIconImage.ts'); describe('tray icon appearance', () => { test('macOS always uses the template image', () => { @@ -69,20 +68,32 @@ describe('unread dot', () => { }); }); +describe('padTrayIconBitmap', () => { + test('centers the bitmap in a transparent 22:16 canvas', () => { + const padded = padTrayIconBitmap(Buffer.alloc(16 * 16 * 4, 0xff), 16, 16); + assert.equal(padded.width, 22); + assert.equal(padded.height, 22); + assert.deepEqual(pixel(padded.buffer, 22, 2, 2), [0, 0, 0, 0]); + assert.deepEqual(pixel(padded.buffer, 22, 3, 3), [0xff, 0xff, 0xff, 0xff]); + assert.deepEqual(pixel(padded.buffer, 22, 18, 18), [0xff, 0xff, 0xff, 0xff]); + assert.deepEqual(pixel(padded.buffer, 22, 19, 19), [0, 0, 0, 0]); + }); +}); + describe('renderTrayIcon', () => { test('returns the base image untouched for the colored icon without a dot', () => { const base = FakeNativeImage.fromBitmap(16); - assert.equal(renderTrayIcon(base, 'colored', false), base); + assert.equal(renderTrayIcon(base, 'colored', false, false), base); const template = FakeNativeImage.fromBitmap(16); template.setTemplateImage(true); - assert.equal(renderTrayIcon(template, 'template', false), template); + assert.equal(renderTrayIcon(template, 'template', false, false), template); }); test('paints every scale factor and keeps the template flag', () => { const base = FakeNativeImage.fromBitmap(16); base.addRepresentation({scaleFactor: 2, width: 32, height: 32, buffer: discBitmap(32)}); base.setTemplateImage(true); - const result = renderTrayIcon(base, 'template', true); + const result = renderTrayIcon(base, 'template', true, false); assert.notEqual(result, base); assert.deepEqual(result.getScaleFactors(), [1, 2]); assert.equal(result.isTemplateImage(), true); @@ -91,16 +102,36 @@ describe('renderTrayIcon', () => { }); test('combines the monochrome glyph with a red dot', () => { - const result = renderTrayIcon(FakeNativeImage.fromBitmap(32), 'white', true); + const result = renderTrayIcon(FakeNativeImage.fromBitmap(32), 'white', true, false); assert.equal(result.isTemplateImage(), false); const bitmap = result.toBitmap(); assert.deepEqual(pixel(bitmap, 32, 25, 7), [0x00, 0x00, 0xdd, 0xff]); assert.deepEqual(pixel(bitmap, 32, 6, 16), [0xff, 0xff, 0xff, 0xff]); }); + test('pads the glyph to the 16px-in-22px tray grid at every scale', () => { + const base = FakeNativeImage.fromBitmap(16); + base.addRepresentation({scaleFactor: 2, width: 32, height: 32, buffer: discBitmap(32)}); + const result = renderTrayIcon(base, 'colored', false, true); + assert.notEqual(result, base); + assert.deepEqual(result.getSize(1), {width: 22, height: 22}); + assert.deepEqual(result.getSize(2), {width: 22, height: 22}); + const bitmap = result.toBitmap(); + assert.deepEqual(pixel(bitmap, 22, 11, 1), [0, 0, 0, 0]); + assert.deepEqual(pixel(bitmap, 22, 11, 4), BLUE_BGRA); + assert.deepEqual(pixel(result.toBitmap({scaleFactor: 2}), 44, 22, 7), BLUE_BGRA); + assert.deepEqual(pixel(result.toBitmap({scaleFactor: 2}), 44, 22, 5), [0, 0, 0, 0]); + }); + + test('keeps the dot on the glyph corner when padded', () => { + const bitmap = renderTrayIcon(FakeNativeImage.fromBitmap(32), 'white', true, true).toBitmap(); + assert.deepEqual(pixel(bitmap, 44, 31, 13), [0x00, 0x00, 0xdd, 0xff]); + assert.deepEqual(pixel(bitmap, 44, 42, 1), [0, 0, 0, 0]); + }); + test('skips representations whose bitmap does not match their size', () => { const base = FakeNativeImage.fromBitmap(16); base.toBitmap = () => Buffer.alloc(3); - assert.equal(renderTrayIcon(base, 'white', true), base); + assert.equal(renderTrayIcon(base, 'white', true, false), base); }); }); diff --git a/fluxer_desktop/src/main/TrayIconImage.ts b/fluxer_desktop/src/main/TrayIconImage.ts index 79e15e86d..7ad3d49fc 100644 --- a/fluxer_desktop/src/main/TrayIconImage.ts +++ b/fluxer_desktop/src/main/TrayIconImage.ts @@ -12,6 +12,7 @@ const SUBSAMPLES = 4; const MIN_GLYPH_CONTRAST = 32; const GLYPH_CUTOUT_START = 0.1; const GLYPH_CUTOUT_END = 0.5; +const KDE_TRAY_CANVAS_RATIO = 22 / 16; const DOT_BGR: Bgr = [0x00, 0x00, 0xdd]; const TEMPLATE_DOT_BGR: Bgr = [0x00, 0x00, 0x00]; const GLYPH_BGR: Readonly> = { @@ -114,9 +115,30 @@ export function paintTrayIndicatorDot(bitmap: Buffer, width: number, height: num } } -export function renderTrayIcon(base: NativeImage, appearance: TrayIconAppearance, indicator: boolean): NativeImage { +export function padTrayIconBitmap( + bitmap: Buffer, + width: number, + height: number, +): {buffer: Buffer; width: number; height: number} { + const paddedWidth = Math.round(width * KDE_TRAY_CANVAS_RATIO); + const paddedHeight = Math.round(height * KDE_TRAY_CANVAS_RATIO); + const left = Math.floor((paddedWidth - width) / 2); + const top = Math.floor((paddedHeight - height) / 2); + const buffer = Buffer.alloc(paddedWidth * paddedHeight * 4); + for (let y = 0; y < height; y++) { + bitmap.copy(buffer, ((top + y) * paddedWidth + left) * 4, y * width * 4, (y + 1) * width * 4); + } + return {buffer, width: paddedWidth, height: paddedHeight}; +} + +export function renderTrayIcon( + base: NativeImage, + appearance: TrayIconAppearance, + indicator: boolean, + padded: boolean, +): NativeImage { const monochrome = appearance === 'white' || appearance === 'black'; - if (!monochrome && !indicator) { + if (!monochrome && !indicator && !padded) { return base; } const result = nativeImage.createEmpty(); @@ -133,7 +155,10 @@ export function renderTrayIcon(base: NativeImage, appearance: TrayIconAppearance if (indicator) { paintTrayIndicatorDot(bitmap, width, height, appearance === 'template' ? TEMPLATE_DOT_BGR : DOT_BGR); } - result.addRepresentation({scaleFactor, width, height, buffer: bitmap}); + result.addRepresentation({ + scaleFactor, + ...(padded ? padTrayIconBitmap(bitmap, width, height) : {width, height, buffer: bitmap}), + }); } if (result.isEmpty()) { return base;