UrlbarProviderCalculator.sys.mjs (13687B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 6 7 import { 8 UrlbarProvider, 9 UrlbarUtils, 10 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 11 12 const lazy = {}; 13 14 ChromeUtils.defineESModuleGetters(lazy, { 15 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 16 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 17 UrlbarView: "moz-src:///browser/components/urlbar/UrlbarView.sys.mjs", 18 }); 19 20 ChromeUtils.defineLazyGetter(lazy, "l10n", () => { 21 return new Localization(["browser/browser.ftl"], true); 22 }); 23 24 XPCOMUtils.defineLazyServiceGetter( 25 lazy, 26 "ClipboardHelper", 27 "@mozilla.org/widget/clipboardhelper;1", 28 Ci.nsIClipboardHelper 29 ); 30 31 // This pref is relative to the `browser.urlbar` branch. 32 const ENABLED_PREF = "suggest.calculator"; 33 34 const DYNAMIC_RESULT_TYPE = "calculator"; 35 36 const VIEW_TEMPLATE = { 37 attributes: { 38 selectable: true, 39 }, 40 children: [ 41 { 42 name: "content", 43 tag: "span", 44 attributes: { class: "urlbarView-no-wrap" }, 45 children: [ 46 { 47 name: "icon", 48 tag: "img", 49 attributes: { class: "urlbarView-favicon" }, 50 }, 51 { 52 name: "input", 53 tag: "strong", 54 }, 55 { 56 name: "action", 57 tag: "span", 58 }, 59 ], 60 }, 61 ], 62 }; 63 64 // Minimum number of parts of the expression before we show a result. 65 const MIN_EXPRESSION_LENGTH = 3; 66 const UNDEFINED_VALUE = "undefined"; 67 // Minimum and maximum value of result before it switches to scientific 68 // notation. Displaying numbers longer than 10 digits long or a decimal 69 // containing 5 or more leading zeroes in scientific notation improves 70 // readability. 71 const FULL_NUMBER_MAX_THRESHOLD = 1 * 10 ** 10; 72 const FULL_NUMBER_MIN_THRESHOLD = 10 ** -5; 73 74 /** 75 * A provider that returns a suggested url to the user based on what 76 * they have currently typed so they can navigate directly. 77 */ 78 export class UrlbarProviderCalculator extends UrlbarProvider { 79 constructor() { 80 super(); 81 lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); 82 lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); 83 } 84 85 /** 86 * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>} 87 */ 88 get type() { 89 return UrlbarUtils.PROVIDER_TYPE.PROFILE; 90 } 91 92 /** 93 * Whether this provider should be invoked for the given context. 94 * If this method returns false, the providers manager won't start a query 95 * with this provider, to save on resources. 96 * 97 * @param {UrlbarQueryContext} queryContext The query context object 98 */ 99 async isActive(queryContext) { 100 return ( 101 queryContext.trimmedSearchString && 102 !queryContext.searchMode && 103 lazy.UrlbarPrefs.get(ENABLED_PREF) 104 ); 105 } 106 107 /** 108 * Starts querying. 109 * 110 * @param {UrlbarQueryContext} queryContext 111 * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback 112 * Callback invoked by the provider to add a new result. 113 */ 114 async startQuery(queryContext, addCallback) { 115 try { 116 // Calculator will throw when given an invalid expression, therefore 117 // addCallback will never be called. 118 let postfix = Calculator.infix2postfix(queryContext.searchString); 119 if (postfix.length < MIN_EXPRESSION_LENGTH) { 120 return; 121 } 122 let value = Calculator.evaluatePostfix(postfix); 123 const result = new lazy.UrlbarResult({ 124 type: UrlbarUtils.RESULT_TYPE.DYNAMIC, 125 source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, 126 suggestedIndex: 1, 127 payload: { 128 value, 129 input: queryContext.searchString, 130 dynamicType: DYNAMIC_RESULT_TYPE, 131 }, 132 }); 133 addCallback(this, result); 134 } catch (e) {} 135 } 136 137 getViewUpdate(result) { 138 const { value } = result.payload; 139 140 return { 141 icon: { 142 attributes: { 143 src: "chrome://global/skin/icons/edit-copy.svg", 144 }, 145 }, 146 input: 147 value == UNDEFINED_VALUE 148 ? { 149 l10n: { id: "urlbar-result-action-undefined-calculator-result" }, 150 } 151 : { 152 textContent: `= ${value}`, 153 attributes: { dir: "ltr" }, 154 }, 155 action: { 156 l10n: { id: "urlbar-result-action-copy-to-clipboard" }, 157 }, 158 }; 159 } 160 161 onEngagement(queryContext, controller, details) { 162 const { result } = details; 163 const input = this.getViewUpdate(result).input; 164 let localizedResult; 165 if ("l10n" in input) { 166 const args = input.l10n.args || {}; 167 localizedResult = lazy.l10n.formatValueSync(input.l10n.id, args); 168 } else { 169 localizedResult = input.textContent.replace(/^=\s*/, ""); 170 } 171 172 lazy.ClipboardHelper.copyString(localizedResult); 173 } 174 } 175 176 /** 177 * Base implementation of a basic calculator. 178 */ 179 class BaseCalculator { 180 // Holds the current symbols for calculation 181 stack = []; 182 numberSystems = []; 183 184 addNumberSystem(system) { 185 this.numberSystems.push(system); 186 } 187 188 isNumeric(value) { 189 return value - 0 == value && value.length; 190 } 191 192 isOperator(value) { 193 return this.numberSystems.some(sys => sys.isOperator(value)); 194 } 195 196 isNumericToken(char) { 197 return this.numberSystems.some(sys => sys.isNumericToken(char)); 198 } 199 200 /** 201 * Parses a string into a float accounting for different localisations. 202 * 203 * @param {string} num 204 */ 205 parsel10nFloat(num) { 206 for (const system of this.numberSystems) { 207 num = system.transformNumber(num); 208 } 209 return parseFloat(num); 210 } 211 212 precedence(val) { 213 if (["-", "+"].includes(val)) { 214 return 2; 215 } 216 if (["*", "/"].includes(val)) { 217 return 3; 218 } 219 if ("^" === val) { 220 return 4; 221 } 222 223 return null; 224 } 225 226 isLeftAssociative(val) { 227 if (["-", "+", "*", "/"].includes(val)) { 228 return true; 229 } 230 if ("^" === val) { 231 return false; 232 } 233 234 return null; 235 } 236 237 // This is a basic implementation of the shunting yard algorithm 238 // described http://en.wikipedia.org/wiki/Shunting-yard_algorithm 239 // Currently functions are unimplemented 240 infix2postfix(infix) { 241 let parser = new Parser(infix, this); 242 let tokens = parser.parse(); 243 let output = []; 244 let stack = []; 245 246 tokens.forEach(token => { 247 if (token.number) { 248 output.push(this.parsel10nFloat(token.value)); 249 } 250 251 if (this.isOperator(token.value)) { 252 let i = this.precedence; 253 while ( 254 stack.length && 255 this.isOperator(stack[stack.length - 1]) && 256 (i(token.value) < i(stack[stack.length - 1]) || 257 (i(token.value) == i(stack[stack.length - 1]) && 258 this.isLeftAssociative(token.value))) 259 ) { 260 output.push(stack.pop()); 261 } 262 stack.push(token.value); 263 } 264 265 if (token.value === "(") { 266 stack.push(token.value); 267 } 268 269 if (token.value === ")") { 270 while (stack.length && stack[stack.length - 1] !== "(") { 271 output.push(stack.pop()); 272 } 273 // This is the ( 274 stack.pop(); 275 } 276 }); 277 278 while (stack.length) { 279 output.push(stack.pop()); 280 } 281 return output; 282 } 283 284 evaluate = { 285 "*": (a, b) => a * b, 286 "+": (a, b) => a + b, 287 "-": (a, b) => a - b, 288 "/": (a, b) => a / b, 289 "^": (a, b) => a ** b, 290 }; 291 292 evaluatePostfix(postfix) { 293 let stack = []; 294 295 for (const token of postfix) { 296 if (!this.isOperator(token)) { 297 stack.push(token); 298 } else { 299 let op2 = stack.pop(); 300 let op1 = stack.pop(); 301 let result = this.evaluate[token](op1, op2); 302 if (token == "/" && op2 == 0) { 303 return UNDEFINED_VALUE; 304 } 305 if (isNaN(result) || !isFinite(result)) { 306 throw new Error("Value is " + result); 307 } 308 stack.push(result); 309 } 310 } 311 let finalResult = stack.pop(); 312 if (isNaN(finalResult) || !isFinite(finalResult)) { 313 throw new Error("Value is " + finalResult); 314 } 315 316 let locale = Services.locale.appLocaleAsBCP47; 317 318 if ( 319 Math.abs(finalResult) >= FULL_NUMBER_MAX_THRESHOLD || 320 (Math.abs(finalResult) <= FULL_NUMBER_MIN_THRESHOLD && finalResult != 0) 321 ) { 322 return new Intl.NumberFormat(locale, { 323 style: "decimal", 324 notation: "scientific", 325 minimumFractionDigits: 1, 326 maximumFractionDigits: 8, 327 numberingSystem: "latn", 328 }) 329 .format(finalResult) 330 .toLowerCase(); 331 } else if (Math.abs(finalResult) < 1) { 332 return new Intl.NumberFormat(locale, { 333 style: "decimal", 334 maximumSignificantDigits: 9, 335 numberingSystem: "latn", 336 }).format(finalResult); 337 } 338 return new Intl.NumberFormat(locale, { 339 style: "decimal", 340 useGrouping: false, 341 maximumFractionDigits: 8, 342 numberingSystem: "latn", 343 }).format(finalResult); 344 } 345 } 346 347 function Parser(input, calculator) { 348 this.calculator = calculator; 349 this.init(input); 350 } 351 352 Parser.prototype = { 353 init(input) { 354 // No spaces. 355 input = input.replace(/[ \t\v\n]/g, ""); 356 357 // String to array: 358 this._chars = []; 359 for (let i = 0; i < input.length; ++i) { 360 this._chars.push(input[i]); 361 } 362 363 this._tokens = []; 364 }, 365 366 // This method returns an array of objects with these properties: 367 // - number: true/false 368 // - value: the token value 369 parse() { 370 // The input must be a "block" without any digit left. 371 if (!this._tokenizeBlock() || this._chars.length) { 372 throw new Error("Wrong input"); 373 } 374 375 return this._tokens; 376 }, 377 378 _tokenizeBlock() { 379 if (!this._chars.length) { 380 return false; 381 } 382 383 // "(" + something + ")" 384 if (this._chars[0] == "(") { 385 this._tokens.push({ number: false, value: this._chars[0] }); 386 this._chars.shift(); 387 388 if (!this._tokenizeBlock()) { 389 return false; 390 } 391 392 if (!this._chars.length || this._chars[0] != ")") { 393 return false; 394 } 395 396 this._chars.shift(); 397 398 this._tokens.push({ number: false, value: ")" }); 399 } else if (!this._tokenizeNumber()) { 400 // number + ... 401 return false; 402 } 403 404 if (!this._chars.length || this._chars[0] == ")") { 405 return true; 406 } 407 408 while (this._chars.length && this._chars[0] != ")") { 409 if (!this._tokenizeOther()) { 410 return false; 411 } 412 413 if (!this._tokenizeBlock()) { 414 return false; 415 } 416 } 417 418 return true; 419 }, 420 421 // This is a simple float parser. 422 _tokenizeNumber() { 423 if (!this._chars.length) { 424 return false; 425 } 426 427 // {+,-}something 428 let number = []; 429 if (/[+-]/.test(this._chars[0])) { 430 number.push(this._chars.shift()); 431 } 432 433 let tokenizeNumberInternal = () => { 434 if ( 435 !this._chars.length || 436 !this.calculator.isNumericToken(this._chars[0]) 437 ) { 438 return false; 439 } 440 441 while ( 442 this._chars.length && 443 this.calculator.isNumericToken(this._chars[0]) 444 ) { 445 number.push(this._chars.shift()); 446 } 447 448 return true; 449 }; 450 451 if (!tokenizeNumberInternal()) { 452 return false; 453 } 454 455 // 123{e...} 456 if (!this._chars.length || this._chars[0] != "e") { 457 this._tokens.push({ number: true, value: number.join("") }); 458 return true; 459 } 460 461 number.push(this._chars.shift()); 462 463 // 123e{+,-} 464 if (/[+-]/.test(this._chars[0])) { 465 number.push(this._chars.shift()); 466 } 467 468 if (!this._chars.length) { 469 return false; 470 } 471 472 // the number 473 if (!tokenizeNumberInternal()) { 474 return false; 475 } 476 477 this._tokens.push({ number: true, value: number.join("") }); 478 return true; 479 }, 480 481 _tokenizeOther() { 482 if (!this._chars.length) { 483 return false; 484 } 485 486 if (this.calculator.isOperator(this._chars[0])) { 487 this._tokens.push({ number: false, value: this._chars.shift() }); 488 return true; 489 } 490 491 return false; 492 }, 493 }; 494 495 export let Calculator = new BaseCalculator(); 496 497 Calculator.addNumberSystem({ 498 isOperator: char => ["÷", "×", "-", "+", "*", "/", "^"].includes(char), 499 isNumericToken: char => /^[0-9\.,]/.test(char), 500 /** 501 * parseFloat will only handle numbers that use periods as decimal 502 * separators, various countries use commas. This function attempts 503 * to fixup the number so parseFloat will accept it. 504 * 505 * @param {string} num 506 */ 507 transformNumber: num => { 508 let firstComma = num.indexOf(","); 509 let firstPeriod = num.indexOf("."); 510 511 if (firstPeriod != -1 && firstComma != -1 && firstPeriod < firstComma) { 512 // Contains both a period and a comma and the period came first 513 // so using comma as decimal seperator, strip . and replace , with . 514 // (ie 1.999,5). 515 num = num.replace(/\./g, ""); 516 num = num.replace(/,/g, "."); 517 } else if (firstPeriod != -1 && firstComma != -1) { 518 // Contains both a period and a comma and the comma came first 519 // so strip the comma (ie 1,999.5). 520 num = num.replace(/,/g, ""); 521 } else if (firstComma != -1 && num.includes(",", firstComma + 1)) { 522 // Contains multiple commas and no periods, strip commas 523 num = num.replace(/,/g, ""); 524 } else if (firstPeriod != -1 && num.includes(".", firstPeriod + 1)) { 525 // Contains multiple periods and no commas, strip periods 526 num = num.replace(/\./g, ""); 527 } else if (firstComma != -1) { 528 // Has a single comma and no periods, treat comma as decimal seperator 529 num = num.replace(/,/g, "."); 530 } 531 return num; 532 }, 533 });