Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5d39a93
feat(stage-tamagotchi): add auto-hide controls island settings
leaft Mar 29, 2026
bcdbad7
feat(stage-tamagotchi): watch show delay and hide delay
leaft Mar 29, 2026
2f5562b
feat(settings): add auto-hide opacity setting for controls island
leaft Mar 31, 2026
98981fd
feat(settings): remove controls island icon size setting and simplify…
leaft Mar 31, 2026
91c6ad2
feat(settings): update auto-hide controls island setting to use isSta…
leaft Mar 31, 2026
a2b65bc
feat(tests): add unit tests for settings-controls-island store
leaft Mar 31, 2026
a41440a
feat(settings): refactor auto-hide logic using refDebounced for impro…
leaft Mar 31, 2026
b944bd8
feat(settings): update auto-hide logic to use computed for dynamic de…
leaft Mar 31, 2026
0597d3f
feat(settings): update auto-hide delay logic to use configurable sett…
leaft Mar 31, 2026
c29304c
feat(stage-ui): remove redundant background color class from island div
leaft Apr 1, 2026
577b945
feat(controls-island): refactor auto-hide logic to use useTimeoutFn f…
leaft Apr 1, 2026
a0e185e
feat(controls-island): implement auto-hide and auto-collapse composab…
leaft Apr 1, 2026
72492c1
fix(controls-island): correct import name for auto-hide composable
leaft Apr 1, 2026
4c4ea5c
feat(tests): add unit tests for useControlsIslandAutoHide and useCont…
leaft Apr 1, 2026
33ea17a
refactor(useControlsIslandAutoHide): remove unused variables and stre…
leaft Apr 1, 2026
6018619
feat(controls-island): add useControlsIslandCollapse composable and r…
leaft Apr 1, 2026
a76fb38
[claudesquad] update from 'beautify-feat' on 02 Apr 26 15:47 CST (pau…
leaft Apr 2, 2026
9d35be2
feat(settings): expose auto-hide controls island settings in deprecat…
leaft Apr 2, 2026
7773b30
fix(controls-island): prevent flicker during quick mouse movements
leaft Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { defineInvoke } from '@moeru/eventa'
import { useElectronEventaContext, useElectronEventaInvoke, useElectronMouseInElement } from '@proj-airi/electron-vueuse'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettings, useSettingsAudioDevice, useSettingsControlsIsland } from '@proj-airi/stage-ui/stores/settings'
import { useTheme } from '@proj-airi/ui'
import { refDebounced, useIntervalFn } from '@vueuse/core'
import { useIntervalFn } from '@vueuse/core'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

建议保留 refDebounced 的导入。它在处理基于延迟的状态变化(如自动折叠菜单)时,比手动计时或轮询更简洁且精确。

import { refDebounced, useIntervalFn } from '@vueuse/core'

import { storeToRefs } from 'pinia'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
Expand All @@ -29,9 +29,11 @@ const { t } = useI18n()

const settingsAudioDeviceStore = useSettingsAudioDevice()
const settingsStore = useSettings()
const settingsControlsIslandStore = useSettingsControlsIsland()
const context = useElectronEventaContext()
const { enabled } = storeToRefs(settingsAudioDeviceStore)
const { alwaysOnTop, controlsIslandIconSize } = storeToRefs(settingsStore)
const { alwaysOnTop } = storeToRefs(settingsStore)
const { autoHideControlsIsland, autoHideDelay, autoShowDelay, autoHideOpacity } = storeToRefs(settingsControlsIslandStore)
const openSettings = useElectronEventaInvoke(electronOpenSettings)
const openChat = useElectronEventaInvoke(electronOpenChat)
const isLinux = useElectronEventaInvoke(electron.app.isLinux)
Expand All @@ -46,7 +48,10 @@ const blockingOverlays = reactive(new Set<string>())
const isBlocked = computed(() => blockingOverlays.size > 0)

function setOverlay(key: string, active: boolean) {
active ? blockingOverlays.add(key) : blockingOverlays.delete(key)
if (active)
blockingOverlays.add(key)
else
blockingOverlays.delete(key)
}

// Expose for parent (e.g. to disable click-through when a dialog is open)
Expand All @@ -56,9 +61,64 @@ defineExpose({
})

const { isOutside } = useElectronMouseInElement(islandRef)
const isOutsideAfter2seconds = refDebounced(isOutside, 1500)

watch(isOutsideAfter2seconds, (outside) => {
// Auto-hide logic with configurable delays
const autoHideDelayMs = computed(() => autoHideDelay.value * 1000)
const autoShowDelayMs = computed(() => autoShowDelay.value * 1000)

// Track time since mouse left/entered
const timeSinceOutside = ref(0)
const timeSinceInside = ref(0)

let lastUpdateTime = Date.now()

// Update time tracking
useIntervalFn(() => {
const now = Date.now()
const delta = now - lastUpdateTime
lastUpdateTime = now

if (isOutside.value) {
timeSinceOutside.value += delta
timeSinceInside.value = 0
}
else {
timeSinceInside.value += delta
timeSinceOutside.value = 0
}
}, 100)

// Calculate opacity when hidden (0-100 range converted to 0-1)
const hiddenOpacity = computed(() => autoHideOpacity.value / 100)

// Auto-hide: hide controls island when mouse leaves after delay
// Auto-show: show controls island when mouse enters after delay
const isHidden = computed(() => {
if (!autoHideControlsIsland.value)
return false

// Don't hide if there's a blocking overlay or expanded panel should stay
if (isBlocked.value || expanded.value)
return false

// When mouse is inside, always show immediately (unless show delay is set)
if (!isOutside.value) {
if (autoShowDelay.value > 0) {
// Wait for mouse to be inside for the configured delay before showing
return timeSinceInside.value < autoShowDelayMs.value
}
return false
}

// When mouse is outside, hide after delay
return timeSinceOutside.value >= autoHideDelayMs.value
})

watch(isOutside, (outside) => {
lastUpdateTime = Date.now()
timeSinceOutside.value = 0
timeSinceInside.value = 0

if (outside && expanded.value && !isBlocked.value) {
expanded.value = false
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

此处的 watch 逻辑在鼠标移出时立即将 expanded 设为 false,这会导致菜单立即折叠,忽略了用户设置的 autoHideDelay 延迟。建议移除此处的立即折叠逻辑,并恢复使用 refDebounced 来实现符合预期的延迟折叠行为。

watch(isOutside, () => {
  lastUpdateTime = Date.now()
  timeSinceOutside.value = 0
  timeSinceInside.value = 0
})

const isOutsideAfterDelay = refDebounced(isOutside, autoHideDelayMs)
watch(isOutsideAfterDelay, (outside) => {
  if (outside && expanded.value && !isBlocked.value) {
    expanded.value = false
  }
})

Expand All @@ -74,7 +134,7 @@ useIntervalFn(() => {
if (expanded.value && isOutside.value && !isBlocked.value) {
expanded.value = false
}
}, 1500)
}, () => autoHideDelayMs.value || 5000)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

如果采用了基于 refDebounced 的监听逻辑,这个 useIntervalFn 将变得冗余。此外,使用轮询来实现延迟逻辑不够精确,建议清理。


// Apply alwaysOnTop on mount and when it changes
watch(alwaysOnTop, (val) => {
Expand All @@ -85,31 +145,13 @@ function toggleAlwaysOnTop() {
alwaysOnTop.value = !alwaysOnTop.value
}

// Grouped classes for icon / border / padding and combined style class
const adjustStyleClasses = computed(() => {
let isLarge: boolean

// Determine size based on setting
switch (controlsIslandIconSize.value) {
case 'large':
isLarge = true
break
case 'small':
isLarge = false
break
case 'auto':
default:
// Fixed to large for better visibility in the new layout,
// can be changed to windowHeight based check if absolutely needed.
isLarge = true
break
}

const icon = isLarge ? 'size-5' : 'size-3'
const border = isLarge ? 'border-2' : 'border-0'
const padding = isLarge ? 'p-2' : 'p-0.5'
return { icon, border, padding, button: `${border} ${padding}` }
})
// Static style classes for icon / border / padding
const adjustStyleClasses = {
icon: 'size-5',
border: 'border-2',
padding: 'p-2',
button: 'border-2 p-2',
}

/**
* This is a know issue (or expected behavior maybe) to Electron.
Expand All @@ -125,7 +167,14 @@ function refreshWindow() {
</script>

<template>
<div ref="islandRef" fixed bottom-2 right-2>
<div
ref="islandRef"
fixed bottom-2 right-2
:style="autoHideControlsIsland ? { opacity: isHidden ? hiddenOpacity : 1 } : {}"
:class="[
autoHideControlsIsland ? 'transition-opacity duration-300' : '',
]"
>
<div flex flex-col items-end gap-1>
<!-- iOS Style Drawer Panel -->
<Transition
Expand Down
76 changes: 58 additions & 18 deletions packages/stage-pages/src/components/settings-general-fields.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
<script setup lang="ts">
import { all } from '@proj-airi/i18n'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldCombobox, useTheme } from '@proj-airi/ui'
import { useSettings, useSettingsControlsIsland } from '@proj-airi/stage-ui/stores/settings'
import { FieldCheckbox, FieldCombobox, FieldRange, useTheme } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

const props = withDefaults(defineProps<{
needsControlsIslandIconSizeSetting?: boolean
needsAutoHideControlsIslandSetting?: boolean
}>(), {
needsControlsIslandIconSizeSetting: import.meta.env.RUNTIME_ENVIRONMENT === 'electron',
needsAutoHideControlsIslandSetting: isStageTamagotchi(),
})

const settings = useSettings()
const settingsControlsIsland = useSettingsControlsIsland()

const showControlsIsland = computed(() => props.needsControlsIslandIconSizeSetting)
const showAutoHideControlsIsland = computed(() => props.needsAutoHideControlsIslandSetting)
const showAnalyticsSettings = computed(() => isPosthogAvailableInBuild())
const analyticsToggleValue = computed({
get: () => showAnalyticsSettings.value ? settings.analyticsEnabled : false,
Expand Down Expand Up @@ -59,24 +61,62 @@ const languages = computed(() => {
:options="languages"
/>

<FieldCombobox
v-if="showControlsIsland"
v-model="settings.controlsIslandIconSize"
<FieldCheckbox
v-if="showAutoHideControlsIsland"
v-model="settingsControlsIsland.autoHideControlsIsland"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (4 * 10)"
:delay="4 * 50"
:class="['transition-all', 'ease-in-out', 'duration-250']"
:label="t('settings.controls-island.icon-size.title')"
:description="t('settings.controls-island.icon-size.description')"
:options="[
{ value: 'auto', label: t('settings.controls-island.icon-size.auto') },
{ value: 'large', label: t('settings.controls-island.icon-size.large') },
{ value: 'small', label: t('settings.controls-island.icon-size.small') },
]"
:duration="250 + (5 * 10)"
:delay="5 * 50"
label="Auto-hide controls island"
description="Hide controls island after mouse leaves, show after mouse enters"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处使用了硬编码的字符串。为了保持项目的一致性并支持多语言,请使用 t() 函数配合 i18n 翻译键。

      :label="t('settings.controls-island.auto-hide.title')"
      :description="t('settings.controls-island.auto-hide.description')"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这里的 labeldescription 使用了硬编码的英文文本。建议使用 t() 函数并引用语言包中的键值。但请注意,如果这些硬编码值是临时性的且未来可能独立修改,根据项目规则,建议保持它们独立以方便后续调整,避免过早进行抽象。

References
  1. Avoid abstracting duplicated hardcoded values into a constant if they are temporary and intended to be replaced individually. Keeping them separate can improve visibility and simplify future modifications.

/>

<template v-if="showAutoHideControlsIsland && settingsControlsIsland.autoHideControlsIsland">
<FieldRange
v-model.number="settingsControlsIsland.autoHideDelay"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (6 * 10)"
:delay="6 * 50"
:min="0"
:max="5"
:step="0.1"
:format-value="v => `${v}s`"
label="Hide delay"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处使用了硬编码的字符串。请使用 i18n 翻译键。

        :label="t('settings.controls-island.auto-hide.hide-delay')"

/>

<FieldRange
v-model.number="settingsControlsIsland.autoShowDelay"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (7 * 10)"
:delay="7 * 50"
:min="0"
:max="5"
:step="0.1"
:format-value="v => `${v}s`"
label="Show delay"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处使用了硬编码的字符串。请使用 i18n 翻译键。

        :label="t('settings.controls-island.auto-hide.show-delay')"

/>

<FieldRange
v-model.number="settingsControlsIsland.autoHideOpacity"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + (8 * 10)"
:delay="8 * 50"
:min="0"
:max="100"
:step="1"
:format-value="v => `${v}%`"
label="Hidden opacity"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处使用了硬编码的字符串。请使用 i18n 翻译键。

        :label="t('settings.controls-island.auto-hide.opacity')"

/>
</template>

<FieldCheckbox
v-model="analyticsToggleValue"
v-motion
Expand Down
67 changes: 67 additions & 0 deletions packages/stage-ui/src/stores/settings/controls-island.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { useSettingsControlsIsland } from './controls-island'

describe('store settings-controls-island', () => {
beforeEach(() => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
setActivePinia(pinia)
})

it('should have correct default values', () => {
const store = useSettingsControlsIsland()

expect(store.allowVisibleOnAllWorkspaces).toBe(true)
expect(store.alwaysOnTop).toBe(true)
expect(store.autoHideControlsIsland).toBe(false)
expect(store.autoHideDelay).toBe(0.5)
expect(store.autoShowDelay).toBe(0.5)
expect(store.autoHideOpacity).toBe(30)
})

it('should allow updating values', () => {
const store = useSettingsControlsIsland()

store.allowVisibleOnAllWorkspaces = false
store.alwaysOnTop = false
store.autoHideControlsIsland = true
store.autoHideDelay = 2
store.autoShowDelay = 1.5
store.autoHideOpacity = 50

expect(store.allowVisibleOnAllWorkspaces).toBe(false)
expect(store.alwaysOnTop).toBe(false)
expect(store.autoHideControlsIsland).toBe(true)
expect(store.autoHideDelay).toBe(2)
expect(store.autoShowDelay).toBe(1.5)
expect(store.autoHideOpacity).toBe(50)
})

it('should reset to default values via resetState action', () => {
const store = useSettingsControlsIsland()

// Modify values
store.allowVisibleOnAllWorkspaces = false
store.alwaysOnTop = false
store.autoHideControlsIsland = true
store.autoHideDelay = 3
store.autoShowDelay = 2
store.autoHideOpacity = 80

// Verify values were modified
expect(store.allowVisibleOnAllWorkspaces).toBe(false)

// Reset via action
store.resetState()

// Check defaults are restored
expect(store.allowVisibleOnAllWorkspaces).toBe(true)
expect(store.alwaysOnTop).toBe(true)
expect(store.autoHideControlsIsland).toBe(false)
expect(store.autoHideDelay).toBe(0.5)
expect(store.autoShowDelay).toBe(0.5)
expect(store.autoHideOpacity).toBe(30)
})
})
15 changes: 12 additions & 3 deletions packages/stage-ui/src/stores/settings/controls-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@ import { defineStore } from 'pinia'
export const useSettingsControlsIsland = defineStore('settings-controls-island', () => {
const allowVisibleOnAllWorkspaces = useLocalStorageManualReset<boolean>('settings/allow-visible-on-all-workspaces', true)
const alwaysOnTop = useLocalStorageManualReset<boolean>('settings/always-on-top', true)
const controlsIslandIconSize = useLocalStorageManualReset<'auto' | 'large' | 'small'>('settings/controls-island/icon-size', 'auto')
const autoHideControlsIsland = useLocalStorageManualReset<boolean>('settings/controls-island/auto-hide', false)
const autoHideDelay = useLocalStorageManualReset<number>('settings/controls-island/auto-hide-delay', 0.5)
const autoShowDelay = useLocalStorageManualReset<number>('settings/controls-island/auto-show-delay', 0.5)
const autoHideOpacity = useLocalStorageManualReset<number>('settings/controls-island/auto-hide-opacity', 30)

function resetState() {
allowVisibleOnAllWorkspaces.reset()
alwaysOnTop.reset()
controlsIslandIconSize.reset()
autoHideControlsIsland.reset()
autoHideDelay.reset()
autoShowDelay.reset()
autoHideOpacity.reset()
}

return {
allowVisibleOnAllWorkspaces,
alwaysOnTop,
controlsIslandIconSize,
autoHideControlsIsland,
autoHideDelay,
autoShowDelay,
autoHideOpacity,
resetState,
}
})
1 change: 0 additions & 1 deletion packages/stage-ui/src/stores/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ export const useSettings = defineStore('settings', () => {
// UI settings
allowVisibleOnAllWorkspaces: controlsIslandRefs.allowVisibleOnAllWorkspaces,
alwaysOnTop: controlsIslandRefs.alwaysOnTop,
Comment on lines 83 to 84
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

虽然 useSettings 已被标记为弃用,但作为统一的设置入口,建议也将新增加的自动隐藏相关设置项(autoHideControlsIsland, autoHideDelay, autoShowDelay, autoHideOpacity)同步暴露出来,以保持接口的一致性。在处理这些 UI 隐藏相关的设置时,请注意根据背景类型进行审美适配。

Suggested change
allowVisibleOnAllWorkspaces: controlsIslandRefs.allowVisibleOnAllWorkspaces,
alwaysOnTop: controlsIslandRefs.alwaysOnTop,
allowVisibleOnAllWorkspaces: controlsIslandRefs.allowVisibleOnAllWorkspaces,
alwaysOnTop: controlsIslandRefs.alwaysOnTop,
autoHideControlsIsland: controlsIslandRefs.autoHideControlsIsland,
autoHideDelay: controlsIslandRefs.autoHideDelay,
autoShowDelay: controlsIslandRefs.autoShowDelay,
autoHideOpacity: controlsIslandRefs.autoHideOpacity,
References
  1. Conditionally hide UI elements that clash aesthetically with certain background types, such as custom images.

controlsIslandIconSize: controlsIslandRefs.controlsIslandIconSize,

// Methods
setThemeColorsHue: theme.setThemeColorsHue,
Expand Down
Loading