1212 lines
42 KiB
Python
1212 lines
42 KiB
Python
"""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,
|
|
applicable_rule_ids_for_type,
|
|
)
|
|
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"),
|
|
}
|
|
|
|
# Orden fijo en el desplegable de anulación manual
|
|
_USER_EDITABLE_TYPE_IDS: tuple[str, ...] = (
|
|
"integer", "decimal", "date", "boolean", "categorical", "text", "mixed", "empty",
|
|
)
|
|
_TYPE_MENU_VALUES: list[str] = [
|
|
_TYPE_LABELS.get(t, t) for t in _USER_EDITABLE_TYPE_IDS
|
|
]
|
|
|
|
_RULE_SHORT_LABELS: dict[str, str] = {
|
|
"strip_special_spaces": "Sin especiales/espacios (no antes de fecha+hora)",
|
|
"strip_special_keep_date_seps": "Símbolos: conserva /, -, :, espacios",
|
|
"strip_spaces": "Sin espacios",
|
|
"to_integer": "A entero",
|
|
"date_to_ddmmyyyy": "Normalizar fecha → DD/MM/AAAA",
|
|
"date_numeric_to_ddmmyyyy": "Fecha numérica → DD/MM/AAAA",
|
|
"date_alpha_to_ddmmyyyy": "Fecha alfanumérica → DD/MM/AAAA",
|
|
"date_messy_text_to_ddmmyyyy": "Fecha con ruido (texto) → DD/MM/AAAA",
|
|
"round_decimals_custom": "Aprox. decimales (>0.5 sube)",
|
|
"doc_id_split_tipo_numero": "Dividir doc (tipo + cifras)",
|
|
"doc_type_homologate": "Homologar tipo doc",
|
|
}
|
|
|
|
|
|
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
|
|
type_menu: ctk.CTkOptionMenu
|
|
count_label: ctk.CTkLabel
|
|
stats_frame: ctk.CTkFrame
|
|
rules_frame: ctk.CTkFrame
|
|
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
|
|
# Tipo de dato forzado por el usuario (id interno) por columna; vacío = inferido
|
|
self._type_overrides: dict[str, str] = {}
|
|
self._syncing_rule_vars = False
|
|
|
|
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")
|
|
|
|
self.loading_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
|
self.loading_frame.grid(
|
|
row=2, column=0, columnspan=4, sticky="ew", padx=15, pady=(0, 12)
|
|
)
|
|
self.loading_frame.grid_columnconfigure(1, weight=1)
|
|
self.loading_label = ctk.CTkLabel(
|
|
self.loading_frame,
|
|
text="",
|
|
font=self.fonts["italic_small"],
|
|
text_color=self.colors["gray_light"],
|
|
anchor="w",
|
|
)
|
|
self.loading_label.grid(row=0, column=0, sticky="w", padx=(0, 10))
|
|
self.loading_progress = ctk.CTkProgressBar(
|
|
self.loading_frame,
|
|
mode="indeterminate",
|
|
height=8,
|
|
)
|
|
self.loading_progress.grid(row=0, column=1, sticky="ew")
|
|
self.loading_frame.grid_remove()
|
|
|
|
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))
|
|
|
|
self._bulk_rule_var = ctk.StringVar(value=CLEANING_RULES[0]["name"])
|
|
self.bulk_rule_menu = ctk.CTkOptionMenu(
|
|
bulk_row, values=[r["name"] for r in CLEANING_RULES], variable=self._bulk_rule_var,
|
|
width=380, font=self.fonts["ui"],
|
|
)
|
|
self.bulk_rule_menu.pack(side="left", padx=(0, 6))
|
|
self._sync_bulk_rule_menu()
|
|
self._bulk_type_var.trace_add("write", lambda *_: self._sync_bulk_rule_menu())
|
|
|
|
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")
|
|
|
|
# Frame plano: el scroll único es el de `self.scroll` (evita doble rueda del ratón)
|
|
self.columns_container = ctk.CTkFrame(
|
|
frame, fg_color="transparent",
|
|
)
|
|
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_loading(self, loading: bool, text: str = ""):
|
|
"""Muestra u oculta una barra indeterminada para operaciones de CSV."""
|
|
if loading:
|
|
self.loading_label.configure(text=text or "Procesando CSV...")
|
|
self.loading_frame.grid()
|
|
try:
|
|
self.loading_progress.start()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
try:
|
|
self.loading_progress.stop()
|
|
except Exception:
|
|
pass
|
|
self.loading_label.configure(text="")
|
|
self.loading_frame.grid_remove()
|
|
|
|
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 _sync_bulk_rule_menu(self) -> None:
|
|
"""Acota las reglas del desplegable según el tipo (p. ej. entero: sin a entero/approx)."""
|
|
if not hasattr(self, "bulk_rule_menu") or not hasattr(
|
|
self, "_bulk_type_var",
|
|
):
|
|
return
|
|
type_label = self._bulk_type_var.get()
|
|
type_id = next(
|
|
(k for k, v in _TYPE_LABELS.items() if v == type_label), "text"
|
|
)
|
|
want = set(applicable_rule_ids_for_type(type_id))
|
|
names = [r["name"] for r in CLEANING_RULES if r["id"] in want]
|
|
if not names:
|
|
return
|
|
cur = self._bulk_rule_var.get()
|
|
self.bulk_rule_menu.configure(values=names)
|
|
if cur in names:
|
|
self._bulk_rule_var.set(cur)
|
|
else:
|
|
self._bulk_rule_var.set(names[0])
|
|
|
|
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()
|
|
self._type_overrides.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")
|
|
|
|
eff0 = self._type_overrides.get(summary.name) or summary.inferred_type
|
|
if eff0 not in _TYPE_LABELS:
|
|
eff0 = "text"
|
|
tcol = _TYPE_COLORS.get(eff0, ("#64748B", "#64748B"))
|
|
tmenu = ctk.CTkOptionMenu(
|
|
header,
|
|
values=_TYPE_MENU_VALUES,
|
|
width=150,
|
|
height=28,
|
|
font=self.fonts["ui"],
|
|
corner_radius=10,
|
|
button_color=tcol[0] if tcol[0] else tcol[1],
|
|
button_hover_color=tcol[0],
|
|
fg_color=tcol[0] if tcol[0] else tcol[1],
|
|
)
|
|
tmenu.set(_TYPE_LABELS.get(eff0, _TYPE_LABELS["text"]))
|
|
tmenu.grid(row=0, column=1, padx=(0, 6), sticky="w")
|
|
tmenu.configure(
|
|
command=lambda v, c=summary.name, m=tmenu: self._on_user_column_type_change(
|
|
c, m, v
|
|
)
|
|
)
|
|
|
|
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))
|
|
|
|
stats_frame = self._build_stats_chips(body, summary, eff0)
|
|
stats_frame.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")
|
|
|
|
eff_rules = self._type_overrides.get(summary.name) or summary.inferred_type
|
|
if eff_rules not in _TYPE_LABELS:
|
|
eff_rules = "text"
|
|
applic = set(applicable_rule_ids_for_type(eff_rules))
|
|
ordered_ids = [r["id"] for r in CLEANING_RULES if r["id"] in applic]
|
|
|
|
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, r=rid: self._on_rule_toggle(c, r),
|
|
)
|
|
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,
|
|
type_menu=tmenu,
|
|
count_label=count_lbl,
|
|
stats_frame=stats_frame,
|
|
rules_frame=rules_frame,
|
|
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, display_type: Optional[str] = None
|
|
) -> ctk.CTkFrame:
|
|
"""Construye una fila de chips con la información clave de la columna."""
|
|
row = ctk.CTkFrame(parent, fg_color="transparent")
|
|
dtype = (display_type or summary.inferred_type) or "text"
|
|
if dtype not in _TYPE_LABELS:
|
|
dtype = "text"
|
|
|
|
# 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 dtype 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 dtype == "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 dtype 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 _effective_inferred_type(self, ui: _ColumnUI) -> str:
|
|
"""Tipo mostrado para la columna: anulación del usuario o inferido."""
|
|
t = self._type_overrides.get(ui.summary.name) or ui.summary.inferred_type
|
|
return t if t in _TYPE_LABELS else "text"
|
|
|
|
def _on_user_column_type_change(
|
|
self, col_name: str, type_menu: ctk.CTkOptionMenu, spanish_label: str
|
|
) -> None:
|
|
type_id = next(
|
|
(k for k, v in _TYPE_LABELS.items() if v == spanish_label), "text"
|
|
)
|
|
ui = self._column_uis.get(col_name)
|
|
if ui is None:
|
|
return
|
|
s = ui.summary
|
|
if type_id == s.inferred_type:
|
|
self._type_overrides.pop(col_name, None)
|
|
else:
|
|
self._type_overrides[col_name] = type_id
|
|
eff = self._effective_inferred_type(ui)
|
|
tcol = _TYPE_COLORS.get(eff, ("#64748B", "#64748B"))
|
|
type_menu.configure(
|
|
button_color=tcol[0] if tcol[0] else tcol[1],
|
|
button_hover_color=tcol[0],
|
|
fg_color=tcol[0] if tcol[0] else tcol[1],
|
|
)
|
|
ui.stats_frame.destroy()
|
|
new_stats = self._build_stats_chips(ui.body, s, eff)
|
|
new_stats.grid(row=1, column=0, sticky="ew", padx=4, pady=(0, 4))
|
|
ui.stats_frame = new_stats
|
|
for w in list(ui.rules_frame.winfo_children()):
|
|
w.destroy()
|
|
applic = set(applicable_rule_ids_for_type(eff))
|
|
new_vars: list[tuple[str, ctk.CTkCheckBox, ctk.BooleanVar]] = []
|
|
col_in_row = 0
|
|
row_in_rules = 0
|
|
rmap = {r["id"]: r for r in CLEANING_RULES}
|
|
for rid in (r["id"] for r in CLEANING_RULES if r["id"] in applic):
|
|
rule = rmap.get(rid)
|
|
if rule is None:
|
|
continue
|
|
var = ctk.BooleanVar(value=False)
|
|
var.trace_add(
|
|
"write",
|
|
lambda *_a, c=col_name, r=rid: self._on_rule_toggle(c, r),
|
|
)
|
|
label = _RULE_SHORT_LABELS.get(rid, rule["name"])
|
|
chk = ctk.CTkCheckBox(
|
|
ui.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)
|
|
new_vars.append((rid, chk, var))
|
|
col_in_row += 1
|
|
if col_in_row >= 2:
|
|
col_in_row = 0
|
|
row_in_rules += 1
|
|
ui.rule_vars = new_vars
|
|
self.set_status(f"Columna «{col_name}»: tipo «{_TYPE_LABELS.get(eff, eff)}».")
|
|
self._on_rule_toggle(col_name)
|
|
|
|
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, changed_rule_id: Optional[str] = None):
|
|
ui = self._column_uis.get(col_name)
|
|
if ui is None:
|
|
return
|
|
if (
|
|
changed_rule_id == "doc_id_split_tipo_numero"
|
|
and not self._syncing_rule_vars
|
|
):
|
|
split_selected = any(
|
|
rid == "doc_id_split_tipo_numero" and bool(var.get())
|
|
for rid, _chk, var in ui.rule_vars
|
|
)
|
|
if split_selected:
|
|
for rid, _chk, var in ui.rule_vars:
|
|
if rid == "doc_type_homologate" and not bool(var.get()):
|
|
self._syncing_rule_vars = True
|
|
try:
|
|
var.set(True)
|
|
finally:
|
|
self._syncing_rule_vars = False
|
|
self.set_status(
|
|
f"Columna «{col_name}»: se activó homologación de tipo de documento."
|
|
)
|
|
break
|
|
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 self._effective_inferred_type(ui) != 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))
|