chromium/chrome/browser/resources/compose/word_streamer.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert} from '//resources/js/assert.js';

// Accepts text updates, chunks the text up, and provides the caller with
// updates to a list of chunks.
// Chunks content by whitespace, and limits the rate of chunks.
export class WordStreamer {
  private msPerTick_ = 100;
  private charsPerTick_ = 10;
  private msWaitBeforeComplete_ = 300;

  // All words provided to WordStreamer.
  private words_: string[] = [];
  private displayState_: DisplayState = new DisplayState();
  private nextTick_: number|undefined;
  private finalTick_: number|undefined;
  private finalTextReceived_ = false;

  constructor(public callback: (words: string[], isComplete: boolean) => void) {
  }

  // Stop any processing and reset to the initial state.
  reset() {
    if (this.nextTick_) {
      window.clearTimeout(this.nextTick_);
    }
    if (this.finalTick_) {
      window.clearTimeout(this.finalTick_);
    }
    this.words_ = [];
    this.displayState_ = new DisplayState();
    this.nextTick_ = undefined;
    this.finalTextReceived_ = false;
  }

  // Set the current text to chunk. isFinal should be true on the last call to
  // setText.
  setText(text: string, isFinal: boolean) {
    // Should receive isFinal=true once.
    assert(!isFinal || !this.finalTextReceived_);
    // Note that until isFinal==true, we don't want the last word to prevent
    // display of partial words.
    this.words_ = splitIntoWords(text, /*includeLast=*/ isFinal);

    // Verify that the already displayed words are still the beginning of the
    // provided text param. If the already displayed words and the new provided
    // text have diverged, restart from the point of diversion.
    if (this.displayState_.update(0, this.words_)) {
      this.callback([...this.displayState_.words], false);
    }

    this.finalTextReceived_ = isFinal;
    if (!this.nextTick_) {
      this.scheduleUpdate();
    }
  }

  private scheduleUpdate() {
    if (this.nextTick_) {
      window.clearTimeout(this.nextTick_);
    }
    this.nextTick_ = window.setTimeout(() => this.tick(), this.msPerTick_);
  }

  private tick() {
    this.nextTick_ = undefined;

    // Advance N characters per tick.
    const displayChanged =
        this.displayState_.update(this.charsPerTick_, this.words_);
    const pendingWordsToDisplay =
        this.displayState_.words.length !== this.words_.length;
    const finalStateDisplayed =
        this.finalTextReceived_ && !pendingWordsToDisplay;

    if (displayChanged) {
      this.callback([...this.displayState_.words], false);
    }
    if (pendingWordsToDisplay) {
      this.scheduleUpdate();
    } else {
      // Don't let pending characters build up if we're constrained on the input
      // side.
      this.displayState_.clearPendingChars();
    }
    if (finalStateDisplayed) {
      // Delay before transitioning to the completed ux.
      this.finalTick_ = window.setTimeout(() => {
        this.callback([...this.displayState_.words], true);
        this.finalTick_ = undefined;
      }, this.msWaitBeforeComplete_);
    }
  }
  setMsPerTickForTesting(msPerTick: number) {
    this.msPerTick_ = msPerTick;
  }
  setCharsPerTickForTesting(charsPerTick: number) {
    this.charsPerTick_ = charsPerTick;
  }
  setMsWaitBeforeCompleteForTesting(msWaitBeforeComplete: number) {
    this.msWaitBeforeComplete_ = msWaitBeforeComplete;
  }
}

// Splits `text` into space-delimited words. If `includeLast` is false,
// the last word is not returned.
function splitIntoWords(text: string, includeLast: boolean) {
  let words = text.split(' ');
  if (words.length > 0 && !includeLast) {
    words.pop();
  }
  // ''.split(' ') --> [""], but we want []
  if (words.length === 1 && words[0] === '') {
    words = [];
  }
  return words.map((s, i) => (i > 0 ? ' ' : '') + s);
}

// Owns and calculates the words that should be displayed.
class DisplayState {
  // The displayed words.
  private words_: string[] = [];
  // The number of additional characters that should be displayed.
  private pendingChars_: number = 0;

  clearPendingChars() {
    this.pendingChars_ = 0;
  }

  // Updates the state. `additionalChars` is the additional number of characters
  // that should be displayed. `allWords` is the list of all words that should
  // be eventually displayed. `this.words()` will eventually converge to be
  // equal to `allWords`. Returns true if displayed words has changed.
  update(additionalChars: number, allWords: string[]): boolean {
    this.pendingChars_ += additionalChars;
    let modified = false;

    // Truncate `words_` if it's not a prefix of allWords.
    let prefixLen = 0;
    while (prefixLen < Math.min(allWords.length, this.words_.length) &&
           allWords[prefixLen] === this.words_[prefixLen]) {
      ++prefixLen;
    }
    if (prefixLen !== this.words_.length) {
      modified = true;
      this.words_.splice(prefixLen);
    }

    // Append words.
    while (allWords.length > this.words_.length) {
      const nextWord = allWords[this.words_.length];
      if (nextWord.length > this.pendingChars_) {
        break;
      }
      this.words_.push(nextWord);
      this.pendingChars_ -= nextWord.length;
      modified = true;
    }
    return modified;
  }

  // Words that should be displayed.
  get words(): string[] {
    return this.words_;
  }
}