""" ui.py — Reusable Streamlit UI rendering helpers. Keeps all layout/widget code that isn't tied to a single page out of app.py, while keeping utils.py pure-Python and state.py focused on callbacks. """ import os import streamlit as st import plotly.express as px import utils from state import ( updateCategoryOptions, removeCategory, addCategory, applyGlobalRenames, addGlobalRename, removeGlobalRename, _global_rename_key, randomize_speaker_clip, apply_inline_rename, ) # --------------------------------------------------------------------------- # Chart rendering # --------------------------------------------------------------------------- def render_chart(fig, tab, pdf_path, svg_path, pdf_name, svg_name, pdf_key, svg_key, plotly_config=None): """Render a Plotly figure inside a tab with PDF/SVG download buttons.""" cfg = plotly_config or {"displayModeBar": True, "modeBarButtonsToRemove": []} with tab: st.plotly_chart(fig, use_container_width=True, config=cfg) col_l, col_r = st.columns(2) try: fig.write_image(pdf_path) fig.write_image(svg_path) except Exception: pass with col_l: if os.path.exists(pdf_path): with open(pdf_path, "rb") as f: st.download_button("Save As PDF", f, pdf_name, "application/pdf", key=pdf_key, on_click="ignore") with col_r: if os.path.exists(svg_path): with open(svg_path, "rb") as f: st.download_button("Save As SVG", f, svg_name, "image/svg+xml", key=svg_key, on_click="ignore") # --------------------------------------------------------------------------- # Sidebar — categories section # --------------------------------------------------------------------------- def render_categories_sidebar(currFile, categorySelections, all_speakers_display, raw_to_display): """Render category multiselects, remove buttons, and Add category input.""" for i, category in enumerate(st.session_state.categories): ms_key = f"multiselect_{category}" speakerSet = categorySelections[i] default_disp = [raw_to_display.get(sp, sp) for sp in speakerSet] if ms_key not in st.session_state: st.session_state[ms_key] = default_disp st.sidebar.multiselect( category, all_speakers_display, key=ms_key, on_change=updateCategoryOptions, args=(currFile,), ) st.sidebar.button( f"Remove {category}", key=f"remove_{category}", on_click=removeCategory, args=(i,), ) st.sidebar.text_input("Add Category", key="categoryInput", on_change=addCategory) # --------------------------------------------------------------------------- # Sidebar — rename speakers section # --------------------------------------------------------------------------- def render_rename_sidebar(currFile, speakerNames, all_speaker_tokens): """Render the full Rename Speakers sidebar section.""" st.sidebar.divider() st.sidebar.subheader("Rename Speakers") st.sidebar.markdown( "

" "Assign a name and select which speaker labels (across all files) it applies to. " "Changes apply to all matched speakers instantly.

", unsafe_allow_html=True, ) st.sidebar.divider() def _on_grename_change(idx): st.session_state.globalRenames[idx]["speakers"] = list( st.session_state[_global_rename_key(idx)] ) applyGlobalRenames() for idx, entry in enumerate(st.session_state.globalRenames): grkey = _global_rename_key(idx) if grkey not in st.session_state: st.session_state[grkey] = list(entry["speakers"]) st.sidebar.markdown(f"**{entry['name']}**") st.sidebar.multiselect( f"Speakers for {entry['name']}", options=all_speaker_tokens, key=grkey, on_change=_on_grename_change, args=(idx,), label_visibility="collapsed", ) st.sidebar.button( f"Remove '{entry['name']}'", key=f"remove_grename_{idx}", on_click=removeGlobalRename, args=(idx,), ) st.sidebar.text_input( "Add rename", placeholder="e.g. John", key="globalRenameInput", on_change=addGlobalRename, ) # --------------------------------------------------------------------------- # Data tab — custom scrollable table # --------------------------------------------------------------------------- def render_data_table(tableDF, speakerNames, raw_to_display, currFile): """Render the custom column-based scrollable table (Speaker, Start, Finish).""" other_cols = [c for c in tableDF.columns if c != "Speaker"] col_widths = [2] + [1] * len(other_cols) # Header header_cols = st.columns(col_widths) header_cols[0].markdown("**Speaker**") for i, col_name in enumerate(other_cols): header_cols[i + 1].markdown(f"**{col_name}**") st.markdown("
", unsafe_allow_html=True) with st.container(height=480, border=False): for _, row in tableDF.iterrows(): row_cols = st.columns(col_widths) display_sp = row["Speaker"] row_cols[0].write(display_sp) for i, col_name in enumerate(other_cols): row_cols[i + 1].write(row[col_name]) # --------------------------------------------------------------------------- # Rename Speaker tab — speaker / audio sample table # --------------------------------------------------------------------------- def render_speaker_samples_tab(speakerNames, raw_to_display, currFile): """Render a table: Speaker (with inline ✎ rename + history dropdown) | Audio Sample | ↺ button.""" file_samples = st.session_state.speakerClips.get(currFile, {}) has_waveform = currFile in st.session_state.speakerWaveforms has_samples = bool(file_samples) if "inline_rename_active" not in st.session_state: st.session_state.inline_rename_active = {} if "inline_rename_history" not in st.session_state: st.session_state.inline_rename_history = [] # Header header_cols = st.columns([3, 3, 1]) header_cols[0].markdown("**Speaker**") header_cols[1].markdown("**Audio Sample**") header_cols[2].markdown("** **", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) if not has_samples: st.info("Audio samples are only available for files analyzed from audio (not RTTM/CSV/TXT).") for sp in speakerNames: display_name = raw_to_display.get(sp, sp) edit_key = (currFile, sp) is_editing = st.session_state.inline_rename_active.get(edit_key, False) row_cols = st.columns([3, 3, 1]) # --- Speaker cell --- if is_editing: select_key = f"inline_rename_select_{currFile}_{sp}" input_key = f"inline_rename_input_{currFile}_{sp}" confirm_key = f"inline_rename_confirm_{currFile}_{sp}" cancel_key = f"inline_rename_cancel_{currFile}_{sp}" history = st.session_state.inline_rename_history current_val = display_name if display_name != sp else "" with row_cols[0]: if history: # Selectbox with index=None shows placeholder and lets user # type to filter — single combined widget, no separate input. options = history default_idx = history.index(current_val) if current_val in history else None chosen = st.selectbox( "Rename", options=options, index=default_idx, key=select_key, label_visibility="collapsed", placeholder="Type or select a name…", ) new_name = chosen or "" else: # No history yet — plain text input new_name = st.text_input( "Rename", value=current_val, key=input_key, label_visibility="collapsed", placeholder=f"Rename {sp}…", ) btn_col1, btn_col2 = st.columns(2) if btn_col1.button("✓", key=confirm_key, help="Confirm rename"): confirmed = new_name.strip() if confirmed: apply_inline_rename(currFile, sp, confirmed) if confirmed not in st.session_state.inline_rename_history: st.session_state.inline_rename_history.append(confirmed) st.session_state.inline_rename_active[edit_key] = False st.rerun() if btn_col2.button("✕", key=cancel_key, help="Cancel"): st.session_state.inline_rename_active[edit_key] = False st.rerun() else: with row_cols[0]: name_col, pencil_col = st.columns([4, 1]) name_col.write(display_name) if pencil_col.button( "✎", key=f"inline_rename_edit_{currFile}_{sp}", help=f"Rename {sp}", ): st.session_state.inline_rename_active[edit_key] = True st.rerun() # --- Audio sample cell --- if sp in file_samples: row_cols[1].audio(file_samples[sp], format="audio/wav") sp_segs = st.session_state.speakerSegments.get(currFile, {}).get(sp, []) if has_waveform and sp_segs: if row_cols[2].button( "↺", key=f"sample_randomize_{currFile}_{sp}", help="Try a different audio sample for this speaker", ): randomize_speaker_clip(currFile, sp) st.rerun() else: row_cols[1].write("—") # --------------------------------------------------------------------------- # Multi-file summary expander # --------------------------------------------------------------------------- def render_multifile_summary(plotly_config=None): """Render the Multi-file Summary Data expander if enough files are analyzed.""" cfg = plotly_config or {"displayModeBar": True, "modeBarButtonsToRemove": []} if not st.session_state.results: return with st.expander("Multi-file Summary Data"): st.header("Multi-file Summary Data") with st.spinner("Processing summary results..."): validNames = [ fn for fn in st.session_state.file_names if fn in st.session_state.results and len(st.session_state.results[fn]) == 2 ] if len(validNames) <= 1: return df6, allCategories = utils.build_multifile_category_df( validNames, st.session_state.results, st.session_state.summaries, st.session_state.categories, st.session_state.categorySelect, ) st.plotly_chart( px.bar(df6, x="files", y=allCategories, title="Time Spoken by Each Speaker in Each File"), use_container_width=True, config=cfg, ) df7, _ = utils.build_multifile_voice_df(validNames, st.session_state.summaries) for sort_cols, ascending, title in [ (["One Voice", "Multi Voice"], True, "Cross-file Voice Categories sorted for One Voice"), (["Multi Voice","One Voice"], True, "Cross-file Voice Categories sorted for Multi Voice"), (["No Voice", "Multi Voice"], False, "Cross-file Voice Categories sorted for Any Voice"), ]: st.plotly_chart( px.bar(df7.sort_values(by=sort_cols, ascending=ascending), x="files", y=["One Voice", "Multi Voice", "No Voice"], title=title), use_container_width=True, config=cfg, )