// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ash/input_method/longpress_diacritics_suggester.h"
#include <optional>
#include <string>
#include <string_view>
#include "ash/constants/ash_features.h"
#include "ash/constants/notifier_catalogs.h"
#include "ash/public/cpp/system/anchored_nudge_data.h"
#include "ash/public/cpp/system/anchored_nudge_manager.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/webui/settings/public/constants/routes.mojom.h"
#include "ash/webui/settings/public/constants/setting.mojom.h"
#include "base/containers/fixed_flat_map.h"
#include "base/containers/flat_map.h"
#include "base/feature_list.h"
#include "base/logging.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_split.h"
#include "base/strings/utf_string_conversions.h"
#include "chrome/browser/ash/input_method/native_input_method_engine_observer.h"
#include "chrome/browser/ash/input_method/suggestion_handler_interface.h"
#include "chrome/browser/profiles/profile_manager.h"
#include "chrome/grit/generated_resources.h"
#include "chromeos/ash/services/ime/public/cpp/assistive_suggestions.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
#include "ui/base/ime/ash/ime_bridge.h"
#include "ui/base/ime/ash/text_input_target.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/chromeos/strings/grit/ui_chromeos_strings.h"
#include "ui/events/keycodes/dom/dom_code.h"
namespace content {
class WebContents;
}
namespace ash::input_method {
namespace {
// The id used for the diacritics nudge.
constexpr char kDiacriticsNudgeId[] = "DiacriticsNudge";
using AssistiveWindowButton = ui::ime::AssistiveWindowButton;
std::vector<std::u16string> SplitDiacritics(std::u16string_view diacritics) {
return base::SplitString(diacritics, kDiacriticsSeperator,
base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY);
}
std::vector<std::u16string> GetDiacriticsFor(char key_character,
std::string_view engine_id) {
// Currently only supporting US English.
// TODO(b/260915965): Add support for other engines.
if (engine_id != "xkb:us::eng") {
return {};
}
// Current diacritics ordering is based on the Gboard ordering so it keeps
// distance from target key consistent.
// TODO(b/260915965): Add more sets here for other engines.
static constexpr auto kUSEnglishDiacriticsMap =
base::MakeFixedFlatMap<char, std::u16string_view>(
{{'a', u"à;á;â;ä;æ;ã;å;ā"},
{'A', u"À;Á;Â;Ä;Æ;Ã;Å;Ā"},
{'c', u"ç"},
{'C', u"Ç"},
{'e', u"é;è;ê;ë;ē"},
{'E', u"É;È;Ê;Ë;Ē"},
{'i', u"í;î;ï;ī;ì"},
{'I', u"Í;Î;Ï;Ī;Ì"},
{'n', u"ñ"},
{'N', u"Ñ"},
{'o', u"ó;ô;ö;ò;œ;ø;ō;õ"},
{'O', u"Ó;Ô;Ö;Ò;Œ;Ø;Ō;Õ"},
{'s', u"ß"},
{'S', u"ẞ"},
{'u', u"ú;û;ü;ù;ū"},
{'U', u"Ú;Û;Ü;Ù;Ū"}});
if (const auto it = kUSEnglishDiacriticsMap.find(key_character);
it != kUSEnglishDiacriticsMap.end()) {
return SplitDiacritics(it->second);
}
return {};
}
AssistiveWindowButton CreateButtonFor(size_t index,
std::u16string announce_string) {
AssistiveWindowButton button = {
.id = ui::ime::ButtonId::kSuggestion,
.window_type =
ash::ime::AssistiveWindowType::kLongpressDiacriticsSuggestion,
.suggestion_index = index,
.announce_string = announce_string,
};
return button;
}
void RecordActionMetric(IMEPKLongpressDiacriticAction action) {
base::UmaHistogramEnumeration(
"InputMethod.PhysicalKeyboard.LongpressDiacritics.Action", action);
TextInputTarget* input_context = IMEBridge::Get()->GetInputContextHandler();
if (!input_context) {
return;
}
auto sourceId = input_context->GetClientSourceForMetrics();
ukm::builders::InputMethod_LongpressDiacritics(sourceId)
.SetActions(static_cast<long>(action))
.Record(ukm::UkmRecorder::Get());
}
void RecordAcceptanceCharCodeMetric(const std::u16string diacritic) {
// Recording -1 as default value just in case there are issues with
// encoding in utf-16 that means some character isn't
// properly captured in one utf-16 char (for example if emojis are added in
// the future).
int char_code = -1;
if (diacritic.length() == 1) {
char_code = int(diacritic[0]);
}
base::UmaHistogramSparse(
"InputMethod.PhysicalKeyboard.LongpressDiacritics.AcceptedChar",
char_code);
}
} // namespace
LongpressDiacriticsSuggester::LongpressDiacriticsSuggester(
SuggestionHandlerInterface* suggestion_handler)
: LongpressSuggester(suggestion_handler) {}
LongpressDiacriticsSuggester::~LongpressDiacriticsSuggester() = default;
bool LongpressDiacriticsSuggester::TrySuggestOnLongpress(char key_character) {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "Unable to suggest diacritics on longpress, no context_id";
return false;
}
std::vector<std::u16string> diacritics_candidates =
GetDiacriticsFor(key_character, engine_id_);
if (diacritics_candidates.empty()) {
ShowDiacriticsNudge();
return false;
}
AssistiveWindowProperties properties;
properties.type =
ash::ime::AssistiveWindowType::kLongpressDiacriticsSuggestion;
properties.visible = true;
properties.candidates = diacritics_candidates;
properties.announce_string =
l10n_util::GetStringUTF16(IDS_SUGGESTION_DIACRITICS_OPEN);
properties.show_setting_link = true;
std::string error;
suggestion_handler_->SetAssistiveWindowProperties(focused_context_id_.value(),
properties, &error);
if (error.empty()) {
displayed_window_base_character_ = key_character;
RecordActionMetric(IMEPKLongpressDiacriticAction::kShowWindow);
return true;
}
LOG(ERROR) << "Unable to suggest diacritics on longpress: " << error;
return false;
}
void LongpressDiacriticsSuggester::SetEngineId(const std::string& engine_id) {
engine_id_ = engine_id;
}
bool LongpressDiacriticsSuggester::HasDiacriticSuggestions(char c) {
return !GetDiacriticsFor(c, engine_id_).empty();
}
SuggestionStatus LongpressDiacriticsSuggester::HandleKeyEvent(
const ui::KeyEvent& event) {
ui::DomCode code = event.code();
// The diacritic suggester is not set up.
if (focused_context_id_ == std::nullopt ||
displayed_window_base_character_ == std::nullopt ||
!GetCurrentShownDiacritics().size()) {
return SuggestionStatus::kNotHandled;
}
// The diacritic suggester is displaying, but its just the repeat key of the
// base character (probably because user is still holding down the key).
if (*displayed_window_base_character_ == event.GetCharacter() &&
event.is_repeat()) {
return SuggestionStatus::kNotHandled;
}
size_t new_index = 0;
bool move_next = false;
switch (code) {
case kDismissDomCode:
DismissSuggestion();
RecordActionMetric(IMEPKLongpressDiacriticAction::kDismiss);
return SuggestionStatus::kDismiss;
case kAcceptDomCode:
if (highlighted_index_.has_value()) {
AcceptSuggestion(*highlighted_index_);
return SuggestionStatus::kAccept;
}
return SuggestionStatus::kNotHandled;
case kNextDomCode:
case kTabDomCode:
case kPreviousDomCode:
move_next = (code == kNextDomCode || code == kTabDomCode);
if (highlighted_index_ == std::nullopt) {
// We want the cursor to start at the end if you press back, and at the
// beginning if you press next.
new_index = move_next ? 0 : GetCurrentShownDiacritics().size();
} else {
SetButtonHighlighted(*highlighted_index_, false);
// Size+1 since we include the highlight button add 1 to size.
if (move_next) {
new_index = (*highlighted_index_ + 1) %
(GetCurrentShownDiacritics().size() + 1);
} else {
new_index = (*highlighted_index_ > 0)
? *highlighted_index_ - 1
: GetCurrentShownDiacritics().size();
}
}
SetButtonHighlighted(new_index, true);
highlighted_index_ = new_index;
return SuggestionStatus::kBrowsing;
default:
size_t key_number = 0;
// If the key value is a number then accept the corresponding suggestion.
if (base::StringToSizeT(std::u16string(1, event.GetCharacter()),
&key_number)) {
// Ignore 0 values, make sure the key numbers are valid.
if (1 <= key_number && key_number <= 9 &&
key_number <= GetCurrentShownDiacritics().size()) {
// The "key" char value starts from 1.
// The actual index of the suggestions start at 0.
size_t index_to_accept = key_number - 1;
if (AcceptSuggestion(index_to_accept)) {
return SuggestionStatus::kAccept;
}
}
}
// Commit current text if there is a selection.
if (highlighted_index_.has_value()) {
AcceptSuggestion(*highlighted_index_);
} else {
DismissSuggestion();
RecordActionMetric(IMEPKLongpressDiacriticAction::kDismiss);
}
// NotHandled is passed so that the IME will let the key event pass
// through.
return SuggestionStatus::kNotHandled;
}
}
bool LongpressDiacriticsSuggester::TrySuggestWithSurroundingText(
const std::u16string& text,
const gfx::Range selection_range) {
// Suggestions should dismiss on text change.
return false;
}
bool LongpressDiacriticsSuggester::AcceptSuggestion(size_t index) {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "suggest: Failed to accept suggestion. No context id.";
return false;
}
std::vector<std::u16string> current_suggestions = GetCurrentShownDiacritics();
if (index >= current_suggestions.size()) {
return false;
}
std::string error;
suggestion_handler_->AcceptSuggestionCandidate(
*focused_context_id_, current_suggestions[index],
/* delete_previous_utf16_len=*/1, /*use_replace_surrounding_text=*/
base::FeatureList::IsEnabled(
features::kDiacriticsUseReplaceSurroundingText),
&error);
if (error.empty()) {
suggestion_handler_->Announce(
l10n_util::GetStringUTF16(IDS_SUGGESTION_DIACRITICS_INSERTED));
} else {
LOG(ERROR) << "Failed to accept suggestion. " << error;
return false;
}
RecordActionMetric(IMEPKLongpressDiacriticAction::kAccept);
RecordAcceptanceCharCodeMetric(current_suggestions[index]);
Reset();
return true;
}
void LongpressDiacriticsSuggester::DismissSuggestion() {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "suggest: Failed to dismiss suggestion. No context id.";
return;
}
std::string error;
AssistiveWindowProperties properties;
properties.type =
ash::ime::AssistiveWindowType::kLongpressDiacriticsSuggestion;
properties.visible = false;
properties.announce_string =
l10n_util::GetStringUTF16(IDS_SUGGESTION_DIACRITICS_DISMISSED);
suggestion_handler_->SetAssistiveWindowProperties(*focused_context_id_,
properties, &error);
if (!error.empty()) {
LOG(ERROR) << "Failed to dismiss suggestion. " << error;
return;
}
Reset();
return;
}
AssistiveType LongpressDiacriticsSuggester::GetProposeActionType() {
return AssistiveType::kLongpressDiacritics;
}
void LongpressDiacriticsSuggester::ShowDiacriticsNudge() {
AnchoredNudgeData nudge_data(
kDiacriticsNudgeId, ash::NudgeCatalogName::kDisableDiacritics,
l10n_util::GetStringUTF16(IDS_CHROMEOS_DIACRITIC_NUDGE_TEXT));
AnchoredNudgeManager::Get()->Show(nudge_data);
}
void LongpressDiacriticsSuggester::SetButtonHighlighted(size_t index,
bool highlighted) {
if (!focused_context_id_.has_value()) {
LOG(ERROR) << "suggest: Failed to set button highlighted. No context id.";
return;
}
std::string error;
if (index == GetCurrentShownDiacritics().size()) {
suggestion_handler_->SetButtonHighlighted(
*focused_context_id_,
{
.id = ui::ime::ButtonId::kLearnMore,
.window_type =
ash::ime::AssistiveWindowType::kLongpressDiacriticsSuggestion,
},
highlighted, &error);
} else {
suggestion_handler_->SetButtonHighlighted(
*focused_context_id_,
CreateButtonFor(index, GetCurrentShownDiacritics()[index]),
/* highlighted=*/highlighted, &error);
}
if (!error.empty()) {
LOG(ERROR) << "suggest: Failed to set button highlighted. " << error;
}
}
std::vector<std::u16string>
LongpressDiacriticsSuggester::GetCurrentShownDiacritics() {
if (displayed_window_base_character_ == std::nullopt) {
return {};
}
return GetDiacriticsFor(*displayed_window_base_character_, engine_id_);
}
void LongpressDiacriticsSuggester::Reset() {
displayed_window_base_character_ = std::nullopt;
highlighted_index_ = std::nullopt;
}
} // namespace ash::input_method