mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): crisp taskbar badges and a dark badge option (#3495)
This commit is contained in:
1 parent
4b8ce8bf63
commit
c424656bad
113 files changed
+890
-22
No files matched your search
@@ -0,0 +1,201 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as zlib from 'node:zlib';
|
||||
import {app, BrowserWindow} from 'electron';
|
||||
|
||||
const ROOT_DIR = path.resolve(import.meta.dirname, '..');
|
||||
const BUILD_RESOURCES_DIR = path.join(ROOT_DIR, 'build_resources');
|
||||
const FONT_FILE = path.join(ROOT_DIR, '..', 'packages', 'fonts', 'files', 'FluxerSans', 'FluxerSans-SemiBold.woff2');
|
||||
const ICON_DIRECTORIES = Object.freeze(['icons-stable', 'icons-canary', 'icons-development']);
|
||||
const FRAME_SIZES = Object.freeze([16, 20, 24, 32, 40, 48]);
|
||||
const SUPERSAMPLE = 8;
|
||||
const BADGE_COUNT = 11;
|
||||
const DOT_INDEX = 11;
|
||||
const CIRCLE_DIAMETER_RATIO = 0.875;
|
||||
const DIGIT_HEIGHT_RATIO = 0.49;
|
||||
const WIDE_LABEL_MAX_WIDTH_RATIO = 0.7;
|
||||
const DOT_DIAMETER_RATIO = 0.375;
|
||||
const GLYPH_COLOR = '#ffffff';
|
||||
const VARIANTS = Object.freeze([
|
||||
{prefix: 'badge', fill: '#f35a5a'},
|
||||
{prefix: 'badge-dark', fill: '#26252d'},
|
||||
]);
|
||||
|
||||
function labelFor(index) {
|
||||
if (index === DOT_INDEX) return null;
|
||||
if (index === BADGE_COUNT - 1) return '9+';
|
||||
return String(index);
|
||||
}
|
||||
|
||||
function renderInPage(options) {
|
||||
return document.fonts.load(`600 100px BadgeFont`).then(() => {
|
||||
const results = [];
|
||||
for (const job of options.jobs) {
|
||||
const size = job.size * options.supersample;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d', {willReadFrequently: true});
|
||||
context.fillStyle = job.fill;
|
||||
context.beginPath();
|
||||
context.arc(size / 2, size / 2, (size * options.circleDiameterRatio) / 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.fillStyle = options.glyphColor;
|
||||
if (job.label === null) {
|
||||
context.beginPath();
|
||||
context.arc(size / 2, size / 2, (size * options.dotDiameterRatio) / 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
} else {
|
||||
context.font = '600 100px BadgeFont';
|
||||
const reference = context.measureText('8');
|
||||
const referenceHeight = reference.actualBoundingBoxAscent + reference.actualBoundingBoxDescent;
|
||||
let fontSize = (100 * size * options.digitHeightRatio) / referenceHeight;
|
||||
context.font = `600 ${fontSize}px BadgeFont`;
|
||||
let metrics = context.measureText(job.label);
|
||||
let width = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
|
||||
const maxWidth = size * options.wideLabelMaxWidthRatio;
|
||||
if (width > maxWidth) {
|
||||
fontSize *= maxWidth / width;
|
||||
context.font = `600 ${fontSize}px BadgeFont`;
|
||||
metrics = context.measureText(job.label);
|
||||
width = metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight;
|
||||
}
|
||||
const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
|
||||
const x = (size - width) / 2 + metrics.actualBoundingBoxLeft;
|
||||
const y = (size - height) / 2 + metrics.actualBoundingBoxAscent;
|
||||
context.fillText(job.label, x, y);
|
||||
}
|
||||
results.push(Array.from(context.getImageData(0, 0, size, size).data));
|
||||
}
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
function downsample(rgba, size, factor) {
|
||||
const source = size * factor;
|
||||
const output = Buffer.alloc(size * size * 4);
|
||||
const samples = factor * factor;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
let blue = 0;
|
||||
let alpha = 0;
|
||||
for (let sy = 0; sy < factor; sy++) {
|
||||
for (let sx = 0; sx < factor; sx++) {
|
||||
const offset = ((y * factor + sy) * source + (x * factor + sx)) * 4;
|
||||
const weight = rgba[offset + 3];
|
||||
red += rgba[offset] * weight;
|
||||
green += rgba[offset + 1] * weight;
|
||||
blue += rgba[offset + 2] * weight;
|
||||
alpha += weight;
|
||||
}
|
||||
}
|
||||
const target = (y * size + x) * 4;
|
||||
if (alpha === 0) continue;
|
||||
output[target] = Math.round(red / alpha);
|
||||
output[target + 1] = Math.round(green / alpha);
|
||||
output[target + 2] = Math.round(blue / alpha);
|
||||
output[target + 3] = Math.round(alpha / samples);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const chunk = Buffer.alloc(12 + data.length);
|
||||
chunk.writeUInt32BE(data.length, 0);
|
||||
chunk.write(type, 4, 'latin1');
|
||||
data.copy(chunk, 8);
|
||||
chunk.writeUInt32BE(zlib.crc32(chunk.subarray(4, 8 + data.length)), 8 + data.length);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function encodePng(rgba, size) {
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header.writeUInt8(8, 8);
|
||||
header.writeUInt8(6, 9);
|
||||
const rows = Buffer.alloc(size * (size * 4 + 1));
|
||||
for (let y = 0; y < size; y++) {
|
||||
rgba.copy(rows, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk('IHDR', header),
|
||||
pngChunk('IDAT', zlib.deflateSync(rows, {level: 9})),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
function encodeIcon(frames) {
|
||||
const header = Buffer.alloc(6 + frames.length * 16);
|
||||
header.writeUInt16LE(0, 0);
|
||||
header.writeUInt16LE(1, 2);
|
||||
header.writeUInt16LE(frames.length, 4);
|
||||
let offset = header.length;
|
||||
frames.forEach(({size, data}, index) => {
|
||||
const entry = 6 + index * 16;
|
||||
header.writeUInt8(size, entry);
|
||||
header.writeUInt8(size, entry + 1);
|
||||
header.writeUInt16LE(1, entry + 4);
|
||||
header.writeUInt16LE(32, entry + 6);
|
||||
header.writeUInt32LE(data.length, entry + 8);
|
||||
header.writeUInt32LE(offset, entry + 12);
|
||||
offset += data.length;
|
||||
});
|
||||
return Buffer.concat([header, ...frames.map(({data}) => data)]);
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const font = fs.readFileSync(FONT_FILE).toString('base64');
|
||||
const window = new BrowserWindow({show: false, webPreferences: {offscreen: true}});
|
||||
const html = `<style>@font-face{font-family:BadgeFont;font-weight:600;src:url(data:font/woff2;base64,${font}) format('woff2')}</style>`;
|
||||
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
|
||||
const jobs = VARIANTS.flatMap((variant) =>
|
||||
Array.from({length: BADGE_COUNT}, (_, offset) => offset + 1).flatMap((index) =>
|
||||
FRAME_SIZES.map((size) => ({variant: variant.prefix, index, size, fill: variant.fill, label: labelFor(index)})),
|
||||
),
|
||||
);
|
||||
const options = {
|
||||
jobs: jobs.map(({size, fill, label}) => ({size, fill, label})),
|
||||
supersample: SUPERSAMPLE,
|
||||
circleDiameterRatio: CIRCLE_DIAMETER_RATIO,
|
||||
digitHeightRatio: DIGIT_HEIGHT_RATIO,
|
||||
wideLabelMaxWidthRatio: WIDE_LABEL_MAX_WIDTH_RATIO,
|
||||
dotDiameterRatio: DOT_DIAMETER_RATIO,
|
||||
glyphColor: GLYPH_COLOR,
|
||||
};
|
||||
const rendered = await window.webContents.executeJavaScript(
|
||||
`(${renderInPage.toString()})(${JSON.stringify(options)})`,
|
||||
);
|
||||
window.destroy();
|
||||
const icons = new Map();
|
||||
jobs.forEach((job, position) => {
|
||||
const name = `${job.variant}-${job.index}.ico`;
|
||||
const frames = icons.get(name) ?? [];
|
||||
const pixels = downsample(rendered[position], job.size, SUPERSAMPLE);
|
||||
frames.push({size: job.size, data: encodePng(pixels, job.size)});
|
||||
icons.set(name, frames);
|
||||
});
|
||||
for (const directory of ICON_DIRECTORIES) {
|
||||
const target = path.join(BUILD_RESOURCES_DIR, directory, 'badges');
|
||||
fs.mkdirSync(target, {recursive: true});
|
||||
for (const [name, frames] of icons) {
|
||||
fs.writeFileSync(path.join(target, name), encodeIcon(frames));
|
||||
}
|
||||
}
|
||||
console.log(`Wrote ${icons.size} badge icons to ${ICON_DIRECTORIES.length} channel directories`);
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(generate)
|
||||
.then(() => app.exit(0))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
app.exit(1);
|
||||
});
|
||||
Reference in new issue
Block a user