chromium/ui/base/cocoa/text_services_context_menu.mm

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

#include "ui/base/cocoa/text_services_context_menu.h"

#import <AppKit/AppKit.h>

#include <utility>

#include "base/mac/mac_util.h"
#include "base/strings/sys_string_conversions.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/strings/grit/ui_strings.h"

namespace {

// Returns the TextDirection associated associated with the given BiDi
// |command_id|.
base::i18n::TextDirection GetTextDirectionFromCommandId(int command_id) {
  switch (command_id) {
    case ui::TextServicesContextMenu::kWritingDirectionDefault:
      return base::i18n::UNKNOWN_DIRECTION;
    case ui::TextServicesContextMenu::kWritingDirectionLtr:
      return base::i18n::LEFT_TO_RIGHT;
    case ui::TextServicesContextMenu::kWritingDirectionRtl:
      return base::i18n::RIGHT_TO_LEFT;
    default:
      NOTREACHED();
  }
}

}  // namespace

namespace ui {

TextServicesContextMenu::TextServicesContextMenu(Delegate* delegate)
    : speech_submenu_model_(this),
      bidi_submenu_model_(this),
      delegate_(delegate) {
  DCHECK(delegate);

  speech_submenu_model_.AddItemWithStringId(kSpeechStartSpeaking,
                                            IDS_SPEECH_START_SPEAKING_MAC);
  speech_submenu_model_.AddItemWithStringId(kSpeechStopSpeaking,
                                            IDS_SPEECH_STOP_SPEAKING_MAC);

  bidi_submenu_model_.AddCheckItemWithStringId(
      kWritingDirectionDefault, IDS_CONTENT_CONTEXT_WRITING_DIRECTION_DEFAULT);
  bidi_submenu_model_.AddCheckItemWithStringId(
      kWritingDirectionLtr, IDS_CONTENT_CONTEXT_WRITING_DIRECTION_LTR);
  bidi_submenu_model_.AddCheckItemWithStringId(
      kWritingDirectionRtl, IDS_CONTENT_CONTEXT_WRITING_DIRECTION_RTL);
}

// A note about the Speech submenu.
//
// All standard AppKit implementations of `-(IBAction)startSpeaking:(id)sender`
// and `-(IBAction)stopSpeaking:(id)sender` funnel into messages to
// `NSApplication`:

}  // namespace ui

@interface NSApplication (Speech)
- (void)speakString:(NSString*)string;
- (IBAction)stopSpeaking:(id)sender;
- (BOOL)isSpeaking;
@end

namespace ui {

// When running on an OS release earlier than macOS 14, or running on macOS 14.4
// and later, do this as well, for two reasons:
//
// 1. Interoperability with the other parts of the system that use this same
//    speech synthesizer.
//
// 2. Working around a bug in `AVSpeechSynthesizer` which does not provide the
//    correct voice when a specific voice is chosen in the system accessibility
//    settings (see https://crbug.com/40072850#comment10, FB13197951).
//
// However, for macOS 14.0 through 14.3, directly use the deprecated
// NSSpeechSynthesizer class, as there is a bug with the NSApplication provided
// methods that causes occasional hiccups in the audio (see
// https://crbug.com/40074199, FB13261400).

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"

namespace FB13261400Workaround {

NSSpeechSynthesizer* SharedNSSpeechSynthesizer() {
  static NSSpeechSynthesizer* speech_synthesizer =
      [[NSSpeechSynthesizer alloc] initWithVoice:nil];
  return speech_synthesizer;
}

bool IsSpeaking() {
  return SharedNSSpeechSynthesizer().speaking;
}

void StopSpeaking() {
  [SharedNSSpeechSynthesizer() stopSpeaking];
}

void SpeakText(const std::u16string& text) {
  if (IsSpeaking()) {
    StopSpeaking();
  }

  [SharedNSSpeechSynthesizer()
      startSpeakingString:base::SysUTF16ToNSString(text)];
}

}  // namespace FB13261400Workaround

#pragma clang diagnostic pop

void TextServicesContextMenu::SpeakText(const std::u16string& text) {
  int version = base::mac::MacOSVersion();
  if (version >= 14'00'00 && version < 14'04'00) {
    FB13261400Workaround::SpeakText(text);
  } else {
    [NSApp speakString:base::SysUTF16ToNSString(text)];
  }
}

void TextServicesContextMenu::StopSpeaking() {
  int version = base::mac::MacOSVersion();
  if (version >= 14'00'00 && version < 14'04'00) {
    FB13261400Workaround::StopSpeaking();
  } else {
    [NSApp stopSpeaking:nil];
  }
}

bool TextServicesContextMenu::IsSpeaking() {
  int version = base::mac::MacOSVersion();
  if (version >= 14'00'00 && version < 14'04'00) {
    return FB13261400Workaround::IsSpeaking();
  } else {
    return [NSApp isSpeaking];
  }
}

void TextServicesContextMenu::AppendToContextMenu(SimpleMenuModel* model) {
  model->AddSeparator(NORMAL_SEPARATOR);
  model->AddSubMenuWithStringId(kSpeechMenu, IDS_SPEECH_MAC,
                                &speech_submenu_model_);
}

void TextServicesContextMenu::AppendEditableItems(SimpleMenuModel* model) {
  // MacOS provides a contextual menu to set writing direction for BiDi
  // languages. This functionality is exposed as a keyboard shortcut on
  // Windows and Linux.
  model->AddSubMenuWithStringId(kWritingDirectionMenu,
                                IDS_CONTENT_CONTEXT_WRITING_DIRECTION_MENU,
                                &bidi_submenu_model_);
}

bool TextServicesContextMenu::SupportsCommand(int command_id) const {
  switch (command_id) {
    case kWritingDirectionMenu:
    case kWritingDirectionDefault:
    case kWritingDirectionLtr:
    case kWritingDirectionRtl:
    case kSpeechMenu:
    case kSpeechStartSpeaking:
    case kSpeechStopSpeaking:
      return true;
  }

  return false;
}

bool TextServicesContextMenu::IsCommandIdChecked(int command_id) const {
  switch (command_id) {
    case kWritingDirectionDefault:
    case kWritingDirectionLtr:
    case kWritingDirectionRtl:
      return delegate_->IsTextDirectionChecked(
          GetTextDirectionFromCommandId(command_id));
    case kSpeechStartSpeaking:
    case kSpeechStopSpeaking:
      return false;
  }

  NOTREACHED();
}

bool TextServicesContextMenu::IsCommandIdEnabled(int command_id) const {
  switch (command_id) {
    case kSpeechMenu:
    case kWritingDirectionMenu:
      return true;
    case kWritingDirectionDefault:
    case kWritingDirectionLtr:
    case kWritingDirectionRtl:
      return delegate_->IsTextDirectionEnabled(
          GetTextDirectionFromCommandId(command_id));
    case kSpeechStartSpeaking:
      return true;
    case kSpeechStopSpeaking:
      return IsSpeaking();
  }

  NOTREACHED();
}

void TextServicesContextMenu::ExecuteCommand(int command_id, int event_flags) {
  switch (command_id) {
    case kWritingDirectionDefault:
    case kWritingDirectionLtr:
    case kWritingDirectionRtl:
      delegate_->UpdateTextDirection(GetTextDirectionFromCommandId(command_id));
      break;
    case kSpeechStartSpeaking:
      SpeakText(delegate_->GetSelectedText());
      break;
    case kSpeechStopSpeaking:
      StopSpeaking();
      break;
    default:
      NOTREACHED();
  }
}

}  // namespace ui