Toolbar badge: paint the count into the icon · SecondCityOsD/greasemonkey-for-UXP-browsers@5a36b28

GitHub

5 min read Original article ↗
1+

// Draws the toolbar button's count (how many user scripts ran on the

2+

// selected tab; browser.js works it out) into the bottom corner of the

3+

// button's own icon.

4+

//

5+

// The platform's badge (the badged-button class) wraps the icon in an

6+

// extra stack. The theme's rules for a menu-button's icon then stop

7+

// reaching it, so the icon loses its padding and frame, and the badge

8+

// hangs over the dropdown arrow. Painting into the image changes neither:

9+

// the button keeps the theme's layout and the count stays on the icon.

10+11+

var GM_ToolbarBadge = {

12+

// Painted icons as data: URIs, by icon, count, scale and direction.

13+

"cache": new Map(),

14+

// Loaded icons by URL; null for one that failed to load.

15+

"images": new Map(),

16+

// Per button, bumped by every paint() so a slow icon load can't show a

17+

// stale count.

18+

"generations": new WeakMap(),

19+

};

20+21+

GM_ToolbarBadge.HTML_NS = "http://www.w3.org/1999/xhtml";

22+

/** Painted icons kept: tab switches mostly move between a few counts. */

23+

GM_ToolbarBadge.CACHE_SIZE = 24;

24+

/** The platform's own badge red, with white figures. */

25+

GM_ToolbarBadge.FILL = "#d90000";

26+

GM_ToolbarBadge.TEXT = "#fff";

27+28+

/**

29+

* Shows aCount in the corner of aButton's icon, or the plain icon at 0.

30+

* The icon painted on is the stylesheet's, so a theme's own icon and the

31+

* small-icons one both work. The count sits in the icon's bottom-right

32+

* corner (bottom-left in right-to-left locales, where the arrow is on the

33+

* left), inside the icon, so it never covers the arrow.

34+

*

35+

* @param {Element} aButton - The toolbar button.

36+

* @param {number} aCount - How many scripts ran; 0 for no badge.

37+

*/

38+

GM_ToolbarBadge.paint = function (aButton, aCount) {

39+

let generations = GM_ToolbarBadge.generations;

40+

let generation = (generations.get(aButton) || 0) + 1;

41+

generations.set(aButton, generation);

42+

// Read the stylesheet's icon with the painted one taken off. Nothing

43+

// is drawn in between, so the button doesn't flicker.

44+

aButton.style.removeProperty("list-style-image");

45+

aButton.style.removeProperty("-moz-image-region");

46+

if (!(aCount > 0)) {

47+

return undefined;

48+

}

49+50+

let style = window.getComputedStyle(aButton, null);

51+

let url = /^url\("(.+)"\)$/.exec(style.getPropertyValue("list-style-image"));

52+

let region = GM_ToolbarBadge.parseRegion(

53+

style.getPropertyValue("-moz-image-region"));

54+

if (!url || (region === undefined)) {

55+

return undefined;

56+

}

57+

let source = {

58+

"font": style.getPropertyValue("font-family") || "sans-serif",

59+

"region": region,

60+

"rtl": style.getPropertyValue("direction") == "rtl",

61+

"url": url[1],

62+

};

63+

let text = (aCount > 99) ? "99+" : String(aCount);

64+

let scale = window.devicePixelRatio || 1;

65+66+

GM_ToolbarBadge.loadImage(source.url, function (aImage) {

67+

if (!aImage || (generation != generations.get(aButton))) {

68+

return undefined;

69+

}

70+

let key = [source.url, JSON.stringify(region), source.rtl, scale, text]

71+

.join("|");

72+

let uri = GM_ToolbarBadge.cache.get(key);

73+

if (uri === undefined) {

74+

uri = GM_ToolbarBadge.draw(aImage, source, text, scale);

75+

if (GM_ToolbarBadge.cache.size >= GM_ToolbarBadge.CACHE_SIZE) {

76+

GM_ToolbarBadge.cache.clear();

77+

}

78+

GM_ToolbarBadge.cache.set(key, uri);

79+

}

80+

if (uri) {

81+

aButton.style.setProperty("list-style-image", 'url("' + uri + '")', "");

82+

if (region) {

83+

// The painted image is only that part of the icon.

84+

aButton.style.setProperty("-moz-image-region", "auto", "");

85+

}

86+

}

87+

});

88+

};

89+90+

/**

91+

* Parses a computed -moz-image-region (a theme's icon sprite).

92+

*

93+

* @param {string} aValue - "auto", or "rect(top, right, bottom, left)".

94+

* @returns {?Object} null for the whole image, {left, top, width, height}

95+

* for a part of it, or undefined for a value this can't read (no badge).

96+

*/

97+

GM_ToolbarBadge.parseRegion = function (aValue) {

98+

if (!aValue || (aValue == "auto")) {

99+

return null;

100+

}

101+

let edges = /^rect\(([\d.]+)px,\s*([\d.]+)px,\s*([\d.]+)px,\s*([\d.]+)px\)$/

102+

.exec(aValue);

103+

if (!edges) {

104+

return undefined;

105+

}

106+

let [top, right, bottom, left] = edges.slice(1).map(Number);

107+

if ((right <= left) || (bottom <= top)) {

108+

return undefined;

109+

}

110+

return {

111+

"height": bottom - top,

112+

"left": left,

113+

"top": top,

114+

"width": right - left,

115+

};

116+

};

117+118+

/**

119+

* Calls aCallback with the loaded image at aUrl, or null when it can't be

120+

* loaded. Loaded once per URL, then answered at once.

121+

*/

122+

GM_ToolbarBadge.loadImage = function (aUrl, aCallback) {

123+

if (GM_ToolbarBadge.images.has(aUrl)) {

124+

aCallback(GM_ToolbarBadge.images.get(aUrl));

125+

return undefined;

126+

}

127+

let image = document.createElementNS(GM_ToolbarBadge.HTML_NS, "img");

128+

image.addEventListener("load", function () {

129+

GM_ToolbarBadge.images.set(aUrl, image);

130+

aCallback(image);

131+

}, false);

132+

image.addEventListener("error", function () {

133+

GM_ToolbarBadge.images.set(aUrl, null);

134+

aCallback(null);

135+

}, false);

136+

image.src = aUrl;

137+

};

138+139+

/**

140+

* Paints the icon with aText in a pill in its bottom corner.

141+

*

142+

* @param {HTMLImageElement} aImage - The loaded icon.

143+

* @param {Object} aSource - font, region, rtl (see paint()).

144+

* @param {string} aText - The figures.

145+

* @param {number} aScale - Device pixels per CSS pixel.

146+

* @returns {?string} A data: URI the size of the icon, or null.

147+

*/

148+

GM_ToolbarBadge.draw = function (aImage, aSource, aText, aScale) {

149+

let region = aSource.region;

150+

let width = region ? region.width : aImage.naturalWidth;

151+

let height = region ? region.height : aImage.naturalHeight;

152+

if (!width || !height) {

153+

return null;

154+

}

155+

let canvas = document.createElementNS(GM_ToolbarBadge.HTML_NS, "canvas");

156+

canvas.width = Math.round(width * aScale);

157+

canvas.height = Math.round(height * aScale);

158+

let ctx = canvas.getContext("2d");

159+

ctx.scale(canvas.width / width, canvas.height / height);

160+

if (region) {

161+

ctx.drawImage(aImage, region.left, region.top, width, height,

162+

0, 0, width, height);

163+

} else {

164+

ctx.drawImage(aImage, 0, 0, width, height);

165+

}

166+167+

// Figures a third of the icon tall (8px on the 24px icon), 7px at

168+

// least, in a pill 2px taller: a circle for one figure, wider for more.

169+

let fontSize = Math.max(7, Math.round(height / 3));

170+

ctx.font = "bold " + fontSize + "px " + aSource.font + ", sans-serif";

171+

let pillHeight = fontSize + 2;

172+

let pillWidth = Math.min(width, Math.max(pillHeight,

173+

Math.ceil(ctx.measureText(aText).width) + 4));

174+

let x = aSource.rtl ? 0 : width - pillWidth;

175+

let y = height - pillHeight;

176+177+

// A 1px gap cut out of the icon around the pill sets it apart on any

178+

// icon and toolbar colour.

179+

ctx.globalCompositeOperation = "destination-out";

180+

GM_ToolbarBadge.pillPath(ctx, x - 1, y - 1, pillWidth + 2, pillHeight + 2);

181+

ctx.fill();

182+

ctx.globalCompositeOperation = "source-over";

183+

GM_ToolbarBadge.pillPath(ctx, x, y, pillWidth, pillHeight);

184+

ctx.fillStyle = GM_ToolbarBadge.FILL;

185+

ctx.fill();

186+

// Centre the figures on their height (about 0.72 of the font size), not

187+

// on the font's whole line, which sits them too low.

188+

ctx.fillStyle = GM_ToolbarBadge.TEXT;

189+

ctx.textAlign = "center";

190+

ctx.textBaseline = "alphabetic";

191+

ctx.fillText(aText, x + pillWidth / 2,

192+

y + (pillHeight + fontSize * 0.72) / 2);

193+194+

let png = canvas.toDataURL("image/png");

195+

if ((canvas.width == width) && (canvas.height == height)) {

196+

return png;

197+

}

198+

// On a high-DPI screen the bigger bitmap has to show at the icon's

199+

// size: an SVG of that size around it says so.

200+

let svg = '<svg xmlns="http://www.w3.org/2000/svg"'

201+

+ ' xmlns:xlink="http://www.w3.org/1999/xlink"'

202+

+ ' width="' + width + '" height="' + height + '">'

203+

+ '<image width="' + width + '" height="' + height + '"'

204+

+ ' xlink:href="' + png + '"/></svg>';

205+

return "data:image/svg+xml," + encodeURIComponent(svg);

206+

};

207+208+

/** Traces a pill (a rectangle with round ends) for fill(). */

209+

GM_ToolbarBadge.pillPath = function (aCtx, aX, aY, aWidth, aHeight) {

210+

let radius = aHeight / 2;

211+

aCtx.beginPath();

212+

aCtx.moveTo(aX + radius, aY);

213+

aCtx.lineTo(aX + aWidth - radius, aY);

214+

aCtx.arc(aX + aWidth - radius, aY + radius, radius,

215+

-Math.PI / 2, Math.PI / 2, false);

216+

aCtx.lineTo(aX + radius, aY + aHeight);

217+

aCtx.arc(aX + radius, aY + radius, radius,

218+

Math.PI / 2, Math.PI * 3 / 2, false);

219+

aCtx.closePath();

220+

};