From 66d9227421cbde61fa0277d8d2b8950472f2dcfc Mon Sep 17 00:00:00 2001 From: Qijia Liu Date: Tue, 25 Jun 2024 00:31:08 -0400 Subject: [PATCH] a coarse implementation --- include/candidate_window.hpp | 24 +++- include/webview_candidate_window.hpp | 4 +- page/api.ts | 46 +++++++- page/generic.scss | 81 ++++++++++--- page/global.d.ts | 9 +- page/macos.scss | 17 ++- page/scroll.ts | 167 +++++++++++++++++++++++++++ page/ux.ts | 2 + preview/preview.mm | 2 +- src/webview_candidate_window.mm | 12 +- tests/util.ts | 2 +- 11 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 page/scroll.ts diff --git a/include/candidate_window.hpp b/include/candidate_window.hpp index 8864623..fa6d57c 100644 --- a/include/candidate_window.hpp +++ b/include/candidate_window.hpp @@ -25,6 +25,17 @@ enum theme_t { system = 0, light = 1, dark = 2 }; enum writing_mode_t { horizontal_tb = 0, vertical_rl = 1, vertical_lr = 2 }; +enum scroll_state_t { none = 0, ready = 1, scrolling = 2 }; + +enum scroll_key_action_t { + one = 1, + two = 2, + three = 3, + four = 4, + five = 5, + six = 6, + up = 10, down = 11, left = 12, right = 13, home = 14, end = 15, expand = 16, collapse = 17 }; + struct CandidateAction { int id; std::string text; @@ -47,7 +58,9 @@ class CandidateWindow { const formatted &auxUp, const formatted &auxDown) = 0; virtual void set_candidates(const std::vector &candidates, - int highlighted) = 0; + int highlighted, + scroll_state_t scroll_state, bool scroll_end) = 0; + virtual void scroll_key_action(scroll_key_action_t action) = 0; virtual void set_highlight_callback(std::function) = 0; virtual void set_theme(theme_t theme) = 0; virtual void set_writing_mode(writing_mode_t mode) = 0; @@ -59,7 +72,7 @@ class CandidateWindow { init_callback = callback; } - void set_select_callback(std::function callback) { + void set_select_callback(std::function callback) { select_callback = callback; } @@ -72,6 +85,10 @@ class CandidateWindow { page_callback = callback; } + void set_scroll_callback(std::function callback) { + scroll_callback = callback; + } + void set_paging_buttons(bool pageable, bool has_prev, bool has_next) { pageable_ = pageable; has_prev_ = has_prev; @@ -85,8 +102,9 @@ class CandidateWindow { protected: std::function init_callback = []() {}; - std::function select_callback = [](size_t) {}; + std::function select_callback = [](int) {}; std::function page_callback = [](bool) {}; + std::function scroll_callback = [](int, int) {}; std::function action_callback = [](int, int) {}; std::string cursor_text_ = ""; std::string highlight_mark_text_ = ""; diff --git a/include/webview_candidate_window.hpp b/include/webview_candidate_window.hpp index fb0c811..f4e9372 100644 --- a/include/webview_candidate_window.hpp +++ b/include/webview_candidate_window.hpp @@ -20,7 +20,9 @@ class WebviewCandidateWindow : public CandidateWindow { const formatted &auxUp, const formatted &auxDown) override; void set_candidates(const std::vector &candidates, - int highlighted) override; + int highlighted, + scroll_state_t scroll_state, bool scroll_end) override; + void scroll_key_action(scroll_key_action_t action) override; void set_highlight_callback(std::function) override {} void set_theme(theme_t theme) override; void set_writing_mode(writing_mode_t mode) override; diff --git a/page/api.ts b/page/api.ts index 5407d30..f5a3836 100644 --- a/page/api.ts +++ b/page/api.ts @@ -19,6 +19,14 @@ import { } from './theme' import { setStyle } from './customize' import { fcitxLog } from './log' +import { + getScrollState, + setScrollState, + setScrollEnd, + recalculateScroll, + scrollKeyAction, + fetchComplete +} from './scroll' window.fcitxLog = fcitxLog window._onload && window._onload() @@ -83,8 +91,21 @@ const caretRight = common.replace('{}', '0 0 192 512').replace('{}', 'M0 384.662 const arrowBack = common.replace('{}', '0 0 24 24').replace('{}', 'M16.62 2.99a1.25 1.25 0 0 0-1.77 0L6.54 11.3a.996.996 0 0 0 0 1.41l8.31 8.31c.49.49 1.28.49 1.77 0s.49-1.28 0-1.77L9.38 12l7.25-7.25c.48-.48.48-1.28-.01-1.76z') const arrowForward = common.replace('{}', '0 0 24 24').replace('{}', 'M7.38 21.01c.49.49 1.28.49 1.77 0l8.31-8.31a.996.996 0 0 0 0-1.41L9.15 2.98c-.49-.49-1.28-.49-1.77 0s-.49 1.28 0 1.77L14.62 12l-7.25 7.25c-.48.48-.48 1.28.01 1.76z') -function setCandidates (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean) { - hoverables.innerHTML = '' +function setCandidates (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean, scrollState: SCROLL_STATE, scrollEnd: boolean) { + // Keep existing candidates only when both old and new state are scrolling. + if (!(getScrollState() === 2 && scrollState === 2)) { + hoverables.innerHTML = '' + hoverables.scrollTop = 0 // Otherwise last scroll position will be kept. + } else { + fetchComplete() + } + if (scrollState === 2) { + hoverables.classList.add('horizontal-scroll') + setScrollEnd(scrollEnd) + } else { + hoverables.classList.remove('horizontal-scroll') + } + setScrollState(scrollState) for (let i = 0; i < cands.length; ++i) { const candidate = div('candidate', 'hoverable') if (i === 0) { @@ -112,9 +133,9 @@ function setCandidates (cands: Candidate[], highlighted: number, markText: strin candidateInner.append(mark) } - if (cands[i].label) { + if (cands[i].label || scrollState === 2) { const label = div('label') - label.innerHTML = escapeWS(cands[i].label) + label.innerHTML = escapeWS(cands[i].label || '0') candidateInner.append(label) } @@ -134,7 +155,14 @@ function setCandidates (cands: Candidate[], highlighted: number, markText: strin setActions(cands.map(c => c.actions)) - if (pageable) { + if (scrollState === 1) { + hoverables.append(divider(true)) + const expand = div('expand', 'hoverable-inner') + expand.innerHTML = arrowForward + const paging = div('paging', 'scroll', 'hoverable') + paging.append(expand) + hoverables.append(paging) + } else if (scrollState === 0 && pageable) { const isArrow = getPagingButtonsStyle() === 'Arrow' hoverables.append(divider(true)) @@ -163,6 +191,10 @@ function setCandidates (cands: Candidate[], highlighted: number, markText: strin paging.appendChild(prev) paging.appendChild(next) hoverables.appendChild(paging) + } else if (scrollState === 2) { + window.requestAnimationFrame(() => { + recalculateScroll() + }) } for (const hoverable of hoverables.querySelectorAll('.hoverable')) { @@ -207,6 +239,9 @@ hoverables.addEventListener('mouseleave', () => { }) hoverables.addEventListener('wheel', e => { + if (getScrollState() === 2) { + return + } window._page((e).deltaY > 0) }) @@ -222,3 +257,4 @@ window.setAccentColor = setAccentColor window.setStyle = setStyle window.setWritingMode = setWritingMode window.copyHTML = copyHTML +window.scrollKeyAction = scrollKeyAction diff --git a/page/generic.scss b/page/generic.scss index e28efa9..b8d5f9e 100644 --- a/page/generic.scss +++ b/page/generic.scss @@ -32,6 +32,19 @@ body { } } +.candidate-inner { + display: flex; + gap: 6px; + align-items: center; /* English words have lower height */ + line-height: 1em; /* align label and candidates */ + position: relative; /* for absolute position of mark */ +} + +.label { + /* Label is usually a single number. Will look ugly when all parts have vertical writing mode. */ + writing-mode: horizontal-tb; +} + .hoverables { display: flex; @@ -50,30 +63,49 @@ body { &.horizontal { flex-direction: row; + .candidate { + /* When horizontal and there is multi-line candidate, + make sure other candidates are vertical centered. + Don't enable it for vertical. It will shrink highlight. */ + display: flex; + } + .divider { flex-direction: column; } } -} -.horizontal .candidate { - /* When horizontal and there is multi-line candidate, - make sure other candidates are vertical centered. - Don't enable it for vertical. It will shrink highlight. */ - display: flex; -} + &.horizontal-scroll { + block-size: 180px; + inline-size: 400px; + flex-wrap: wrap; + overflow-y: auto; + overscroll-behavior: none; -.candidate-inner { - display: flex; - gap: 6px; - align-items: center; /* English words have lower height */ - line-height: 1em; /* align label and candidates */ - position: relative; /* for absolute position of mark */ + .candidate { + min-inline-size: 60px; + } + + .candidate-inner { + width: 100%; + } + + .label { + opacity: 0; + } + + .highlighted-row .label { + opacity: 1; + } + + .divider { + flex-grow: 1; + } + } } -.label { - /* Label is usually a single number. Will look ugly when all parts have vertical writing mode. */ - writing-mode: horizontal-tb; +:is(.vertical-rl, .vertical-lr) .paging svg { + transform: rotate(90deg); } .paging { @@ -88,10 +120,21 @@ body { block-size: 16px; inline-size: 16px; } -} -:is(.vertical-rl, .vertical-lr) .paging svg { - transform: rotate(90deg); + &.scroll { + .expand { + block-size: 18px; + inline-size: 18px; + display: flex; + justify-content: center; + align-items: center; + + svg { + transform: rotate(90deg); + width: 16px; + } + } + } } /* When horizontal, paging is shorter than candidates, so need to centralize them. */ diff --git a/page/global.d.ts b/page/global.d.ts index 4b3ec9a..662fe49 100644 --- a/page/global.d.ts +++ b/page/global.d.ts @@ -11,6 +11,11 @@ declare global { actions: CandidateAction[] } + type SCROLL_STATE = 0 | 1 | 2 + type SCROLL_SELECT = 1 | 2 | 3 | 4 | 5 | 6 + type SCROLL_MOVE_HIGHLIGHT = 10 | 11 | 12 | 13 | 14 | 15 + type SCROLL_KEY_ACTION = SCROLL_SELECT | SCROLL_MOVE_HIGHLIGHT + interface Window { // C++ APIs that api.ts calls _onload?: () => void @@ -18,11 +23,12 @@ declare global { _copyHTML: (html: string) => void _select: (index: number) => void _page: (next: boolean) => void + _scroll: (start: number, length: number) => void _action: (index: number, id: number) => void _resize: (dx: number, dy: number, shadowTop: number, shadowRight: number, shadowBottom: number, shadowLeft: number, fullWidth: number, fullHeight: number, enlargedWidth: number, enlargedHeight: number, dragging: boolean) => void // JavaScript APIs that webview_candidate_window.mm calls - setCandidates: (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean) => void + setCandidates: (cands: Candidate[], highlighted: number, markText: string, pageable: boolean, hasPrev: boolean, hasNext: boolean, scrollState: SCROLL_STATE, scrollEnd: boolean) => void setLayout: (layout: 0 | 1) => void updateInputPanel: (preeditHTML: string, auxUpHTML: string, auxDownHTML: string) => void resize: (dx: number, dy: number, dragging: boolean, hasContextmenu: boolean) => void @@ -31,6 +37,7 @@ declare global { setStyle: (style: string) => void setWritingMode: (mode: 0 | 1 | 2) => void copyHTML: () => void + scrollKeyAction: (action: SCROLL_KEY_ACTION) => void // Utility functions globally available fcitxLog: (...args: unknown[]) => void diff --git a/page/macos.scss b/page/macos.scss index fe54e5f..208e9bc 100644 --- a/page/macos.scss +++ b/page/macos.scss @@ -46,7 +46,7 @@ $dark-graphite: rgb(105 105 105); .panel { transform: translate(25px, 25px); /* leave top and left for shadow */ - &:has(.horizontal .paging.arrow) { + &:has(.horizontal .paging:is(.arrow, .scroll)) { border-start-end-radius: 15px; border-end-end-radius: 15px; } @@ -62,6 +62,12 @@ $dark-graphite: rgb(105 105 105); } } + .paging.scroll { + inline-size: 28px; + justify-content: center; + align-items: center; + } + .contextmenu { backdrop-filter: blur(16px); } @@ -220,6 +226,10 @@ $dark-graphite: rgb(105 105 105); background-color: $vertical-border-color-light; } } + + .hoverables.horizontal-scroll .divider .divider-middle { + background-color: $panel-color-light; + } } .macos.dark { @@ -312,10 +322,15 @@ $dark-graphite: rgb(105 105 105); background-color: $panel-color-dark; } + /* stylelint-disable-next-line no-descending-specificity */ .divider-middle { background-color: $vertical-border-color-dark; } } + + .hoverables.horizontal-scroll .divider .divider-middle { + background-color: $panel-color-dark; + } } .macos.light, .macos.dark { diff --git a/page/scroll.ts b/page/scroll.ts new file mode 100644 index 0000000..5ffe651 --- /dev/null +++ b/page/scroll.ts @@ -0,0 +1,167 @@ +import { + hoverables +} from './selector' + +let scrollState: SCROLL_STATE = 0 + +export function getScrollState () { + return scrollState +} + +export function setScrollState (state: SCROLL_STATE) { + scrollState = state +} + +let scrollEnd = false + +export function setScrollEnd (end: boolean) { + scrollEnd = end +} + +let rowItemCount: number[] = [] +let highlighted = 0 + +function itemCountInFirstNRows (n: number): number { + return rowItemCount.slice(0, n).reduce((sum, count) => sum + count, 0) +} + +function getHighlightedRow (): number { + let skipped = 0 + for (let i = 0; i < rowItemCount.length - 1; ++i) { + const end = skipped + rowItemCount[i] + if (highlighted < end) { + return i + } + skipped = end + } + return rowItemCount.length - 1 +} + +function renderHighlightAndLabels (newHighlighted: number, clearOld: boolean) { + const candidates = hoverables.querySelectorAll('.candidate') + if (clearOld) { + const highlightedRow = getHighlightedRow() + const skipped = itemCountInFirstNRows(highlightedRow) + for (let i = skipped; i < skipped + rowItemCount[highlightedRow]; ++i) { + const candidate = candidates[i] + candidate.classList.remove('highlighted-row') + candidate.querySelector('.label')!.innerHTML = '0' + } + candidates[highlighted].classList.remove('highlighted') + } + + highlighted = newHighlighted + + const highlightedRow = getHighlightedRow() + const skipped = itemCountInFirstNRows(highlightedRow) + for (let i = skipped; i < skipped + rowItemCount[highlightedRow]; ++i) { + const candidate = candidates[i] + candidate.classList.add('highlighted-row') + candidate.querySelector('.label')!.innerHTML = `${i - skipped + 1}` + } + candidates[highlighted].classList.add('highlighted') +} + +export function recalculateScroll () { + const candidates = hoverables.querySelectorAll('.candidate') + let currentY = candidates[0].getBoundingClientRect().y + rowItemCount = [] + let itemCount = 0 + for (const candidate of candidates) { + candidate.classList.remove('highlighted-row') + const { y } = candidate.getBoundingClientRect() + if (y === currentY) { + ++itemCount + } else { + rowItemCount.push(itemCount) + itemCount = 1 + currentY = y + } + } + rowItemCount.push(itemCount) + renderHighlightAndLabels(0, false) +} + +function getNeighborCandidate (direction: SCROLL_MOVE_HIGHLIGHT): number { + const highlightedRow = getHighlightedRow() + const candidates = hoverables.querySelectorAll('.candidate') + const { left, right } = candidates[highlighted].getBoundingClientRect() + const mid = (left + right) / 2 + + function helper (row: number) { + if (row < 0 || row === rowItemCount.length) { + return -1 + } + const skipped = itemCountInFirstNRows(row) + const last = skipped + rowItemCount[row] - 1 + for (let i = skipped; i < last; ++i) { + const rect = candidates[i].getBoundingClientRect() + if (rect.right <= left) { + continue + } + return rect.right > mid || rect.right - left > left - rect.left ? i : i + 1 + } + return last + } + + switch (direction) { + case 10: { + return helper(highlightedRow - 1) + } + case 11: { + return helper(highlightedRow + 1) + } + case 12: + return highlighted - 1 + case 13: + if (highlighted + 1 < itemCountInFirstNRows(rowItemCount.length + 1)) { + return highlighted + 1 + } + return -1 + } + return -1 +} + +export function scrollKeyAction (action: SCROLL_KEY_ACTION) { + if (action >= 1 && action <= 6) { + const highlightedRow = getHighlightedRow() + const n = rowItemCount[highlightedRow] + if (action > n) { + return + } + return window._select(itemCountInFirstNRows(highlightedRow) + action - 1) + } + switch (action) { + case 10: + case 11: + case 12: + case 13: { + const newHighlighted = getNeighborCandidate(action) + if (newHighlighted >= 0) { + renderHighlightAndLabels(newHighlighted, true) + } + break + } + } +} + +// A lock that prevents fetching same candidates simultaneously. +let fetching = false + +export function fetchComplete () { + fetching = false +} + +hoverables.addEventListener('scroll', () => { + if (scrollEnd || fetching) { + return + } + // This is safe since there are at least 7 lines. + const bottomRightIndex = itemCountInFirstNRows(rowItemCount.length - 1) - 1 + const candidates = hoverables.querySelectorAll('.candidate') + const bottomRight = candidates[bottomRightIndex] + if (bottomRight.getBoundingClientRect().top < hoverables.clientHeight) { + fetching = true + window._scroll(candidates.length, 6) + } +}) diff --git a/page/ux.ts b/page/ux.ts index 5f6a3cc..8a78bee 100644 --- a/page/ux.ts +++ b/page/ux.ts @@ -158,6 +158,8 @@ document.addEventListener('mouseup', e => { return window._page(false) } else if (target.classList.contains('next')) { return window._page(true) + } else if (target.classList.contains('expand')) { + return window._scroll(0, 42) // 6 visible rows plus 1 hidden row } target = target.parentElement! } diff --git a/preview/preview.mm b/preview/preview.mm index 4d45921..695cc34 100644 --- a/preview/preview.mm +++ b/preview/preview.mm @@ -11,7 +11,7 @@ int main(int argc, const char *argv[]) { std::unique_ptr candidateWindow = std::make_unique(); - candidateWindow->set_select_callback([](size_t index) { + candidateWindow->set_select_callback([](int index) { std::cout << "selected " << index << std::endl; }); candidateWindow->set_init_callback( diff --git a/src/webview_candidate_window.mm b/src/webview_candidate_window.mm index dad463f..361606b 100644 --- a/src/webview_candidate_window.mm +++ b/src/webview_candidate_window.mm @@ -167,6 +167,10 @@ NSRect getNearestScreenFrame(double x, double y) { bind("_page", [this](bool next) { page_callback(next); }); + bind("_scroll", [this](int start, int length) { + scroll_callback(start, length); + }); + bind("_action", [this](size_t i, int id) { action_callback(i, id); }); bind("_onload", [this]() { init_callback(); }); @@ -225,14 +229,18 @@ NSRect getNearestScreenFrame(double x, double y) { } void WebviewCandidateWindow::set_candidates( - const std::vector &candidates, int highlighted) { + const std::vector &candidates, int highlighted, scroll_state_t scroll_state, bool scroll_end) { std::vector escaped_candidates; escaped_candidates.reserve(candidates.size()); std::transform(candidates.begin(), candidates.end(), std::back_inserter(escaped_candidates), escape_candidate); invoke_js("setCandidates", escaped_candidates, highlighted, escape_html(highlight_mark_text_), pageable_, has_prev_, - has_next_); + has_next_, scroll_state, scroll_end); +} + +void WebviewCandidateWindow::scroll_key_action(scroll_key_action_t action) { + invoke_js("scrollKeyAction", action); } void WebviewCandidateWindow::set_theme(theme_t theme) { diff --git a/tests/util.ts b/tests/util.ts index bf65ab2..ab29b29 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -31,7 +31,7 @@ export async function init (page: Page) { export function setCandidates (page: Page, cands: Candidate[], highlighted: number) { return page.evaluate(({ cands, highlighted }) => - window.setCandidates(cands, highlighted, '', false, false, false), { cands, highlighted }) + window.setCandidates(cands, highlighted, '', false, false, false, 0, false), { cands, highlighted }) } export function setLayout (page: Page, layout: 0 | 1) {