feat: Implement search filter persistence and UI synchronization
This commit is contained in:
+65
-5
@@ -374,14 +374,31 @@ class MedTrackerApp:
|
|||||||
self.pathology_manager,
|
self.pathology_manager,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
# Initially hidden - can be toggled with Ctrl+F
|
# Restore prior visibility state from preferences
|
||||||
self.search_filter_visible = False
|
self.search_filter_visible = bool(get_pref("search_panel_visible", False))
|
||||||
|
if self.search_filter_visible:
|
||||||
|
self.search_filter_widget.show()
|
||||||
|
|
||||||
# --- Create Status Bar ---
|
# --- Create Status Bar ---
|
||||||
self.status_bar = self.ui_manager.create_status_bar(main_frame)
|
self.status_bar = self.ui_manager.create_status_bar(main_frame)
|
||||||
|
|
||||||
# Load data
|
# Load data, optionally restoring saved filters and syncing the UI
|
||||||
self.refresh_data_display()
|
saved_summary = get_pref("last_filter_state", None)
|
||||||
|
has_saved_filters = bool(
|
||||||
|
isinstance(saved_summary, dict) and saved_summary.get("has_filters")
|
||||||
|
)
|
||||||
|
if has_saved_filters:
|
||||||
|
# Force one-time restoration in refresh and reflect in the UI if visible
|
||||||
|
try:
|
||||||
|
self.refresh_data_display(apply_filters=True)
|
||||||
|
if self.search_filter_visible and hasattr(
|
||||||
|
self.search_filter_widget, "sync_ui_from_filter"
|
||||||
|
):
|
||||||
|
self.search_filter_widget.sync_ui_from_filter()
|
||||||
|
except Exception:
|
||||||
|
self.refresh_data_display()
|
||||||
|
else:
|
||||||
|
self.refresh_data_display()
|
||||||
|
|
||||||
# Initialize status bar with ready message
|
# Initialize status bar with ready message
|
||||||
self.ui_manager.update_status("Application ready", "info")
|
self.ui_manager.update_status("Application ready", "info")
|
||||||
@@ -1088,10 +1105,14 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
|||||||
self.search_filter_widget.hide()
|
self.search_filter_widget.hide()
|
||||||
self.search_filter_visible = False
|
self.search_filter_visible = False
|
||||||
self.ui_manager.update_status("Search panel hidden", "info")
|
self.ui_manager.update_status("Search panel hidden", "info")
|
||||||
|
set_pref("search_panel_visible", False)
|
||||||
|
save_preferences()
|
||||||
else:
|
else:
|
||||||
self.search_filter_widget.show()
|
self.search_filter_widget.show()
|
||||||
self.search_filter_visible = True
|
self.search_filter_visible = True
|
||||||
self.ui_manager.update_status("Search panel shown", "info")
|
self.ui_manager.update_status("Search panel shown", "info")
|
||||||
|
set_pref("search_panel_visible", True)
|
||||||
|
save_preferences()
|
||||||
|
|
||||||
def _on_filter_update(self) -> None:
|
def _on_filter_update(self) -> None:
|
||||||
"""Handle filter updates from the search widget."""
|
"""Handle filter updates from the search widget."""
|
||||||
@@ -1104,6 +1125,12 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
|||||||
|
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
self.root.after_cancel(self._filter_debounce_id) # type: ignore[attr-defined]
|
self.root.after_cancel(self._filter_debounce_id) # type: ignore[attr-defined]
|
||||||
|
# Persist filters to preferences for next run
|
||||||
|
try:
|
||||||
|
set_pref("last_filter_state", self.data_filter.get_filter_summary())
|
||||||
|
save_preferences()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Schedule refresh after short delay
|
# Schedule refresh after short delay
|
||||||
self._filter_debounce_id = self.root.after( # type: ignore[attr-defined]
|
self._filter_debounce_id = self.root.after( # type: ignore[attr-defined]
|
||||||
250, lambda: self.refresh_data_display(apply_filters=True)
|
250, lambda: self.refresh_data_display(apply_filters=True)
|
||||||
@@ -1333,6 +1360,32 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
|||||||
logger.debug("Loading data from CSV.")
|
logger.debug("Loading data from CSV.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# One-time restoration of last filter state (best-effort)
|
||||||
|
if apply_filters and not hasattr(self, "_restored_filters_once"):
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
self._restored_filters_once = True # type: ignore[attr-defined]
|
||||||
|
summary = get_pref("last_filter_state", None)
|
||||||
|
if isinstance(summary, dict) and summary.get("has_filters"):
|
||||||
|
self.data_filter.set_search_term(summary.get("search_term", ""))
|
||||||
|
date_rng = summary.get("filters", {}).get("date_range") or {}
|
||||||
|
self.data_filter.set_date_range_filter(
|
||||||
|
date_rng.get("start") or None, date_rng.get("end") or None
|
||||||
|
)
|
||||||
|
meds = summary.get("filters", {}).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)
|
||||||
|
paths = summary.get("filters", {}).get("pathologies") or {}
|
||||||
|
for key, _range_text in paths.items():
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
parts = str(_range_text).split("-")
|
||||||
|
mn = parts[0].strip()
|
||||||
|
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)
|
||||||
# Load data from the CSV file once
|
# Load data from the CSV file once
|
||||||
# Use cached graph-ready data for plotting & base data for table
|
# Use cached graph-ready data for plotting & base data for table
|
||||||
df_full: pd.DataFrame = self.data_manager.load_data()
|
df_full: pd.DataFrame = self.data_manager.load_data()
|
||||||
@@ -1340,7 +1393,8 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
|||||||
original_df = df.copy() # Keep a copy for graph updates
|
original_df = df.copy() # Keep a copy for graph updates
|
||||||
|
|
||||||
# Apply filters if requested and filters are active
|
# Apply filters if requested and filters are active
|
||||||
if apply_filters and self.data_filter.get_filter_summary()["has_filters"]:
|
filter_summary = self.data_filter.get_filter_summary()
|
||||||
|
if apply_filters and filter_summary["has_filters"]:
|
||||||
df = self.data_filter.apply_filters(df)
|
df = self.data_filter.apply_filters(df)
|
||||||
self.current_filtered_data = df
|
self.current_filtered_data = df
|
||||||
else:
|
else:
|
||||||
@@ -1376,6 +1430,12 @@ Use Ctrl+S to save entries and Ctrl+Q to quit."""
|
|||||||
else:
|
else:
|
||||||
self.ui_manager.update_file_info(self.filename, displayed_entries)
|
self.ui_manager.update_file_info(self.filename, displayed_entries)
|
||||||
|
|
||||||
|
# Update tiny filter activity hint
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.ui_manager.set_filter_hint(bool(filter_summary["has_filters"]))
|
||||||
|
|
||||||
if displayed_entries == 0:
|
if displayed_entries == 0:
|
||||||
status_msg = (
|
status_msg = (
|
||||||
"No data matches filters" if apply_filters else "No data to display"
|
"No data matches filters" if apply_filters else "No data to display"
|
||||||
|
|||||||
@@ -442,6 +442,48 @@ class SearchFilterWidget:
|
|||||||
"""Get the main widget for embedding in UI (may be None until shown)."""
|
"""Get the main widget for embedding in UI (may be None until shown)."""
|
||||||
return self.frame
|
return self.frame
|
||||||
|
|
||||||
|
def sync_ui_from_filter(self) -> None:
|
||||||
|
"""Synchronize the UI controls with the current DataFilter state.
|
||||||
|
|
||||||
|
Best-effort: silently ignores keys not present in the UI (e.g., when
|
||||||
|
managers have changed). Does not trigger an immediate callback; traces
|
||||||
|
may schedule a debounced update which is acceptable.
|
||||||
|
"""
|
||||||
|
# Search term
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.search_var.set(self.data_filter.search_term or "")
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
date_filter = self.data_filter.active_filters.get("date_range", {})
|
||||||
|
self.start_date_var.set(date_filter.get("start", "") or "")
|
||||||
|
self.end_date_var.set(date_filter.get("end", "") or "")
|
||||||
|
|
||||||
|
# Medicine filters
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
meds = self.data_filter.active_filters.get("medicines", {})
|
||||||
|
for key, var in self.medicine_vars.items():
|
||||||
|
if key in meds:
|
||||||
|
var.set("taken" if meds[key] else "not taken")
|
||||||
|
else:
|
||||||
|
var.set("any")
|
||||||
|
|
||||||
|
# Pathology ranges
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
paths = self.data_filter.active_filters.get("pathologies", {})
|
||||||
|
for key, rng in paths.items():
|
||||||
|
if key in self.pathology_min_vars:
|
||||||
|
mn = rng.get("min")
|
||||||
|
self.pathology_min_vars[key].set("" if mn is None else str(mn))
|
||||||
|
if key in self.pathology_max_vars:
|
||||||
|
mx = rng.get("max")
|
||||||
|
self.pathology_max_vars[key].set("" if mx is None else str(mx))
|
||||||
|
|
||||||
|
# Update status text
|
||||||
|
self._update_status()
|
||||||
|
|
||||||
def show(self) -> None:
|
def show(self) -> None:
|
||||||
"""Show the search filter widget and configure the parent row."""
|
"""Show the search filter widget and configure the parent row."""
|
||||||
if not self._ui_initialized:
|
if not self._ui_initialized:
|
||||||
|
|||||||
@@ -686,6 +686,19 @@ class UIManager:
|
|||||||
# Pack after file_info so it appears to the left of it
|
# Pack after file_info so it appears to the left of it
|
||||||
self.last_backup_label.pack(side=tk.RIGHT)
|
self.last_backup_label.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
# Tiny filter activity hint (right side, left of backup info)
|
||||||
|
self.filter_hint_label = tk.Label(
|
||||||
|
self.status_bar,
|
||||||
|
text="",
|
||||||
|
anchor=tk.E,
|
||||||
|
font=("TkDefaultFont", 9),
|
||||||
|
padx=8,
|
||||||
|
pady=2,
|
||||||
|
bg=theme_colors["bg"],
|
||||||
|
fg="#6c757d",
|
||||||
|
)
|
||||||
|
self.filter_hint_label.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
return self.status_bar
|
return self.status_bar
|
||||||
|
|
||||||
def update_last_backup(self, when_text: str) -> None:
|
def update_last_backup(self, when_text: str) -> None:
|
||||||
@@ -815,6 +828,18 @@ class UIManager:
|
|||||||
# Non-fatal UI convenience; ignore errors
|
# Non-fatal UI convenience; ignore errors
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def set_filter_hint(self, active: bool, text: str | None = None) -> None:
|
||||||
|
"""Show or hide a small status hint when filters are active.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active: Whether filters are currently active
|
||||||
|
text: Optional custom hint text (defaults to 'Filters active')
|
||||||
|
"""
|
||||||
|
if not self.filter_hint_label:
|
||||||
|
return
|
||||||
|
hint_text = (text or "Filters active") if active else ""
|
||||||
|
self.filter_hint_label.config(text=hint_text)
|
||||||
|
|
||||||
def create_edit_window(
|
def create_edit_window(
|
||||||
self, values: tuple[str, ...], callbacks: dict[str, Callable]
|
self, values: tuple[str, ...], callbacks: dict[str, Callable]
|
||||||
) -> tk.Toplevel:
|
) -> tk.Toplevel:
|
||||||
|
|||||||
Reference in New Issue
Block a user