"""Pestaña de Proceso de Limpieza de CSV. Permite al usuario cargar un CSV, ver un resumen inteligente por columna y aplicar reglas de limpieza seleccionables antes de exportar el resultado. """ from __future__ import annotations from dataclasses import dataclass from tkinter import ttk from typing import Optional import customtkinter as ctk import pandas as pd from application.csv_cleaning import CLEANING_RULES, ColumnSummary from gui.styles import AppStyles _PREVIEW_MAX_ROWS = 50 _PREVIEW_MAX_CELL_LEN = 80 _TYPE_LABELS = { "integer": "Entero", "decimal": "Decimal", "date": "Fecha", "boolean": "Booleano", "categorical": "Categórico", "text": "Texto", "mixed": "Mixto", "empty": "Vacío", } # Color por tipo (badge). Tuplas (light, dark) compatibles con CTk. _TYPE_COLORS: dict[str, tuple[str, str]] = { "integer": ("#1F6FEB", "#1F6FEB"), "decimal": ("#0EA5E9", "#0EA5E9"), "date": ("#D97706", "#D97706"), "boolean": ("#7C3AED", "#7C3AED"), "categorical": ("#16A34A", "#16A34A"), "text": ("#475569", "#64748B"), "mixed": ("#DC2626", "#DC2626"), "empty": ("#6B7280", "#6B7280"), } _RULE_SHORT_LABELS: dict[str, str] = { "strip_special_spaces": "Sin especiales/espacios", "strip_special_keep_date_seps": "Sin especiales (mantiene / - espacio)", "strip_spaces": "Sin espacios", "to_integer": "A entero", "date_numeric_to_ddmmyyyy": "Fecha numérica → DD/MM/AAAA", "date_alpha_to_ddmmyyyy": "Fecha alfanumérica → DD/MM/AAAA", "round_decimals_custom": "Aprox. decimales (>0.5 sube)", } def _fmt_number(x) -> str: """Formatea un número para mostrar en chip (entero limpio o 2 decimales).""" if x is None: return "—" try: f = float(x) except (TypeError, ValueError): return str(x) if f.is_integer(): return f"{int(f):,}" return f"{f:,.2f}" @dataclass class _ColumnUI: """Referencias a los widgets de una tarjeta de columna.""" card: ctk.CTkFrame body: ctk.CTkFrame toggle_btn: ctk.CTkButton count_label: ctk.CTkLabel rule_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] summary: ColumnSummary expanded: bool = True class CleaningTab: """UI de la pestaña Proceso de Limpieza.""" def __init__(self, parent_tab): self.tab = parent_tab self.fonts = AppStyles.get_fonts() self.colors = AppStyles.get_colors() self.spacing = AppStyles.get_spacing() self._column_uis: dict[str, _ColumnUI] = {} self._search_var: Optional[ctk.StringVar] = None self._setup_layout() self._create_file_section() self._create_preview_section() self._create_bulk_actions_section() self._create_columns_section() self._create_action_section() self._create_result_section() # -- Layout ------------------------------------------------------------------ def _setup_layout(self): self.tab.grid_columnconfigure(0, weight=1) self.tab.grid_rowconfigure(0, weight=1) self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent") self.scroll.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) self.scroll.grid_columnconfigure(0, weight=1) # -- Sección archivo --------------------------------------------------------- def _create_file_section(self): frame = ctk.CTkFrame(self.scroll, corner_radius=8) frame.grid(row=0, column=0, sticky="ew", padx=5, pady=(5, 4)) frame.grid_columnconfigure(2, weight=1) lbl_title = ctk.CTkLabel( frame, text="Archivo CSV", font=self.fonts["section"], ) lbl_title.grid(row=0, column=0, columnspan=4, sticky="w", padx=15, pady=(10, 4)) self.btn_select_csv = ctk.CTkButton( frame, text="📂 Seleccionar CSV", font=self.fonts["ui"], width=170, fg_color=self.colors["primary_button"], hover_color=self.colors["primary_hover"], ) self.btn_select_csv.grid(row=1, column=0, padx=(15, 8), pady=(0, 12), sticky="w") self.lbl_csv_path = ctk.CTkLabel( frame, text="Ningún archivo seleccionado", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], anchor="w", ) self.lbl_csv_path.grid(row=1, column=1, columnspan=2, padx=4, pady=(0, 12), sticky="ew") # Chips informativos: filas, columnas, delimitador chips_frame = ctk.CTkFrame(frame, fg_color="transparent") chips_frame.grid(row=1, column=3, padx=(8, 15), pady=(0, 12), sticky="e") self.chip_rows = self._make_chip(chips_frame, "Filas: —") self.chip_rows.pack(side="left", padx=(0, 6)) self.chip_cols = self._make_chip(chips_frame, "Columnas: —") self.chip_cols.pack(side="left", padx=(0, 6)) self.chip_delim = self._make_chip(chips_frame, "Delim: —") self.chip_delim.pack(side="left") def _make_chip(self, parent, text: str) -> ctk.CTkLabel: return ctk.CTkLabel( parent, text=text, font=self.fonts["italic_small"], fg_color=("gray85", "gray25"), corner_radius=12, padx=10, pady=2, ) # -- Vista previa ------------------------------------------------------------ def _create_preview_section(self): frame = ctk.CTkFrame(self.scroll, corner_radius=8) frame.grid(row=1, column=0, sticky="ew", padx=5, pady=4) frame.grid_columnconfigure(0, weight=1) header = ctk.CTkFrame(frame, fg_color="transparent") header.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4)) header.grid_columnconfigure(0, weight=1) lbl = ctk.CTkLabel(header, text="Vista previa", font=self.fonts["section"]) lbl.pack(side="left") self.lbl_preview_info = ctk.CTkLabel( header, text="(sin datos)", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) self.lbl_preview_info.pack(side="left", padx=(12, 0)) self.btn_toggle_preview = ctk.CTkButton( header, text="Ocultar", width=80, height=24, font=self.fonts["italic_small"], fg_color="transparent", border_width=1, command=self._on_toggle_preview, ) self.btn_toggle_preview.pack(side="right") self.preview_table_holder = ctk.CTkFrame( frame, fg_color=("gray92", "gray18"), corner_radius=6, height=220, ) self.preview_table_holder.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10)) self.preview_table_holder.grid_propagate(False) self.preview_table_holder.grid_columnconfigure(0, weight=1) self.preview_table_holder.grid_rowconfigure(0, weight=1) self._preview_tree: Optional[ttk.Treeview] = None self._preview_visible = True self._render_table_placeholder( self.preview_table_holder, "Selecciona un CSV para ver la vista previa.", ) def _on_toggle_preview(self): if self._preview_visible: self.preview_table_holder.grid_remove() self.btn_toggle_preview.configure(text="Mostrar") else: self.preview_table_holder.grid() self.btn_toggle_preview.configure(text="Ocultar") self._preview_visible = not self._preview_visible # -- Acciones masivas -------------------------------------------------------- def _create_bulk_actions_section(self): frame = ctk.CTkFrame(self.scroll, corner_radius=8) frame.grid(row=2, column=0, sticky="ew", padx=5, pady=4) frame.grid_columnconfigure(0, weight=1) header = ctk.CTkFrame(frame, fg_color="transparent") header.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4)) header.grid_columnconfigure(0, weight=1) lbl = ctk.CTkLabel( header, text="Acciones rápidas", font=self.fonts["section"], ) lbl.pack(side="left") body = ctk.CTkFrame(frame, fg_color="transparent") body.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10)) body.grid_columnconfigure(2, weight=1) # Buscador lbl_search = ctk.CTkLabel(body, text="🔍 Buscar columna:", font=self.fonts["ui"]) lbl_search.grid(row=0, column=0, padx=(0, 6), pady=4, sticky="w") self._search_var = ctk.StringVar(value="") self._search_var.trace_add("write", lambda *_: self._apply_search_filter()) self.entry_search = ctk.CTkEntry( body, textvariable=self._search_var, width=240, placeholder_text="nombre de columna...", ) self.entry_search.grid(row=0, column=1, padx=(0, 12), pady=4, sticky="w") # Botones colapsar/expandir todas btns_right = ctk.CTkFrame(body, fg_color="transparent") btns_right.grid(row=0, column=2, sticky="e") self.btn_expand_all = ctk.CTkButton( btns_right, text="Expandir todas", width=130, height=28, font=self.fonts["ui"], fg_color="transparent", border_width=1, command=lambda: self._set_all_expanded(True), ) self.btn_expand_all.pack(side="left", padx=4) self.btn_collapse_all = ctk.CTkButton( btns_right, text="Colapsar todas", width=130, height=28, font=self.fonts["ui"], fg_color="transparent", border_width=1, command=lambda: self._set_all_expanded(False), ) self.btn_collapse_all.pack(side="left", padx=4) self.btn_clear_rules = ctk.CTkButton( btns_right, text="Limpiar selección", width=140, height=28, font=self.fonts["ui"], fg_color=self.colors["danger"], hover_color=self.colors["danger_hover"], command=self._clear_all_rules, ) self.btn_clear_rules.pack(side="left", padx=4) # Aplicar regla a todas las de un tipo bulk_row = ctk.CTkFrame(body, fg_color="transparent") bulk_row.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(8, 0)) lbl_bulk = ctk.CTkLabel( bulk_row, text="Aplicar regla a todas las columnas de tipo:", font=self.fonts["ui"], ) lbl_bulk.pack(side="left", padx=(0, 6)) type_options = list(_TYPE_LABELS.values()) self._bulk_type_var = ctk.StringVar(value=type_options[0]) self.bulk_type_menu = ctk.CTkOptionMenu( bulk_row, values=type_options, variable=self._bulk_type_var, width=130, font=self.fonts["ui"], ) self.bulk_type_menu.pack(side="left", padx=(0, 6)) rule_options = [r["name"] for r in CLEANING_RULES] self._bulk_rule_var = ctk.StringVar(value=rule_options[0]) self.bulk_rule_menu = ctk.CTkOptionMenu( bulk_row, values=rule_options, variable=self._bulk_rule_var, width=380, font=self.fonts["ui"], ) self.bulk_rule_menu.pack(side="left", padx=(0, 6)) self.btn_bulk_apply = ctk.CTkButton( bulk_row, text="Aplicar a todas", width=140, height=28, font=self.fonts["ui"], fg_color=self.colors["secondary_button"], hover_color=self.colors["secondary_hover"], command=self._on_bulk_apply, ) self.btn_bulk_apply.pack(side="left", padx=4) # -- Columnas y reglas ------------------------------------------------------- def _create_columns_section(self): frame = ctk.CTkFrame(self.scroll, corner_radius=8) frame.grid(row=3, column=0, sticky="ew", padx=5, pady=4) frame.grid_columnconfigure(0, weight=1) header = ctk.CTkFrame(frame, fg_color="transparent") header.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4)) header.grid_columnconfigure(0, weight=1) lbl = ctk.CTkLabel( header, text="Resumen y reglas por columna", font=self.fonts["section"], ) lbl.pack(side="left") self.lbl_global_count = ctk.CTkLabel( header, text="0 reglas seleccionadas", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) self.lbl_global_count.pack(side="right") self.columns_container = ctk.CTkScrollableFrame( frame, fg_color="transparent", height=420, ) self.columns_container.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10)) self.columns_container.grid_columnconfigure(0, weight=1) self._empty_label = ctk.CTkLabel( self.columns_container, text="Selecciona un CSV para ver el resumen de columnas.", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) self._empty_label.grid(row=0, column=0, sticky="w", padx=10, pady=20) # -- Acción ------------------------------------------------------------------ def _create_action_section(self): frame = ctk.CTkFrame(self.scroll, corner_radius=8) frame.grid(row=4, column=0, sticky="ew", padx=5, pady=4) frame.grid_columnconfigure(1, weight=1) self.btn_apply = ctk.CTkButton( frame, text="✨ Aplicar limpieza", font=self.fonts["button_large"], fg_color=self.colors["primary_button"], hover_color=self.colors["primary_hover"], height=42, width=220, ) self.btn_apply.grid(row=0, column=0, padx=15, pady=12, sticky="w") self.lbl_status = ctk.CTkLabel( frame, text="", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], anchor="w", ) self.lbl_status.grid(row=0, column=1, padx=10, pady=12, sticky="ew") # -- Resultado --------------------------------------------------------------- def _create_result_section(self): frame = ctk.CTkFrame(self.scroll, corner_radius=8) frame.grid(row=5, column=0, sticky="ew", padx=5, pady=(4, 6)) frame.grid_columnconfigure(0, weight=1) top = ctk.CTkFrame(frame, fg_color="transparent") top.grid(row=0, column=0, sticky="ew", padx=15, pady=(10, 4)) top.grid_columnconfigure(0, weight=1) lbl = ctk.CTkLabel( top, text="Vista previa del resultado", font=self.fonts["section"], ) lbl.pack(side="left") self.lbl_result_info = ctk.CTkLabel( top, text="", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) self.lbl_result_info.pack(side="left", padx=(12, 0)) self.btn_save = ctk.CTkButton( top, text="💾 Guardar CSV", font=self.fonts["ui"], width=160, height=32, fg_color=self.colors["success"], hover_color=self.colors["success_hover"], state="disabled", ) self.btn_save.pack(side="right") self.result_table_holder = ctk.CTkFrame( frame, fg_color=("gray92", "gray18"), corner_radius=6, height=260, ) self.result_table_holder.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 10)) self.result_table_holder.grid_propagate(False) self.result_table_holder.grid_columnconfigure(0, weight=1) self.result_table_holder.grid_rowconfigure(0, weight=1) self._result_tree: Optional[ttk.Treeview] = None self._render_table_placeholder( self.result_table_holder, "Aplica reglas para ver el resultado.", ) # =========================================================================== # API pública # =========================================================================== def set_on_select_csv(self, callback): self.btn_select_csv.configure(command=callback) def set_on_apply(self, callback): self.btn_apply.configure(command=callback) def set_on_save(self, callback): self.btn_save.configure(command=callback) def set_csv_path(self, path: str): self.lbl_csv_path.configure(text=path or "Ningún archivo seleccionado") def set_status(self, text: str): self.lbl_status.configure(text=text) def set_preview(self, df: Optional[pd.DataFrame]): """Renderiza el DataFrame original en la tabla de vista previa.""" if df is None or len(df) == 0: self._render_table_placeholder( self.preview_table_holder, "(sin datos)", ) self.lbl_preview_info.configure(text="(sin datos)") self._preview_tree = None return info = ( f"{len(df):,} filas · {len(df.columns)} columnas · " f"mostrando primeras {min(_PREVIEW_MAX_ROWS, len(df))}" ) self.lbl_preview_info.configure(text=info) self._preview_tree = self._render_dataframe_table( self.preview_table_holder, df, ) self.chip_rows.configure(text=f"Filas: {len(df):,}") self.chip_cols.configure(text=f"Columnas: {len(df.columns)}") def set_result(self, df: Optional[pd.DataFrame], info_text: str = ""): """Renderiza el DataFrame resultante en la tabla de resultado.""" if df is None or len(df) == 0: self._render_table_placeholder( self.result_table_holder, "(sin datos)", ) self.lbl_result_info.configure(text=info_text) self._result_tree = None return summary = ( f"{len(df):,} filas · {len(df.columns)} columnas · " f"mostrando primeras {min(_PREVIEW_MAX_ROWS, len(df))}" ) if info_text: summary = f"{summary} · {info_text}" self.lbl_result_info.configure(text=summary) self._result_tree = self._render_dataframe_table( self.result_table_holder, df, ) def enable_save(self, enabled: bool): self.btn_save.configure(state="normal" if enabled else "disabled") def enable_controls(self, enabled: bool): state = "normal" if enabled else "disabled" self.btn_select_csv.configure(state=state) self.btn_apply.configure(state=state) self.btn_bulk_apply.configure(state=state) self.btn_clear_rules.configure(state=state) self.btn_expand_all.configure(state=state) self.btn_collapse_all.configure(state=state) def populate_columns(self, summaries: list[ColumnSummary]): """Renderiza la lista de columnas con sus reglas aplicables.""" for child in list(self.columns_container.winfo_children()): child.destroy() self._column_uis.clear() if self._search_var is not None: try: self._search_var.set("") except Exception: pass if not summaries: empty = ctk.CTkLabel( self.columns_container, text="El CSV no tiene columnas.", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) empty.grid(row=0, column=0, sticky="w", padx=10, pady=20) self._update_global_count() self.chip_cols.configure(text=f"Columnas: 0") return rules_by_id = {r["id"]: r for r in CLEANING_RULES} for row_idx, summary in enumerate(summaries): ui = self._build_column_card(row_idx, summary, rules_by_id) self._column_uis[summary.name] = ui self.chip_cols.configure(text=f"Columnas: {len(summaries)}") self._update_global_count() def get_selected_rules(self) -> dict[str, list[str]]: """Devuelve un mapa columna -> lista de rule_ids en el orden visual.""" result: dict[str, list[str]] = {} for col, ui in self._column_uis.items(): selected = [rid for rid, _chk, var in ui.rule_vars if bool(var.get())] if selected: result[col] = selected return result # =========================================================================== # Construcción de tarjeta de columna # =========================================================================== def _build_column_card( self, row_idx: int, summary: ColumnSummary, rules_by_id: dict, ) -> _ColumnUI: card = ctk.CTkFrame(self.columns_container, corner_radius=8, border_width=1) card.grid(row=row_idx, column=0, sticky="ew", padx=2, pady=4) card.grid_columnconfigure(0, weight=1) # ---- Header ---- header = ctk.CTkFrame(card, fg_color="transparent") header.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4)) header.grid_columnconfigure(2, weight=1) toggle_btn = ctk.CTkButton( header, text="▼", width=28, height=28, font=self.fonts["ui"], fg_color="transparent", border_width=0, hover=True, ) toggle_btn.grid(row=0, column=0, padx=(0, 6), sticky="w") type_color = _TYPE_COLORS.get(summary.inferred_type, ("gray60", "gray40")) type_label = _TYPE_LABELS.get(summary.inferred_type, summary.inferred_type) badge = ctk.CTkLabel( header, text=type_label.upper(), font=ctk.CTkFont(size=10, weight="bold"), fg_color=type_color, text_color="white", corner_radius=10, padx=10, pady=2, ) badge.grid(row=0, column=1, padx=(0, 10), sticky="w") name_lbl = ctk.CTkLabel( header, text=summary.name, font=self.fonts["bold_large"], anchor="w", ) name_lbl.grid(row=0, column=2, sticky="w") count_lbl = ctk.CTkLabel( header, text="0 reglas", font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) count_lbl.grid(row=0, column=3, padx=(8, 0), sticky="e") # ---- Body (info + reglas) ---- body = ctk.CTkFrame(card, fg_color="transparent") body.grid(row=1, column=0, sticky="ew", padx=10, pady=(0, 10)) body.grid_columnconfigure(0, weight=1) example = summary.example or "(sin datos)" if len(example) > 100: example = example[:97] + "..." lbl_example = ctk.CTkLabel( body, text=f'Ejemplo: "{example}"', font=self.fonts["mono_small"], anchor="w", text_color=self.colors["gray_dark"], justify="left", ) lbl_example.grid(row=0, column=0, sticky="ew", padx=4, pady=(0, 4)) self._build_stats_chips(body, summary).grid( row=1, column=0, sticky="ew", padx=4, pady=(0, 4), ) if summary.top_values: self._build_top_values_panel(body, summary.top_values).grid( row=2, column=0, sticky="ew", padx=4, pady=(0, 6), ) rules_frame = ctk.CTkFrame(body, fg_color=("gray92", "gray18"), corner_radius=6) rules_frame.grid(row=3, column=0, sticky="ew", padx=0, pady=(2, 0)) rules_frame.grid_columnconfigure((0, 1), weight=1, uniform="rules") applicable_ids = summary.applicable_rules or [r["id"] for r in CLEANING_RULES] ordered_ids = [r["id"] for r in CLEANING_RULES if r["id"] in applicable_ids] vars_list: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] = [] col_in_row = 0 row_in_rules = 0 for rid in ordered_ids: rule = rules_by_id.get(rid) if rule is None: continue var = ctk.BooleanVar(value=False) var.trace_add("write", lambda *_a, c=summary.name: self._on_rule_toggle(c)) label = _RULE_SHORT_LABELS.get(rid, rule["name"]) chk = ctk.CTkCheckBox( rules_frame, text=label, variable=var, font=self.fonts["ui"], checkbox_width=18, checkbox_height=18, ) chk.grid(row=row_in_rules, column=col_in_row, sticky="w", padx=10, pady=4) vars_list.append((rid, chk, var)) col_in_row += 1 if col_in_row >= 2: col_in_row = 0 row_in_rules += 1 ui = _ColumnUI( card=card, body=body, toggle_btn=toggle_btn, count_label=count_lbl, rule_vars=vars_list, summary=summary, ) toggle_btn.configure(command=lambda c=summary.name: self._toggle_column(c)) return ui # =========================================================================== # Stats visuales por columna # =========================================================================== def _build_stats_chips(self, parent, summary: ColumnSummary) -> ctk.CTkFrame: """Construye una fila de chips con la información clave de la columna.""" row = ctk.CTkFrame(parent, fg_color="transparent") # Sin datos nulls_color = ( ("#FEE2E2", "#7F1D1D") if summary.null_count > 0 else ("gray88", "gray22") ) nulls_text_color = ( ("#991B1B", "#FECACA") if summary.null_count > 0 else ("gray40", "gray70") ) self._make_stat_chip( row, label="Sin datos", value=f"{summary.null_count} ({summary.null_pct:.0f}%)", bg=nulls_color, text_color=nulls_text_color, ).pack(side="left", padx=(0, 6), pady=2) # Únicos self._make_stat_chip( row, label="Únicos", value=f"{summary.unique_count}", bg=("#DBEAFE", "#1E3A8A"), text_color=("#1E40AF", "#BFDBFE"), ).pack(side="left", padx=(0, 6), pady=2) # Stats por tipo if summary.inferred_type in ("integer", "decimal"): if summary.min is not None: self._make_stat_chip( row, label="Mín", value=_fmt_number(summary.min), bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"), ).pack(side="left", padx=(0, 6), pady=2) if summary.max is not None: self._make_stat_chip( row, label="Máx", value=_fmt_number(summary.max), bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"), ).pack(side="left", padx=(0, 6), pady=2) if summary.mean is not None: self._make_stat_chip( row, label="Promedio", value=_fmt_number(summary.mean), bg=("#DCFCE7", "#14532D"), text_color=("#166534", "#BBF7D0"), ).pack(side="left", padx=(0, 6), pady=2) elif summary.inferred_type == "date": if summary.detected_date_format: self._make_stat_chip( row, label="Formato detectado", value=summary.detected_date_format, bg=("#FFEDD5", "#7C2D12"), text_color=("#9A3412", "#FED7AA"), ).pack(side="left", padx=(0, 6), pady=2) elif summary.inferred_type in ("text", "categorical"): if summary.avg_length is not None: self._make_stat_chip( row, label="Longitud promedio", value=f"{summary.avg_length:.0f} caracteres", bg=("gray88", "gray22"), text_color=("gray35", "gray75"), ).pack(side="left", padx=(0, 6), pady=2) return row def _make_stat_chip( self, parent, *, label: str, value: str, bg, text_color, ) -> ctk.CTkFrame: """Chip con etiqueta pequeña + valor en negrita.""" chip = ctk.CTkFrame(parent, fg_color=bg, corner_radius=10) inner = ctk.CTkFrame(chip, fg_color="transparent") inner.pack(padx=8, pady=2) ctk.CTkLabel( inner, text=label, font=ctk.CTkFont(size=10), text_color=text_color, ).pack(side="left", padx=(0, 4)) ctk.CTkLabel( inner, text=value, font=ctk.CTkFont(size=11, weight="bold"), text_color=text_color, ).pack(side="left") return chip def _build_top_values_panel( self, parent, top_values: list[tuple[str, int]], ) -> ctk.CTkFrame: """Panel con los valores más frecuentes en formato chip.""" container = ctk.CTkFrame(parent, fg_color="transparent") container.grid_columnconfigure(1, weight=1) ctk.CTkLabel( container, text="Más frecuentes:", font=ctk.CTkFont(size=11, weight="bold"), text_color=self.colors["text_muted"], anchor="w", ).grid(row=0, column=0, padx=(0, 8), sticky="nw") chips_row = ctk.CTkFrame(container, fg_color="transparent") chips_row.grid(row=0, column=1, sticky="ew") for value, count in top_values[:5]: display = str(value) if len(display) > 60: display = display[:57] + "..." chip = ctk.CTkFrame( chips_row, fg_color=("gray90", "gray20"), corner_radius=10, ) chip.pack(side="left", padx=(0, 6), pady=2) ctk.CTkLabel( chip, text=f'"{display}"', font=ctk.CTkFont(size=11), text_color=("gray25", "gray85"), ).pack(side="left", padx=(8, 4), pady=2) ctk.CTkLabel( chip, text=f"x{count}", font=ctk.CTkFont(size=10, weight="bold"), text_color=self.colors["primary_button"], ).pack(side="left", padx=(0, 8), pady=2) return container # =========================================================================== # Lógica interna # =========================================================================== def _toggle_column(self, col_name: str): ui = self._column_uis.get(col_name) if ui is None: return if ui.expanded: ui.body.grid_remove() ui.toggle_btn.configure(text="▶") else: ui.body.grid() ui.toggle_btn.configure(text="▼") ui.expanded = not ui.expanded def _set_all_expanded(self, expanded: bool): for col_name, ui in self._column_uis.items(): if ui.expanded != expanded: self._toggle_column(col_name) def _on_rule_toggle(self, col_name: str): ui = self._column_uis.get(col_name) if ui is None: return selected = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get())) text = f"{selected} regla{'s' if selected != 1 else ''}" color = self.colors["success"] if selected else self.colors["gray_light"] ui.count_label.configure(text=text, text_color=color) self._update_global_count() def _update_global_count(self): cols_with_rules = 0 total_rules = 0 for ui in self._column_uis.values(): sel = sum(1 for _rid, _chk, var in ui.rule_vars if bool(var.get())) if sel: cols_with_rules += 1 total_rules += sel if total_rules == 0: self.lbl_global_count.configure( text="0 reglas seleccionadas", text_color=self.colors["gray_light"], ) else: self.lbl_global_count.configure( text=f"{total_rules} reglas en {cols_with_rules} columnas", text_color=self.colors["success"], ) def _clear_all_rules(self): for ui in self._column_uis.values(): for _rid, _chk, var in ui.rule_vars: if bool(var.get()): var.set(False) def _on_bulk_apply(self): """Aplica la regla seleccionada a todas las columnas del tipo elegido.""" type_label = self._bulk_type_var.get() rule_name = self._bulk_rule_var.get() type_id = next( (k for k, v in _TYPE_LABELS.items() if v == type_label), None, ) rule_id = next( (r["id"] for r in CLEANING_RULES if r["name"] == rule_name), None, ) if not type_id or not rule_id: return matched = 0 for ui in self._column_uis.values(): if ui.summary.inferred_type != type_id: continue for rid, _chk, var in ui.rule_vars: if rid == rule_id: var.set(True) matched += 1 break self.set_status( f"Regla aplicada a {matched} columna(s) de tipo {type_label}." ) def _apply_search_filter(self): if self._search_var is None: return query = (self._search_var.get() or "").strip().lower() for col_name, ui in self._column_uis.items(): if not query or query in col_name.lower(): ui.card.grid() else: ui.card.grid_remove() def set_delimiter_chip(self, delimiter: str): """Actualiza el chip del delimitador detectado.""" label = {"\t": "TAB", " ": "SPACE"}.get(delimiter, delimiter) self.chip_delim.configure(text=f"Delim: {label}") # =========================================================================== # Renderizado de tablas # =========================================================================== def _render_table_placeholder(self, holder: ctk.CTkFrame, text: str): """Limpia el holder y muestra un mensaje centrado.""" for child in list(holder.winfo_children()): child.destroy() placeholder = ctk.CTkLabel( holder, text=text, font=self.fonts["italic_small"], text_color=self.colors["gray_light"], ) placeholder.grid(row=0, column=0, padx=20, pady=20, sticky="nsew") def _render_dataframe_table( self, holder: ctk.CTkFrame, df: pd.DataFrame, ) -> ttk.Treeview: """Renderiza un DataFrame como ttk.Treeview con scroll y estilo pulido.""" for child in list(holder.winfo_children()): child.destroy() palette = self._table_palette() style = ttk.Style() try: style.theme_use("clam") except Exception: pass style.configure( "Cleaning.Treeview", rowheight=30, font=("Consolas", 10), background=palette["row_even"], fieldbackground=palette["row_even"], foreground=palette["text"], borderwidth=0, relief="flat", ) style.configure( "Cleaning.Treeview.Heading", font=("Roboto", 10, "bold"), background=palette["header_bg"], foreground=palette["header_fg"], relief="flat", padding=(12, 10), borderwidth=0, ) # Header con separadores verticales (el borde aparece al poner relief="solid") style.map( "Cleaning.Treeview.Heading", background=[("active", palette["header_hover"])], relief=[("active", "flat"), ("!active", "flat")], ) style.map( "Cleaning.Treeview", background=[("selected", palette["selected_bg"])], foreground=[("selected", palette["selected_fg"])], ) # Truco: el layout de la celda le añade un separador visible ajustando # el padding lateral para que la zebra se "vea" entre columnas. try: style.layout( "Cleaning.Treeview.Heading", [ ("Treeheading.cell", {"sticky": "nswe"}), ("Treeheading.border", { "sticky": "nswe", "children": [ ("Treeheading.padding", { "sticky": "nswe", "children": [ ("Treeheading.image", {"side": "right", "sticky": ""}), ("Treeheading.text", {"sticky": "we"}), ], }), ], }), ], ) except Exception: pass # Wrapper con borde sutil para resaltar la tabla outer = ctk.CTkFrame( holder, fg_color=palette["outer_bg"], border_width=1, border_color=palette["border"], corner_radius=6, ) outer.grid(row=0, column=0, sticky="nsew", padx=4, pady=4) outer.grid_columnconfigure(0, weight=1) outer.grid_rowconfigure(0, weight=1) cols = [str(c) for c in df.columns] tree = ttk.Treeview( outer, columns=cols, show="headings", style="Cleaning.Treeview", selectmode="browse", ) for i, col in enumerate(cols): tree.heading(col, text=f" {col} ", anchor="w") width = self._estimate_column_width(df[col], col) anchor = "w" tree.column(col, width=width, minwidth=80, anchor=anchor, stretch=False) # Zebra striping + primera columna con acento sutil tree.tag_configure( "odd", background=palette["row_odd"], foreground=palette["text"], ) tree.tag_configure( "even", background=palette["row_even"], foreground=palette["text"], ) visible = df.head(_PREVIEW_MAX_ROWS) for idx, (_, row) in enumerate(visible.iterrows()): # Espacios laterales en cada celda para separar visualmente las columnas values = [f" {self._truncate_cell(v)}" for v in row.tolist()] tag = "odd" if idx % 2 else "even" tree.insert("", "end", values=values, tags=(tag,)) sb_y = ttk.Scrollbar(outer, orient="vertical", command=tree.yview) sb_x = ttk.Scrollbar(outer, orient="horizontal", command=tree.xview) tree.configure(yscrollcommand=sb_y.set, xscrollcommand=sb_x.set) tree.grid(row=0, column=0, sticky="nsew") sb_y.grid(row=0, column=1, sticky="ns") sb_x.grid(row=1, column=0, sticky="ew") holder.grid_columnconfigure(0, weight=1) holder.grid_rowconfigure(0, weight=1) return tree def _table_palette(self) -> dict[str, str]: """Paleta de colores adaptada al tema claro/oscuro activo.""" try: mode = ctk.get_appearance_mode() except Exception: mode = "Light" if mode == "Dark": return { "outer_bg": "#1F2937", "border": "#374151", "header_bg": "#111827", "header_fg": "#F9FAFB", "header_hover": "#1F2937", "row_even": "#1F2937", "row_odd": "#273142", "text": "#E5E7EB", "selected_bg": "#2563EB", "selected_fg": "#FFFFFF", } return { "outer_bg": "#FFFFFF", "border": "#D1D5DB", "header_bg": "#1F2937", "header_fg": "#FFFFFF", "header_hover": "#374151", "row_even": "#FFFFFF", "row_odd": "#F3F4F6", "text": "#111827", "selected_bg": "#2563EB", "selected_fg": "#FFFFFF", } @staticmethod def _truncate_cell(value) -> str: if value is None: return "" if isinstance(value, float) and pd.isna(value): return "" s = str(value) if len(s) > _PREVIEW_MAX_CELL_LEN: return s[: _PREVIEW_MAX_CELL_LEN - 3] + "..." return s @staticmethod def _estimate_column_width(series: pd.Series, col_name: str) -> int: """Estima el ancho de una columna en pixeles a partir del contenido.""" char_w = 8 # Muestreo más amplio para evitar cortar valores largos. sample = series.head(40).astype(str).tolist() max_len = max([len(col_name)] + [len(s) for s in sample] + [6]) max_len = min(max_len, _PREVIEW_MAX_CELL_LEN) # Padding extra para los espacios laterales agregados en cada celda # (formato " valor ") que mejoran la separación visual entre columnas. return max(110, min(460, max_len * char_w + 40))