terminal: fix web font related rendering issue with xterm.js

xterm.js caches text atlas and paints on a canvas, so we need to:

1. Change the font family after the font (or at least the latin part)
   has loaded
2. Refresh the rendering if new font is loaded (e.g. new non-latin
   character was just printed)

Bug: b/236205389
Change-Id: Ida9709f29518e7861e9b9b97638891112f39f3b7
Reviewed-on: https://chromium-review.googlesource.com/c/apps/libapps/+/3831832
Reviewed-by: Joel Hockey <joelhockey@chromium.org>
Tested-by: kokoro <noreply+kokoro@google.com>
diff --git a/terminal/js/terminal_emulator.js b/terminal/js/terminal_emulator.js
index 5fe621f..00f3f49 100644
--- a/terminal/js/terminal_emulator.js
+++ b/terminal/js/terminal_emulator.js
@@ -10,8 +10,8 @@
 // terminal_tests.js for XtermTerminal.
 
 import {Terminal, FitAddon, WebglAddon} from './xterm.js';
-import {FontManager, TERMINAL_EMULATORS, delayedScheduler, getOSInfo}
-    from './terminal_common.js';
+import {FontManager, TERMINAL_EMULATORS, delayedScheduler, fontManager,
+  getOSInfo, sleep} from './terminal_common.js';
 
 const ANSI_COLOR_NAMES = [
     'black',
@@ -38,6 +38,15 @@
 };
 
 /**
+ * @typedef {{
+ *   term: !Terminal,
+ *   fontManager: !FontManager,
+ *   fitAddon: !FitAddon,
+ * }}
+ */
+export let XtermTerminalTestParams;
+
+/**
  * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
  * so that it can be used in existing code.
  *
@@ -48,23 +57,33 @@
  * @extends {hterm.Terminal}
  * @unrestricted
  */
-class XtermTerminal {
+export class XtermTerminal {
   /**
    * @param {{
    *   storage: !lib.Storage,
    *   profileId: string,
    *   enableWebGL: boolean,
+   *   testParams: (!XtermTerminalTestParams|undefined),
    * }} args
    */
-  constructor({storage, profileId, enableWebGL}) {
+  constructor({storage, profileId, enableWebGL, testParams}) {
     /** @type {!hterm.PreferenceManager} */
     this.prefs_ = new hterm.PreferenceManager(storage, profileId);
     this.enableWebGL_ = enableWebGL;
 
-    this.term = new Terminal();
-    this.fitAddon = new FitAddon();
+    this.term = testParams?.term || new Terminal();
+    this.fontManager_ = testParams?.fontManager || fontManager;
+    this.fitAddon = testParams?.fitAddon || new FitAddon();
+
     this.term.loadAddon(this.fitAddon);
-    this.scheduleFit_ = delayedScheduler(() => this.fitAddon.fit(), 250);
+    this.scheduleFit_ = delayedScheduler(() => this.fitAddon.fit(),
+        testParams ? 0 : 250);
+
+    this.pendingFont_ = null;
+    this.scheduleRefreshFont_ = delayedScheduler(
+        () => this.refreshFont_(), 100);
+    document.fonts.addEventListener('loadingdone',
+        () => this.onFontLoadingDone_());
 
     this.installUnimplementedStubs_();
 
@@ -265,11 +284,71 @@
    * @param {*} value
    */
   updateOption_(key, value) {
+    if (key === 'fontFamily') {
+      this.updateFont_(/** @type {string} */(value));
+      return;
+    }
     // TODO: xterm supports updating multiple options at the same time. We
     // should probably do that.
     this.term.options[key] = value;
     this.scheduleFit_();
   }
+
+  /**
+   * Called when there is a "fontloadingdone" event. We need this because
+   * `FontManager.loadFont()` does not guarantee loading all the font files.
+   */
+  async onFontLoadingDone_() {
+    // If there is a pending font, the font is going to be refresh soon, so we
+    // don't need to do anything.
+    if (!this.pendingFont_) {
+      this.scheduleRefreshFont_();
+    }
+  }
+
+  /**
+   * Refresh xterm rendering for a font related event.
+   */
+  refreshFont_() {
+    // We have to set the fontFamily option to a different string to trigger the
+    // re-rendering. Appending a space at the end seems to be the easiest
+    // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
+    // us.
+    //
+    // TODO: Report a bug to xterm.js and ask for exposing a public function for
+    // the refresh so that we don't need to do this hack.
+    this.term.options.fontFamily += ' ';
+  }
+
+  /**
+   * Update a font.
+   *
+   * @param {string} cssFontFamily
+   */
+  async updateFont_(cssFontFamily) {
+      this.pendingFont_ = cssFontFamily;
+      await this.fontManager_.loadFont(cssFontFamily);
+      // Sleep a bit to wait for flushing fontloadingdone events. This is not
+      // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
+      // to refresh font unnecessarily in some cases.
+      await sleep(30);
+
+      if (this.pendingFont_ !== cssFontFamily) {
+        // `updateFont_()` probably is called again. Abort what we are doing.
+        console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
+            ` (expecting ${cssFontFamily})`);
+        return;
+      }
+
+      if (this.term.options.fontFamily !== cssFontFamily) {
+        this.term.options.fontFamily = cssFontFamily;
+      } else {
+        // If the font is already the same, refresh font just to be safe.
+        this.refreshFont_();
+      }
+      this.pendingFont_ = null;
+      this.scheduleFit_();
+  }
 }
 
 class HtermTerminal extends hterm.Terminal {