Categorize setting items (#338)

* Basic setting panel rework

* refactor

* Style the setting item

* Reject invalid value

* nit

* nit

* Sort settings by label

* info chip as icon

* nit
This commit is contained in:
Chenlei Hu
2024-08-08 17:52:41 -04:00
committed by GitHub
parent 02d7f91e9e
commit a5f0d2b201
10 changed files with 381 additions and 111 deletions

View File

@@ -12,7 +12,11 @@
@unmaximize="maximized = false"
>
<template #header>
<h3>{{ dialogStore.title || ' ' }}</h3>
<component
v-if="dialogStore.headerComponent"
:is="dialogStore.headerComponent"
/>
<h3 v-else>{{ dialogStore.title || ' ' }}</h3>
</template>
<component

View File

@@ -1,116 +1,119 @@
<template>
<table class="comfy-modal-content comfy-table">
<tbody>
<tr v-for="setting in sortedSettings" :key="setting.id">
<td>
<span>
{{ setting.name }}
</span>
<Chip
v-if="setting.tooltip"
icon="pi pi-info-circle"
severity="secondary"
v-tooltip="setting.tooltip"
class="info-chip"
/>
</td>
<td>
<component
:is="markRaw(getSettingComponent(setting))"
:id="setting.id"
:modelValue="settingStore.get(setting.id)"
@update:modelValue="updateSetting(setting, $event)"
v-bind="getSettingAttrs(setting)"
/>
</td>
</tr>
</tbody>
</table>
<div class="settings-container">
<div class="settings-sidebar">
<Listbox
v-model="activeCategory"
:options="categories"
optionLabel="label"
scrollHeight="100%"
:pt="{ root: { class: 'border-none' } }"
/>
</div>
<Divider layout="vertical" />
<div class="settings-content" v-if="activeCategory">
<Tabs :value="activeCategory.label">
<TabPanels>
<TabPanel
v-for="category in categories"
:key="category.key"
:value="category.label"
>
<SettingGroup
v-for="group in sortedGroups(category)"
:key="group.label"
:group="{
label: group.label,
settings: flattenTree<SettingParams>(group)
}"
/>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { type Component, computed, markRaw } from 'vue'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import Chip from 'primevue/chip'
import ToggleSwitch from 'primevue/toggleswitch'
import { useSettingStore } from '@/stores/settingStore'
import { ref, computed, onMounted, watch } from 'vue'
import Listbox from 'primevue/listbox'
import Tabs from 'primevue/tabs'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import CustomSettingValue from '@/components/dialog/content/setting/CustomSettingValue.vue'
import InputSlider from '@/components/dialog/content/setting/InputSlider.vue'
import SettingGroup from './setting/SettingGroup.vue'
import { flattenTree } from '@/utils/treeUtil'
const settingStore = useSettingStore()
const sortedSettings = computed<SettingParams[]>(() => {
return Object.values(settingStore.settings)
.filter((setting: SettingParams) => setting.type !== 'hidden')
.sort((a, b) => a.name.localeCompare(b.name))
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const categories = computed<SettingTreeNode[]>(
() => settingRoot.value.children || []
)
const activeCategory = ref<SettingTreeNode | null>(null)
watch(activeCategory, (newCategory, oldCategory) => {
if (newCategory === null) {
activeCategory.value = oldCategory
}
})
function getSettingAttrs(setting: SettingParams) {
const attrs = { ...(setting.attrs || {}) }
const settingType = setting.type
if (typeof settingType === 'function') {
attrs['renderFunction'] = () =>
settingType(
setting.name,
(v) => updateSetting(setting, v),
settingStore.get(setting.id),
setting.attrs
)
}
switch (setting.type) {
case 'combo':
attrs['options'] = setting.options
if (typeof setting.options[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
onMounted(() => {
activeCategory.value = categories.value[0]
})
attrs['class'] += ' comfy-vue-setting-input'
return attrs
}
function getSettingComponent(setting: SettingParams): Component {
if (typeof setting.type === 'function') {
// return setting.type(
// setting.name, (v) => updateSetting(setting, v), settingStore.get(setting.id), setting.attrs)
return CustomSettingValue
}
switch (setting.type) {
case 'boolean':
return ToggleSwitch
case 'number':
return InputNumber
case 'slider':
return InputSlider
case 'combo':
return Select
default:
return InputText
}
}
const updateSetting = (setting: SettingParams, value: any) => {
if (setting.onChange) setting.onChange(value, settingStore.get(setting.id))
settingStore.set(setting.id, value)
const sortedGroups = (category: SettingTreeNode) => {
return [...(category.children || [])].sort((a, b) =>
a.label.localeCompare(b.label)
)
}
</script>
<style>
.info-chip {
background: transparent !important;
}
.comfy-vue-setting-input {
width: 100%;
/* Remove after we have tailwind setup */
.border-none {
border: none !important;
}
</style>
<style scoped>
.comfy-table {
.settings-container {
display: flex;
height: 80vh;
width: 60vw;
max-width: 1000px;
overflow: hidden;
/* Prevents container from scrolling */
}
.settings-sidebar {
width: 250px;
flex-shrink: 0;
/* Prevents sidebar from shrinking */
overflow-y: auto;
padding: 10px;
}
.settings-content {
flex-grow: 1;
overflow-y: auto;
/* Allows vertical scrolling */
}
/* Ensure the Listbox takes full width of the sidebar */
.settings-sidebar :deep(.p-listbox) {
width: 100%;
}
/* Optional: Style scrollbars for webkit browsers */
.settings-sidebar::-webkit-scrollbar,
.settings-content::-webkit-scrollbar {
width: 1px;
}
.settings-sidebar::-webkit-scrollbar-thumb,
.settings-content::-webkit-scrollbar-thumb {
background-color: transparent;
}
</style>

View File

@@ -5,37 +5,68 @@
@update:modelValue="updateValue"
class="slider-part"
:class="sliderClass"
v-bind="$attrs"
:min="min"
:max="max"
:step="step"
/>
<InputText
:value="modelValue"
@input="updateValue"
<InputNumber
:modelValue="modelValue"
@update:modelValue="updateValue"
class="input-part"
:class="inputClass"
:min="min"
:max="max"
:step="step"
/>
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { ref, watch } from 'vue'
import InputNumber from 'primevue/inputnumber'
import Slider from 'primevue/slider'
const props = defineProps<{
modelValue: number
inputClass?: string
sliderClass?: string
min?: number
max?: number
step?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const updateValue = (newValue: string | number) => {
const numValue =
typeof newValue === 'string' ? parseFloat(newValue) : newValue
if (!isNaN(numValue)) {
emit('update:modelValue', numValue)
const localValue = ref(props.modelValue)
watch(
() => props.modelValue,
(newValue) => {
localValue.value = newValue
}
)
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(props.min) || 0
}
const min = Number(props.min) || Number.NEGATIVE_INFINITY
const max = Number(props.max) || Number.POSITIVE_INFINITY
const step = Number(props.step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(min, Math.min(max, newValue))
// Round to the nearest step
newValue = Math.round(newValue / step) * step
// Update local value and emit change
localValue.value = newValue
emit('update:modelValue', newValue)
}
</script>
@@ -44,7 +75,6 @@ const updateValue = (newValue: string | number) => {
display: flex;
align-items: center;
gap: 1rem;
/* Adjust this value to control space between slider and input */
}
.slider-part {
@@ -52,7 +82,6 @@ const updateValue = (newValue: string | number) => {
}
.input-part {
width: 5rem;
/* Adjust this value to control input width */
width: 5rem !important;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="setting-group">
<Divider />
<h3>{{ group.label }}</h3>
<div
v-for="setting in group.settings"
:key="setting.id"
class="setting-item"
>
<div class="setting-label">
<span
>{{ setting.name }}
<i
v-if="setting.tooltip"
class="pi pi-info-circle info-chip"
v-tooltip="setting.tooltip"
/></span>
</div>
<div class="setting-input">
<component
:is="markRaw(getSettingComponent(setting))"
:id="setting.id"
:modelValue="settingStore.get(setting.id)"
@update:modelValue="updateSetting(setting, $event)"
v-bind="getSettingAttrs(setting)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import { markRaw, type Component } from 'vue'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import Divider from 'primevue/divider'
import CustomSettingValue from '@/components/dialog/content/setting/CustomSettingValue.vue'
import InputSlider from '@/components/dialog/content/setting/InputSlider.vue'
defineProps<{
group: {
label: string
settings: SettingParams[]
}
}>()
const settingStore = useSettingStore()
function getSettingAttrs(setting: SettingParams) {
const attrs = { ...(setting.attrs || {}) }
const settingType = setting.type
if (typeof settingType === 'function') {
attrs['renderFunction'] = () =>
settingType(
setting.name,
(v) => updateSetting(setting, v),
settingStore.get(setting.id),
setting.attrs
)
}
switch (setting.type) {
case 'combo':
attrs['options'] = setting.options
if (typeof setting.options[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
return attrs
}
const updateSetting = (setting: SettingParams, value: any) => {
if (setting.onChange) setting.onChange(value, settingStore.get(setting.id))
settingStore.set(setting.id, value)
}
function getSettingComponent(setting: SettingParams): Component {
if (typeof setting.type === 'function') {
return CustomSettingValue
}
switch (setting.type) {
case 'boolean':
return ToggleSwitch
case 'number':
return InputNumber
case 'slider':
return InputSlider
case 'combo':
return Select
default:
return InputText
}
}
</script>
<style scoped>
.info-chip {
background: transparent;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.setting-label {
display: flex;
align-items: center;
flex: 1;
}
.setting-input {
flex: 1;
display: flex;
justify-content: flex-end;
margin-left: 1rem;
}
/* Ensure PrimeVue components take full width of their container */
.setting-input :deep(.p-inputtext),
.setting-input :deep(.input-slider),
.setting-input :deep(.p-select),
.setting-input :deep(.p-togglebutton) {
width: 100%;
max-width: 200px;
}
.setting-input :deep(.p-inputtext) {
max-width: unset;
}
/* Special case for ToggleSwitch to align it to the right */
.setting-input :deep(.p-toggleswitch) {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div>
<h2>
<i class="pi pi-cog"></i>
<span>Settings</span>
<Tag :value="frontendVersion" severity="secondary" class="version-tag" />
</h2>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
const frontendVersion = 'v' + window['__COMFYUI_FRONTEND_VERSION__']
</script>
<style scoped>
.pi-cog {
font-size: 1.25rem;
margin-right: 0.5rem;
}
.version-tag {
margin-left: 0.5rem;
}
</style>

View File

@@ -10,12 +10,12 @@
import SideBarIcon from './SideBarIcon.vue'
import { useDialogStore } from '@/stores/dialogStore'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__']
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
const dialogStore = useDialogStore()
const showSetting = () => {
dialogStore.showDialog({
title: `Settings (v${frontendVersion})`,
headerComponent: SettingDialogHeader,
component: SettingDialogContent
})
}

View File

@@ -1,3 +1,4 @@
import { useDialogStore } from '@/stores/dialogStore'
import { $el } from '../ui'
export class ComfyDialog<

View File

@@ -7,6 +7,7 @@ import { type Component, markRaw } from 'vue'
interface DialogState {
isVisible: boolean
title: string
headerComponent: Component | null
component: Component | null
props: Record<string, any>
}
@@ -15,6 +16,7 @@ export const useDialogStore = defineStore('dialog', {
state: (): DialogState => ({
isVisible: false,
title: '',
headerComponent: null,
component: null,
props: {}
}),
@@ -22,10 +24,12 @@ export const useDialogStore = defineStore('dialog', {
actions: {
showDialog(options: {
title?: string
headerComponent?: Component
component: Component
props?: Record<string, any>
}) {
this.title = options.title
this.headerComponent = markRaw(options.headerComponent)
this.component = markRaw(options.component)
this.props = options.props || {}
this.isVisible = true

View File

@@ -10,7 +10,13 @@
import { app } from '@/scripts/app'
import { ComfySettingsDialog } from '@/scripts/ui/settings'
import { SettingParams } from '@/types/settingTypes'
import { buildTree } from '@/utils/treeUtil'
import { defineStore } from 'pinia'
import type { TreeNode } from 'primevue/treenode'
export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
interface State {
settingValues: Record<string, any>
@@ -22,6 +28,13 @@ export const useSettingStore = defineStore('setting', {
settingValues: {},
settings: {}
}),
getters: {
settingTree(): SettingTreeNode {
return buildTree(Object.values(this.settings), (setting: SettingParams) =>
setting.id.split('.')
)
}
},
actions: {
addSettings(settings: ComfySettingsDialog) {
for (const id in settings.settingsLookup) {

49
src/utils/treeUtil.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { TreeNode } from 'primevue/treenode'
export function buildTree<T>(
items: T[],
key: string | ((item: T) => string[])
): TreeNode {
const root: TreeNode = {
key: 'root',
label: 'root',
children: []
}
const map: Record<string, TreeNode> = {
root: root
}
for (const item of items) {
const keys = typeof key === 'string' ? item[key] : key(item)
let parent = root
for (const k of keys) {
const id = parent.key + '/' + k
if (!map[id]) {
const node: TreeNode = {
key: id,
label: k,
leaf: false,
children: []
}
map[id] = node
parent.children.push(node)
}
parent = map[id]
}
parent.leaf = true
parent.data = item
}
return root
}
export function flattenTree<T>(tree: TreeNode): T[] {
const result: T[] = []
const stack: TreeNode[] = [tree]
while (stack.length) {
const node = stack.pop()!
if (node.leaf && node.data) result.push(node.data)
stack.push(...(node.children || []))
}
return result
}