Floating menu option (#726)

* Add floating menu

* Fix

* Updates

* Add auto-queue change test

* Fix
This commit is contained in:
pythongosssss
2024-09-16 03:28:04 +01:00
committed by Chenlei Hu
parent 73a7f7dae0
commit 2d1ff64951
14 changed files with 629 additions and 15 deletions

View File

@@ -1,6 +1,7 @@
import type { Page, Locator } from '@playwright/test'
import { test as base } from '@playwright/test'
import { expect } from '@playwright/test'
import { ComfyAppMenu } from './helpers/appMenu'
import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
@@ -237,6 +238,7 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
public readonly appMenu: ComfyAppMenu
constructor(public readonly page: Page) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
@@ -247,6 +249,7 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
this.appMenu = new ComfyAppMenu(page)
}
async getGraphNodesCount(): Promise<number> {

View File

@@ -0,0 +1,120 @@
import type { Response } from '@playwright/test'
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from './ComfyPage'
import { webSocketFixture } from './fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('AppMenu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
/**
* This test ensures that the autoqueue change mode can only queue one change at a time
*/
test('Does not auto-queue multiple changes at a time', async ({
comfyPage,
ws
}) => {
// Enable change auto-queue mode
let queueOpts = await comfyPage.appMenu.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled')
await queueOpts.setMode('change')
await comfyPage.nextFrame()
expect(await queueOpts.getMode()).toBe('change')
await comfyPage.appMenu.queueButton.toggleOptions()
// Intercept the prompt queue endpoint
let promptNumber = 0
comfyPage.page.route('**/api/prompt', async (route, req) => {
await new Promise((r) => setTimeout(r, 100))
route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: promptNumber,
number: ++promptNumber,
node_errors: {},
// Include the request data to validate which prompt was queued so we can validate the width
__request: req.postDataJSON()
})
})
})
// Start watching for a message to prompt
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
// Find and set the width on the latent node
const triggerChange = async (value: number) => {
return await comfyPage.page.evaluate((value) => {
const node = window['app'].graph._nodes.find(
(n) => n.type === 'EmptyLatentImage'
)
node.widgets[0].value = value
window['app'].workflowManager.activeWorkflow.changeTracker.checkState()
}, value)
}
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
}
}
}
} as StatusWsMessage)
}
// Extract the width from the queue response
const getQueuedWidth = async (resp: Promise<Response>) => {
const obj = await (await resp).json()
return obj['__request']['prompt']['5']['inputs']['width']
}
// Trigger a bunch of changes
const START = 32
const END = 64
for (let i = START; i <= END; i += 8) {
await triggerChange(i)
}
// Ensure the queued width is the first value
expect(
await getQueuedWidth(requestPromise),
'the first queued prompt should be the first change width'
).toBe(START)
// Ensure that no other changes are queued
await expect(
comfyPage.page.waitForResponse('**/api/prompt', { timeout: 250 })
).rejects.toThrow()
expect(
promptNumber,
'only 1 prompt should have been queued even though there were multiple changes'
).toBe(1)
// Trigger a status update so auto-queue re-runs
await triggerStatus(1)
await triggerStatus(0)
// Ensure the queued width is the last queued value
expect(
await getQueuedWidth(comfyPage.page.waitForResponse('**/api/prompt')),
'last queued prompt width should be the last change'
).toBe(END)
expect(promptNumber, 'queued prompt count should be 2').toBe(2)
})
})

View File

@@ -0,0 +1,51 @@
import { test as base } from '@playwright/test'
export const webSocketFixture = base.extend<{
ws: { trigger(data: any, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {
// Each time a page loads, to catch navigations
page.on('load', async () => {
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor() {
// @ts-expect-error
super(...arguments)
store[this.url] = this
}
}
})
})
await use({
async trigger(data, url) {
// Trigger a websocket event on the page
await page.evaluate(
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.toString())
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = (window as any).__ws__[url]
ws.dispatchEvent(
new MessageEvent('message', {
data
})
)
},
[JSON.stringify(data), url]
)
}
})
},
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
{ auto: true }
]
})

View File

@@ -0,0 +1,65 @@
import type { Page, Locator } from '@playwright/test'
export class ComfyAppMenu {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
constructor(public readonly page: Page) {
this.root = page.locator('.app-menu')
this.queueButton = new ComfyQueueButton(this)
}
}
class ComfyQueueButton {
public readonly root: Locator
public readonly primaryButton: Locator
public readonly dropdownButton: Locator
constructor(public readonly appMenu: ComfyAppMenu) {
this.root = appMenu.root.getByTestId('queue-button')
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
public async toggleOptions() {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.appMenu.page)
}
}
class ComfyQueueButtonOptions {
public readonly popup: Locator
public readonly modes: {
disabled: { input: Locator; wrapper: Locator }
instant: { input: Locator; wrapper: Locator }
change: { input: Locator; wrapper: Locator }
}
constructor(public readonly page: Page) {
this.popup = page.getByTestId('queue-options')
this.modes = (['disabled', 'instant', 'change'] as const).reduce(
(modes, mode) => {
modes[mode] = {
input: page.locator(`#autoqueue-${mode}`),
wrapper: page.getByTestId(`autoqueue-${mode}`)
}
return modes
},
{} as ComfyQueueButtonOptions['modes']
)
}
public async setMode(mode: keyof ComfyQueueButtonOptions['modes']) {
await this.modes[mode].input.click()
}
public async getMode() {
return (
await Promise.all(
Object.entries(this.modes).map(async ([mode, opt]) => [
mode,
await opt.wrapper.getAttribute('data-p-checked')
])
)
).find(([, checked]) => checked === 'true')?.[0]
}
}

View File

@@ -9,6 +9,7 @@
<GlobalToast />
<UnloadWindowConfirmDialog />
<BrowserTabTitle />
<AppMenu />
</template>
<script setup lang="ts">
@@ -41,6 +42,7 @@ import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
import AppMenu from '@/components/appMenu/AppMenu.vue'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
@@ -67,6 +69,8 @@ watch(
{ immediate: true }
)
setupAutoQueueHandler()
watchEffect(() => {
const fontSize = settingStore.get('Comfy.TextareaWidget.FontSize')
document.documentElement.style.setProperty(

View File

@@ -75,4 +75,9 @@ const gutterClass = computed(() => {
z-index: 999;
border: none;
}
.comfyui-floating-menu .splitter-overlay {
top: var(--comfy-floating-menu-height);
height: calc(100% - var(--comfy-floating-menu-height));
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<Panel v-if="visible" class="app-menu">
<div class="app-menu-content">
<Popover ref="queuePopover" data-testid="queue-options">
<div class="queue-options">
<p class="batch-count">
<FloatLabel v-tooltip="$t('menu.batchCountTooltip')">
<InputNumber id="batchCount" v-model="batchCount" :min="1" />
<label for="batchCount">{{ $t('menu.batchCount') }}</label>
</FloatLabel>
<Slider
v-model="batchCount"
:min="1"
:max="100"
v-tooltip="$t('menu.batchCountTooltip')"
/>
</p>
<Divider layout="vertical" />
<p class="auto-queue">
<span class="label">{{ $t('menu.autoQueue') }}</span>
<template v-for="mode in queueModes" :key="mode">
<div
v-tooltip="$t(`menu.${mode}Tooltip`)"
class="auto-queue-mode"
>
<RadioButton
v-model="queueMode"
:inputId="`autoqueue-${mode}`"
name="dynamic"
:value="mode"
:data-testid="`autoqueue-${mode}`"
/>
<label :for="`autoqueue-${mode}`">{{
$t(`menu.${mode}`)
}}</label>
</div>
</template>
</p>
</div>
</Popover>
<SplitButton
v-tooltip.bottom="$t('menu.queueWorkflow')"
:label="$t('menu.generate')"
:icon="`pi pi-${icon}`"
severity="secondary"
@click="queuePrompt"
:model="[]"
:pt="{
pcDropdown: ({ instance }) => {
instance.onDropdownButtonClick = function (e: Event) {
e.preventDefault()
queuePopover.toggle(e)
}
}
}"
data-testid="queue-button"
>
</SplitButton>
<div class="separator"></div>
<Button
v-tooltip.bottom="$t('menu.interrupt')"
icon="pi pi-times"
severity="secondary"
:disabled="!executingPrompt"
@click="actions.interrupt"
></Button>
<ButtonGroup>
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
severity="secondary"
@click="actions.refresh"
/>
<Button
v-tooltip.bottom="$t('menu.clipspace')"
icon="pi pi-clipboard"
severity="secondary"
@click="actions.openClipspace"
/>
<Button
v-tooltip.bottom="$t('menu.resetView')"
icon="pi pi-expand"
severity="secondary"
@click="actions.resetView"
/>
<Button
v-tooltip.bottom="$t('menu.clear')"
icon="pi pi-ban"
severity="secondary"
@click="actions.clearWorkflow"
/>
</ButtonGroup>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Panel from 'primevue/panel'
import SplitButton from 'primevue/splitbutton'
import Button from 'primevue/button'
import FloatLabel from 'primevue/floatlabel'
import InputNumber from 'primevue/inputnumber'
import Popover from 'primevue/popover'
import Divider from 'primevue/divider'
import Slider from 'primevue/slider'
import RadioButton from 'primevue/radiobutton'
import ButtonGroup from 'primevue/buttongroup'
import {
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
const settingsStore = useSettingStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { batchCount, mode: queueMode } = storeToRefs(useQueueSettingsStore())
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') === 'Floating'
)
const queuePopover = ref(null)
const queueModes = ['disabled', 'instant', 'change']
const icon = computed(() => {
switch (queueMode.value) {
case 'instant':
return 'forward'
case 'change':
return 'step-forward-alt'
default:
return 'play'
}
})
const executingPrompt = computed(() => !!queueCountStore.count.value)
const queuePrompt = (e: MouseEvent) => {
app.queuePrompt(e.shiftKey ? -1 : 0, batchCount.value)
}
const actions = {
interrupt: async () => {
await api.interrupt()
useToastStore().add({
severity: 'info',
summary: 'Interrupted',
detail: 'Execution has been interrupted',
life: 1000
})
},
clearWorkflow: () => {
if (
!(settingsStore.get('Comfy.ComfirmClear') ?? true) ||
confirm('Clear workflow?')
) {
app.clean()
app.graph.clear()
api.dispatchEvent(new CustomEvent('graphCleared'))
}
},
resetView: () => app.resetView(),
openClipspace: () => app['openClipspace'](),
refresh: () => app.refreshComboInNodes()
}
</script>
<style scoped>
.app-menu {
pointer-events: all;
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
.app-menu-content {
display: flex;
gap: 10px;
align-items: center;
}
:deep(.p-panel-content) {
padding: 10px;
}
:deep(.p-panel-header) {
display: none;
}
.separator {
background-color: var(--p-content-border-color);
border-radius: 10px;
opacity: 0.75;
width: 5px;
height: 20px;
}
.queue-options {
display: flex;
}
.batch-count {
padding-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 1em;
}
.p-slider {
--p-slider-border-radius: 5px;
margin: 5px;
padding: 2px;
}
.p-floatlabel label {
left: 2px;
}
.label {
font-size: 12px;
color: var(--p-floatlabel-focus-color);
}
.auto-queue {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.auto-queue-mode {
display: flex;
align-items: center;
gap: 5px;
}
</style>

View File

@@ -57,6 +57,26 @@ const messages = {
coverImagePreview: 'Fit Image Preview',
clearPendingTasks: 'Clear Pending Tasks'
}
},
menu: {
batchCount: 'Batch Count',
batchCountTooltip:
'The number of times the workflow generation should be queued',
autoQueue: 'Auto Queue',
disabled: 'Disabled',
disabledTooltip: 'The workflow will not be automatically queued',
instant: 'Instant',
instantTooltip:
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow',
generate: 'Generate',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
clipspace: 'Open Clipspace',
resetView: 'Reset canvas view',
clear: 'Clear workflow'
}
},
zh: {

View File

@@ -11,7 +11,7 @@ import { getInterruptButton } from './interruptButton'
import './menu.css'
import type { ComfySettingsDialog } from '../settings'
type MenuPosition = 'Disabled' | 'Top' | 'Bottom'
type MenuPosition = 'Disabled' | 'Top' | 'Bottom' | 'Floating'
const collapseOnMobile = (t) => {
;(t.element ?? t).classList.add('comfyui-menu-mobile-collapse')
@@ -89,7 +89,7 @@ export class ComfyAppMenu {
})
)
this.actionsGroup = new ComfyButtonGroup(
const actionButtons = [
new ComfyButton({
icon: 'refresh',
content: 'Refresh',
@@ -123,13 +123,14 @@ export class ComfyAppMenu {
}
}
})
)
]
this.actionsGroup = new ComfyButtonGroup(...actionButtons)
// Keep the settings group as there are custom scripts attaching extra
// elements to it.
this.settingsGroup = new ComfyButtonGroup()
this.viewGroup = new ComfyButtonGroup(
getInterruptButton('nlg-hide').element
)
const interruptButton = getInterruptButton('nlg-hide').element
this.viewGroup = new ComfyButtonGroup(interruptButton)
this.mobileMenuButton = new ComfyButton({
icon: 'menu',
action: (_, btn) => {
@@ -165,15 +166,36 @@ export class ComfyAppMenu {
experimental: true,
tooltip: 'On small screens the menu will always be at the top.',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
options: ['Disabled', 'Floating', 'Top', 'Bottom'],
onChange: async (v: MenuPosition) => {
if (v && v !== 'Disabled') {
if (!resizeHandler) {
resizeHandler = () => {
this.calculateSizeBreak()
const floating = v === 'Floating'
if (floating) {
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
}
this.element.classList.add('floating')
document.body.classList.add('comfyui-floating-menu')
} else {
this.element.classList.remove('floating')
document.body.classList.remove('comfyui-floating-menu')
if (!resizeHandler) {
resizeHandler = () => {
this.calculateSizeBreak()
}
window.addEventListener('resize', resizeHandler)
}
window.addEventListener('resize', resizeHandler)
}
for (const b of [
...actionButtons.map((b) => b.element),
interruptButton,
this.queueButton.element
]) {
b.style.display = floating ? 'none' : null
}
this.updatePosition(v)
} else {
if (resizeHandler) {
@@ -204,7 +226,11 @@ export class ComfyAppMenu {
} else {
this.app.bodyTop.prepend(this.element)
}
this.calculateSizeBreak()
if (v === 'Floating') {
this.updateSizeBreak(0, this.#sizeBreaks.indexOf(this.#sizeBreak), -999)
} else {
this.calculateSizeBreak()
}
}
updateSizeBreak(idx: number, prevIdx: number, direction: number) {

View File

@@ -1,3 +1,6 @@
:root {
--comfy-floating-menu-height: 45px;
}
.relative {
position: relative;
}
@@ -118,6 +121,9 @@
overflow: hidden;
}
.comfyui-button-group:empty {
display: none;
}
.comfyui-button-group > .comfyui-button,
.comfyui-button-group > .comfyui-button-wrapper > .comfyui-button {
padding: 4px 10px;
@@ -142,6 +148,22 @@
overflow: auto;
max-height: 90vh;
}
.comfyui-menu.floating {
width: max-content;
padding: 8px 0 8px 12px;
overflow: hidden;
border-bottom-right-radius: 12px;
height: var(--comfy-floating-menu-height);
position: absolute;
}
.comfyui-menu.floating .comfyui-logo {
padding-right: 8px;
}
.comfyui-floating-menu .comfyui-body-left {
margin-top: var(--comfy-floating-menu-height);
}
.comfyui-menu>* {
flex-shrink: 0;
@@ -708,4 +730,5 @@
}
.comfyui-body-bottom .lt-sm.comfyui-menu > .comfyui-menu-button{
bottom: 41px;
}
}

View File

@@ -232,7 +232,7 @@ export class ComfyWorkflowsMenu {
const r = getExtraMenuOptions?.apply?.(this, arguments)
const setting = app.ui.settings.getSettingValue(
'Comfy.UseNewMenu',
false
'Disabled'
)
if (setting && setting != 'Disabled') {
const t = this

View File

@@ -0,0 +1,41 @@
import {
useQueueSettingsStore,
useQueuePendingTaskCountStore
} from '@/stores/queueStore'
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
export function setupAutoQueueHandler() {
const queueCountStore = useQueuePendingTaskCountStore()
const queueSettingsStore = useQueueSettingsStore()
let graphHasChanged = false
let internalCount = 0 // Use an internal counter here so it is instantly updated when re-queuing
api.addEventListener('graphChanged', () => {
if (queueSettingsStore.mode === 'change') {
if (internalCount) {
graphHasChanged = true
} else {
graphHasChanged = false
app.queuePrompt(0, queueSettingsStore.batchCount)
internalCount++
}
}
})
queueCountStore.$subscribe(
() => {
internalCount = queueCountStore.count
if (!internalCount && !app.lastExecutionError) {
if (
queueSettingsStore.mode === 'instant' ||
(queueSettingsStore.mode === 'change' && graphHasChanged)
) {
graphHasChanged = false
app.queuePrompt(0, queueSettingsStore.batchCount)
}
}
},
{ detached: true }
)
}

View File

@@ -355,3 +355,12 @@ export const useQueuePendingTaskCountStore = defineStore(
}
}
)
export type AutoQueueMode = 'disabled' | 'instant' | 'change'
export const useQueueSettingsStore = defineStore('queueSettingsStore', {
state: () => ({
mode: 'disabled' as AutoQueueMode,
batchCount: 1
})
})

View File

@@ -493,8 +493,8 @@ const zSettings = z.record(z.any()).and(
'Comfy.SnapToGrid.GridSize': z.number(),
'Comfy.TextareaWidget.FontSize': z.number(),
'Comfy.TextareaWidget.Spellcheck': z.boolean(),
'Comfy.UseNewMenu': z.enum(['Disabled', 'Floating', 'Top', 'Bottom']),
'Comfy.TreeExplorer.ItemPadding': z.number(),
'Comfy.UseNewMenu': z.any(),
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),