| // 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_; |
| } |
| } |