Merge pull request #483 from fanlusky/fix/candidate-flicker
fix(server): 减少候选框闪烁与右缘越界
This commit is contained in:
6 files changed
+217
-22
No files matched your search
@@ -171,6 +171,11 @@ void Stop()
|
||||
g_translation_callback = {};
|
||||
}
|
||||
|
||||
bool IsRunning()
|
||||
{
|
||||
return g_running;
|
||||
}
|
||||
|
||||
void OnInputChanged(const std::string &input, bool dedicated_mode, size_t mixed_min_prefix)
|
||||
{
|
||||
{
|
||||
|
||||
@@ -33,6 +33,9 @@ using TranslationCallback = std::function<void(std::vector<TranslationResult> re
|
||||
|
||||
void Start(const std::string &db_path, ApplyCallback apply_callback, TranslationCallback translation_callback = {});
|
||||
void Stop();
|
||||
// While running, every non-empty dedicated-mode input is answered through the
|
||||
// apply callback, even when the dictionary has no match.
|
||||
bool IsRunning();
|
||||
void OnInputChanged(const std::string &input, bool dedicated_mode = false, size_t mixed_min_prefix = 2);
|
||||
void Clear();
|
||||
bool IsCurrent(const std::string &input, uint64_t generation, bool dedicated_mode = false);
|
||||
|
||||
@@ -102,8 +102,15 @@ bool g_english_input_mode = false;
|
||||
// WebView2 candidate HWND — hosts (games) draw via ITfUIElementSink instead.
|
||||
bool g_activate_uiless = false;
|
||||
bool g_session_uiless = false;
|
||||
// Glosses are a cache keyed by TranslationIdentity, not the current page's
|
||||
// results: the next keystroke's page mostly repeats the same words, and
|
||||
// rebuilding it without their glosses first shrank every row, then grew it back
|
||||
// once the lookup answered. Bounded like the cloud cache, and dropped whenever
|
||||
// the provider set changes (the target language is already in the identity).
|
||||
constexpr size_t kMaxCandidateTranslationGlosses = 2048;
|
||||
std::unordered_map<std::string, std::string> g_candidate_translation_glosses;
|
||||
std::string g_candidate_translation_signature;
|
||||
std::string g_candidate_translation_scope;
|
||||
|
||||
// 副候选框:Ctrl+Enter 在高亮候选有多条译义时,把候选框整个换成那几条译义,让空格/
|
||||
// 数字键像选普通候选一样选一条上屏。输入串一个字都没动,所以退出这个子模式时把原来的
|
||||
@@ -231,6 +238,18 @@ void ApplyUiLessFromPacket(const FanyImeNamedpipeData &pipe_data)
|
||||
// task thread.
|
||||
constexpr ULONGLONG kCandidateHideBacklogMs = 24;
|
||||
|
||||
// Dedicated English mode publishes an empty page while its dictionary query is
|
||||
// in flight (see PrepareCandidateList). Painting that page shrank the card to
|
||||
// the bare preedit, and ApplyEnglishCandidates grew it back a moment later — a
|
||||
// collapse-and-expand flash on every keystroke. The English worker answers every
|
||||
// non-empty input (no match becomes the raw fallback), and that answer requests
|
||||
// its own show, so the empty in-between page is never worth painting.
|
||||
bool IsEnglishQueryPendingOnEmptyPage()
|
||||
{
|
||||
return g_english_input_mode && Global::candidate_ui.items.empty() && g_inputSession &&
|
||||
!g_inputSession->get_pinyin_sequence_with_cases().empty() && EnglishIme::IsRunning();
|
||||
}
|
||||
|
||||
void RequestShowCandidateWindow()
|
||||
{
|
||||
if (IsUiLessMode() || !::global_hwnd)
|
||||
@@ -238,6 +257,12 @@ void RequestShowCandidateWindow()
|
||||
CAND_DIAG_LOGF(L"show request skipped uiless={} hwnd_present={}", IsUiLessMode(), ::global_hwnd != nullptr);
|
||||
return;
|
||||
}
|
||||
if (IsEnglishQueryPendingOnEmptyPage())
|
||||
{
|
||||
CAND_DIAG_LOGF(L"show request deferred to english query raw_units={}",
|
||||
GlobalIme::composition.raw_input_with_cases.size());
|
||||
return;
|
||||
}
|
||||
bool expected = false;
|
||||
if (!g_candidate_show_msg_pending.compare_exchange_strong(expected, true))
|
||||
{
|
||||
@@ -1316,17 +1341,30 @@ void PrepareCandidateTranslationRequest()
|
||||
// 译文页不再查译文。已经取出的 glosses 也不能清,退出子模式后原候选还要用。
|
||||
return;
|
||||
}
|
||||
if (!enabled)
|
||||
{
|
||||
g_candidate_translation_glosses.clear();
|
||||
}
|
||||
if (!enabled || ui.items.empty())
|
||||
{
|
||||
if (!g_candidate_translation_signature.empty() || !g_candidate_translation_glosses.empty())
|
||||
// An empty page (English mode between keystrokes, or the end of a
|
||||
// composition) only cancels the pending lookups; the cache stays for
|
||||
// the next page.
|
||||
if (!g_candidate_translation_signature.empty())
|
||||
{
|
||||
g_candidate_translation_signature.clear();
|
||||
g_candidate_translation_glosses.clear();
|
||||
EnglishIme::ClearTranslations();
|
||||
CloudTranslation::Clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const std::string scope = fmt::format("{}|{}|{}", GetConfiguredTencentTmt().enabled,
|
||||
GetConfiguredCustomTranslation().enabled, GetConfiguredNiuTrans().enabled);
|
||||
if (scope != g_candidate_translation_scope)
|
||||
{
|
||||
g_candidate_translation_scope = scope;
|
||||
g_candidate_translation_glosses.clear();
|
||||
}
|
||||
|
||||
std::vector<EnglishIme::TranslationQuery> queries;
|
||||
std::string signature;
|
||||
@@ -1348,7 +1386,6 @@ void PrepareCandidateTranslationRequest()
|
||||
if (signature == g_candidate_translation_signature)
|
||||
return;
|
||||
g_candidate_translation_signature = std::move(signature);
|
||||
g_candidate_translation_glosses.clear();
|
||||
CloudTranslation::Clear();
|
||||
EnglishIme::RequestTranslations(std::move(queries), GetConfiguredTencentTmt().target_language == "en");
|
||||
}
|
||||
@@ -4252,19 +4289,25 @@ void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> resul
|
||||
(g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji))
|
||||
return;
|
||||
|
||||
if (!merge)
|
||||
g_candidate_translation_glosses.clear();
|
||||
|
||||
std::vector<EnglishIme::TranslationQuery> misses;
|
||||
for (auto &result : results)
|
||||
{
|
||||
std::string gloss = std::move(result.gloss);
|
||||
if (gloss.empty() && !merge)
|
||||
gloss = CloudTranslation::LookupCache(result.key, result.direction);
|
||||
const std::string identity = TranslationIdentity({result.key, result.direction});
|
||||
if (!gloss.empty())
|
||||
g_candidate_translation_glosses[TranslationIdentity({result.key, result.direction})] = std::move(gloss);
|
||||
{
|
||||
if (g_candidate_translation_glosses.size() >= kMaxCandidateTranslationGlosses &&
|
||||
g_candidate_translation_glosses.find(identity) == g_candidate_translation_glosses.end())
|
||||
g_candidate_translation_glosses.clear();
|
||||
g_candidate_translation_glosses[identity] = std::move(gloss);
|
||||
}
|
||||
else if (!merge)
|
||||
{
|
||||
// The authoritative lookup found nothing: drop what an earlier
|
||||
// configuration may have cached for this word.
|
||||
g_candidate_translation_glosses.erase(identity);
|
||||
const bool cloud_translatable = result.direction == EnglishIme::TranslationDirection::EnglishToChinese
|
||||
? CloudTranslation::IsCloudTranslatableEnglish(result.key)
|
||||
: CloudTranslation::IsCloudTranslatableChinese(result.key);
|
||||
@@ -5203,8 +5246,9 @@ void ClearState()
|
||||
ClearCandidateUiOwner();
|
||||
UpdateCloudInput("");
|
||||
UpdateEnglishInput("");
|
||||
// The gloss cache survives the composition: the next one starts from the
|
||||
// same common words, and its first frame should already carry them.
|
||||
g_candidate_translation_signature.clear();
|
||||
g_candidate_translation_glosses.clear();
|
||||
g_translation_candidates_active = false;
|
||||
g_translation_saved_items.clear();
|
||||
g_translation_saved_page_index = 0;
|
||||
|
||||
@@ -1825,6 +1825,43 @@ html:not(.msime-hover-armed) #realContainer.hover-active .cand:not(.first):hover
|
||||
})();
|
||||
)";
|
||||
|
||||
// Late additions to the page (translations, cloud/AI/English merges) resize the
|
||||
// card between two frames of one composition, which reads as flicker even
|
||||
// though each frame is complete. Within a composition the card only grows, and
|
||||
// shrinks back only when its natural size drops below kStickyShrinkRatio of the
|
||||
// kept one (the page really got smaller). Runs in the same task as the content
|
||||
// update, so the frame is laid out once with the final size; the native region
|
||||
// measures the same box and follows. An empty update (hide) ends the
|
||||
// composition. Mirrors kStickyCardShrinkRatio in the D2D candidate presenter.
|
||||
constexpr wchar_t kStickyCandidateCardScript[] = LR"(
|
||||
window.MsimeStickyCandidateCard = function (reset) {
|
||||
const kStickyShrinkRatio = 0.7;
|
||||
const box = document.getElementById('realContainer');
|
||||
if (!box) return;
|
||||
const measure = document.getElementById('measureContainer');
|
||||
const boxes = measure ? [box, measure] : [box];
|
||||
boxes.forEach(function (b) { b.style.minWidth = ''; b.style.minHeight = ''; });
|
||||
if (reset) { window.__msimeStickyCard = null; return; }
|
||||
box.style.boxSizing = 'border-box';
|
||||
const rect = box.getBoundingClientRect();
|
||||
const kept = window.__msimeStickyCard || {width: 0, height: 0};
|
||||
const follow = function (keptValue, natural) {
|
||||
return natural >= keptValue || natural < keptValue * kStickyShrinkRatio ? natural : keptValue;
|
||||
};
|
||||
let width = follow(kept.width, rect.width);
|
||||
const maxWidth = Number.parseFloat(box.style.maxWidth);
|
||||
if (maxWidth > 0) width = Math.min(width, maxWidth);
|
||||
const height = follow(kept.height, rect.height);
|
||||
window.__msimeStickyCard = {width: width, height: height};
|
||||
if (width > rect.width + 0.5 || height > rect.height + 0.5) {
|
||||
boxes.forEach(function (b) {
|
||||
b.style.minWidth = width + 'px';
|
||||
b.style.minHeight = height + 'px';
|
||||
});
|
||||
}
|
||||
};
|
||||
)";
|
||||
|
||||
VOID CALLBACK CandidateHoverArmTimerProc(HWND, UINT, UINT_PTR, DWORD)
|
||||
{
|
||||
MaybeArmCandidatePointerHover();
|
||||
@@ -2163,6 +2200,9 @@ void UpdateHtmlContentWithJavaScript(ComPtr<ICoreWebView2> webview, const std::w
|
||||
script.append(GetConfiguredCandidateWindowPreeditStyle() == "empty" ? L"false" : L"true");
|
||||
script.append(L"); }\n");
|
||||
script.append(L"if (window.SetPreeditCaret) { window.SetPreeditCaret(); }\n");
|
||||
script.append(kStickyCandidateCardScript);
|
||||
script.append(newContent.empty() ? L"window.MsimeStickyCandidateCard(true);\n"
|
||||
: L"window.MsimeStickyCandidateCard(false);\n");
|
||||
script.append(L"if (window.CheckContentTruncation) { window.CheckContentTruncation(); }\n");
|
||||
if (diagnosticsEnabled)
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include "engine/core/word_item.h"
|
||||
|
||||
#include <d2d1.h>
|
||||
#include <dwmapi.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -155,6 +156,16 @@ constexpr float kShadowPadRight = 32.0f;
|
||||
constexpr float kShadowPadBottom = 40.0f;
|
||||
constexpr float kCandidateMinWidthDip = 160.0f;
|
||||
constexpr float kDecorationCardOverlap = 5.0f;
|
||||
// Below this fraction of the kept extent, the page really got smaller (fewer or
|
||||
// shorter candidates) and the card follows it; above it, the difference is a
|
||||
// late addition coming or going and the card keeps its size. Mirrors
|
||||
// kStickyShrinkRatio in the WebView2 candidate script.
|
||||
constexpr float kStickyCardShrinkRatio = 0.7f;
|
||||
|
||||
float StickyCardExtent(float kept, float natural)
|
||||
{
|
||||
return natural >= kept || natural < kept * kStickyCardShrinkRatio ? natural : kept;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -972,25 +983,52 @@ void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightD
|
||||
{
|
||||
y = monitor.top + 2 - cardTopPx;
|
||||
}
|
||||
SetWindowPos(hwnd_, HWND_TOPMOST, x, y, widthPx, heightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
// Rendering must use the same scale the window was just sized with: the ui
|
||||
// Rendering must use the same scale the window is sized with: the ui
|
||||
// override replaces GetDpiForWindow (144 inside RDP) so DIPs map exactly
|
||||
// onto the pixels computed above. Must land before EnsureForComposition —
|
||||
// a freshly created target reads it through DpiForHwnd().
|
||||
// a freshly created target reads it through DpiForHwnd() — and before the
|
||||
// resize below, whose WM_SIZE already presents.
|
||||
const FLOAT dpiOverride = scale * 96.0f;
|
||||
impl_->window->SetDpiOverride(dpiOverride);
|
||||
impl_->resources.SetDpiOverride(dpiOverride);
|
||||
// The card is clamped to the work area, but the host around it carries the
|
||||
// shadow padding and the 64px bucket slack, so near the right or bottom edge
|
||||
// it reached onto the neighbouring monitor. DWM applies a window move and a
|
||||
// swap-chain present independently and in no guaranteed order, so a frame
|
||||
// can pair either card with either host origin; whatever it put past the
|
||||
// card then flashed on the next screen. Cut the host at the work-area edge:
|
||||
// nothing can be drawn outside the caret's monitor, whatever DWM pairs up.
|
||||
// The card still fits (it was clamped above); only its shadow is trimmed.
|
||||
const int hostWidthPx = (std::max)(1, (std::min)(widthPx, monitor.right - x));
|
||||
const int hostHeightPx = (std::max)(1, (std::min)(heightPx, monitor.bottom - y));
|
||||
// Submission order still decides which stale pairing is likelier. Moving a
|
||||
// host left/up before the present, and right/down after it, keeps the
|
||||
// stale frame on the side of the card that is already on screen.
|
||||
RECT current{};
|
||||
DWORD cloaked = 0;
|
||||
const bool onScreen =
|
||||
IsWindowVisible(hwnd_) && GetWindowRect(hwnd_, ¤t) && current.top != Global::INVALID_Y &&
|
||||
SUCCEEDED(DwmGetWindowAttribute(hwnd_, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) && cloaked == 0;
|
||||
const int firstX = onScreen && x > current.left ? current.left : x;
|
||||
const int firstY = onScreen && y > current.top ? current.top : y;
|
||||
SetWindowPos(hwnd_, HWND_TOPMOST, firstX, firstY, hostWidthPx, hostHeightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
impl_->resources.EnsureForComposition(hwnd_);
|
||||
Present();
|
||||
if (firstX != x || firstY != y)
|
||||
{
|
||||
SetWindowPos(hwnd_, nullptr, x, y, 0, 0, SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOSIZE);
|
||||
}
|
||||
SetCandidateHostCloaked(false);
|
||||
// Acceptance trace for the RDP candidate-scale fix: which scale authority
|
||||
// won, and the system's own (potentially diverging) DPI values. Coordinates
|
||||
// and DPI only — never user input, per the diagnostic logging red lines.
|
||||
CAND_DIAG_LOGF(L"candidate-d2d place source={} scale={:.3f} hwnd_dpi={} system_dpi={} remote={} caret=({},{}) "
|
||||
L"size_px=({},{})",
|
||||
scaleSource == CandidateScaleSource::RdpForeground ? L"rdp-foreground" : L"monitor", scale,
|
||||
GetDpiForWindow(hwnd_), GetDpiForSystem(), GetSystemMetrics(SM_REMOTESESSION) ? 1 : 0, caret.x,
|
||||
caret.y, widthPx, heightPx);
|
||||
CAND_DIAG_LOGF(
|
||||
L"candidate-d2d place source={} scale={:.3f} hwnd_dpi={} system_dpi={} remote={} caret=({},{}) "
|
||||
L"size_px=({},{}) host=({},{}) first=({},{}) on_screen={} card_px=({},{} {}w) monitor=({},{})-({},{})",
|
||||
scaleSource == CandidateScaleSource::RdpForeground ? L"rdp-foreground" : L"monitor", scale,
|
||||
GetDpiForWindow(hwnd_), GetDpiForSystem(), GetSystemMetrics(SM_REMOTESESSION) ? 1 : 0, caret.x, caret.y,
|
||||
hostWidthPx, hostHeightPx, x, y, firstX, firstY, onScreen, x + cardLeftPx, y + cardTopPx, cardWidthPx,
|
||||
monitor.left, monitor.top, monitor.right, monitor.bottom);
|
||||
}
|
||||
|
||||
void CandidatePresenter::ShowFromGlobalState()
|
||||
@@ -1056,12 +1094,41 @@ void CandidatePresenter::ShowFromGlobalState(POINT caret)
|
||||
const HalfScreenDipLimits limits = ApplyScaleToHalfScreenLimits(caret, scale.scale);
|
||||
const float maxW = limits.maxWidthDip > 1.0 ? static_cast<float>(limits.maxWidthDip) : 480.0f;
|
||||
const float maxH = limits.maxHeightDip > 1.0 ? static_cast<float>(limits.maxHeightDip) : 640.0f;
|
||||
impl_->root->InvalidateMeasure();
|
||||
const msimeui::SizeF measured = impl_->root->MeasureInLayout({maxW, maxH});
|
||||
float widthDip = (std::max)(measured.width, kCandidateMinWidthDip + kShadowPadLeft + kShadowPadRight);
|
||||
float heightDip = (std::max)(measured.height, 36.0f);
|
||||
impl_->root->InvalidateArrange();
|
||||
impl_->root->ArrangeInLayout({0.0f, 0.0f, widthDip, heightDip});
|
||||
float widthDip = 0.0f;
|
||||
float heightDip = 0.0f;
|
||||
auto layoutRoot = [&]() {
|
||||
impl_->root->InvalidateMeasure();
|
||||
const msimeui::SizeF measured = impl_->root->MeasureInLayout({maxW, maxH});
|
||||
widthDip = (std::max)(measured.width, kCandidateMinWidthDip + kShadowPadLeft + kShadowPadRight);
|
||||
heightDip = (std::max)(measured.height, 36.0f);
|
||||
impl_->root->InvalidateArrange();
|
||||
impl_->root->ArrangeInLayout({0.0f, 0.0f, widthDip, heightDip});
|
||||
};
|
||||
// Late additions to the page (translations, cloud/AI/English merges) change
|
||||
// the card's size between two frames of the same composition, and the eye
|
||||
// reads that as flicker even though every frame is complete. Within one
|
||||
// composition the card therefore only grows; it shrinks back only when the
|
||||
// natural size falls well below the kept one (a page that really got
|
||||
// smaller). Hide() starts the next composition from the natural size.
|
||||
if (impl_->card)
|
||||
{
|
||||
impl_->card->SetMinWidth(kCandidateMinWidthDip);
|
||||
impl_->card->ClearMinHeight();
|
||||
}
|
||||
layoutRoot();
|
||||
if (impl_->card)
|
||||
{
|
||||
const msimeui::RectF natural = impl_->card->GetBounds();
|
||||
const float maxCardWidthDip = (std::max)(kCandidateMinWidthDip, maxW - kShadowPadLeft - kShadowPadRight);
|
||||
stickyCardWidthDip_ = (std::min)(StickyCardExtent(stickyCardWidthDip_, natural.width), maxCardWidthDip);
|
||||
stickyCardHeightDip_ = StickyCardExtent(stickyCardHeightDip_, natural.height);
|
||||
if (stickyCardWidthDip_ > natural.width + 0.5f || stickyCardHeightDip_ > natural.height + 0.5f)
|
||||
{
|
||||
impl_->card->SetMinWidth((std::max)(kCandidateMinWidthDip, stickyCardWidthDip_));
|
||||
impl_->card->SetMinHeight(stickyCardHeightDip_);
|
||||
layoutRoot();
|
||||
}
|
||||
}
|
||||
hoverArmed_ = false;
|
||||
if (!GetCursorPos(&hoverBaseline_))
|
||||
{
|
||||
@@ -1099,9 +1166,18 @@ void CandidatePresenter::Hide()
|
||||
{
|
||||
impl_->list->SetHoverEnabled(false);
|
||||
}
|
||||
// Cloaking keeps the swap chain's last frame. The next show presents and
|
||||
// then uncloaks, but DWM may compose the uncloaked host before it latches
|
||||
// that present: one frame of the previous card at the new origin. After a
|
||||
// wide card, a narrower one clamped to the right screen edge sits further
|
||||
// right, so the stale card spilled onto the next monitor. Leave nothing
|
||||
// behind to show.
|
||||
PresentEmptyFrame();
|
||||
SetCandidateHostCloaked(true);
|
||||
lastHostWidthPx_ = 0;
|
||||
lastHostHeightPx_ = 0;
|
||||
stickyCardWidthDip_ = 0.0f;
|
||||
stickyCardHeightDip_ = 0.0f;
|
||||
SetWindowPos(hwnd_, nullptr, 0, Global::INVALID_Y, 0, 0,
|
||||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||||
}
|
||||
@@ -1153,6 +1229,27 @@ void CandidatePresenter::Present()
|
||||
impl_->resources.Present();
|
||||
}
|
||||
|
||||
void CandidatePresenter::PresentEmptyFrame()
|
||||
{
|
||||
if (!bound_ || !impl_)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ID2D1RenderTarget *target = impl_->resources.GetRenderTarget();
|
||||
if (!target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
target->BeginDraw();
|
||||
target->Clear(D2D1::ColorF(0, 0.0f));
|
||||
if (target->EndDraw() == D2DERR_RECREATE_TARGET)
|
||||
{
|
||||
impl_->resources.DiscardTarget();
|
||||
return;
|
||||
}
|
||||
impl_->resources.Present();
|
||||
}
|
||||
|
||||
void CandidatePresenter::ArmHoverIfPointerMoved()
|
||||
{
|
||||
POINT now{};
|
||||
|
||||
@@ -36,6 +36,9 @@ class CandidatePresenter
|
||||
// unset (scale == 0) value makes PlaceAndShow resolve it itself.
|
||||
void PlaceAndShow(POINT caret, float widthDip, float heightDip, float cardLeftDip, float cardTopDip,
|
||||
const ResolvedCandidateScale &scale = {});
|
||||
// Hide leaves the host cloaked; clearing it keeps the next uncloak from
|
||||
// briefly showing the previous card.
|
||||
void PresentEmptyFrame();
|
||||
void ArmHoverIfPointerMoved();
|
||||
void CommitItem(size_t pageIndex);
|
||||
void ShowItemContextMenu(size_t pageIndex, POINT clientPoint);
|
||||
@@ -59,5 +62,8 @@ class CandidatePresenter
|
||||
int lastHostHeightPx_ = 0;
|
||||
float lastLayoutWidthDip_ = 0.0f;
|
||||
float lastLayoutHeightDip_ = 0.0f;
|
||||
// Largest card size kept for the current composition; reset by Hide().
|
||||
float stickyCardWidthDip_ = 0.0f;
|
||||
float stickyCardHeightDip_ = 0.0f;
|
||||
int wheelDeltaAccumulator_ = 0;
|
||||
};
|
||||
Reference in new issue
Block a user