From 9f141ba42f6da408b3fb7305463e5e4c2d409234 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 26 Feb 2026 18:02:24 +0100 Subject: [PATCH] Fix input sync bugs, improve LoRA UX, and harden edge cases - Sync dict_input/dict_textarea/LoRA inputs on update:model-value (not just blur) to prevent silent data loss on quick saves - Split LoRA into name + strength fields, default strength to 1.0 - Stack LoRAs one per line instead of 3-card row - Collapse "Add New Sequence from Source File" into expansion - Add file selector to Pane A in dual-pane mode - Clear secondary pane state on directory change - Fix file radio resetting to first file on refresh - Handle bare-list JSON files and inf/nan edge cases Co-Authored-By: Claude Opus 4.6 --- main.py | 31 +++++--- tab_batch_ng.py | 190 ++++++++++++++++++++++++++++-------------------- 2 files changed, 131 insertions(+), 90 deletions(-) diff --git a/main.py b/main.py index 32b9fbe..f99f00e 100644 --- a/main.py +++ b/main.py @@ -212,34 +212,35 @@ def index(): with ui.row().classes('w-full gap-4'): with ui.column().classes('col'): ui.label('Pane A').classes('section-header q-mb-sm') + _render_pane_file_selector(state) render_batch_processor(state) with ui.column().classes('col pane-secondary'): ui.label('Pane B').classes('section-header q-mb-sm') - _render_secondary_file_selector(s2) + _render_pane_file_selector(s2) if s2.file_path and s2.file_path.exists(): render_batch_processor(s2) else: ui.label('Select a file above to begin.').classes( 'text-caption q-pa-md') - def _render_secondary_file_selector(s2: AppState): - json_files = sorted(s2.current_dir.glob('*.json')) + def _render_pane_file_selector(pane_state: AppState): + json_files = sorted(pane_state.current_dir.glob('*.json')) json_files = [f for f in json_files if f.name not in ( '.editor_config.json', '.editor_snippets.json')] file_names = [f.name for f in json_files] - current_val = s2.file_path.name if s2.file_path else None + current_val = pane_state.file_path.name if pane_state.file_path else None def on_select(e): if not e.value: return - fp = s2.current_dir / e.value + fp = pane_state.current_dir / e.value data, mtime = load_json(fp) - s2.data_cache = data - s2.last_mtime = mtime - s2.loaded_file = str(fp) - s2.file_path = fp - s2.restored_indicator = None + pane_state.data_cache = data + pane_state.last_mtime = mtime + pane_state.loaded_file = str(fp) + pane_state.file_path = fp + pane_state.restored_indicator = None _render_batch_tab_content.refresh() ui.select( @@ -301,6 +302,9 @@ def render_sidebar(state: AppState, dual_pane: dict): state.current_dir = p if dual_pane['state']: dual_pane['state'].current_dir = state.current_dir + dual_pane['state'].file_path = None + dual_pane['state'].loaded_file = None + dual_pane['state'].data_cache = {} state.config['last_dir'] = str(p) save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None @@ -344,6 +348,9 @@ def render_sidebar(state: AppState, dual_pane: dict): state.current_dir = Path(fav) if dual_pane['state']: dual_pane['state'].current_dir = state.current_dir + dual_pane['state'].file_path = None + dual_pane['state'].loaded_file = None + dual_pane['state'].data_cache = {} state.config['last_dir'] = fav save_config(state.current_dir, state.config['favorites'], state.config) state.loaded_file = None @@ -443,9 +450,11 @@ def render_sidebar(state: AppState, dual_pane: dict): ui.label('Select File').classes('subsection-header q-mt-sm') file_names = [f.name for f in json_files] + current = Path(state.loaded_file).name if state.loaded_file else None + selected = current if current in file_names else (file_names[0] if file_names else None) ui.radio( file_names, - value=file_names[0] if file_names else None, + value=selected, on_change=lambda e: state._load_file(e.value) if e.value else None, ).classes('w-full') diff --git a/tab_batch_ng.py b/tab_batch_ng.py index ab3d3cc..a41fab3 100644 --- a/tab_batch_ng.py +++ b/tab_batch_ng.py @@ -79,12 +79,17 @@ def find_insert_position(batch_list, parent_index, parent_seq_num): # --- Helper for repetitive dict-bound inputs --- def dict_input(element_fn, label, seq, key, **kwargs): - """Create an input element bound to seq[key] via blur event.""" + """Create an input element bound to seq[key] via blur and model-value update.""" val = seq.get(key, '') if isinstance(val, (int, float)): val = str(val) if element_fn != ui.number else val el = element_fn(label, value=val, **kwargs) - el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value)) + + def _sync(k=key): + seq[k] = el.value + + el.on('blur', lambda _: _sync()) + el.on('update:model-value', lambda _: _sync()) return el @@ -95,7 +100,7 @@ def dict_number(label, seq, key, default=0, **kwargs): # Try float first to handle "1.5" strings, then check if it's a clean int fval = float(val) val = int(fval) if fval == int(fval) else fval - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): val = default el = ui.number(label, value=val, **kwargs) @@ -103,8 +108,11 @@ def dict_number(label, seq, key, default=0, **kwargs): v = el.value if v is None: v = d - elif isinstance(v, float) and v == int(v): - v = int(v) + elif isinstance(v, float): + try: + v = int(v) if v == int(v) else v + except (OverflowError, ValueError): + v = d seq[k] = v el.on('blur', lambda _: _sync()) @@ -113,9 +121,14 @@ def dict_number(label, seq, key, default=0, **kwargs): def dict_textarea(label, seq, key, **kwargs): - """Textarea bound to seq[key] via blur.""" + """Textarea bound to seq[key] via blur and model-value update.""" el = ui.textarea(label, value=seq.get(key, ''), **kwargs) - el.on('blur', lambda e, k=key: seq.__setitem__(k, e.sender.value)) + + def _sync(k=key): + seq[k] = el.value + + el.on('blur', lambda _: _sync()) + el.on('update:model-value', lambda _: _sync()) return el @@ -126,7 +139,10 @@ def dict_textarea(label, seq, key, **kwargs): def render_batch_processor(state: AppState): data = state.data_cache file_path = state.file_path - is_batch_file = KEY_BATCH_DATA in data or isinstance(data, list) + if isinstance(data, list): + data = {KEY_BATCH_DATA: data} + state.data_cache = data + is_batch_file = KEY_BATCH_DATA in data if not is_batch_file: ui.label('This is a Single file. To use Batch mode, create a copy.').classes( @@ -158,67 +174,65 @@ def render_batch_processor(state: AppState): # Source file data for importing with ui.card().classes('w-full q-pa-md q-mb-lg'): - json_files = sorted(state.current_dir.glob('*.json')) - json_files = [f for f in json_files if f.name not in ( - '.editor_config.json', '.editor_snippets.json')] - file_options = {f.name: f.name for f in json_files} + with ui.expansion('Add New Sequence from Source File', icon='playlist_add').classes('w-full'): + json_files = sorted(state.current_dir.glob('*.json')) + json_files = [f for f in json_files if f.name not in ( + '.editor_config.json', '.editor_snippets.json')] + file_options = {f.name: f.name for f in json_files} - src_file_select = ui.select( - file_options, - value=file_path.name, - label='Source File:', - ).classes('w-64') + src_file_select = ui.select( + file_options, + value=file_path.name, + label='Source File:', + ).classes('w-64') - src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') + src_seq_select = ui.select([], label='Source Sequence:').classes('w-64') - # Track loaded source data - _src_cache = {'data': None, 'batch': [], 'name': None} + # Track loaded source data + _src_cache = {'data': None, 'batch': [], 'name': None} - def _update_src(): - name = src_file_select.value - if name and name != _src_cache['name']: - src_data, _ = load_json(state.current_dir / name) - _src_cache['data'] = src_data - _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) - _src_cache['name'] = name - if _src_cache['batch']: - opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) - for i, s in enumerate(_src_cache['batch'])} - src_seq_select.set_options(opts, value=0) - else: - src_seq_select.set_options({}) + def _update_src(): + name = src_file_select.value + if name and name != _src_cache['name']: + src_data, _ = load_json(state.current_dir / name) + _src_cache['data'] = src_data + _src_cache['batch'] = src_data.get(KEY_BATCH_DATA, []) + _src_cache['name'] = name + if _src_cache['batch']: + opts = {i: format_seq_label(s.get(KEY_SEQUENCE_NUMBER, i+1)) + for i, s in enumerate(_src_cache['batch'])} + src_seq_select.set_options(opts, value=0) + else: + src_seq_select.set_options({}) - src_file_select.on_value_change(lambda _: _update_src()) - _update_src() + src_file_select.on_value_change(lambda _: _update_src()) + _update_src() - # --- Add New Sequence --- - ui.label('Add New Sequence').classes('section-header q-mt-md') + def _add_sequence(new_item): + new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 + for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: + new_item.pop(k, None) + batch_list.append(new_item) + data[KEY_BATCH_DATA] = batch_list + save_json(file_path, data) + render_sequence_list.refresh() - def _add_sequence(new_item): - new_item[KEY_SEQUENCE_NUMBER] = max_main_seq_number(batch_list) + 1 - for k in [KEY_PROMPT_HISTORY, KEY_HISTORY_TREE, 'note', 'loras']: - new_item.pop(k, None) - batch_list.append(new_item) - data[KEY_BATCH_DATA] = batch_list - save_json(file_path, data) - render_sequence_list.refresh() + with ui.row().classes('q-mt-sm'): + def add_empty(): + _add_sequence(DEFAULTS.copy()) - with ui.row(): - def add_empty(): - _add_sequence(DEFAULTS.copy()) + def add_from_source(): + item = copy.deepcopy(DEFAULTS) + src_batch = _src_cache['batch'] + sel_idx = src_seq_select.value + if src_batch and sel_idx is not None: + item.update(copy.deepcopy(src_batch[int(sel_idx)])) + elif _src_cache['data']: + item.update(copy.deepcopy(_src_cache['data'])) + _add_sequence(item) - def add_from_source(): - item = copy.deepcopy(DEFAULTS) - src_batch = _src_cache['batch'] - sel_idx = src_seq_select.value - if src_batch and sel_idx is not None: - item.update(copy.deepcopy(src_batch[int(sel_idx)])) - elif _src_cache['data']: - item.update(copy.deepcopy(_src_cache['data'])) - _add_sequence(item) - - ui.button('Add Empty', icon='add', on_click=add_empty) - ui.button('From Source', icon='file_download', on_click=add_from_source) + ui.button('Add Empty', icon='add', on_click=add_empty) + ui.button('From Source', icon='file_download', on_click=add_from_source) # --- Standard / LoRA / VACE key sets --- lora_keys = ['lora 1 high', 'lora 1 low', 'lora 2 high', 'lora 2 low', @@ -456,28 +470,46 @@ def _render_sequence_card(i, seq, batch_list, data, file_path, state, # --- LoRA Settings --- with ui.expansion('LoRA Settings', icon='style').classes('w-full'): - with ui.row().classes('w-full q-gutter-md'): - for lora_idx in range(1, 4): - with ui.card().classes('col q-pa-sm surface-3'): - ui.label(f'LoRA {lora_idx}').classes('text-subtitle2') - for tier, tier_label in [('high', 'High'), ('low', 'Low')]: - k = f'lora {lora_idx} {tier}' - raw = str(seq.get(k, '')) - disp = raw.replace('', '') + for lora_idx in range(1, 4): + for tier, tier_label in [('high', 'High'), ('low', 'Low')]: + k = f'lora {lora_idx} {tier}' + raw = str(seq.get(k, '')) + inner = raw.replace('', '') + # Split "name:strength" or just "name" + if ':' in inner: + parts = inner.rsplit(':', 1) + lora_name = parts[0] + try: + lora_strength = float(parts[1]) + except ValueError: + lora_name = inner + lora_strength = 1.0 + else: + lora_name = inner + lora_strength = 1.0 - with ui.row().classes('w-full items-center'): - ui.label('').classes('text-caption font-mono') + with ui.row().classes('w-full items-center q-gutter-sm'): + ui.label(f'L{lora_idx} {tier_label}').classes( + 'text-caption').style('min-width: 55px') + name_input = ui.input( + 'Name', + value=lora_name, + ).classes('col').props('outlined dense') + strength_input = ui.number( + 'Str', + value=lora_strength, + min=0, max=10, step=0.1, + ).props('outlined dense').style('max-width: 80px') - def on_lora_blur(e, key=k): - v = e.sender.value - seq[key] = f'' if v else '' + def _lora_sync(key=k, n_inp=name_input, s_inp=strength_input): + name = n_inp.value or '' + strength = s_inp.value if s_inp.value is not None else 1.0 + seq[key] = f'' if name else '' - lora_input.on('blur', on_lora_blur) + name_input.on('blur', lambda _, s=_lora_sync: s()) + name_input.on('update:model-value', lambda _, s=_lora_sync: s()) + strength_input.on('blur', lambda _, s=_lora_sync: s()) + strength_input.on('update:model-value', lambda _, s=_lora_sync: s()) # --- Custom Parameters --- ui.label('Custom Parameters').classes('section-header q-mt-md')