Skip to content

Commit 9ba56c9

Browse files
authored
support multi actions for a status area button (#41)
1 parent a9cba43 commit 9ba56c9

7 files changed

Lines changed: 122 additions & 25 deletions

File tree

src/api.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ export type VirtualKeyboardEvent = {
7878
type: 'COMMIT' | 'SET_INPUT_METHOD'
7979
data: string
8080
} | {
81-
type: 'UNDO' | 'REDO' | 'CUT' | 'COPY' | 'PASTE' | 'COLLAPSE' |
82-
'SELECT' | 'DESELECT' | 'SELECT_ALL' | 'GLOBE'
81+
type: 'UNDO' | 'REDO' | 'CUT' | 'COPY' | 'PASTE' | 'COLLAPSE'
82+
| 'SELECT' | 'DESELECT' | 'SELECT_ALL' | 'GLOBE'
8383
} | {
8484
type: 'SELECT_CANDIDATE' | 'ASK_CANDIDATE_ACTIONS' | 'STATUS_AREA_ACTION'
8585
data: number

src/contextmenu.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { div, handleClick, hide, press, release, show } from './util'
1+
import { div, enableScroll, handleClick, hide, press, release, show } from './util'
22

33
export function hideContextMenu() {
44
hide(document.querySelector('.fcitx-keyboard-contextmenu-container')!)
@@ -7,6 +7,7 @@ export function hideContextMenu() {
77
export function renderContextmenu() {
88
const container = div('fcitx-keyboard-contextmenu-container')
99
const contextmenu = div('fcitx-keyboard-contextmenu')
10+
enableScroll(contextmenu)
1011
container.appendChild(contextmenu)
1112
container.addEventListener('touchstart', (event) => {
1213
// Don't hide if touching menu instead of outside.
@@ -29,17 +30,27 @@ function renderItem(text: string) {
2930

3031
export function showContextmenu(element: Element, items: {
3132
text: string
33+
separator?: boolean
3234
callback: () => void
3335
}[]) {
3436
const contextmenu = document.querySelector('.fcitx-keyboard-contextmenu') as HTMLElement
3537
contextmenu.innerHTML = ''
36-
for (const item of items) {
37-
const element = renderItem(item.text)
38-
handleClick(element, () => {
39-
item.callback()
40-
hideContextMenu()
41-
})
42-
contextmenu.appendChild(element)
38+
for (let i = 0; i < items.length; i++) {
39+
const item = items[i]
40+
if (item.separator) {
41+
if (i === 0 || i === items.length - 1 || items[i - 1].separator) {
42+
continue
43+
}
44+
contextmenu.appendChild(document.createElement('hr'))
45+
}
46+
else {
47+
const element = renderItem(item.text)
48+
handleClick(element, () => {
49+
item.callback()
50+
hideContextMenu()
51+
})
52+
contextmenu.appendChild(element)
53+
}
4354
}
4455
show(contextmenu.parentElement!)
4556
const containerBox = contextmenu.parentElement!.getBoundingClientRect()
@@ -48,12 +59,21 @@ export function showContextmenu(element: Element, items: {
4859
const containerYBar = (containerBox.top + containerBox.bottom) / 2
4960
const targetYBar = (targetBox.top + targetBox.bottom) / 2
5061
let y: number
62+
// First step: place it above or below the target element.
5163
if (targetYBar < containerYBar) {
5264
y = targetBox.bottom
5365
}
5466
else {
5567
y = targetBox.top - menuBox.height
5668
}
69+
// Next step: adjust the bottom position if it exceeds the container.
70+
if (y + menuBox.height > containerBox.bottom) {
71+
y = containerBox.bottom - menuBox.height
72+
}
73+
// Final step: adjust the top position if it exceeds the container.
74+
if (y < containerBox.top) {
75+
y = containerBox.top
76+
}
5777
const x = Math.min(targetBox.left, containerBox.right - menuBox.width)
5878
contextmenu.style.left = `${x}px`
5979
contextmenu.style.top = `${y}px`

src/layout.d.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ export interface GlobeKey {
5454

5555
export type Key = {
5656
flex?: string
57-
} & (NormalKey |
58-
EnterKey |
59-
BackspaceKey |
60-
ShiftKey |
61-
SpaceKey |
62-
GlobeKey |
63-
SymbolKey | {
57+
} & (NormalKey
58+
| EnterKey
59+
| BackspaceKey
60+
| ShiftKey
61+
| SpaceKey
62+
| GlobeKey
63+
| SymbolKey | {
6464
type: 'placeholder'
6565
})
6666

src/preset.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,15 @@
329329
background-color: white;
330330
box-shadow: 0 0 8px rgb(0 0 0 / 30%);
331331
position: fixed;
332+
max-height: 100cqh;
333+
overflow: scroll;
334+
}
335+
336+
.fcitx-keyboard-contextmenu hr {
337+
margin: 0;
338+
border: none;
339+
height: 1px;
340+
background-color: #7f7f7f;
332341
}
333342

334343
.fcitx-keyboard-contextmenu-item {

src/statusArea.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import HalfPunc from 'bundle-text:../svg/half-punc.svg'
55
import HalfWidth from 'bundle-text:../svg/half-width.svg'
66
import LightbulbOutline from 'bundle-text:../svg/lightbulb-outline.svg'
77
import Lightbulb from 'bundle-text:../svg/lightbulb.svg'
8+
import { showContextmenu } from './contextmenu'
89
import { div, getStatusArea, handleClick } from './util'
910
import { sendEvent } from './ux'
1011

1112
export function renderStatusArea() {
1213
return div('fcitx-keyboard-status-area')
1314
}
1415

15-
function getLabel(icon: string) {
16+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
17+
18+
function getLabel(icon: string, desc: string) {
1619
switch (icon) {
1720
case 'fcitx-chttrans-active':
1821
return '繁'
@@ -30,8 +33,10 @@ function getLabel(icon: string) {
3033
return Lightbulb
3134
case 'fcitx-remind-inactive':
3235
return LightbulbOutline
33-
default:
34-
return ''
36+
default: {
37+
const segmentData = Array.from(segmenter.segment(desc))
38+
return segmentData.length ? segmentData[0].segment : ''
39+
}
3540
}
3641
}
3742

@@ -41,10 +46,16 @@ export function setStatusArea(actions: StatusAreaAction[]) {
4146
for (const action of actions) {
4247
const button = div('fcitx-keyboard-status-area-container')
4348
const circle = div('fcitx-keyboard-status-area-circle')
44-
circle.innerHTML = getLabel(action.icon)
49+
circle.innerHTML = getLabel(action.icon, action.desc)
4550
handleClick(circle, () => {
4651
if (action.children) {
47-
// TODO: implement it for rime
52+
showContextmenu(circle, action.children.map(child => ({
53+
text: child.desc,
54+
separator: child.separator,
55+
callback: () => {
56+
sendEvent({ type: 'STATUS_AREA_ACTION', data: child.id })
57+
},
58+
})))
4859
}
4960
else {
5061
sendEvent({ type: 'STATUS_AREA_ACTION', data: action.id })

tests/test-status-area.spec.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Page } from '@playwright/test'
22
import { expect, test } from '@playwright/test'
3-
import { getKey, getSentEvents, getToolbarButton, init, sendSystemEvent, tapReturn } from './util'
3+
import { getBox, getKey, getSentEvents, getToolbarButton, init, sendSystemEvent, tapReturn } from './util'
44

55
function gotoStatusArea(page: Page) {
66
return getToolbarButton(page, 5).tap()
@@ -40,3 +40,56 @@ test('Return', async ({ page }) => {
4040
await tapReturn(page)
4141
await expect(q).toBeVisible()
4242
})
43+
44+
test('Multiple actions: align bottom', async ({ page }) => {
45+
await init(page)
46+
47+
await sendSystemEvent(page, {
48+
type: 'STATUS_AREA',
49+
data: [{ desc: '😁 → 😭', children: Array.from({ length: 5 }).map((_, i) => ({
50+
desc: `子项${i}`,
51+
icon: '',
52+
id: i,
53+
})), icon: '', id: -1 }],
54+
})
55+
await gotoStatusArea(page)
56+
57+
const button = page.locator('.fcitx-keyboard-status-area-circle')
58+
await expect(button).toHaveText('😁')
59+
60+
await button.tap()
61+
const contextmenu = page.locator('.fcitx-keyboard-contextmenu')
62+
await expect(contextmenu).toBeVisible()
63+
const box = await getBox(contextmenu)
64+
const containerBox = await getBox(page.locator('.fcitx-keyboard-container'))
65+
expect(box.y + box.height).toBeCloseTo(containerBox.y + containerBox.height, 1)
66+
})
67+
68+
test('Many actions: align top', async ({ page }) => {
69+
await init(page)
70+
71+
await sendSystemEvent(page, {
72+
type: 'STATUS_AREA',
73+
data: [{ desc: '有 → 无', children: Array.from({ length: 10 }).map((_, i) => ({
74+
desc: `子项${i}`,
75+
icon: '',
76+
id: i,
77+
})), icon: '', id: -1 }],
78+
})
79+
await gotoStatusArea(page)
80+
81+
const button = page.locator('.fcitx-keyboard-status-area-circle')
82+
await expect(button).toHaveText('有')
83+
84+
await button.tap()
85+
const contextmenu = page.locator('.fcitx-keyboard-contextmenu')
86+
await expect(contextmenu).toBeVisible()
87+
const box = await getBox(contextmenu)
88+
const containerBox = await getBox(page.locator('.fcitx-keyboard-container'))
89+
expect(box.y).toBeCloseTo(containerBox.y, 1)
90+
const lastItem = contextmenu.locator('.fcitx-keyboard-contextmenu-item').last()
91+
await expect(lastItem).not.toBeInViewport()
92+
93+
await contextmenu.evaluate(element => element.scrollBy(0, element.scrollHeight))
94+
await expect(lastItem).toBeInViewport()
95+
})

tests/util.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ export async function init(page: Page) {
1717
await page.goto(url)
1818
}
1919

20+
export async function getBox(locator: Locator) {
21+
return (await locator.boundingBox())!
22+
}
23+
2024
async function center(locator: Locator) {
21-
const box = (await locator.boundingBox())!
25+
const box = await getBox(locator)
2226
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
2327
}
2428

2529
async function isInside(point: { x: number, y: number }, locator: Locator) {
26-
const box = (await locator.boundingBox())!
30+
const box = await getBox(locator)
2731
return box.x <= point.x && point.x <= box.x + box.width && box.y <= point.y && point.y <= box.y + box.height
2832
}
2933

0 commit comments

Comments
 (0)