feat(stage-tamagotchi): adaptive controls island
This commit is contained in:
@@ -20,10 +20,11 @@ import { isMacOS } from 'std-env'
|
||||
import icon from '../../../resources/icon.png?asset'
|
||||
import macOSTrayIcon from '../../../resources/tray-icon-macos.png?asset'
|
||||
|
||||
import { findDominantDisplayArea } from '../../shared/utils/electron/display'
|
||||
import { onAppBeforeQuit } from '../libs/bootkit/lifecycle'
|
||||
import { setupInlayWindow } from '../windows/inlay'
|
||||
import { Animator } from '../windows/shared/animator'
|
||||
import { computeResizedBoundsAnchoredToDominantDisplay, findDominantDisplayArea } from '../windows/shared/display'
|
||||
import { computeResizedBoundsAnchoredToDominantDisplay } from '../windows/shared/display'
|
||||
import { toggleWindowShow } from '../windows/shared/window'
|
||||
|
||||
const RECOMMENDED_WIDTH = 450
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { BrowserWindow, Rectangle } from 'electron'
|
||||
|
||||
import type { DisplayArea } from '../../../shared/utils/electron/display'
|
||||
|
||||
import { screen } from 'electron'
|
||||
|
||||
import { findDominantDisplayArea } from '../../../shared/utils/electron/display'
|
||||
|
||||
export function currentDisplayBounds(window: BrowserWindow) {
|
||||
const bounds = window.getBounds()
|
||||
const nearbyDisplay = screen.getDisplayMatching(bounds)
|
||||
@@ -65,20 +69,13 @@ export function centerWindowOnDisplay(window: Pick<BrowserWindow, 'getBounds' |
|
||||
return centeredBounds
|
||||
}
|
||||
|
||||
export interface ResizableDisplayArea {
|
||||
/** Full display bounds used to decide which physical display owns most of a window. */
|
||||
bounds: Rectangle
|
||||
/** Usable display area used for quadrant anchoring and final window clamping. */
|
||||
workArea: Rectangle
|
||||
}
|
||||
|
||||
export interface DominantDisplayResizeOptions {
|
||||
/** Current window bounds in Electron display coordinates. */
|
||||
currentBounds: Rectangle
|
||||
/** Desired size before display work-area clamping. */
|
||||
targetSize: Pick<Rectangle, 'width' | 'height'>
|
||||
/** Displays from Electron screen APIs. */
|
||||
displays: readonly ResizableDisplayArea[]
|
||||
displays: readonly DisplayArea[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,43 +135,6 @@ export function computeResizedBoundsAnchoredToDominantDisplay(options: DominantD
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the display that owns the largest visible share of `bounds`.
|
||||
*/
|
||||
export function findDominantDisplayArea(bounds: Rectangle, displays: readonly ResizableDisplayArea[]): ResizableDisplayArea | undefined {
|
||||
let dominantDisplay: ResizableDisplayArea | undefined
|
||||
let dominantArea = -1
|
||||
|
||||
for (const display of displays) {
|
||||
// Use full display bounds, not workArea. Menu bars and docks shrink
|
||||
// workArea, but they should not change which physical display owns a
|
||||
// cross-screen window.
|
||||
const area = intersectionArea(bounds, display.bounds)
|
||||
if (area > dominantArea) {
|
||||
dominantDisplay = display
|
||||
dominantArea = area
|
||||
}
|
||||
}
|
||||
|
||||
return dominantDisplay
|
||||
}
|
||||
|
||||
function intersectionArea(a: Rectangle, b: Rectangle): number {
|
||||
// Each side of the overlap rectangle is the inner edge from the two source
|
||||
// rectangles. If the right edge crosses the left edge, or bottom crosses top,
|
||||
// the rectangles do not overlap.
|
||||
const left = Math.max(a.x, b.x)
|
||||
const top = Math.max(a.y, b.y)
|
||||
const right = Math.min(a.x + a.width, b.x + b.width)
|
||||
const bottom = Math.min(a.y + a.height, b.y + b.height)
|
||||
|
||||
if (right <= left || bottom <= top) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (right - left) * (bottom - top)
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
+21
-5
@@ -1,9 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||
import type { TooltipContentProps } from 'reka-ui'
|
||||
|
||||
const { side = 'top' } = defineProps<{
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
side?: TooltipContentProps['side'] | 'inward'
|
||||
}>(), {
|
||||
side: 'top',
|
||||
})
|
||||
|
||||
const { isLeft } = useControlsIslandPlacement()
|
||||
const resolvedSide = computed<NonNullable<TooltipContentProps['side']>>(() => {
|
||||
if (props.side === 'inward') {
|
||||
return isLeft.value ? 'right' : 'left'
|
||||
}
|
||||
|
||||
return props.side
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +40,7 @@ const { side = 'top' } = defineProps<{
|
||||
'rounded-lg backdrop-blur-md',
|
||||
'max-w-[min(18rem,calc(100vw-1rem))] break-words text-center text-xs leading-4 whitespace-normal',
|
||||
]"
|
||||
:side="side"
|
||||
:side="resolvedSide"
|
||||
:side-offset="4"
|
||||
>
|
||||
<slot name="tooltip" />
|
||||
|
||||
+11
-8
@@ -1,21 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProfileSwitcherPopoverProps } from '@proj-airi/stage-ui/components'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
|
||||
import { ProfileSwitcherPopover } from '@proj-airi/stage-ui/components'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { electronOpenSettings } from '../../../../shared/eventa'
|
||||
import { useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
placement: String as PropType<ProfileSwitcherPopoverProps['placement']>,
|
||||
})
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const openSettings = useElectronEventaInvoke(electronOpenSettings)
|
||||
const { isLeft, isTop } = useControlsIslandPlacement()
|
||||
const contentSide = computed(() => isTop.value ? 'bottom' : 'top')
|
||||
const contentAlign = computed(() => isLeft.value ? 'start' : 'end')
|
||||
|
||||
function handleManage() {
|
||||
openSettings({ route: '/settings/airi-card' })
|
||||
@@ -23,7 +21,12 @@ function handleManage() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProfileSwitcherPopover v-model:open="open" :placement="props.placement" @manage="handleManage">
|
||||
<ProfileSwitcherPopover
|
||||
v-model:open="open"
|
||||
:content-side="contentSide"
|
||||
:content-align="contentAlign"
|
||||
@manage="handleManage"
|
||||
>
|
||||
<template #default="{ open: popoverOpen, toggle, activeCard }">
|
||||
<slot :open="popoverOpen" :toggle="toggle" :active-card="activeCard" />
|
||||
</template>
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { Display, Rectangle } from 'electron'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, shallowRef } from 'vue'
|
||||
|
||||
import ControlsIslandRoot from './controls-island-root.vue'
|
||||
|
||||
import { resolveControlsIslandDock, useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
const primaryDisplay = {
|
||||
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
|
||||
workArea: { x: 0, y: 25, width: 1920, height: 1055 },
|
||||
} as Display
|
||||
|
||||
const displays = shallowRef([primaryDisplay])
|
||||
const windowBounds = {
|
||||
x: shallowRef(1370),
|
||||
y: shallowRef(430),
|
||||
width: shallowRef(450),
|
||||
height: shallowRef(600),
|
||||
}
|
||||
vi.mock('@proj-airi/electron-vueuse', () => ({
|
||||
useElectronAllDisplays: () => displays,
|
||||
useElectronWindowBounds: () => windowBounds,
|
||||
}))
|
||||
|
||||
const mountedApps: Array<{ host: HTMLElement, unmount: () => void }> = []
|
||||
|
||||
function resolve(windowBounds: Rectangle) {
|
||||
return resolveControlsIslandDock({
|
||||
displays: [primaryDisplay],
|
||||
previousDock: 'bottom-right',
|
||||
windowBounds,
|
||||
})
|
||||
}
|
||||
|
||||
function mountRoot() {
|
||||
const frozen = shallowRef(false)
|
||||
const ContextConsumer = defineComponent({
|
||||
setup() {
|
||||
const placement = useControlsIslandPlacement()
|
||||
|
||||
return () => h('output', {
|
||||
'data-dock': placement.dock.value,
|
||||
'data-phase': placement.motionPhase.value,
|
||||
})
|
||||
},
|
||||
})
|
||||
const host = document.createElement('div')
|
||||
const app = createApp({
|
||||
setup() {
|
||||
return () => h(ControlsIslandRoot, { frozen: frozen.value }, {
|
||||
default: () => h(ContextConsumer),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
document.body.appendChild(host)
|
||||
app.mount(host)
|
||||
mountedApps.push({
|
||||
host,
|
||||
unmount: () => app.unmount(),
|
||||
})
|
||||
|
||||
return { frozen, host }
|
||||
}
|
||||
|
||||
function readPlacement(host: HTMLElement) {
|
||||
const output = host.querySelector('[data-dock]')
|
||||
|
||||
return {
|
||||
dock: output?.getAttribute('data-dock'),
|
||||
phase: output?.getAttribute('data-phase'),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('matchMedia', vi.fn((query: string): MediaQueryList => ({
|
||||
addEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
removeEventListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
})))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const mounted of mountedApps) {
|
||||
mounted.unmount()
|
||||
mounted.host.remove()
|
||||
}
|
||||
mountedApps.length = 0
|
||||
displays.value = [primaryDisplay]
|
||||
windowBounds.x.value = 1370
|
||||
windowBounds.y.value = 430
|
||||
windowBounds.width.value = 450
|
||||
windowBounds.height.value = 600
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('resolveControlsIslandDock', () => {
|
||||
it('places the island in the top-left screen quadrant', () => {
|
||||
expect(resolve({ x: 100, y: 100, width: 450, height: 600 })).toBe('top-left')
|
||||
})
|
||||
|
||||
it('places the island in the top-right screen quadrant', () => {
|
||||
expect(resolve({ x: 1370, y: 100, width: 450, height: 600 })).toBe('top-right')
|
||||
})
|
||||
|
||||
it('places the island in the bottom-left screen quadrant', () => {
|
||||
expect(resolve({ x: 100, y: 430, width: 450, height: 600 })).toBe('bottom-left')
|
||||
})
|
||||
|
||||
it('places the island in the bottom-right screen quadrant', () => {
|
||||
expect(resolve({ x: 1370, y: 430, width: 450, height: 600 })).toBe('bottom-right')
|
||||
})
|
||||
|
||||
it('uses the display that contains the largest window area', () => {
|
||||
const secondaryDisplay = {
|
||||
bounds: { x: -1600, y: -900, width: 1600, height: 900 },
|
||||
workArea: { x: -1600, y: -900, width: 1600, height: 860 },
|
||||
} as Display
|
||||
|
||||
const dock = resolveControlsIslandDock({
|
||||
displays: [primaryDisplay, secondaryDisplay],
|
||||
previousDock: 'bottom-right',
|
||||
windowBounds: { x: -500, y: -300, width: 450, height: 600 },
|
||||
})
|
||||
|
||||
expect(dock).toBe('bottom-right')
|
||||
})
|
||||
|
||||
it('keeps the previous dock inside the display center dead zone', () => {
|
||||
const dock = resolveControlsIslandDock({
|
||||
displays: [primaryDisplay],
|
||||
previousDock: 'top-left',
|
||||
windowBounds: { x: 735, y: 253, width: 450, height: 600 },
|
||||
})
|
||||
|
||||
expect(dock).toBe('top-left')
|
||||
})
|
||||
|
||||
it('keeps the current dock until display data is available', () => {
|
||||
const dock = resolveControlsIslandDock({
|
||||
displays: [],
|
||||
previousDock: 'top-right',
|
||||
windowBounds: { x: 100, y: 100, width: 450, height: 600 },
|
||||
})
|
||||
|
||||
expect(dock).toBe('top-right')
|
||||
})
|
||||
|
||||
it('keeps the default dock until window bounds are available', () => {
|
||||
const dock = resolveControlsIslandDock({
|
||||
displays: [primaryDisplay],
|
||||
previousDock: 'bottom-right',
|
||||
windowBounds: { x: 0, y: 0, width: 0, height: 0 },
|
||||
})
|
||||
|
||||
expect(dock).toBe('bottom-right')
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlsIslandRoot', () => {
|
||||
it('changes corners one second after the last window movement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { host } = mountRoot()
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-right',
|
||||
phase: 'idle',
|
||||
})
|
||||
|
||||
windowBounds.x.value = 100
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
windowBounds.x.value = 120
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-right',
|
||||
phase: 'idle',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(readPlacement(host).phase).toBe('leaving')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-right',
|
||||
phase: 'leaving',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-left',
|
||||
phase: 'entering',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15)
|
||||
|
||||
expect(readPlacement(host).phase).toBe('entering')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(readPlacement(host).phase).toBe('arriving')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
|
||||
expect(readPlacement(host).phase).toBe('arriving')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(readPlacement(host).phase).toBe('idle')
|
||||
})
|
||||
|
||||
it('waits for an active Island interaction to end before it moves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { frozen, host } = mountRoot()
|
||||
|
||||
frozen.value = true
|
||||
windowBounds.x.value = 100
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-right',
|
||||
phase: 'idle',
|
||||
})
|
||||
|
||||
frozen.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(readPlacement(host).phase).toBe('leaving')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(315)
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-left',
|
||||
phase: 'arriving',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(readPlacement(host)).toEqual({
|
||||
dock: 'bottom-left',
|
||||
phase: 'idle',
|
||||
})
|
||||
})
|
||||
})
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import type { ControlsIslandDock, ControlsIslandMotionPhase, ControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
import { useElectronAllDisplays, useElectronWindowBounds } from '@proj-airi/electron-vueuse'
|
||||
import { refDebounced, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef, watch } from 'vue'
|
||||
|
||||
import { controlsIslandPlacementKey, resolveControlsIslandDock } from './use-controls-island-placement'
|
||||
|
||||
interface Props {
|
||||
/** Prevents the Island from moving while a user interacts with it. */
|
||||
frozen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineSlots<{
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const displays = useElectronAllDisplays()
|
||||
const windowBounds = useElectronWindowBounds()
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const dock = shallowRef<ControlsIslandDock>('bottom-right')
|
||||
const pendingDock = shallowRef<ControlsIslandDock>()
|
||||
const relocationTarget = shallowRef<ControlsIslandDock>()
|
||||
const motionPhase = shallowRef<ControlsIslandMotionPhase>('idle')
|
||||
|
||||
/** The window must stay still for this period before the Island changes corners. */
|
||||
const placementSettleDelayMs = 1000
|
||||
|
||||
/** The old corner fades out before the dock changes. */
|
||||
const placementLeaveDurationMs = 150
|
||||
|
||||
/** One frame keeps the new corner hidden before its entrance starts. */
|
||||
const placementEnterPreparationMs = 16
|
||||
|
||||
/** The new corner fades in and moves into its resting position. */
|
||||
const placementArrivalDurationMs = 150
|
||||
|
||||
const { start: finishArrival, stop: stopArrival } = useTimeoutFn(() => {
|
||||
motionPhase.value = 'idle'
|
||||
relocationTarget.value = undefined
|
||||
}, placementArrivalDurationMs, { immediate: false })
|
||||
|
||||
const { start: startArrival, stop: stopEnterPreparation } = useTimeoutFn(() => {
|
||||
motionPhase.value = 'arriving'
|
||||
finishArrival()
|
||||
}, placementEnterPreparationMs, { immediate: false })
|
||||
|
||||
const { start: finishLeave, stop: stopLeave } = useTimeoutFn(() => {
|
||||
if (!relocationTarget.value) {
|
||||
motionPhase.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
dock.value = relocationTarget.value
|
||||
motionPhase.value = 'entering'
|
||||
startArrival()
|
||||
}, placementLeaveDurationMs, { immediate: false })
|
||||
|
||||
function stopRelocation() {
|
||||
stopLeave()
|
||||
stopEnterPreparation()
|
||||
stopArrival()
|
||||
}
|
||||
|
||||
function relocate(nextDock: ControlsIslandDock) {
|
||||
stopRelocation()
|
||||
|
||||
if (nextDock === dock.value) {
|
||||
relocationTarget.value = undefined
|
||||
motionPhase.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
if (preferredMotion.value === 'reduce') {
|
||||
dock.value = nextDock
|
||||
relocationTarget.value = undefined
|
||||
motionPhase.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
relocationTarget.value = nextDock
|
||||
motionPhase.value = 'leaving'
|
||||
finishLeave()
|
||||
}
|
||||
|
||||
const liveWindowBounds = computed(() => ({
|
||||
x: windowBounds.x.value,
|
||||
y: windowBounds.y.value,
|
||||
width: windowBounds.width.value,
|
||||
height: windowBounds.height.value,
|
||||
}))
|
||||
const settledWindowBounds = refDebounced(liveWindowBounds, placementSettleDelayMs)
|
||||
const candidateDock = computed(() => resolveControlsIslandDock({
|
||||
displays: displays.value,
|
||||
previousDock: dock.value,
|
||||
windowBounds: settledWindowBounds.value,
|
||||
}))
|
||||
|
||||
watch(candidateDock, (nextDock) => {
|
||||
if (props.frozen) {
|
||||
pendingDock.value = nextDock
|
||||
return
|
||||
}
|
||||
|
||||
relocate(nextDock)
|
||||
pendingDock.value = undefined
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.frozen, (frozen) => {
|
||||
if (frozen || !pendingDock.value) {
|
||||
return
|
||||
}
|
||||
|
||||
relocate(pendingDock.value)
|
||||
pendingDock.value = undefined
|
||||
})
|
||||
|
||||
const isLeft = computed(() => dock.value.endsWith('left'))
|
||||
const isTop = computed(() => dock.value.startsWith('top'))
|
||||
const placement: ControlsIslandPlacement = {
|
||||
dock,
|
||||
isLeft,
|
||||
isTop,
|
||||
motionPhase,
|
||||
}
|
||||
|
||||
provide(controlsIslandPlacementKey, placement)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
+12
-1
@@ -1,11 +1,21 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import { createApp, h, nextTick, shallowRef } from 'vue'
|
||||
|
||||
import ControlsIslandStopSpeaking from './controls-island-stop-speaking.vue'
|
||||
|
||||
import { controlsIslandPlacementKey } from './use-controls-island-placement'
|
||||
|
||||
const nowSpeakingRef = { value: false }
|
||||
const stopAllSpeakingMock = vi.fn()
|
||||
const placement: ControlsIslandPlacement = {
|
||||
dock: shallowRef('bottom-right'),
|
||||
isLeft: shallowRef(false),
|
||||
isTop: shallowRef(false),
|
||||
motionPhase: shallowRef('idle'),
|
||||
}
|
||||
|
||||
vi.mock('@proj-airi/stage-ui/stores/audio', () => ({
|
||||
useSpeakingStore: () => ({
|
||||
@@ -48,6 +58,7 @@ describe('controlsIslandStopSpeaking', () => {
|
||||
iconClass: 'size-5',
|
||||
}),
|
||||
})
|
||||
app.provide(controlsIslandPlacementKey, placement)
|
||||
app.mount(host)
|
||||
return { host, app }
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ const { stopAllSpeaking } = useStopSpeakingButton()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlButton :button-style @click="stopAllSpeaking()">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
|
||||
+88
-16
@@ -6,7 +6,7 @@ import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/
|
||||
import { useTheme } from '@proj-airi/ui'
|
||||
import { refDebounced, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import StatusIsland from '../status-island/index.vue'
|
||||
@@ -28,9 +28,18 @@ import {
|
||||
electronStartDraggingWindow,
|
||||
electronWindowSetAlwaysOnTop,
|
||||
} from '../../../../shared/eventa'
|
||||
import { useControlsIslandPlacement } from './use-controls-island-placement'
|
||||
|
||||
interface Emits {
|
||||
/** Reports whether an active interaction must delay placement changes. */
|
||||
interactionChange: [active: boolean]
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const { dock, isLeft, isTop, motionPhase } = useControlsIslandPlacement()
|
||||
|
||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||
const settingsStore = useSettings()
|
||||
@@ -45,7 +54,7 @@ const setAlwaysOnTop = useElectronEventaInvoke(electronWindowSetAlwaysOnTop)
|
||||
const centerMainWindow = useElectronEventaInvoke(electronCenterMainWindow)
|
||||
|
||||
const expanded = ref(false)
|
||||
const islandRef = ref<HTMLElement>()
|
||||
const islandElement = useTemplateRef<HTMLElement>('island')
|
||||
|
||||
// Tracks open overlays/dialogs that should prevent auto-collapse (e.g. 'hearing', 'profile-picker')
|
||||
const blockingOverlays = reactive(new Set<string>())
|
||||
@@ -60,13 +69,14 @@ function setOverlay(key: string, active: boolean) {
|
||||
blockingOverlays.delete(key)
|
||||
}
|
||||
|
||||
// Expose for parent (e.g. to disable click-through when a dialog is open)
|
||||
// The stage page observes this element for cursor hit testing.
|
||||
defineExpose({
|
||||
get element() { return islandElement.value },
|
||||
get hearingDialogOpen() { return blockingOverlays.has('hearing') },
|
||||
set hearingDialogOpen(v: boolean) { setOverlay('hearing', v) },
|
||||
})
|
||||
|
||||
const { isOutside } = useElectronMouseInElement(islandRef)
|
||||
const { isOutside } = useElectronMouseInElement(islandElement)
|
||||
const isOutsideAfter2seconds = refDebounced(isOutside, 1500)
|
||||
|
||||
watch(isOutsideAfter2seconds, (outside) => {
|
||||
@@ -81,6 +91,10 @@ watch(expanded, (isExpanded) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([expanded, isBlocked], ([isExpanded, isInteractionBlocked]) => {
|
||||
emit('interactionChange', isExpanded || isInteractionBlocked)
|
||||
}, { immediate: true })
|
||||
|
||||
useIntervalFn(() => {
|
||||
if (expanded.value && isOutside.value && !isBlocked.value) {
|
||||
expanded.value = false
|
||||
@@ -126,6 +140,45 @@ const adjustStyleClasses = computed(() => {
|
||||
return { icon, border, padding, button: `${border} ${padding}` }
|
||||
})
|
||||
|
||||
const islandPositionClasses = computed(() => [
|
||||
isTop.value ? 'top-2' : 'bottom-2',
|
||||
isLeft.value ? 'left-2' : 'right-2',
|
||||
])
|
||||
const islandMotionClasses = computed(() => {
|
||||
const isHidden = motionPhase.value === 'leaving' || motionPhase.value === 'entering'
|
||||
|
||||
return [
|
||||
motionPhase.value === 'entering'
|
||||
? 'transition-none'
|
||||
: 'transition-[opacity,transform] duration-200 ease-out',
|
||||
motionPhase.value === 'idle' ? '' : 'will-change-[opacity,transform] pointer-events-none',
|
||||
isHidden ? 'opacity-0 scale-95' : 'opacity-100 scale-100',
|
||||
isHidden && isLeft.value ? '-translate-x-3' : '',
|
||||
isHidden && !isLeft.value ? 'translate-x-3' : '',
|
||||
isHidden && isTop.value ? '-translate-y-2' : '',
|
||||
isHidden && !isTop.value ? 'translate-y-2' : '',
|
||||
]
|
||||
})
|
||||
const islandLayoutClasses = computed(() => [
|
||||
isTop.value ? 'flex-col-reverse' : 'flex-col',
|
||||
isLeft.value ? 'items-start' : 'items-end',
|
||||
])
|
||||
const mainControlsLayoutClasses = computed(() => [
|
||||
'flex gap-1',
|
||||
isTop.value ? 'flex-col-reverse' : 'flex-col',
|
||||
])
|
||||
const panelPositionClasses = computed(() => {
|
||||
if (dock.value === 'top-left')
|
||||
return ['mt-2', 'origin-top-left']
|
||||
if (dock.value === 'top-right')
|
||||
return ['mt-2', 'origin-top-right']
|
||||
if (dock.value === 'bottom-left')
|
||||
return ['mb-2', 'origin-bottom-left']
|
||||
|
||||
return ['mb-2', 'origin-bottom-right']
|
||||
})
|
||||
const panelHiddenTransformClass = computed(() => isTop.value ? '-translate-y-8' : 'translate-y-8')
|
||||
|
||||
/**
|
||||
* This is a know issue (or expected behavior maybe) to Electron.
|
||||
* We don't use this approach on Linux because it's not working.
|
||||
@@ -147,16 +200,35 @@ function resetMainWindowPosition() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="islandRef" fixed bottom-2 right-2>
|
||||
<div flex flex-col items-end gap-1>
|
||||
<div
|
||||
ref="island"
|
||||
:class="[
|
||||
'fixed',
|
||||
islandPositionClasses,
|
||||
islandMotionClasses,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex gap-1',
|
||||
islandLayoutClasses,
|
||||
]"
|
||||
>
|
||||
<!-- iOS Style Drawer Panel -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-500 cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
leave-active-class="transition-all duration-400 cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
enter-from-class="opacity-0 translate-y-8 scale-90 blur-sm"
|
||||
leave-to-class="opacity-0 translate-y-8 scale-90 blur-sm"
|
||||
:enter-from-class="`opacity-0 ${panelHiddenTransformClass} scale-90 blur-sm`"
|
||||
:leave-to-class="`opacity-0 ${panelHiddenTransformClass} scale-90 blur-sm`"
|
||||
>
|
||||
<div v-if="expanded" border="1 neutral-200 dark:neutral-800" mb-2 flex flex-col gap-1 rounded-2xl p-2 backdrop-blur-xl class="bg-neutral-100/80 shadow-2xl shadow-black/20 dark:bg-neutral-900/80">
|
||||
<div
|
||||
v-if="expanded"
|
||||
:class="[
|
||||
'flex flex-col gap-1 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-800',
|
||||
'bg-neutral-100/80 shadow-2xl shadow-black/20 backdrop-blur-xl dark:bg-neutral-900/80',
|
||||
panelPositionClasses,
|
||||
]"
|
||||
>
|
||||
<ControlsIslandAuthButton
|
||||
:button-style="adjustStyleClasses.button"
|
||||
:icon-class="adjustStyleClasses.icon"
|
||||
@@ -178,7 +250,7 @@ function resetMainWindowPosition() {
|
||||
</ControlButtonTooltip>
|
||||
|
||||
<ControlButtonTooltip disable-hoverable-content>
|
||||
<ControlsIslandProfilePicker placement="up" :open="blockingOverlays.has('profile-picker')" @update:open="setOverlay('profile-picker', $event)">
|
||||
<ControlsIslandProfilePicker :open="blockingOverlays.has('profile-picker')" @update:open="setOverlay('profile-picker', $event)">
|
||||
<template #default="{ toggle }">
|
||||
<ControlButton
|
||||
v-track-button="{ name: 'controls_island_action', action: 'toggle_profile_picker' }"
|
||||
@@ -283,8 +355,8 @@ function resetMainWindowPosition() {
|
||||
</Transition>
|
||||
|
||||
<!-- Main Controls -->
|
||||
<div flex flex-col gap-1>
|
||||
<ControlButtonTooltip side="left">
|
||||
<div :class="mainControlsLayoutClasses">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlButton
|
||||
v-track-button="{
|
||||
name: 'controls_island_action',
|
||||
@@ -295,7 +367,7 @@ function resetMainWindowPosition() {
|
||||
@click="toggleControls"
|
||||
>
|
||||
<div
|
||||
:class="[adjustStyleClasses.icon, expanded ? 'rotate-180' : 'rotate-0']"
|
||||
:class="[adjustStyleClasses.icon, isTop !== expanded ? 'rotate-180' : 'rotate-0']"
|
||||
i-solar:alt-arrow-up-line-duotone scale-110 transition-all duration-300
|
||||
text="neutral-800 dark:neutral-300"
|
||||
/>
|
||||
@@ -311,7 +383,7 @@ function resetMainWindowPosition() {
|
||||
:icon-class="adjustStyleClasses.icon"
|
||||
/>
|
||||
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlButton
|
||||
v-track-button="{ name: 'controls_island_action', action: 'toggle_chat' }"
|
||||
:button-style="adjustStyleClasses.button"
|
||||
@@ -325,7 +397,7 @@ function resetMainWindowPosition() {
|
||||
</template>
|
||||
</ControlButtonTooltip>
|
||||
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlsIslandHearingConfig :show="blockingOverlays.has('hearing')" @update:show="setOverlay('hearing', $event)">
|
||||
<div class="relative">
|
||||
<ControlButton :button-style="adjustStyleClasses.button">
|
||||
@@ -346,7 +418,7 @@ function resetMainWindowPosition() {
|
||||
:icon-class="adjustStyleClasses.icon"
|
||||
/>
|
||||
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlButton :button-style="adjustStyleClasses.button" cursor-move :class="{ 'drag-region': isLinux }" @mousedown="startDraggingWindow?.()">
|
||||
<div i-ph:arrows-out-cardinal :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
|
||||
</ControlButton>
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import type { Rectangle } from 'electron'
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { DisplayArea } from '../../../../shared/utils/electron/display'
|
||||
|
||||
import { inject } from 'vue'
|
||||
|
||||
import { findDominantDisplayArea } from '../../../../shared/utils/electron/display'
|
||||
|
||||
/** A corner of the AIRI window where the Controls Island can dock. */
|
||||
export type ControlsIslandDock = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||
|
||||
/** Inputs for the Controls Island quadrant policy. */
|
||||
export interface ResolveControlsIslandDockOptions {
|
||||
/** Available displays in Electron logical coordinates. */
|
||||
displays: readonly DisplayArea[]
|
||||
/** Dock that remains active while display data is missing or the window is near the display center. */
|
||||
previousDock: ControlsIslandDock
|
||||
/** AIRI window bounds in Electron logical coordinates. Zero width or height means that the bounds are not available. */
|
||||
windowBounds: Rectangle
|
||||
}
|
||||
|
||||
/** The half-width of the center band that prevents repeated flips near an axis. */
|
||||
const displayCenterDeadZoneRatio = 0.05
|
||||
|
||||
/**
|
||||
* Resolves the window corner that matches the current display quadrant.
|
||||
*
|
||||
* The screen geometry stays in Electron logical coordinates. The returned
|
||||
* dock contains no DOM coordinates, so display scaling cannot affect layout.
|
||||
*/
|
||||
export function resolveControlsIslandDock(options: ResolveControlsIslandDockOptions): ControlsIslandDock {
|
||||
if (options.windowBounds.width <= 0 || options.windowBounds.height <= 0) {
|
||||
return options.previousDock
|
||||
}
|
||||
|
||||
const display = findDominantDisplayArea(options.windowBounds, options.displays)
|
||||
if (!display) {
|
||||
return options.previousDock
|
||||
}
|
||||
|
||||
const windowCenterX = options.windowBounds.x + options.windowBounds.width / 2
|
||||
const windowCenterY = options.windowBounds.y + options.windowBounds.height / 2
|
||||
const displayCenterX = display.workArea.x + display.workArea.width / 2
|
||||
const displayCenterY = display.workArea.y + display.workArea.height / 2
|
||||
const horizontalDeadZone = display.workArea.width * displayCenterDeadZoneRatio
|
||||
const verticalDeadZone = display.workArea.height * displayCenterDeadZoneRatio
|
||||
|
||||
let horizontalDock: 'left' | 'right' = options.previousDock.endsWith('left') ? 'left' : 'right'
|
||||
let verticalDock: 'top' | 'bottom' = options.previousDock.startsWith('top') ? 'top' : 'bottom'
|
||||
|
||||
if (windowCenterX < displayCenterX - horizontalDeadZone) {
|
||||
horizontalDock = 'left'
|
||||
}
|
||||
else if (windowCenterX > displayCenterX + horizontalDeadZone) {
|
||||
horizontalDock = 'right'
|
||||
}
|
||||
|
||||
if (windowCenterY < displayCenterY - verticalDeadZone) {
|
||||
verticalDock = 'top'
|
||||
}
|
||||
else if (windowCenterY > displayCenterY + verticalDeadZone) {
|
||||
verticalDock = 'bottom'
|
||||
}
|
||||
|
||||
if (verticalDock === 'top') {
|
||||
return horizontalDock === 'left' ? 'top-left' : 'top-right'
|
||||
}
|
||||
|
||||
return horizontalDock === 'left' ? 'bottom-left' : 'bottom-right'
|
||||
}
|
||||
|
||||
/** Visual phase for a Controls Island corner change. */
|
||||
export type ControlsIslandMotionPhase = 'idle' | 'leaving' | 'entering' | 'arriving'
|
||||
|
||||
/** Placement state shared by the Controls Island and its anchored surfaces. */
|
||||
export interface ControlsIslandPlacement {
|
||||
/** Current corner inside the AIRI window. */
|
||||
dock: Readonly<Ref<ControlsIslandDock>>
|
||||
/** True when the Island uses the left edge of the AIRI window. */
|
||||
isLeft: Readonly<Ref<boolean>>
|
||||
/** True when the Island uses the top edge of the AIRI window. */
|
||||
isTop: Readonly<Ref<boolean>>
|
||||
/** Current phase of the fade and move animation. */
|
||||
motionPhase: Readonly<Ref<ControlsIslandMotionPhase>>
|
||||
}
|
||||
|
||||
/** Placement contract provided by the Controls Island root. */
|
||||
export const controlsIslandPlacementKey: InjectionKey<ControlsIslandPlacement> = Symbol('controls-island-placement')
|
||||
|
||||
/** Returns the placement from the nearest Controls Island root. */
|
||||
export function useControlsIslandPlacement(): ControlsIslandPlacement {
|
||||
const placement = inject(controlsIslandPlacementKey)
|
||||
if (!placement) {
|
||||
throw new Error('useControlsIslandPlacement() requires a parent ControlsIslandRoot')
|
||||
}
|
||||
|
||||
return placement
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const tooltipLabel = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ControlButtonTooltip side="left">
|
||||
<ControlButtonTooltip side="inward">
|
||||
<ControlButton
|
||||
:button-style="props.buttonStyle"
|
||||
:aria-label="tooltipLabel"
|
||||
|
||||
@@ -35,6 +35,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ControlsIslandRoot from '../components/stage-islands/controls-island/controls-island-root.vue'
|
||||
import ControlsIsland from '../components/stage-islands/controls-island/index.vue'
|
||||
import ResourceStatusIsland from '../components/stage-islands/resource-status-island/index.vue'
|
||||
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
} from '../utils/voice-input-suppression'
|
||||
|
||||
const controlsIslandRef = ref<InstanceType<typeof ControlsIsland>>()
|
||||
const controlsIslandInteractionActive = shallowRef(false)
|
||||
const controlsIslandElement = toRef(() => controlsIslandRef.value?.element)
|
||||
const widgetStageRef = ref<InstanceType<typeof WidgetStage>>()
|
||||
const stageCanvas = toRef(() => widgetStageRef.value?.canvasElement())
|
||||
const componentStateStage = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
@@ -65,7 +68,7 @@ const onboardingStore = useOnboardingStore()
|
||||
const openOnboarding = useElectronEventaInvoke(electronOpenOnboarding)
|
||||
|
||||
const { isOutside: isOutsideWindow } = useElectronMouseInWindow()
|
||||
const { isOutside } = useElectronMouseInElement(controlsIslandRef)
|
||||
const { isOutside } = useElectronMouseInElement(controlsIslandElement)
|
||||
const isOutsideFor250Ms = refDebounced(isOutside, 250)
|
||||
const { x: relativeMouseX, y: relativeMouseY } = useElectronRelativeMouse()
|
||||
// NOTICE: In real-world use cases of Fade on Hover feature, the cursor may move around the edge of the
|
||||
@@ -822,7 +825,12 @@ const cursorPosition = computed(() => ({
|
||||
:paused="stagePaused"
|
||||
/>
|
||||
<HoloCoupon />
|
||||
<ControlsIsland ref="controlsIslandRef" />
|
||||
<ControlsIslandRoot :frozen="controlsIslandInteractionActive">
|
||||
<ControlsIsland
|
||||
ref="controlsIslandRef"
|
||||
@interaction-change="controlsIslandInteractionActive = $event"
|
||||
/>
|
||||
</ControlsIslandRoot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading overlay sits on top, does not hide the stage -->
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Rectangle } from 'electron'
|
||||
|
||||
/** Display geometry that is safe to use in main and renderer processes. */
|
||||
export interface DisplayArea {
|
||||
/** Full bounds used to decide which display owns a cross-screen window. */
|
||||
bounds: Rectangle
|
||||
/** Usable bounds that exclude the system menu bar, dock, or taskbar. */
|
||||
workArea: Rectangle
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the display that owns the largest visible share of a window.
|
||||
*/
|
||||
export function findDominantDisplayArea(bounds: Rectangle, displays: readonly DisplayArea[]): DisplayArea | undefined {
|
||||
let dominantDisplay: DisplayArea | undefined
|
||||
let dominantArea = -1
|
||||
|
||||
for (const display of displays) {
|
||||
// Full display bounds identify the physical display. System UI must not
|
||||
// change display ownership for a window that crosses the work-area edge.
|
||||
const area = intersectionArea(bounds, display.bounds)
|
||||
if (area > dominantArea) {
|
||||
dominantDisplay = display
|
||||
dominantArea = area
|
||||
}
|
||||
}
|
||||
|
||||
return dominantDisplay
|
||||
}
|
||||
|
||||
function intersectionArea(a: Rectangle, b: Rectangle): number {
|
||||
const left = Math.max(a.x, b.x)
|
||||
const top = Math.max(a.y, b.y)
|
||||
const right = Math.min(a.x + a.width, b.x + b.width)
|
||||
const bottom = Math.min(a.y + a.height, b.y + b.height)
|
||||
|
||||
if (right <= left || bottom <= top) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (right - left) * (bottom - top)
|
||||
}
|
||||
Reference in New Issue
Block a user