diff --git a/javascript/style.css b/javascript/style.css index e1bfd2027..f8a28d227 100644 --- a/javascript/style.css +++ b/javascript/style.css @@ -215,19 +215,31 @@ div#extras_scale_to_tab div.form{ } #settings{ - display: block; + display: flex; + flex-flow: row wrap; + gap: var(--layout-gap); } -#settings > div{ +#settings div { border: none; - margin-left: 10em; +} + +#settings > div.tab-content { + flex: 100000 0 75%; +} + +#settings > div.tab-content > div{ + border: none; + padding: 0; } #settings > div.tab-nav{ - float: left; - display: block; - margin-left: 0; - width: 14em; + display: grid; + grid-template-columns: repeat(auto-fill, .3em minmax(10em, 1fr)); + flex: 1 0 auto; + width: 11em; + align-self: flex-start; + gap: var(--spacing-md); } #settings > div.tab-nav button{ @@ -235,10 +247,52 @@ div#extras_scale_to_tab div.form{ border: none; text-align: left; white-space: initial; + padding: 0; +} + +#settings > div.tab-nav > #settings_show_all_pages { + padding: var(--size-2) var(--size-4); +} + +#settings .block.gradio-checkbox { + margin: 0; + width: auto; +} + +#settings .dirtyable { + display: grid; + grid-template-columns: .3em auto; + gap: .5em; +} + +#settings .dirtyable.hidden { + display: none; +} + +#settings .modification-indicator { + width: 100%; + height: 100%; + border-radius: 1em !important; + padding: 0; +} + +#settings .modification-indicator:disabled { + visibility: hidden; +} + +#settings .modification-indicator.unsaved { + background: var(--color-accent-soft); +} + +#settings .modification-indicator.changed { + background: var(--color-accent); +} + +#settings .modification-indicator.changed.unsaved { + background-image: linear-gradient(var(--color-accent) 25%, var(--color-accent-soft) 75%); } #settings_result{ - height: 1.4em; margin: 0 1.2em; } diff --git a/javascript/ui.js b/javascript/ui.js index 2a2c6c9b4..ce2418951 100644 --- a/javascript/ui.js +++ b/javascript/ui.js @@ -199,6 +199,32 @@ function register_drag_drop() { }); } +opts = {} +opts_metadata = {} +function updateOpts(json_string){ + let settings_data = JSON.parse(json_string) + opts = settings_data.values + opts_metadata = settings_data.metadata + opts_tabs = {} + Object.entries(opts_metadata).forEach(([opt, meta]) => { + opts_tabs[meta.tab_name] ||= {} + let unsaved = (opts_tabs[meta.tab_name].unsaved_keys ||= new Set()) + if (!meta.is_stored) unsaved.add(opt) + }) +} + +function showAllSettings() { + // Try to ensure that the show all settings tab is opened by clicking on its tab button + let tab_dirty_indicator = gradioApp().getElementById('modification_indicator_show_all_pages'); + if (tab_dirty_indicator && tab_dirty_indicator.nextSibling) { + tab_dirty_indicator.nextSibling.click(); + } + gradioApp().querySelectorAll('#settings > .tab-content > .tabitem').forEach((elem) => { + if (elem.id === 'settings_tab_licenses' || elem.id === 'settings_show_all_pages') return; + elem.style.display = 'block'; + }); +} + onAfterUiUpdate(() => { sort_ui_elements(); if (Object.keys(opts).length !== 0) return; @@ -207,7 +233,7 @@ onAfterUiUpdate(() => { json_elem.parentElement.style.display = 'none'; const textarea = json_elem.querySelector('textarea'); const jsdata = textarea.value; - opts = JSON.parse(jsdata); + updateOpts(jsdata); executeCallbacks(optionsChangedCallbacks); register_drag_drop(); @@ -216,7 +242,7 @@ onAfterUiUpdate(() => { const valueProp = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value'); const oldValue = valueProp.get.call(textarea); valueProp.set.call(textarea, newValue); - if (oldValue !== newValue) opts = JSON.parse(textarea.value); + if (oldValue !== newValue) updateOpts(textarea.value); executeCallbacks(optionsChangedCallbacks); }, get() { @@ -240,28 +266,19 @@ onAfterUiUpdate(() => { registerTextarea('txt2img_neg_prompt', 'txt2img_negative_token_counter', 'txt2img_negative_token_button'); registerTextarea('img2img_prompt', 'img2img_token_counter', 'img2img_token_button'); registerTextarea('img2img_neg_prompt', 'img2img_negative_token_counter', 'img2img_negative_token_button'); - const show_all_pages = gradioApp().getElementById('settings_show_all_pages'); - const settings_tabs = gradioApp().querySelector('#settings div'); - if (show_all_pages && settings_tabs) { - settings_tabs.appendChild(show_all_pages); - show_all_pages.onclick = () => { - gradioApp().querySelectorAll('#settings > div').forEach((elem) => { - elem.style.display = 'block'; - }); - }; - } + const settings_search = gradioApp().querySelectorAll('#settings_search > label > textarea')[0]; settings_search.oninput = (e) => { setTimeout(() => { - gradioApp().querySelectorAll('#settings > div').forEach((elem) => { - if (elem.id === 'settings_tab_licenses') return; - elem.style.display = 'block'; - }); + showAllSettings(); gradioApp().querySelectorAll('#tab_settings .tabitem').forEach((section) => { - section.querySelectorAll('.block').forEach((setting) => { + section.querySelectorAll('.dirtyable').forEach((setting) => { const visible = setting.innerText.toLowerCase().includes(e.target.value.toLowerCase()) || setting.id.toLowerCase().includes(e.target.value.toLowerCase()); - if (setting.parentElement.classList.contains('form')) setting.parentElement.style.display = visible ? 'flex' : 'none'; - else setting.style.display = visible ? 'block' : 'none'; + if (!visible) { + setting.style.display = 'none' + } else { + setting.style.removeProperty('display') + } }); }); }, 50); @@ -280,6 +297,86 @@ onOptionsChanged(() => { } }); +onOptionsChanged(function(){ + let setting_elems = gradioApp().querySelectorAll('#settings [id^="setting_"]') + setting_elems.forEach(function(elem){ + setting_name = elem.id.replace("setting_", "") + markIfModified(setting_name, opts[setting_name]) + }) +}) + +onUiLoaded(function(){ + let tab_nav_element = gradioApp().querySelector('#settings > .tab-nav') + let tab_nav_buttons = gradioApp().querySelectorAll('#settings > .tab-nav > button') + let tab_elements = gradioApp().querySelectorAll('#settings > div:not(.tab-nav)') + + // HACK Add mutation observer to keep gradio from closing setting tabs when showing all pages + const observer = new MutationObserver(function(mutations) { + const show_all_pages_dummy = gradioApp().getElementById('settings_show_all_pages') + if (show_all_pages_dummy.style.display == "none") + return; + function mutation_on_style(mut) { + return mut.type === 'attributes' && mut.attributeName === 'style' + } + if (mutations.some(mutation_on_style)) { + showAllSettings(); + } + }) + + // Add a wrapper for the tab content (everything but the tab nav) + const tab_content_wrapper = document.createElement('div') + tab_content_wrapper.className = "tab-content" + tab_nav_element.parentElement.insertBefore(tab_content_wrapper, tab_nav_element.nextSibling) + + tab_elements.forEach(function(elem, index){ + // Move the modification indicator to the toplevel tab button + let tab_name = elem.id.replace("settings_", "") + let indicator = gradioApp().getElementById("modification_indicator_"+tab_name) + tab_nav_element.insertBefore(indicator, tab_nav_buttons[index]) + + // Add the tab content to the wrapper + tab_content_wrapper.appendChild(elem) + + // Add the mutation observer to the tab element + observer.observe(elem, { attributes: true, attributeFilter: ['style'] }) + }) +}) + +function markIfModified(setting_name, value) { + let elem = gradioApp().getElementById("modification_indicator_"+setting_name) + if(elem == null) return; + // Use JSON.stringify to compare nested objects (e.g. arrays for checkbox-groups) + let previous_value_json = JSON.stringify(opts[setting_name]) + let changed_value = JSON.stringify(value) != previous_value_json + if (changed_value) { + elem.title = `Click to revert to previous value: ${previous_value_json}` + } + + is_unsaved = !opts_metadata[setting_name].is_stored + if (is_unsaved) { + elem.title = 'Default value (not saved to config)'; + } + elem.disabled = !(is_unsaved || changed_value) + elem.classList.toggle('changed', changed_value) + elem.classList.toggle('unsaved', is_unsaved) + + let tab_name = opts_metadata[setting_name].tab_name + let changed_items = (opts_tabs[tab_name].changed ||= new Set()) + changed_value ? changed_items.add(setting_name) : changed_items.delete(setting_name) + let unsaved = opts_tabs[tab_name].unsaved_keys + + // Set the indicator on the tab nav element + let tab_nav_indicator = gradioApp().getElementById('modification_indicator_'+tab_name) + tab_nav_indicator.disabled = (changed_items.size == 0) && (unsaved.size == 0) + tab_nav_indicator.title = ''; + tab_nav_indicator.classList.toggle('changed', changed_items.size > 0) + tab_nav_indicator.classList.toggle('unsaved', unsaved.size > 0) + if (changed_items.size > 0) + tab_nav_indicator.title += `Click to reset ${changed_items.size} unapplied change${changed_items.size > 1 ? 's': ''} in this tab.\n` + if (unsaved.size > 0) + tab_nav_indicator.title += `${unsaved.size} new default value${unsaved.size > 1 ? 's':''} (not yet saved).`; +} + let txt2img_textarea; let img2img_textarea; const wait_time = 800; diff --git a/modules/shared.py b/modules/shared.py index 58c7f6ab4..f3b24535e 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -426,7 +426,7 @@ options_templates.update(options_section(('ui', "User interface"), { "ui_extra_networks_tab_reorder": OptionInfo("Checkpoints, Lora, LyCORIS, Textual Inversion, Hypernetworks", "Extra networks tab order"), })) -options_templates.update(options_section(('ui', "Live previews"), { +options_templates.update(options_section(('live-preview', "Live previews"), { "show_progressbar": OptionInfo(True, "Show progressbar"), "live_previews_enable": OptionInfo(True, "Show live previews of the created image"), "show_progress_grid": OptionInfo(True, "Show previews of all images generated in a batch as a grid"), @@ -644,7 +644,13 @@ class Options: def dumpjson(self): d = {k: self.data.get(k, self.data_labels.get(k).default) for k in self.data_labels.keys()} - return json.dumps(d) + metadata = { + k: { + "is_stored": k in self.data, + "tab_name": v.section[0] + } for k, v in self.data_labels.items() + } + return json.dumps({"values": d, "metadata": metadata}) def add_option(self, key, info): self.data_labels[key] = info diff --git a/modules/ui.py b/modules/ui.py index 35dc107f9..082f973bc 100644 --- a/modules/ui.py +++ b/modules/ui.py @@ -1274,6 +1274,18 @@ def create_ui(): else: raise ValueError(f'bad options item type: {t} for key {key}') elem_id = f"setting_{key}" + + if not is_quicksettings: + # FIXME: the visibility is only copied once initially, so if the user changes it, it won't be updated + # This can probably be fixed by using a proper wrapper element + dirtyable_setting = gr.Group(elem_classes="dirtyable", visible=(args or {}).get("visible", True)) + dirtyable_setting.__enter__() + dirty_indicator = gr.Button( + "", + elem_classes="modification-indicator", + elem_id="modification_indicator_" + key + ) + if info.refresh is not None: if is_quicksettings: res = comp(label=info.label, value=fun(), elem_id=elem_id, **(args or {})) @@ -1284,8 +1296,28 @@ def create_ui(): create_refresh_button(res, info.refresh, info.component_args, f"refresh_{key}") else: res = comp(label=info.label, value=fun(), elem_id=elem_id, **(args or {})) + + if not is_quicksettings: + res.change(fn=None, inputs=res, _js=f'(val) => markIfModified("{key}", val)') + dirty_indicator.click(fn=lambda: getattr(opts, key), outputs=res, show_progress=False) + dirtyable_setting.__exit__() + return res + def create_dirty_indicator(key, keys_to_reset, **kwargs): + def get_opt_values(): + return [getattr(opts, _key) for _key in keys_to_reset] + + elements_to_reset = [component_dict[_key] for _key in keys_to_reset] + indicator = gr.Button( + "", + elem_classes="modification-indicator", + elem_id="modification_indicator_" + key, + **kwargs + ) + indicator.click(fn=get_opt_values, outputs=elements_to_reset, show_progress=False) + return indicator + components = [] component_dict = {} modules.shared.settings_components = component_dict @@ -1337,6 +1369,7 @@ def create_ui(): quicksettings_names = {x: i for i, x in enumerate(quicksettings_names) if x != 'quicksettings'} quicksettings_list = [] previous_section = None + tab_item_keys = [] current_tab = None current_row = None with gr.Tabs(elem_id="settings"): @@ -1345,9 +1378,10 @@ def create_ui(): if previous_section != item.section and not section_must_be_skipped: elem_id, text = item.section if current_tab is not None: + create_dirty_indicator(previous_section[0], tab_item_keys) + tab_item_keys = [] current_row.__exit__() current_tab.__exit__() - gr.Group() current_tab = gr.TabItem(elem_id=f"settings_{elem_id}", label=text) current_tab.__enter__() current_row = gr.Column(variant='compact') @@ -1361,15 +1395,20 @@ def create_ui(): else: component = create_setting_component(k) component_dict[k] = component + tab_item_keys.append(k) components.append(component) if current_tab is not None: + create_dirty_indicator(previous_section[0], tab_item_keys) + tab_item_keys = [] current_row.__exit__() current_tab.__exit__() request_notifications = gr.Button(value='Request browser notifications', elem_id="request_notifications", visible=False) - _show_all_pages = gr.Button(value="Show all pages", variant='primary', elem_id="settings_show_all_pages") with gr.TabItem("Licenses", id="licenses", elem_id="settings_tab_licenses"): gr.HTML(modules.shared.html("licenses.html"), elem_id="licenses") + create_dirty_indicator("tab_licenses", [], interactive=False) + with gr.TabItem("Show all pages", variant='primary', elem_id="settings_show_all_pages"): + create_dirty_indicator("show_all_pages", [], interactive=False) def unload_sd_weights(): modules.sd_models.unload_model_weights()