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+};