/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = XPCOMUtils.declareLazy({ UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", clearTimeout: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", logger: () => lazy.UrlbarUtils.getLogger({ prefix: "EventBufferer" }), }); /** * Array of keyCodes to defer. * * @type {Set} */ const DEFERRED_KEY_CODES = new Set([ KeyboardEvent.DOM_VK_RETURN, KeyboardEvent.DOM_VK_DOWN, KeyboardEvent.DOM_VK_TAB, ]); /** * Status of the current or last query. */ const QUERY_STATUS = Object.freeze({ UKNOWN: 0, RUNNING: 1, RUNNING_GOT_ALL_HEURISTIC_RESULTS: 2, COMPLETE: 3, }); /** * The UrlbarEventBufferer can queue up events and replay them later, to make * the urlbar results more predictable. * * Search results arrive asynchronously, which means that keydown events may * arrive before results do, and therefore not have the effect the user intends. * That's especially likely to happen with the down arrow and enter keys, due to * the one-off search buttons: if the user very quickly pastes something in the * input, presses the down arrow key, and then hits enter, they are probably * expecting to visit the first result. But if there are no results, then * pressing down and enter will trigger the first one-off button. * To prevent that undesirable behavior, certain keys are buffered and deferred * until more results arrive, at which time they're replayed. */ export class UrlbarEventBufferer { // Maximum time events can be deferred for. In automation providers can be // quite slow, thus we need a longer timeout to avoid intermittent failures. // Note: to avoid handling events too early, this timer should be larger than // UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS. static DEFERRING_TIMEOUT_MS = Cu.isInAutomation ? 1500 : 300; /** * Initialises the class. * * @param {UrlbarInput} input * The urlbar input object. */ constructor(input) { this.input = input; this.input.inputField.addEventListener("blur", this); this.#lastQuery = { // The time at which the current or last search was started. This is used // to check how much time passed while deferring the user's actions. Must // be set using the monotonic ChromeUtils.now() helper. startDate: ChromeUtils.now(), // Status of the query; one of QUERY_STATUS.* status: QUERY_STATUS.UKNOWN, // The query context. context: null, }; // Start listening for queries. this.input.controller.addListener(this); } // UrlbarController listener methods. /** * Handles when a query is started. * * @param {UrlbarQueryContext} queryContext */ onQueryStarted(queryContext) { this.#lastQuery = { startDate: ChromeUtils.now(), status: QUERY_STATUS.RUNNING, context: queryContext, }; if (this.#deferringTimeout) { lazy.clearTimeout(this.#deferringTimeout); this.#deferringTimeout = null; } } onQueryCancelled() { this.#lastQuery.status = QUERY_STATUS.COMPLETE; } onQueryFinished() { this.#lastQuery.status = QUERY_STATUS.COMPLETE; } /** * Handles results of the query. * * @param {UrlbarQueryContext} queryContext */ onQueryResults(queryContext) { if (queryContext.pendingHeuristicProviders.size) { return; } this.#lastQuery.status = QUERY_STATUS.RUNNING_GOT_ALL_HEURISTIC_RESULTS; // Ensure this runs after other results handling code. Services.tm.dispatchToMainThread(() => { this.replayDeferredEvents(true); }); } /** * Handles DOM events. * * @param {Event} event * DOM event from the input. */ handleEvent(event) { if (event.type == "blur") { lazy.logger.debug("Clearing queue on blur"); // The input field was blurred, pending events don't matter anymore. // Clear the timeout and the queue. this.#eventsQueue.length = 0; if (this.#deferringTimeout) { lazy.clearTimeout(this.#deferringTimeout); this.#deferringTimeout = null; } } } /** * Receives DOM events, eventually queues them up, and calls back when it's * the right time to handle the event. * * @param {KeyboardEvent} event DOM event from the input. * @param {() => void} callback to be invoked when it's the right time to handle * the event. */ maybeDeferEvent(event, callback) { if (!callback) { throw new Error("Must provide a callback"); } if (this.shouldDeferEvent(event)) { this.deferEvent(event, callback); return; } // If it has not been deferred, handle the callback immediately. callback(); } /** * Adds a deferrable event to the deferred event queue. * * @param {KeyboardEvent} event The event to defer. * @param {() => void} callback to be invoked when it's the right time to handle * the event. */ deferEvent(event, callback) { // Check we don't try to defer events more than once. if (this.#eventsQueue.find(item => item.event == event)) { throw new Error(`Event ${event.type}:${event.keyCode} already deferred!`); } lazy.logger.debug(`Deferring ${event.type}:${event.keyCode} event`); this.#eventsQueue.push({ event, callback, // Also store the current search string, as an added safety check. If the // string will differ later, the event is stale and should be dropped. searchString: this.#lastQuery.context.searchString, }); if (!this.#deferringTimeout) { let elapsed = ChromeUtils.now() - this.#lastQuery.startDate; let remaining = UrlbarEventBufferer.DEFERRING_TIMEOUT_MS - elapsed; this.#deferringTimeout = lazy.setTimeout( () => { this.replayDeferredEvents(false); this.#deferringTimeout = null; }, Math.max(0, remaining) ); } } /** * Replays deferred key events. * * @param {boolean} onlyIfSafe replays only if it's a safe time to do so. * Setting this to false will replay all the queue events, without any * checks, that is something we want to do only if the deferring * timeout elapsed, and we don't want to appear ignoring user's input. */ replayDeferredEvents(onlyIfSafe) { if (typeof onlyIfSafe != "boolean") { throw new Error("Must provide a boolean argument"); } if (!this.#eventsQueue.length) { return; } let { event, callback, searchString } = this.#eventsQueue[0]; if (onlyIfSafe && !this.isSafeToPlayDeferredEvent(event)) { return; } // Remove the event from the queue and play it. this.#eventsQueue.shift(); // Safety check: handle only if the search string didn't change meanwhile. if (searchString == this.#lastQuery.context.searchString) { callback(); } Services.tm.dispatchToMainThread(() => { this.replayDeferredEvents(onlyIfSafe); }); } /** * Checks whether a given event should be deferred * * @param {KeyboardEvent} event The event that should maybe be deferred. * @returns {boolean} Whether the event should be deferred. */ shouldDeferEvent(event) { // If any event has been deferred for this search, then defer all subsequent // events so that the user does not experience them out of order. // All events will be replayed when #deferringTimeout fires. if (this.#eventsQueue.length) { return true; } // At this point, no events have been deferred for this search; we must // figure out if this event should be deferred. let isMacNavigation = AppConstants.platform == "macosx" && event.ctrlKey && this.input.view.isOpen && (event.key === "n" || event.key === "p"); if (!DEFERRED_KEY_CODES.has(event.keyCode) && !isMacNavigation) { return false; } if (DEFERRED_KEY_CODES.has(event.keyCode)) { // Defer while the user is composing. if (this.input.editor.composing) { return true; } if (this.input.controller.keyEventMovesCaret(event)) { return false; } } // This is an event that we'd defer, but if enough time has passed since the // start of the search, we don't want to block the user's workflow anymore. if ( this.#lastQuery.startDate + UrlbarEventBufferer.DEFERRING_TIMEOUT_MS <= ChromeUtils.now() ) { return false; } if ( event.keyCode == KeyEvent.DOM_VK_TAB && !this.input.view.isOpen && !this.waitingDeferUserSelectionProviders ) { // The view is closed and the user pressed the Tab key. The focus should // move out of the urlbar immediately. return false; } return !this.isSafeToPlayDeferredEvent(event); } /** * Checks if the bufferer is deferring events. * * @returns {boolean} Whether the bufferer is deferring events. */ get isDeferringEvents() { return !!this.#eventsQueue.length; } /** * Checks if any of the current query provider asked to defer user selection * events. * * @returns {boolean} Whether a provider asked to defer events. */ get waitingDeferUserSelectionProviders() { return !!this.#lastQuery.context?.deferUserSelectionProviders.size; } /** * Returns true if the given deferred event can be played now without possibly * surprising the user. This depends on the state of the view, the results, * and the type of event. * Use this method only after determining that the event should be deferred, * or after it has been deferred and you want to know if it can be played now. * * @param {KeyboardEvent} event The event. * @returns {boolean} Whether the event can be played. */ isSafeToPlayDeferredEvent(event) { if ( this.#lastQuery.status == QUERY_STATUS.COMPLETE || this.#lastQuery.status == QUERY_STATUS.UKNOWN ) { // The view can't get any more results, so there's no need to further // defer events. return true; } let waitingHeuristicResults = this.#lastQuery.status == QUERY_STATUS.RUNNING; if (event.keyCode == KeyEvent.DOM_VK_RETURN) { // Check if we're waiting for providers that requested deferring. if (this.waitingDeferUserSelectionProviders) { return false; } // Play a deferred Enter if the heuristic result is not selected, or we // are not waiting for heuristic results yet. let selectedResult = this.input.view.selectedResult; return ( (selectedResult && !selectedResult.heuristic) || !waitingHeuristicResults ); } if ( waitingHeuristicResults || !this.input.view.isOpen || this.waitingDeferUserSelectionProviders ) { // We're still waiting on some results, or the popup hasn't opened yet. return false; } let isMacDownNavigation = AppConstants.platform == "macosx" && event.ctrlKey && this.input.view.isOpen && event.key === "n"; if (event.keyCode == KeyEvent.DOM_VK_DOWN || isMacDownNavigation) { // Don't play the event if the last result is selected so that the user // doesn't accidentally arrow down into the one-off buttons when they // didn't mean to. Note TAB is unaffected because it only navigates // results, not one-offs. return !this.lastResultIsSelected; } return true; } get lastResultIsSelected() { // TODO Bug 1536818: Once one-off buttons are fully implemented, it would be // nice to have a better way to check if the next down will focus one-off buttons. let results = this.#lastQuery.context.results; return ( results.length && results[results.length - 1] == this.input.view.selectedResult ); } /** * A queue of deferred events. * The callback is invoked when it's the right time to handle the event, * but it may also never be invoked, if the context changed and the event * became obsolete. * * @type {{event: KeyboardEvent, callback: () => void, searchString: string}[]} */ #eventsQueue = []; /** * If this timer fires, we will unconditionally replay all the deferred * events so that, after a certain point, we don't keep blocking the user's * actions, when nothing else has caused the events to be replayed. * At that point we won't check whether it's safe to replay the events, * because otherwise it may look like we ignored the user's actions. * * @type {?number} */ #deferringTimeout = null; /** * Tracks the current or last query status. * * @type {{ startDate: number, status: Values, context: UrlbarQueryContext}} */ #lastQuery; }