Skip to content

Commit 0df7a53

Browse files
pythongosssssgithub-actionschristian-byrne
authored
Rework theme menu (#5161)
* Change theme "button" to sub menu of all themes * Add test for theme menu * Prevent separator being added before View * Refactor test * Update locales [skip ci] * Fix has-text vs text-is change breaking other tests --------- Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: bymyself <cbyrne@comfy.org>
1 parent 0854194 commit 0df7a53

File tree

14 files changed

+234
-49
lines changed

14 files changed

+234
-49
lines changed

browser_tests/fixtures/ComfyPage.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,32 @@ export class ComfyPage {
453453
await workflowsTab.close()
454454
}
455455

456+
/**
457+
* Attach a screenshot to the test report.
458+
* By default, screenshots are only taken in non-CI environments.
459+
* @param name - Name for the screenshot attachment
460+
* @param options - Optional configuration
461+
* @param options.runInCI - Whether to take screenshot in CI (default: false)
462+
* @param options.fullPage - Whether to capture full page (default: false)
463+
*/
464+
async attachScreenshot(
465+
name: string,
466+
options: { runInCI?: boolean; fullPage?: boolean } = {}
467+
) {
468+
const { runInCI = false, fullPage = false } = options
469+
470+
// Skip in CI unless explicitly requested
471+
if (process.env.CI && !runInCI) {
472+
return
473+
}
474+
475+
const testInfo = comfyPageFixture.info()
476+
await testInfo.attach(name, {
477+
body: await this.page.screenshot({ fullPage }),
478+
contentType: 'image/png'
479+
})
480+
}
481+
456482
async resetView() {
457483
if (await this.resetViewButton.isVisible()) {
458484
await this.resetViewButton.click()

browser_tests/fixtures/components/Topbar.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import { Locator, Page } from '@playwright/test'
1+
import { Locator, Page, expect } from '@playwright/test'
22

33
export class Topbar {
4-
constructor(public readonly page: Page) {}
4+
private readonly menuLocator: Locator
5+
private readonly menuTrigger: Locator
6+
7+
constructor(public readonly page: Page) {
8+
this.menuLocator = page.locator('.comfy-command-menu')
9+
this.menuTrigger = page.locator('.comfyui-logo-wrapper')
10+
}
511

612
async getTabNames(): Promise<string[]> {
713
return await this.page
@@ -15,10 +21,33 @@ export class Topbar {
1521
.innerText()
1622
}
1723

18-
getMenuItem(itemLabel: string): Locator {
24+
/**
25+
* Get a menu item by its label, optionally within a specific parent container
26+
*/
27+
getMenuItem(itemLabel: string, parent?: Locator): Locator {
28+
if (parent) {
29+
return parent.locator(`.p-tieredmenu-item:has-text("${itemLabel}")`)
30+
}
31+
1932
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
2033
}
2134

35+
/**
36+
* Get the visible submenu (last visible submenu in case of nested menus)
37+
*/
38+
getVisibleSubmenu(): Locator {
39+
return this.page.locator('.p-tieredmenu-submenu:visible').last()
40+
}
41+
42+
/**
43+
* Check if a menu item has an active checkmark
44+
*/
45+
async isMenuItemActive(menuItem: Locator): Promise<boolean> {
46+
const checkmark = menuItem.locator('.pi-check')
47+
const classes = await checkmark.getAttribute('class')
48+
return classes ? !classes.includes('invisible') : false
49+
}
50+
2251
getWorkflowTab(tabName: string): Locator {
2352
return this.page
2453
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
@@ -66,10 +95,50 @@ export class Topbar {
6695

6796
async openTopbarMenu() {
6897
await this.page.waitForTimeout(1000)
69-
await this.page.locator('.comfyui-logo-wrapper').click()
70-
const menu = this.page.locator('.comfy-command-menu')
71-
await menu.waitFor({ state: 'visible' })
72-
return menu
98+
await this.menuTrigger.click()
99+
await this.menuLocator.waitFor({ state: 'visible' })
100+
return this.menuLocator
101+
}
102+
103+
/**
104+
* Close the topbar menu by clicking outside
105+
*/
106+
async closeTopbarMenu() {
107+
await this.page.locator('body').click({ position: { x: 10, y: 10 } })
108+
await expect(this.menuLocator).not.toBeVisible()
109+
}
110+
111+
/**
112+
* Navigate to a submenu by hovering over a menu item
113+
*/
114+
async openSubmenu(menuItemLabel: string): Promise<Locator> {
115+
const menuItem = this.getMenuItem(menuItemLabel)
116+
await menuItem.hover()
117+
const submenu = this.getVisibleSubmenu()
118+
await submenu.waitFor({ state: 'visible' })
119+
return submenu
120+
}
121+
122+
/**
123+
* Get theme menu items and interact with theme switching
124+
*/
125+
async getThemeMenuItems() {
126+
const themeSubmenu = await this.openSubmenu('Theme')
127+
return {
128+
submenu: themeSubmenu,
129+
darkTheme: this.getMenuItem('Dark (Default)', themeSubmenu),
130+
lightTheme: this.getMenuItem('Light', themeSubmenu)
131+
}
132+
}
133+
134+
/**
135+
* Switch to a specific theme
136+
*/
137+
async switchTheme(theme: 'dark' | 'light') {
138+
const { darkTheme, lightTheme } = await this.getThemeMenuItems()
139+
const themeItem = theme === 'dark' ? darkTheme : lightTheme
140+
const themeLabel = themeItem.locator('.p-menubar-item-label')
141+
await themeLabel.click()
73142
}
74143

75144
async triggerTopbarCommand(path: string[]) {
@@ -79,9 +148,7 @@ export class Topbar {
79148

80149
const menu = await this.openTopbarMenu()
81150
const tabName = path[0]
82-
const topLevelMenuItem = this.page.locator(
83-
`.p-menubar-item-label:text-is("${tabName}")`
84-
)
151+
const topLevelMenuItem = this.getMenuItem(tabName)
85152
const topLevelMenu = menu
86153
.locator('.p-tieredmenu-item')
87154
.filter({ has: topLevelMenuItem })

browser_tests/tests/menu.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,72 @@ test.describe('Menu', () => {
178178
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
179179
expect(await comfyPage.getVisibleToastCount()).toBe(1)
180180
})
181+
182+
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
183+
comfyPage
184+
}) => {
185+
const { topbar } = comfyPage.menu
186+
187+
// Take initial screenshot with default theme
188+
await comfyPage.attachScreenshot('theme-initial')
189+
190+
// Open the topbar menu
191+
const menu = await topbar.openTopbarMenu()
192+
await expect(menu).toBeVisible()
193+
194+
// Get theme menu items
195+
const {
196+
submenu: themeSubmenu,
197+
darkTheme: darkThemeItem,
198+
lightTheme: lightThemeItem
199+
} = await topbar.getThemeMenuItems()
200+
201+
await expect(darkThemeItem).toBeVisible()
202+
await expect(lightThemeItem).toBeVisible()
203+
204+
// Switch to Light theme
205+
await topbar.switchTheme('light')
206+
207+
// Verify menu stays open and Light theme shows as active
208+
await expect(menu).toBeVisible()
209+
await expect(themeSubmenu).toBeVisible()
210+
211+
// Check that Light theme is active
212+
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
213+
214+
// Screenshot with light theme active
215+
await comfyPage.attachScreenshot('theme-menu-light-active')
216+
217+
// Verify ColorPalette setting is set to "light"
218+
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('light')
219+
220+
// Close menu to see theme change
221+
await topbar.closeTopbarMenu()
222+
223+
// Re-open menu and get theme items again
224+
await topbar.openTopbarMenu()
225+
const themeItems2 = await topbar.getThemeMenuItems()
226+
227+
// Switch back to Dark theme
228+
await topbar.switchTheme('dark')
229+
230+
// Verify menu stays open and Dark theme shows as active
231+
await expect(menu).toBeVisible()
232+
await expect(themeItems2.submenu).toBeVisible()
233+
234+
// Check that Dark theme is active and Light theme is not
235+
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
236+
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
237+
238+
// Screenshot with dark theme active
239+
await comfyPage.attachScreenshot('theme-menu-dark-active')
240+
241+
// Verify ColorPalette setting is set to "dark"
242+
expect(await comfyPage.getSetting('Comfy.ColorPalette')).toBe('dark')
243+
244+
// Close menu
245+
await topbar.closeTopbarMenu()
246+
})
181247
})
182248

183249
// Only test 'Top' to reduce test time.

src/components/topbar/CommandMenubar.vue

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -28,29 +28,7 @@
2828
@show="onMenuShow"
2929
>
3030
<template #item="{ item, props }">
31-
<div
32-
v-if="item.key === 'theme'"
33-
class="flex items-center gap-4 px-4 py-5"
34-
@click.stop.prevent
35-
>
36-
{{ item.label }}
37-
<SelectButton
38-
:options="[darkLabel, lightLabel]"
39-
:model-value="activeTheme"
40-
@click.stop.prevent
41-
@update:model-value="onThemeChange"
42-
>
43-
<template #option="{ option }">
44-
<div class="flex items-center gap-2">
45-
<i v-if="option === lightLabel" class="pi pi-sun" />
46-
<i v-if="option === darkLabel" class="pi pi-moon" />
47-
<span>{{ option }}</span>
48-
</div>
49-
</template>
50-
</SelectButton>
51-
</div>
5231
<a
53-
v-else
5432
class="p-menubar-item-link px-4 py-2"
5533
v-bind="props.action"
5634
:href="item.url"
@@ -95,7 +73,6 @@
9573

9674
<script setup lang="ts">
9775
import type { MenuItem } from 'primevue/menuitem'
98-
import SelectButton from 'primevue/selectbutton'
9976
import TieredMenu, {
10077
type TieredMenuMethods,
10178
type TieredMenuState
@@ -106,6 +83,7 @@ import { useI18n } from 'vue-i18n'
10683
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
10784
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
10885
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
86+
import { useColorPaletteService } from '@/services/colorPaletteService'
10987
import { useDialogService } from '@/services/dialogService'
11088
import { useCommandStore } from '@/stores/commandStore'
11189
import { useDialogStore } from '@/stores/dialogStore'
@@ -121,6 +99,7 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
12199
import { whileMouseDown } from '@/utils/mouseDownUtil'
122100
123101
const colorPaletteStore = useColorPaletteStore()
102+
const colorPaletteService = useColorPaletteService()
124103
const menuItemsStore = useMenuItemStore()
125104
const commandStore = useCommandStore()
126105
const dialogStore = useDialogStore()
@@ -184,11 +163,26 @@ const showManageExtensions = async () => {
184163
}
185164
}
186165
187-
const extraMenuItems = computed<MenuItem[]>(() => [
166+
const themeMenuItems = computed(() => {
167+
return colorPaletteStore.palettes.map((palette) => ({
168+
key: `theme-${palette.id}`,
169+
label: palette.name,
170+
parentPath: 'theme',
171+
comfyCommand: {
172+
active: () => colorPaletteStore.activePaletteId === palette.id
173+
},
174+
command: async () => {
175+
await colorPaletteService.loadColorPalette(palette.id)
176+
}
177+
}))
178+
})
179+
180+
const extraMenuItems = computed(() => [
188181
{ separator: true },
189182
{
190183
key: 'theme',
191-
label: t('menu.theme')
184+
label: t('menu.theme'),
185+
items: themeMenuItems.value
192186
},
193187
{ separator: true },
194188
{
@@ -211,19 +205,6 @@ const extraMenuItems = computed<MenuItem[]>(() => [
211205
}
212206
])
213207
214-
const lightLabel = computed(() => t('menu.light'))
215-
const darkLabel = computed(() => t('menu.dark'))
216-
217-
const activeTheme = computed(() => {
218-
return colorPaletteStore.completedActivePalette.light_theme
219-
? lightLabel.value
220-
: darkLabel.value
221-
})
222-
223-
const onThemeChange = async () => {
224-
await commandStore.execute('Comfy.ToggleTheme')
225-
}
226-
227208
const translatedItems = computed(() => {
228209
const items = menuItemsStore.menuItems.map(translateMenuItem)
229210
let helpIndex = items.findIndex((item) => item.key === 'Help')
@@ -308,7 +289,12 @@ const handleItemClick = (item: MenuItem, event: MouseEvent) => {
308289
}
309290
310291
const hasActiveStateSiblings = (item: MenuItem): boolean => {
311-
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
292+
// Check if this item has siblings with active state (either from store or theme items)
293+
return (
294+
item.parentPath &&
295+
(item.parentPath === 'theme' ||
296+
menuItemsStore.menuItemHasActiveStateChildren[item.parentPath])
297+
)
312298
}
313299
</script>
314300

@@ -332,6 +318,18 @@ const hasActiveStateSiblings = (item: MenuItem): boolean => {
332318
</style>
333319

334320
<style>
321+
.comfy-command-menu {
322+
--p-tieredmenu-item-focus-background: color-mix(
323+
in srgb,
324+
var(--fg-color) 15%,
325+
transparent
326+
);
327+
--p-tieredmenu-item-active-background: color-mix(
328+
in srgb,
329+
var(--fg-color) 10%,
330+
transparent
331+
);
332+
}
335333
.comfy-command-menu ul {
336334
background-color: var(--comfy-menu-secondary-bg) !important;
337335
}

src/constants/coreMenuCommands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const CORE_MENU_COMMANDS = [
2323
'Comfy.Memory.UnloadModelsAndExecutionCache'
2424
]
2525
],
26+
[['View'], []],
2627
[
2728
['Help'],
2829
[

src/locales/ar/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,7 @@
842842
"Ungroup selected group nodes": "فك تجميع عقد المجموعة المحددة",
843843
"Unlock Canvas": "فتح قفل اللوحة",
844844
"Unpack the selected Subgraph": "فك تجميع الرسم البياني الفرعي المحدد",
845+
"View": "عرض",
845846
"Workflows": "سير العمل",
846847
"Zoom In": "تكبير",
847848
"Zoom Out": "تصغير",

src/locales/en/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,7 @@
10041004
"menuLabels": {
10051005
"Workflow": "Workflow",
10061006
"Edit": "Edit",
1007+
"View": "View",
10071008
"Manager": "Manager",
10081009
"Help": "Help",
10091010
"Check for Updates": "Check for Updates",

src/locales/es/main.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,11 @@
825825
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
826826
"Unload Models": "Descargar modelos",
827827
"Unload Models and Execution Cache": "Descargar modelos y caché de ejecución",
828+
"Unlock Canvas": "Desbloquear lienzo",
829+
"Unpack the selected Subgraph": "Desempaquetar el Subgrafo seleccionado",
830+
"View": "Ver",
828831
"Workflow": "Flujo de trabajo",
832+
"Workflows": "Flujos de trabajo",
829833
"Zoom In": "Acercar",
830834
"Zoom Out": "Alejar",
831835
"Zoom to fit": "Ajustar al tamaño"

0 commit comments

Comments
 (0)