Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
feat: add WidgetSelectToggle and enhance webcam widget with dynamic c…
…ontrols

Implements a proper Vue toggle widget component and enhances the webcam widget to dynamically show/hide related controls based on camera state, with automatic restoration on component unmount.
  • Loading branch information
Myestery committed Nov 21, 2025
commit 0ac768722ce371c9e01d4a2333c14a11d09cad56
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue'

import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'

import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'

const props = defineProps<{
widget: SimplifiedWidget<string | number | boolean>
}>()

const modelValue = defineModel<string | number | boolean>({ required: true })

const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)

interface ToggleOption {
label: string
value: string | number | boolean
}

const options = computed<ToggleOption[]>(() => {
// Get options from widget spec or widget options
const widgetOptions = props.widget.options?.values || props.widget.spec?.[0]

if (Array.isArray(widgetOptions)) {
// If options are strings/numbers, convert to {label, value} format
return widgetOptions.map((opt) => {
if (
typeof opt === 'object' &&
opt !== null &&
'label' in opt &&
'value' in opt
) {
return opt as ToggleOption
}
return { label: String(opt), value: opt }
})
}

// Default options for boolean widgets
if (typeof modelValue.value === 'boolean') {
return [
{ label: 'On', value: true },
{ label: 'Off', value: false }
]
}

// Fallback default options
return [
{ label: 'Yes', value: true },
{ label: 'No', value: false }
]
})

function handleSelect(value: string | number | boolean) {
modelValue.value = value
}
</script>

<template>
<WidgetLayoutField :widget>
<div
v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'flex gap-0.5 p-0.5 w-full')"
role="group"
:aria-label="widget.name"
>
<button
v-for="option in options"
:key="String(option.value)"
type="button"
:class="
cn(
'flex-1 px-2 py-1 text-xs font-medium rounded transition-all duration-150',
'bg-transparent border-none',
'focus:outline-none',
modelValue === option.value
? 'bg-interface-menu-component-surface-selected text-primary'
: 'text-secondary hover:bg-interface-menu-component-surface-hovered'
)
"
:aria-pressed="modelValue === option.value"
@click="handleSelect(option.value)"
>
{{ option.label }}
</button>
</div>
</WidgetLayoutField>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
@click="handleTurnOnCamera"
>
{{ t('g.turnOnCamera', 'Turn on Camera') }}
<i-lucide:video class="ml-1" />
</Button>
</div>
<LODFallback />
Expand All @@ -16,10 +15,11 @@

<script setup lang="ts">
import { Button } from 'primevue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'

import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { app } from '@/scripts/app'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
Expand All @@ -32,11 +32,22 @@ const props = defineProps<{

const isCameraOn = ref(false)

// Store original widget states for restoration
const originalWidgets = ref<IBaseWidget[]>([])

const litegraphNode = computed(() => {
if (!props.nodeId || !app.rootGraph) return null
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
})

function storeOriginalWidgets() {
const node = litegraphNode.value
if (!node?.widgets) return

// Deep clone the original widgets to preserve their state
originalWidgets.value = [...node.widgets]
}

function hideWidgets() {
const node = litegraphNode.value
if (!node?.widgets) return
Expand All @@ -48,6 +59,24 @@ function hideWidgets() {
)

if (shouldHide) {
// Special handling for capture_on_queue widget
if (widget.name === 'capture_on_queue') {
return {
...widget,
type: 'selectToggle',
label: 'Capture Image',
value: widget.value ?? false,
options: {
...widget.options,
hidden: true,
values: [
{ label: 'On Run', value: true },
{ label: 'Manually', value: false }
]
}
}
}

return {
...widget,
options: {
Expand All @@ -62,6 +91,14 @@ function hideWidgets() {
node.widgets = newWidgets
}

function restoreWidgets() {
const node = litegraphNode.value
if (!node?.widgets || originalWidgets.value.length === 0) return

// Restore the original widgets
node.widgets = originalWidgets.value
}

function showWidgets() {
const node = litegraphNode.value
if (!node?.widgets) return
Expand All @@ -73,6 +110,24 @@ function showWidgets() {
)

if (shouldShow) {
// Special handling for capture_on_queue widget
if (widget.name === 'capture_on_queue') {
return {
...widget,
type: 'selectToggle',
label: 'Capture Image',
value: widget.value ?? false,
options: {
...widget.options,
hidden: false,
values: [
{ label: 'On Run', value: true },
{ label: 'Manually', value: false }
]
}
}
}

return {
...widget,
options: {
Expand Down Expand Up @@ -108,7 +163,13 @@ async function handleTurnOnCamera() {
}

onMounted(() => {
// Store original widget states before modifying them
storeOriginalWidgets()
// Hide all widgets initially until camera is turned on
hideWidgets()
})

onUnmounted(() => {
restoreWidgets()
})
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ const WidgetAudioUI = defineAsyncComponent(
const WidgetWebcam = defineAsyncComponent(
() => import('../components/WidgetWebcam.vue')
)
const WidgetSelectToggle = defineAsyncComponent(
() => import('../components/WidgetSelectToggle.vue')
)
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
Expand Down Expand Up @@ -164,6 +167,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
[
'selectToggle',
{
component: WidgetSelectToggle,
aliases: ['SELECT_TOGGLE'],
essential: false
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }]
]

Expand Down