feat(stage-tamagotchi): adaptive controls island

This commit is contained in:
Neko Ayaka
2026-08-19 23:30:57 +08:00
parent ae170f1ee0
commit 702b4c862d
16 changed files with 719 additions and 87 deletions
+2 -1
View File
@@ -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)
}
@@ -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" />
@@ -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>
@@ -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',
})
})
})
@@ -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>
@@ -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 }
}
@@ -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
@@ -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>
@@ -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)
}