diff --git a/src/export_manager.py b/src/export_manager.py index fefd714..d91e372 100644 --- a/src/export_manager.py +++ b/src/export_manager.py @@ -54,10 +54,12 @@ class ExportManager: self.pathology_manager = pathology_manager self.logger = logger - def export_data_to_json(self, export_path: str) -> bool: + def export_data_to_json( + self, export_path: str, df: pd.DataFrame | None = None + ) -> bool: """Export CSV data to JSON format.""" try: - df = self.data_manager.load_data() + df = df if df is not None else self.data_manager.load_data() if df.empty: self.logger.warning("No data to export") return False @@ -87,10 +89,12 @@ class ExportManager: self.logger.error(f"Error exporting to JSON: {str(e)}") return False - def export_data_to_xml(self, export_path: str) -> bool: + def export_data_to_xml( + self, export_path: str, df: pd.DataFrame | None = None + ) -> bool: """Export CSV data to XML format.""" try: - df = self.data_manager.load_data() + df = df if df is not None else self.data_manager.load_data() if df.empty: self.logger.warning("No data to export") return False @@ -203,10 +207,15 @@ class ExportManager: self.logger.error(f"Error saving graph image: {str(e)}") return None - def export_to_pdf(self, export_path: str, include_graph: bool = True) -> bool: + def export_to_pdf( + self, + export_path: str, + include_graph: bool = True, + df: pd.DataFrame | None = None, + ) -> bool: """Export data and optionally graph to PDF format.""" try: - df = self.data_manager.load_data() + df = df if df is not None else self.data_manager.load_data() # Create PDF document in landscape format for better table/graph display doc = SimpleDocTemplate( diff --git a/src/export_window.py b/src/export_window.py index cb704e9..684ec15 100644 --- a/src/export_window.py +++ b/src/export_window.py @@ -5,6 +5,7 @@ Provides a GUI interface for exporting data and graphs to various formats. """ import tkinter as tk +from collections.abc import Callable from pathlib import Path from tkinter import filedialog, messagebox, ttk @@ -14,9 +15,15 @@ from export_manager import ExportManager class ExportWindow: """Export window for data and graph export functionality.""" - def __init__(self, parent: tk.Tk, export_manager: ExportManager) -> None: + def __init__( + self, + parent: tk.Tk, + export_manager: ExportManager, + get_current_filtered_df: Callable[[], object] | None = None, + ) -> None: self.parent = parent self.export_manager = export_manager + self._get_current_filtered_df = get_current_filtered_df # Create the export window self.window = tk.Toplevel(parent) @@ -113,6 +120,21 @@ Medicines: {", ".join(export_info["medicines"])}""" ) graph_check.pack(anchor=tk.W, pady=(0, 10)) + # Export scope option + self.scope_var = tk.StringVar(value="all") + scope_frame = ttk.Frame(options_frame) + scope_frame.pack(fill=tk.X, pady=(0, 10)) + ttk.Label(scope_frame, text="Scope:").pack(side=tk.LEFT) + ttk.Radiobutton( + scope_frame, text="All data", variable=self.scope_var, value="all" + ).pack(side=tk.LEFT, padx=10) + ttk.Radiobutton( + scope_frame, + text="Current (filtered) view", + variable=self.scope_var, + value="filtered", + ).pack(side=tk.LEFT) + # Format selection format_label = ttk.Label(options_frame, text="Export Format:") format_label.pack(anchor=tk.W) @@ -182,17 +204,27 @@ Medicines: {", ".join(export_info["medicines"])}""" if not filename: return + # Determine scope DataFrame (if requested and available) + scoped_df = None + if self.scope_var.get() == "filtered" and self._get_current_filtered_df: + try: + scoped_df = self._get_current_filtered_df() + except Exception: + scoped_df = None + # Perform export based on selected format success = False try: if selected_format == "JSON": - success = self.export_manager.export_data_to_json(filename) + success = self.export_manager.export_data_to_json( + filename, df=scoped_df + ) elif selected_format == "XML": - success = self.export_manager.export_data_to_xml(filename) + success = self.export_manager.export_data_to_xml(filename, df=scoped_df) elif selected_format == "PDF": include_graph = self.include_graph_var.get() success = self.export_manager.export_to_pdf( - filename, include_graph=include_graph + filename, include_graph=include_graph, df=scoped_df ) if success: diff --git a/src/main.py b/src/main.py index 6a6cb23..6467525 100644 --- a/src/main.py +++ b/src/main.py @@ -681,7 +681,20 @@ Use Ctrl+S to save entries and Ctrl+Q to quit.""" def _open_export_window(self) -> None: """Open the export window.""" self.ui_manager.update_status("Opening export window", "info") - ExportWindow(self.root, self.export_manager) + + def _get_current_filtered_df(): + try: + if self.current_filtered_data is not None: + return self.current_filtered_data + # If no live filtered DF, but filters are active, compute one-off + if self.data_filter.get_filter_summary().get("has_filters"): + df = self.data_manager.load_data() + return self.data_filter.apply_filters(df) + except Exception: + return None + return None + + ExportWindow(self.root, self.export_manager, _get_current_filtered_df) if hasattr(self.ui_manager, "show_toast"): self.ui_manager.show_toast("Export window opened", 1200) diff --git a/src/preferences.py b/src/preferences.py index bdc3e97..60c5e47 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -19,6 +19,12 @@ _DEFAULTS: dict[str, Any] = { "last_window_geometry": "", # Keep window always on top "always_on_top": False, + # Search/filter UI state + "search_panel_visible": False, + "last_filter_state": None, + # Table column UX + "column_widths": {}, + "last_sort": {"column": None, "ascending": True}, } _PREFERENCES: dict[str, Any] = dict(_DEFAULTS) diff --git a/src/search_filter_ui.py b/src/search_filter_ui.py index 83cf6be..dc22624 100644 --- a/src/search_filter_ui.py +++ b/src/search_filter_ui.py @@ -2,9 +2,10 @@ import tkinter as tk from collections.abc import Callable -from tkinter import ttk +from tkinter import messagebox, simpledialog, ttk from init import logger +from preferences import get_pref, save_preferences, set_pref from search_filter import DataFilter, QuickFilters, SearchHistory @@ -41,10 +42,10 @@ class SearchFilterWidget: # Visibility and UI init state self.is_visible = False self._ui_initialized = False - self.frame: ttk.LabelFrame | None = None + self.frame = None # Debouncing mechanism to reduce filter update frequency - self._update_timer: str | None = None + self._update_timer = None self._debounce_delay = 450 # milliseconds # History and UI state variables @@ -53,10 +54,13 @@ class SearchFilterWidget: self.start_date_var = tk.StringVar() self.end_date_var = tk.StringVar() + # Presets state + self.preset_var = tk.StringVar() + # Medicine and pathology filter variables - self.medicine_vars: dict[str, tk.StringVar] = {} - self.pathology_min_vars: dict[str, tk.StringVar] = {} - self.pathology_max_vars: dict[str, tk.StringVar] = {} + self.medicine_vars = {} + self.pathology_min_vars = {} + self.pathology_max_vars = {} def _setup_ui(self) -> None: """Set up the search and filter UI.""" @@ -68,9 +72,29 @@ class SearchFilterWidget: content_frame.pack(fill="both", expand=True) # Top row: Search and Quick filters + # Top row: Presets, Search and Quick filters top_row = ttk.Frame(content_frame) top_row.pack(fill="x", pady=(0, 5)) + # Presets section (leftmost) + presets_frame = ttk.Frame(top_row) + presets_frame.pack(side="left", padx=(0, 10)) + ttk.Label(presets_frame, text="Preset:").pack(side="left") + self.preset_combo = ttk.Combobox( + presets_frame, textvariable=self.preset_var, state="readonly", width=18 + ) + self._refresh_presets_combo() + self.preset_combo.pack(side="left", padx=(5, 5)) + ttk.Button(presets_frame, text="Load", command=self._load_preset).pack( + side="left", padx=(0, 2) + ) + ttk.Button(presets_frame, text="Save", command=self._save_preset).pack( + side="left", padx=(0, 2) + ) + ttk.Button(presets_frame, text="Delete", command=self._delete_preset).pack( + side="left" + ) + # Search section (left side of top row) search_frame = ttk.Frame(top_row) search_frame.pack(side="left", fill="x", expand=True, padx=(0, 10)) @@ -438,6 +462,96 @@ class SearchFilterWidget: self.status_label.config(text=status_text) + # --------------------- + # Presets management + # --------------------- + def _refresh_presets_combo(self) -> None: + presets = get_pref("filter_presets", {}) or {} + names = sorted(presets.keys()) + if hasattr(self, "preset_combo") and self.preset_combo: + self.preset_combo["values"] = names + if names and not self.preset_var.get(): + self.preset_var.set(names[0]) + + def _apply_filter_summary(self, summary: dict) -> None: + """Apply a saved summary dict into the DataFilter and UI, then update.""" + import contextlib + + if not isinstance(summary, dict): + return + # Clear then set pieces + self.data_filter.clear_all_filters() + self.data_filter.set_search_term(summary.get("search_term", "")) + filt = summary.get("filters", {}) or {} + # Date + date_rng = filt.get("date_range") or {} + self.data_filter.set_date_range_filter( + date_rng.get("start") or None, date_rng.get("end") or None + ) + # Medicines + meds = filt.get("medicines") or {} + for key in meds.get("taken", []) or []: + self.data_filter.set_medicine_filter(key, True) + for key in meds.get("not_taken", []) or []: + self.data_filter.set_medicine_filter(key, False) + # Pathologies + paths = filt.get("pathologies") or {} + for key, range_text in paths.items(): + with contextlib.suppress(Exception): + s = str(range_text) + parts = s.split("-") + mn = parts[0].strip() if parts else "" + mx = parts[1].strip() if len(parts) > 1 else "" + mn_i = int(mn) if mn and mn.lower() != "any" else None + mx_i = int(mx) if mx and mx.lower() != "any" else None + self.data_filter.set_pathology_range_filter(key, mn_i, mx_i) + # Sync UI and notify + self.sync_ui_from_filter() + self.update_callback() + + def _load_preset(self) -> None: + name = self.preset_var.get().strip() + if not name: + return + presets = get_pref("filter_presets", {}) or {} + summary = presets.get(name) + if not summary: + messagebox.showwarning("Preset", f"Preset '{name}' not found.") + return + self._apply_filter_summary(summary) + + def _save_preset(self) -> None: + # Ask for a name + name = simpledialog.askstring("Save Preset", "Preset name:", parent=self.parent) + if not name: + return + name = name.strip() + if not name: + return + presets = get_pref("filter_presets", {}) or {} + presets[name] = self.data_filter.get_filter_summary() + set_pref("filter_presets", presets) + save_preferences() + self._refresh_presets_combo() + self.preset_var.set(name) + self._update_status() + + def _delete_preset(self) -> None: + name = self.preset_var.get().strip() + if not name: + return + if not messagebox.askyesno( + "Delete Preset", f"Delete preset '{name}'?", parent=self.parent + ): + return + presets = get_pref("filter_presets", {}) or {} + if name in presets: + del presets[name] + set_pref("filter_presets", presets) + save_preferences() + self.preset_var.set("") + self._refresh_presets_combo() + def get_widget(self) -> ttk.LabelFrame | None: """Get the main widget for embedding in UI (may be None until shown).""" return self.frame diff --git a/src/ui_manager.py b/src/ui_manager.py index f574b73..9a1bf22 100644 --- a/src/ui_manager.py +++ b/src/ui_manager.py @@ -12,6 +12,7 @@ from PIL import Image, ImageTk from medicine_manager import MedicineManager from pathology_manager import PathologyManager +from preferences import get_pref, save_preferences, set_pref from tooltip_system import TooltipManager @@ -410,6 +411,28 @@ class UIManager: for col, width, anchor in col_settings: tree.column(col, width=width, anchor=anchor) + # Apply saved column widths if available + try: + saved_widths = get_pref("column_widths", {}) or {} + if isinstance(saved_widths, dict): + for col in tree["columns"]: + w = saved_widths.get(col) + if isinstance(w, int) and w > 0: + tree.column(col, width=w) + except Exception: + pass + + # Initialize last sort from preferences + try: + last_sort = get_pref("last_sort", {}) or {} + col = last_sort.get("column") + asc = last_sort.get("ascending", True) + if col in tree["columns"]: + self._last_sorted_column = col + self._last_sorted_ascending = bool(asc) + except Exception: + pass + tree.pack(side="left", fill="both", expand=True) # Add scrollbars with optimized scroll handling @@ -422,6 +445,9 @@ class UIManager: # Optimize tree scrolling performance self._optimize_tree_scrolling(tree) + # Install debounced save of column widths + self._install_column_width_persistence(tree) + return {"frame": table_frame, "tree": tree} # ------------------------------------------------------------------ @@ -462,6 +488,13 @@ class UIManager: # Re-apply alternating row tags after sort self.normalize_tree_stripes(tree) + # Persist last sort + try: + set_pref("last_sort", {"column": column, "ascending": ascending}) + save_preferences() + except Exception: + pass + def _sort_tree_column_direction( self, tree: ttk.Treeview, column: str, ascending: bool ) -> None: @@ -572,6 +605,35 @@ class UIManager: # Ensure alternating stripes are normalized after updates self.normalize_tree_stripes(tree) + # --- Column width persistence helpers --- + def _install_column_width_persistence(self, tree: ttk.Treeview) -> None: + import contextlib + + self._col_width_save_after_id = None + + def _debounced_save(*_args): + if getattr(self, "_col_width_save_after_id", None): + with contextlib.suppress(Exception): + self.root.after_cancel(self._col_width_save_after_id) + self._col_width_save_after_id = self.root.after(600, _save_now) + + def _save_now(): + widths = {} + for col in tree["columns"]: + try: + widths[col] = int(tree.column(col, option="width")) + except Exception: + continue + try: + set_pref("column_widths", widths) + save_preferences() + except Exception: + pass + self._col_width_save_after_id = None + + tree.bind("", _debounced_save, add="+") + tree.bind("", _debounced_save, add="+") + def normalize_tree_stripes(self, tree: ttk.Treeview) -> None: """Normalize alternating row tags based on current visual order.