chromium/chrome/browser/resources/chromeos/accessibility/chromevox/background/input/smart_sticky_mode.ts

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

/**
 * @fileoverview Handles automatic sticky mode toggles. Turns sticky mode off
 * when the current range is over an editable; restores sticky mode when not on
 * an editable.
 */
import {AutomationUtil} from '/common/automation_util.js';
import {CursorRange} from '/common/cursors/range.js';
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {EarconId} from '../../common/earcon_id.js';
import {SettingsManager} from '../../common/settings_manager.js';
import {ChromeVox} from '../chromevox.js';
import {ChromeVoxRange, ChromeVoxRangeObserver} from '../chromevox_range.js';
import {ChromeVoxState} from '../chromevox_state.js';
import {ChromeVoxPrefs} from '../prefs.js';

import AutomationNode = chrome.automation.AutomationNode;
import StateType = chrome.automation.StateType;

export class SmartStickyMode implements ChromeVoxRangeObserver {
  /**
   * Tracks whether we (and not the user) turned off sticky mode when over an
   * editable.
   */
  private didTurnOffStickyMode_ = false;
  private ignoreRangeChanges_ = false;
  private ignoredNodeSubtree_?: AutomationNode | null;

  static instance?: SmartStickyMode;

  constructor() {
    ChromeVoxRange.addObserver(this);
  }

  static init(): void {
    if (SmartStickyMode.instance) {
      throw new Error('SmartStickyMode.init() should only be called once');
    }
    SmartStickyMode.instance = new SmartStickyMode();
  }

  onCurrentRangeChanged(
      newRange: CursorRange | null, fromEditing?: boolean): void {
    if (!newRange || this.ignoreRangeChanges_ ||
      // TODO(b/314203187): Not null asserted, check that this is correct.
      ChromeVoxState.instance!.isReadingContinuously || fromEditing ||
        !SettingsManager.get('smartStickyMode')) {
      return;
    }

    const node = newRange.start.node;
    if (this.ignoredNodeSubtree_) {
      const editableOrRelatedEditable =
          this.getEditableOrRelatedEditable_(node);
      if (editableOrRelatedEditable &&
          AutomationUtil.isDescendantOf(
              editableOrRelatedEditable, this.ignoredNodeSubtree_)) {
        return;
      }

      // Clear it when the user leaves the subtree.
      this.ignoredNodeSubtree_ = undefined;
    }

    // Several cases arise which may lead to a sticky mode toggle:
    // The node is either editable itself or a descendant of an editable.
    // The node is a relation target of an editable.
    const shouldTurnOffStickyMode =
        Boolean(this.getEditableOrRelatedEditable_(node));

    // This toggler should not make any changes when the range isn't what we're
    // looking for and we haven't previously tracked any sticky mode state from
    // the user.
    if (!shouldTurnOffStickyMode && !this.didTurnOffStickyMode_) {
      return;
    }

    if (shouldTurnOffStickyMode) {
      if (!ChromeVoxPrefs.isStickyPrefOn) {
        // Sticky mode was already off; do not track the current sticky state
        // since we may have set it ourselves.
        return;
      }

      if (this.didTurnOffStickyMode_) {
        // This should not be possible with |ChromeVoxPrefs.isStickyPrefOn| set
        // to true.
        throw 'Unexpected sticky state value encountered.';
      }

      // Save the sticky state for restoration later.
      this.didTurnOffStickyMode_ = true;
      ChromeVox.earcons.playEarcon(EarconId.SMART_STICKY_MODE_OFF);
      // TODO(b/314203187): Not null asserted, check that this is correct.
      ChromeVoxPrefs.instance!.setAndAnnounceStickyPref(false);
    } else if (this.didTurnOffStickyMode_) {
      // Restore the previous sticky mode state.
      ChromeVox.earcons.playEarcon(EarconId.SMART_STICKY_MODE_ON);
      // TODO(b/314203187): Not null asserted, check that this is correct.
      ChromeVoxPrefs.instance!.setAndAnnounceStickyPref(true);
      this.didTurnOffStickyMode_ = false;
    }
  }

  /**
   * When called, ignores all changes in the current range when toggling sticky
   * mode without user input.
   */
  startIgnoringRangeChanges(): void {
    this.ignoreRangeChanges_ = true;
  }

  /**
   * When called, stops ignoring changes in the current range when toggling
   * sticky mode without user input.
   */
  stopIgnoringRangeChanges(): void {
    this.ignoreRangeChanges_ = false;
  }

  /**
   * Called whenever a user toggles sticky mode. In this case, we need to ensure
   * we reset our internal state appropriately.
   * @param range The range when the sticky mode command was received.
   */
  private onStickyModeCommand_(range: CursorRange): void {
    if (!this.didTurnOffStickyMode_) {
      return;
    }

    this.didTurnOffStickyMode_ = false;

    // Resetting the above isn't quite enough. We now have to track the current
    // range, if it is editable or has an editable relation, to ensure we don't
    // interfere with the user's sticky mode state.
    if (!range || !range.start) {
      return;
    }

    let editable = this.getEditableOrRelatedEditable_(range.start.node);
    while (editable && !editable.nonAtomicTextFieldRoot) {
      // TODO(b/314203187): Not nulls asserted, check that this is correct.
      if (!editable.parent ||
          editable.parent.state![chrome.automation.StateType.EDITABLE]) {
        // Not all editables from all trees (e.g. Android, views) set the
        // editable root boolean attribute.
        break;
      }
      editable = editable.parent;
    }
    this.ignoredNodeSubtree_ = editable;
  }

  /** Toggles basic stickyMode on or off. */
  toggle(): void {
    // TODO(b/314203187): Not null asserted, check that this is correct.
    ChromeVoxPrefs.instance!.setAndAnnounceStickyPref(
        !ChromeVoxPrefs.isStickyPrefOn);

    if (ChromeVoxRange.current) {
      this.onStickyModeCommand_(ChromeVoxRange.current);
    }
  }

  private getEditableOrRelatedEditable_(
      node: AutomationNode | null): AutomationNode | null {
    if (!node) {
      return null;
    }

    // TODO(b/314203187): Not nulls asserted, check that this is correct.
    if (node.state![StateType.EDITABLE]) {
      return node;
    } else if (node.parent && node.parent.state![StateType.EDITABLE]) {
      // This covers inline text boxes (which are not
      // editable themselves, but may have an editable parent).
      return node.parent;
    } else {
      let focus: AutomationNode | undefined = node;
      let found;
      while (!found && focus) {
        if (focus.activeDescendantFor && focus.activeDescendantFor.length) {
          // TODO(b/314203187): Not nulls asserted, check that this is correct.
          found =
              focus.activeDescendantFor.find(n => n.state![StateType.EDITABLE]);
        }

        if (found) {
          return found;
        }

        if (focus.controlledBy && focus.controlledBy.length) {
          // TODO(b/314203187): Not nulls asserted, check that this is correct.
          found = focus.controlledBy.find(n => n.state![StateType.EDITABLE]);
        }

        if (found) {
          return found;
        }

        focus = focus.parent;
      }
    }

    return null;
  }
}

TestImportManager.exportForTesting(SmartStickyMode);