import logging import os import sys import tkinter as tk from collections.abc import Callable from datetime import datetime from tkinter import messagebox, ttk from typing import Any import pandas as pd from PIL import Image, ImageTk from medicine_manager import MedicineManager from pathology_manager import PathologyManager from tooltip_system import TooltipManager class UIManager: """Handle UI creation and management for the application. Test suite historically instantiated UIManager with only (root, logger). To preserve backward compatibility we make other dependencies optional and provide minimal shims when not supplied so unit tests focused on widget construction still work without full managers. """ def __init__( self, root: tk.Tk, logger: logging.Logger, medicine_manager: MedicineManager | None = None, pathology_manager: PathologyManager | None = None, theme_manager: Any | None = None, # Avoid circular import typing ) -> None: self.root = root self.logger = logger # Provide lightweight fallback managers if not provided (tests use fixed keys) class _FallbackMedicineMgr: def get_medicine_keys(self): return [ "bupropion", "hydroxyzine", "gabapentin", "propranolol", "quetiapine", ] def get_medicine(self, key): # pragma: no cover - simple data holder class M: def __init__(self, k): self.key = k self.display_name = k.capitalize() self.dosage_info = "" self.color = "#CCCCCC" return M(key) def get_all_medicines(self): return {k: self.get_medicine(k) for k in self.get_medicine_keys()} def get_quick_doses(self, _key): return [] class _FallbackPathologyMgr: def get_pathology_keys(self): return ["depression", "anxiety", "sleep", "appetite"] def get_pathology(self, key): # pragma: no cover - simple data holder class P: def __init__(self, k): self.key = k self.display_name = k.capitalize() self.scale_info = "0-10" self.scale_min = 0 self.scale_max = 10 self.scale_orientation = ( "inverted" if k in ("sleep", "appetite") else "normal" ) return P(key) def get_all_pathologies(self): return {k: self.get_pathology(k) for k in self.get_pathology_keys()} class _FallbackThemeMgr: def get_theme_colors(self): return { "bg": "#FFFFFF", "alt_bg": "#F5F5F5", "select_bg": "#2E86AB", "select_fg": "#FFFFFF", "fg": "#000000", } # Bind managers (use fallbacks if not provided) self.medicine_manager = medicine_manager or _FallbackMedicineMgr() self.pathology_manager = pathology_manager or _FallbackPathologyMgr() self.theme_manager = theme_manager or _FallbackThemeMgr() # Status bar attributes self.status_bar: tk.Frame | None = None self.status_label: tk.Label | None = None self.file_info_label: tk.Label | None = None self.last_backup_label: tk.Label | None = None # Initialize tooltip manager self.tooltip_manager = TooltipManager(self.theme_manager) def setup_application_icon(self, img_path: str) -> bool: """Set up the application icon.""" try: self.logger.info(f"Trying to load icon from: {img_path}") # Try to find the icon in various locations # Check if we're in PyInstaller bundle if not os.path.exists(img_path) and hasattr(sys, "_MEIPASS"): # PyInstaller creates a temp folder and stores path in _MEIPASS base_path: str = sys._MEIPASS potential_paths: list[str] = [ os.path.join(base_path, os.path.basename(img_path)), os.path.join(base_path, "chart-671.png"), ] for path in potential_paths: if os.path.exists(path): self.logger.info(f"Found icon in PyInstaller bundle: {path}") img_path = path break icon_image: Image.Image = Image.open(img_path) icon_image = icon_image.resize( size=(32, 32), resample=Image.Resampling.NEAREST ) icon_photo: ImageTk.PhotoImage = ImageTk.PhotoImage(image=icon_image) self.root.iconphoto(True, icon_photo) self.root.wm_iconphoto(True, icon_photo) return True except FileNotFoundError: self.logger.warning(f"Icon file not found at {img_path}") return False except Exception as e: self.logger.error(f"Error setting icon: {str(e)}") return False def create_input_frame(self, parent_frame: ttk.Frame) -> dict[str, Any]: """Create and configure the input frame with all widgets.""" # Create main container for the scrollable input frame main_container = ttk.LabelFrame( parent_frame, text="New Entry", style="Card.TLabelframe" ) main_container.grid(row=2, column=0, padx=10, pady=10, sticky="nsew") main_container.grid_rowconfigure(0, weight=1) main_container.grid_columnconfigure(0, weight=1) # Create canvas and scrollbar for scrolling theme_colors = self.theme_manager.get_theme_colors() canvas = tk.Canvas( main_container, highlightthickness=0, bg=theme_colors["bg"], ) scrollbar = ttk.Scrollbar( main_container, orient="vertical", command=canvas.yview ) canvas.configure(yscrollcommand=scrollbar.set) # Create the actual input frame inside the canvas input_frame = ttk.Frame(canvas) input_frame.grid_columnconfigure(1, weight=1) # Place canvas and scrollbar in the container canvas.grid(row=0, column=0, sticky="nsew") scrollbar.grid(row=0, column=1, sticky="ns") # Create window in canvas for the input frame canvas_window = canvas.create_window((0, 0), window=input_frame, anchor="nw") # Configure canvas window width to fill available space def configure_canvas_width(event=None): canvas_width = canvas.winfo_width() canvas.itemconfig(canvas_window, width=canvas_width) # Configure canvas scrolling def configure_scroll_region(event=None): canvas.configure(scrollregion=canvas.bbox("all")) def on_mousewheel(event): # Check if canvas is scrollable before scrolling if canvas.cget("scrollregion"): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def on_mousewheel_linux_up(event): # Linux mouse wheel up if canvas.cget("scrollregion"): canvas.yview_scroll(-1, "units") def on_mousewheel_linux_down(event): # Linux mouse wheel down if canvas.cget("scrollregion"): canvas.yview_scroll(1, "units") input_frame.bind("", configure_scroll_region) canvas.bind("", configure_canvas_width) # Bind mouse wheel events to canvas and main container canvas.bind("", on_mousewheel) # Windows/Linux canvas.bind("", on_mousewheel_linux_up) # Linux canvas.bind("", on_mousewheel_linux_down) # Linux main_container.bind("", on_mousewheel) # Windows/Linux main_container.bind("", on_mousewheel_linux_up) # Linux main_container.bind("", on_mousewheel_linux_down) # Linux # Bind mouse wheel to input frame and its children for better scrolling self._bind_mousewheel_to_widget_tree(input_frame, canvas) # Set focus to canvas to ensure it receives scroll events canvas.focus_set() # Add mouse enter event to manage focus for scrolling def on_mouse_enter(event): canvas.focus_set() main_container.bind("", on_mouse_enter) canvas.bind("", on_mouse_enter) # Create variables for pathologies dynamically pathology_vars: dict[str, tk.IntVar] = {} for pathology_key in self.pathology_manager.get_pathology_keys(): pathology_vars[pathology_key] = tk.IntVar(value=0) # Create enhanced scales for pathologies dynamically pathology_configs = [] for pathology in self.pathology_manager.get_all_pathologies().values(): pathology_configs.append((pathology.display_name, pathology.key)) # Configure input frame columns for better layout input_frame.grid_columnconfigure(1, weight=1) for idx, (label, var_name) in enumerate(pathology_configs): self._create_enhanced_pathology_scale( input_frame, idx, label, var_name, 0, pathology_vars ) # Medicine tracking section (simplified) - adjust row number dynamically medicine_row = len(pathology_configs) ttk.Label(input_frame, text="Treatment:").grid( row=medicine_row, column=0, sticky="w", padx=5, pady=2 ) medicine_frame = ttk.LabelFrame( input_frame, text="Medicine", style="Card.TLabelframe" ) medicine_frame.grid(row=medicine_row, column=1, padx=0, pady=10, sticky="nsew") medicine_frame.grid_columnconfigure(0, weight=1) # Store medicine variables (checkboxes only) - dynamic based on medicine manager medicine_vars: dict[str, tuple[tk.IntVar, str]] = {} for medicine_key in self.medicine_manager.get_medicine_keys(): medicine = self.medicine_manager.get_medicine(medicine_key) if medicine: var = tk.IntVar(value=0) text = f"{medicine.display_name} {medicine.dosage_info}" medicine_vars[medicine_key] = (var, text) for idx, (med_key, (var, text)) in enumerate(medicine_vars.items()): # Just checkbox for medicine taken checkbox = ttk.Checkbutton( medicine_frame, text=text, variable=var, style="Modern.TCheckbutton" ) checkbox.grid(row=idx, column=0, sticky="w", padx=5, pady=2) # Add tooltip for medicine checkbox medicine = self.medicine_manager.get_medicine(med_key) if medicine: self.tooltip_manager.add_medicine_tooltip( checkbox, medicine.display_name ) # Note and Date fields - adjust row numbers note_row = medicine_row + 1 date_row = medicine_row + 2 note_var: tk.StringVar = tk.StringVar() date_var: tk.StringVar = tk.StringVar() ttk.Label(input_frame, text="Note:").grid( row=note_row, column=0, sticky="w", padx=5, pady=2 ) ttk.Entry(input_frame, textvariable=note_var, style="Modern.TEntry").grid( row=note_row, column=1, sticky="ew", padx=5, pady=2 ) ttk.Label(input_frame, text="Date (mm/dd/yyyy):").grid( row=date_row, column=0, sticky="w", padx=5, pady=2 ) ttk.Entry( input_frame, textvariable=date_var, justify="center", style="Modern.TEntry", ).grid(row=date_row, column=1, sticky="ew", padx=5, pady=2) # Set default date to today date_var.set(datetime.now().strftime("%m/%d/%Y")) # Ensure mouse wheel binding is applied to all newly created widgets main_container.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) self._bind_mousewheel_to_widget_tree(input_frame, canvas) # Return all UI elements and variables # Tests expect keys symptom_vars & medicine_vars (legacy naming). Provide both. return { "frame": main_container, "pathology_vars": pathology_vars, "symptom_vars": pathology_vars, # backward compatibility alias "medicine_vars": medicine_vars, "note_var": note_var, "date_var": date_var, } def create_table_frame(self, parent_frame: ttk.Frame) -> dict[str, Any]: """Create and configure the table frame with a treeview.""" table_frame: ttk.LabelFrame = ttk.LabelFrame( parent_frame, text="Log (Double-click to edit)", style="Card.TLabelframe" ) table_frame.grid(row=2, column=1, padx=10, pady=10, sticky="nsew") # Configure table frame to expand table_frame.grid_rowconfigure(0, weight=1) table_frame.grid_columnconfigure(0, weight=1) # Build columns dynamically columns: list[str] = ["Date"] col_labels: list[str] = ["Date"] col_settings: list[tuple[str, int, str]] = [("Date", 80, "center")] # Add pathology columns dynamically for pathology_key in self.pathology_manager.get_pathology_keys(): pathology = self.pathology_manager.get_pathology(pathology_key) if pathology: columns.append(pathology.display_name) col_labels.append(pathology.display_name) col_settings.append((pathology.display_name, 80, "center")) # Add medicine columns dynamically for medicine_key in self.medicine_manager.get_medicine_keys(): medicine = self.medicine_manager.get_medicine(medicine_key) if medicine: columns.append(medicine.display_name) col_labels.append(f"{medicine.display_name} {medicine.dosage_info}") col_settings.append((medicine.display_name, 120, "center")) columns.append("Note") col_labels.append("Note") col_settings.append(("Note", 300, "w")) tree: ttk.Treeview = ttk.Treeview( table_frame, columns=columns, show="headings", style="Modern.Treeview" ) # Configure treeview for optimal scrolling performance tree.configure(selectmode="browse") # Single selection mode # Disable some visual effects that can cause flickering during scroll import contextlib with contextlib.suppress(tk.TclError): # These settings help reduce redraws during scrolling tree.configure(displaycolumns=columns) # Configure row tags for alternating colors theme_colors = self.theme_manager.get_theme_colors() tree.tag_configure("evenrow", background=theme_colors["bg"]) tree.tag_configure("oddrow", background=theme_colors["alt_bg"]) # Configure selection highlighting tree.tag_configure( "selected", background=theme_colors["select_bg"], foreground=theme_colors["select_fg"], ) # Bind selection events to ensure proper highlighting def on_selection_change(event): """Handle treeview selection changes to ensure proper highlighting.""" selection = tree.selection() if selection: # Force focus to ensure selection is visible tree.focus(selection[0]) tree.bind("<>", on_selection_change) # Column sort state tracking self._tree_sort_directions: dict[str, bool] = {} self._last_sorted_column: str | None = None self._last_sorted_ascending: bool | None = None def make_sort_callback(col_name: str): def _callback(): self.sort_tree_column(tree, col_name) # Remember last sort state self._last_sorted_column = col_name self._last_sorted_ascending = self._tree_sort_directions.get(col_name) return _callback for col, label in zip(columns, col_labels, strict=False): tree.heading(col, text=label, command=make_sort_callback(col)) for col, width, anchor in col_settings: tree.column(col, width=width, anchor=anchor) tree.pack(side="left", fill="both", expand=True) # Add scrollbars with optimized scroll handling vscroll = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview) hscroll = ttk.Scrollbar(table_frame, orient="horizontal", command=tree.xview) tree.configure(yscrollcommand=vscroll.set, xscrollcommand=hscroll.set) vscroll.pack(side="right", fill="y") hscroll.pack(side="bottom", fill="x") # Optimize tree scrolling performance self._optimize_tree_scrolling(tree) return {"frame": table_frame, "tree": tree} # ------------------------------------------------------------------ # Table Utilities # ------------------------------------------------------------------ def sort_tree_column(self, tree: ttk.Treeview, column: str) -> None: """Sort a treeview column, toggling ascending/descending.""" data = [] for item in tree.get_children(""): values = tree.item(item, "values") # Map heading column name to index try: col_index = tree["columns"].index(column) except ValueError: continue data.append((values[col_index], item, values)) # Determine direction ascending = not self._tree_sort_directions.get(column, True) self._tree_sort_directions[column] = ascending def try_cast(v: Any): for caster in (int, float): try: return caster(v) except Exception: continue return str(v) data.sort(key=lambda tup: try_cast(tup[0]), reverse=not ascending) for index, (_value, item, _vals) in enumerate(data): tree.move(item, "", index) # Update heading arrow (basic glyph) direction_glyph = "▲" if ascending else "▼" tree.heading(column, text=f"{column} {direction_glyph}") # Re-apply alternating row tags after sort self.normalize_tree_stripes(tree) def _sort_tree_column_direction( self, tree: ttk.Treeview, column: str, ascending: bool ) -> None: """Sort a treeview column in a specific direction without toggling state.""" data = [] for item in tree.get_children(""): values = tree.item(item, "values") try: col_index = tree["columns"].index(column) except ValueError: continue data.append((values[col_index], item, values)) def try_cast(v: Any): for caster in (int, float): try: return caster(v) except Exception: continue return str(v) data.sort(key=lambda tup: try_cast(tup[0]), reverse=not ascending) for index, (_value, item, _vals) in enumerate(data): tree.move(item, "", index) direction_glyph = "▲" if ascending else "▼" tree.heading(column, text=f"{column} {direction_glyph}") # Re-apply alternating row tags after sort self.normalize_tree_stripes(tree) def reapply_last_sort(self, tree: ttk.Treeview) -> None: """Reapply the last known sort to the tree after data refresh.""" if not self._last_sorted_column or self._last_sorted_ascending is None: return import contextlib with contextlib.suppress(Exception): self._sort_tree_column_direction( tree, self._last_sorted_column, bool(self._last_sorted_ascending) ) def diff_update_tree(self, tree: ttk.Treeview, df: pd.DataFrame) -> None: """Apply minimal changes to treeview vs full rebuild. Rows keyed by 'date'. If structure mismatch or too large diff, fallback to full rebuild. """ if df.empty: for child in tree.get_children(""): tree.delete(child) return # Build desired mapping if "date" not in df.columns: # Fallback children = tree.get_children("") if children: tree.delete(*children) for _idx, row in df.iterrows(): tree.insert("", "end", values=list(row)) return desired = {str(row["date"]): list(row) for _i, row in df.iterrows()} existing_ids = tree.get_children("") existing_map = {} for item_id in existing_ids: vals = tree.item(item_id, "values") if vals: existing_map[str(vals[0])] = (item_id, list(vals)) # Heuristic: fallback if large diff (>30% changes) change_budget = max(10, int(len(desired) * 0.3)) changes = 0 # Update & insert for date_key, row_vals in desired.items(): if date_key in existing_map: item_id, current_vals = existing_map[date_key] if current_vals != row_vals: tree.item(item_id, values=row_vals) changes += 1 else: tag = "evenrow" if (len(existing_map) + changes) % 2 == 0 else "oddrow" tree.insert("", "end", values=row_vals, tags=(tag,)) changes += 1 if changes > change_budget: break # Delete orphaned if under budget if changes <= change_budget: for date_key, (item_id, _) in existing_map.items(): if date_key not in desired: tree.delete(item_id) changes += 1 if changes > change_budget: break # Fallback to full rebuild if budget exceeded if changes > change_budget: children = tree.get_children("") if children: tree.delete(*children) for idx, row in df.iterrows(): tag = "evenrow" if idx % 2 == 0 else "oddrow" tree.insert("", "end", values=list(row), tags=(tag,)) # Ensure alternating stripes are normalized after updates self.normalize_tree_stripes(tree) def normalize_tree_stripes(self, tree: ttk.Treeview) -> None: """Normalize alternating row tags based on current visual order. Keeps even/odd striping consistent after inserts, deletes, and sorts. """ try: for idx, item in enumerate(tree.get_children("")): tag = "evenrow" if idx % 2 == 0 else "oddrow" tree.item(item, tags=(tag,)) except Exception: # Best-effort visual enhancement; ignore errors pass def create_graph_frame(self, parent_frame: ttk.Frame) -> ttk.LabelFrame: """Create and configure the graph frame.""" graph_frame: ttk.LabelFrame = ttk.LabelFrame( parent_frame, text="Evolution", style="Card.TLabelframe" ) graph_frame.grid(row=0, column=0, columnspan=2, padx=10, pady=10, sticky="nsew") return graph_frame def add_action_buttons( self, frame: ttk.Frame, buttons_config: list[dict[str, Any]] ) -> ttk.Frame: """Add buttons to a frame based on configuration.""" button_frame: ttk.Frame = ttk.Frame(frame) button_frame.grid(row=7, column=0, columnspan=2, pady=10) for btn_config in buttons_config: button = ttk.Button( button_frame, text=btn_config["text"], command=btn_config["command"], style="Action.TButton", ) button.pack( side="left", padx=5, fill=btn_config.get("fill", None), expand=btn_config.get("expand", False), ) # Add tooltips based on button text button_text = btn_config["text"].lower() if "add" in button_text or "save" in button_text: self.tooltip_manager.add_button_tooltip(button, "save") elif "quit" in button_text or "exit" in button_text: self.tooltip_manager.add_button_tooltip(button, "quit") return button_frame # Backward compatibility: some tests reference add_buttons def add_buttons( self, frame: ttk.Frame, buttons_config: list[dict[str, Any]] ): # pragma: no cover - simple delegate return self.add_action_buttons(frame, buttons_config) def create_status_bar(self, parent_frame: tk.Widget) -> tk.Frame: """Create and configure the status bar at the bottom of the application.""" # Get theme colors for consistent styling theme_colors = self.theme_manager.get_theme_colors() # Create the status bar frame self.status_bar = tk.Frame( parent_frame, relief=tk.SUNKEN, bd=1, bg=theme_colors["bg"], ) self.status_bar.grid(row=3, column=0, columnspan=2, sticky="ew", padx=5, pady=2) # Configure the parent to make the status bar stretch parent_frame.grid_columnconfigure(0, weight=1) # Create status message label (left side) self.status_label = tk.Label( self.status_bar, text="Ready", anchor=tk.W, font=("TkDefaultFont", 9), padx=10, pady=2, bg=theme_colors["bg"], fg=theme_colors["fg"], ) self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True) # Create file info label (right side) self.file_info_label = tk.Label( self.status_bar, text="", anchor=tk.E, font=("TkDefaultFont", 9), padx=10, pady=2, bg=theme_colors["bg"], fg=theme_colors["fg"], ) self.file_info_label.pack(side=tk.RIGHT) # Create last backup label (right side, next to file info) self.last_backup_label = tk.Label( self.status_bar, text="Last backup: —", anchor=tk.E, font=("TkDefaultFont", 9), padx=10, pady=2, bg=theme_colors["bg"], fg=theme_colors["fg"], ) # Pack after file_info so it appears to the left of it 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 def update_last_backup(self, when_text: str) -> None: """Update the 'Last backup' indicator in the status bar.""" if not self.last_backup_label: return self.last_backup_label.config(text=f"Last backup: {when_text}") def update_status(self, message: str, message_type: str = "info") -> None: """ Update the status bar with a message. Args: message: The message to display message_type: Type of message ('info', 'success', 'warning', 'error') """ if not self.status_label: return # Color mapping for different message types colors = { "info": "#000000", # Black "success": "#28A745", # Green "warning": "#FFC107", # Yellow/Orange "error": "#DC3545", # Red } color = colors.get(message_type, "#000000") self.status_label.config(text=message, fg=color) # Clear the message after 5 seconds for non-info messages if message_type != "info": self.root.after(5000, lambda: self.update_status("Ready", "info")) def update_file_info( self, filename: str, entry_count: int = 0, filter_status: str = None ) -> None: """ Update the file information in the status bar. Args: filename: Name of the current data file entry_count: Number of entries in the file filter_status: Optional filter status string (e.g., "filtered (5/10)") """ if not self.file_info_label: return file_display = os.path.basename(filename) if filename else "No file" info_text = f"{file_display}" if entry_count > 0: if filter_status: info_text += f" ({entry_count} entries, {filter_status})" else: info_text += f" ({entry_count} entries)" self.file_info_label.config(text=info_text) def show_status_message(self, message: str, duration: int = 3000) -> None: """ Show a temporary status message for a specific duration. Args: message: The message to display duration: How long to show the message in milliseconds """ if not self.status_label: return original_text = self.status_label.cget("text") original_color = self.status_label.cget("fg") self.status_label.config(text=message, fg="#2E86AB") self.root.after( duration, lambda: self.status_label.config(text=original_text, fg=original_color), ) def show_toast(self, message: str, duration_ms: int = 3000) -> None: """Display a transient toast-style message near the bottom-right. Creates a small borderless window that auto-destroys after duration_ms. Safe to call from anywhere; failures are ignored. """ try: toast = tk.Toplevel(self.root) toast.overrideredirect(True) toast.attributes("-topmost", True) # Styling based on theme colors = self.theme_manager.get_theme_colors() bg = colors.get("alt_bg", "#333333") fg = colors.get("fg", "#000000") frame = tk.Frame(toast, bg=bg, bd=1, relief=tk.SOLID) frame.pack(fill=tk.BOTH, expand=True) label = tk.Label( frame, text=message, bg=bg, fg=fg, padx=12, pady=8, font=("TkDefaultFont", 9), anchor=tk.W, justify=tk.LEFT, ) label.pack() self.root.update_idletasks() # Position in bottom-right of the root window root_x = self.root.winfo_rootx() root_y = self.root.winfo_rooty() root_w = self.root.winfo_width() root_h = self.root.winfo_height() toast.update_idletasks() tw = toast.winfo_width() or 240 th = toast.winfo_height() or 48 x = root_x + root_w - tw - 20 y = root_y + root_h - th - 20 toast.geometry(f"{tw}x{th}+{max(0, x)}+{max(0, y)}") # Auto-destroy after duration toast.after(duration_ms, toast.destroy) except Exception: # Non-fatal UI convenience; ignore errors 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( self, values: tuple[str, ...], callbacks: dict[str, Callable] ) -> tk.Toplevel: """Create a new window for editing an entry with improved UI.""" edit_win: tk.Toplevel = tk.Toplevel(master=self.root) edit_win.title("Edit Entry") edit_win.transient(self.root) # Make window modal edit_win.minsize(600, 700) edit_win.geometry("800x800") # Create scrollable container canvas = tk.Canvas(edit_win, highlightthickness=0) scrollbar = ttk.Scrollbar(edit_win, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=scrollbar.set) # Configure main container with padding inside the canvas main_container = ttk.Frame(canvas, padding="20") # Pack canvas and scrollbar canvas.pack(side="left", fill="both", expand=True) scrollbar.pack(side="right", fill="y") # Create window in canvas for the main container canvas_window = canvas.create_window((0, 0), window=main_container, anchor="nw") # Configure grid for main container main_container.grid_columnconfigure(0, weight=1) # Configure scrolling def configure_scroll_region(event=None): canvas.configure(scrollregion=canvas.bbox("all")) def configure_canvas_width(event=None): canvas_width = canvas.winfo_width() canvas.itemconfig(canvas_window, width=canvas_width) def on_mousewheel(event): # Check if canvas is scrollable before scrolling if canvas.cget("scrollregion"): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def on_mousewheel_linux_up(event): # Linux mouse wheel up if canvas.cget("scrollregion"): canvas.yview_scroll(-1, "units") def on_mousewheel_linux_down(event): # Linux mouse wheel down if canvas.cget("scrollregion"): canvas.yview_scroll(1, "units") main_container.bind("", configure_scroll_region) canvas.bind("", configure_canvas_width) # Bind mouse wheel events to canvas and edit window canvas.bind("", on_mousewheel) # Windows/Linux canvas.bind("", on_mousewheel_linux_up) # Linux canvas.bind("", on_mousewheel_linux_down) # Linux edit_win.bind("", on_mousewheel) # Windows/Linux edit_win.bind("", on_mousewheel_linux_up) # Linux edit_win.bind("", on_mousewheel_linux_down) # Linux # Bind mouse wheel to main container and its children for better scrolling self._bind_mousewheel_to_widget_tree(main_container, canvas) # Set focus to canvas to ensure it receives scroll events canvas.focus_set() # Add mouse enter event to manage focus for scrolling def on_mouse_enter(event): canvas.focus_set() edit_win.bind("", on_mouse_enter) canvas.bind("", on_mouse_enter) # Unpack values dynamically # Expected format: date, pathology1, pathology2, ..., # medicine1, medicine1_doses, medicine2, medicine2_doses, ..., note # Parse values dynamically. Legacy tests pass a compressed tuple: # (date, p1, p2, p3, p4, m1, m2, m3, m4, note) values_list = list(values) legacy_mode = False if len(values_list) == 10: # heuristic matching test tuple legacy_mode = True # Extract date date = values_list[0] if len(values_list) > 0 else "" # Extract pathology values pathology_values = {} pathology_keys = self.pathology_manager.get_pathology_keys() for i, pathology_key in enumerate(pathology_keys): if i + 1 < len(values_list): pathology_values[pathology_key] = values_list[i + 1] else: pathology_values[pathology_key] = 0 # Extract medicine values and doses medicine_values = {} medicine_doses = {} medicine_keys = self.medicine_manager.get_medicine_keys() # Start index after date and pathologies medicine_start_idx = 1 + len(pathology_keys) for i, medicine_key in enumerate(medicine_keys): if legacy_mode: # After pathologies, next up to len(medicine_keys) values map directly legacy_idx = 1 + len(pathology_keys) + i if legacy_idx < len(values_list) - 1: # last element is note medicine_values[medicine_key] = values_list[legacy_idx] else: medicine_values[medicine_key] = 0 medicine_doses[medicine_key] = "" # No dose info in legacy tuple else: # Each medicine has 2 values: checkbox value and doses string checkbox_idx = medicine_start_idx + (i * 2) doses_idx = medicine_start_idx + (i * 2) + 1 if checkbox_idx < len(values_list): medicine_values[medicine_key] = values_list[checkbox_idx] else: medicine_values[medicine_key] = 0 if doses_idx < len(values_list): medicine_doses[medicine_key] = values_list[doses_idx] else: medicine_doses[medicine_key] = "" # Extract note (should be the last value) note = values_list[-1] if len(values_list) > 0 else "" # Create improved UI sections vars_dict = self._create_edit_ui( main_container, date, pathology_values, medicine_values, medicine_doses, note, ) # Add action buttons self._add_edit_buttons(main_container, vars_dict, callbacks, edit_win) # Update scroll region after adding all content edit_win.update_idletasks() canvas.configure(scrollregion=canvas.bbox("all")) # Ensure mouse wheel binding is applied to all newly created widgets self._bind_mousewheel_to_widget_tree(main_container, canvas) # Make window modal edit_win.focus_set() edit_win.grab_set() return edit_win def _create_edit_ui( self, parent: ttk.Frame, date: str, pathology_values: dict[str, int], medicine_values: dict[str, int], medicine_doses: dict[str, str], note: str, ) -> dict[str, Any]: """Create UI layout for edit window with dynamic pathologies and medicines.""" vars_dict = {} row = 0 # Header with entry date header_frame = ttk.Frame(parent) header_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) header_frame.grid_columnconfigure(1, weight=1) ttk.Label( header_frame, text="Editing Entry for:", font=("TkDefaultFont", 12, "bold") ).grid(row=0, column=0, sticky="w") vars_dict["date"] = tk.StringVar(value=str(date)) date_entry = ttk.Entry( header_frame, textvariable=vars_dict["date"], font=("TkDefaultFont", 12), width=15, ) date_entry.grid(row=0, column=1, sticky="w", padx=(10, 0)) row += 1 # Pathologies section pathologies_frame = ttk.LabelFrame( parent, text="Daily Pathologies", padding="15" ) pathologies_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15)) pathologies_frame.grid_columnconfigure(1, weight=1) # Create pathology scales dynamically for i, (pathology_key, value) in enumerate(pathology_values.items()): pathology = self.pathology_manager.get_pathology(pathology_key) if pathology: label = f"{pathology.display_name} ({pathology.scale_info})" self._create_symptom_scale( pathologies_frame, i, label, pathology_key, value, vars_dict ) row += 1 # Medications section meds_frame = ttk.LabelFrame(parent, text="Medications Taken", padding="15") meds_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15)) meds_frame.grid_columnconfigure(0, weight=1) # Create medicine checkboxes dynamically med_vars = self._create_medicine_section(meds_frame, medicine_values) vars_dict.update(med_vars) row += 1 # Dose tracking section dose_frame = ttk.LabelFrame(parent, text="Dose Tracking", padding="15") dose_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15)) dose_frame.grid_columnconfigure(0, weight=1) dose_vars = self._create_dose_tracking(dose_frame, medicine_doses) vars_dict.update(dose_vars) row += 1 # Notes section notes_frame = ttk.LabelFrame(parent, text="Notes", padding="15") notes_frame.grid(row=row, column=0, sticky="ew", pady=(0, 20)) notes_frame.grid_columnconfigure(0, weight=1) vars_dict["note"] = tk.StringVar(value=str(note)) note_text = tk.Text( notes_frame, height=4, width=50, wrap=tk.WORD, font=("TkDefaultFont", 10), relief="solid", borderwidth=1, ) note_text.grid(row=0, column=0, sticky="ew", padx=5, pady=5) note_text.insert("1.0", str(note)) vars_dict["note_text"] = note_text # Store the widget for access during save # Bind text widget to string var for easy access def update_note(*args): vars_dict["note"].set(note_text.get("1.0", tk.END).strip()) note_text.bind("", update_note) note_text.bind("", update_note) return vars_dict def _create_symptom_scale( self, parent: ttk.Frame, row: int, label: str, key: str, value: int, vars_dict: dict[str, Any], ) -> None: """Create a symptom scale with visual feedback.""" # Ensure value is properly converted try: value = int(float(value)) if value not in ["", None] else 0 except (ValueError, TypeError): value = 0 vars_dict[key] = tk.IntVar(value=value) # Label ttk.Label(parent, text=f"{label}:", font=("TkDefaultFont", 10, "bold")).grid( row=row, column=0, sticky="w", pady=8 ) # Scale container scale_container = ttk.Frame(parent) scale_container.grid(row=row, column=1, sticky="ew", padx=(20, 0), pady=8) scale_container.grid_columnconfigure(0, weight=1) # Scale with value labels scale_frame = ttk.Frame(scale_container) scale_frame.grid(row=0, column=0, sticky="ew") scale_frame.grid_columnconfigure(1, weight=1) # Current value display value_label = ttk.Label( scale_frame, text=str(value), font=("TkDefaultFont", 12, "bold"), foreground="#2E86AB", width=3, ) value_label.grid(row=0, column=0, padx=(0, 10)) # Scale widget scale = ttk.Scale( scale_frame, from_=0, to=10, variable=vars_dict[key], orient=tk.HORIZONTAL, length=300, ) scale.grid(row=0, column=1, sticky="ew") # Scale labels (0, 5, 10) labels_frame = ttk.Frame(scale_container) labels_frame.grid(row=1, column=0, sticky="ew", pady=(5, 0)) ttk.Label(labels_frame, text="0", font=("TkDefaultFont", 8)).grid( row=0, column=0, sticky="w" ) labels_frame.grid_columnconfigure(1, weight=1) ttk.Label(labels_frame, text="5", font=("TkDefaultFont", 8)).grid( row=0, column=1 ) ttk.Label(labels_frame, text="10", font=("TkDefaultFont", 8)).grid( row=0, column=2, sticky="e" ) # Update label when scale changes def update_value_label(event=None): current_val = vars_dict[key].get() value_label.configure(text=str(current_val)) # Change color based on value if current_val <= 3: value_label.configure(foreground="#28A745") # Green for low/good elif current_val <= 6: value_label.configure(foreground="#FFC107") # Yellow for medium else: value_label.configure(foreground="#DC3545") # Red for high/bad scale.bind("", update_value_label) scale.bind("", update_value_label) scale.bind("", update_value_label) update_value_label() # Set initial color def _create_enhanced_pathology_scale( self, parent: ttk.Frame, row: int, label: str, key: str, value: int, vars_dict: dict[str, tk.IntVar], ) -> None: """Create enhanced pathology scale for new entry form.""" # Ensure value is properly converted try: value = int(float(value)) if value not in ["", None] else 0 except (ValueError, TypeError): value = 0 # Get pathology configuration pathology = self.pathology_manager.get_pathology(key) if not pathology: # Fallback for missing pathology pathology_info = f"{label} (0-10):" scale_min, scale_max = 0, 10 scale_orientation = "normal" else: pathology_info = f"{pathology.display_name} ({pathology.scale_info}):" scale_min, scale_max = pathology.scale_min, pathology.scale_max scale_orientation = pathology.scale_orientation # Label label_widget = ttk.Label( parent, text=pathology_info, font=("TkDefaultFont", 10, "bold") ) label_widget.grid(row=row, column=0, sticky="w", padx=5, pady=8) # Scale container scale_container = ttk.Frame(parent) scale_container.grid(row=row, column=1, sticky="ew", padx=(20, 5), pady=8) scale_container.grid_columnconfigure(0, weight=1) # Scale with value labels scale_frame = ttk.Frame(scale_container) scale_frame.grid(row=0, column=0, sticky="ew") scale_frame.grid_columnconfigure(1, weight=1) # Current value display value_label = ttk.Label( scale_frame, text=str(value), font=("TkDefaultFont", 12, "bold"), foreground="#2E86AB", width=3, ) value_label.grid(row=0, column=0, padx=(0, 10)) # Scale widget scale = ttk.Scale( scale_frame, from_=scale_min, to=scale_max, variable=vars_dict[key], orient=tk.HORIZONTAL, length=250, style="Modern.Horizontal.TScale", ) scale.grid(row=0, column=1, sticky="ew") # Add tooltip for the scale pathology = self.pathology_manager.get_pathology(key) if pathology: self.tooltip_manager.add_scale_tooltip(scale, pathology.display_name) # Scale labels labels_frame = ttk.Frame(scale_container) labels_frame.grid(row=1, column=0, sticky="ew", pady=(5, 0)) ttk.Label(labels_frame, text=str(scale_min), font=("TkDefaultFont", 8)).grid( row=0, column=0, sticky="w" ) labels_frame.grid_columnconfigure(1, weight=1) mid_value = (scale_min + scale_max) // 2 ttk.Label(labels_frame, text=str(mid_value), font=("TkDefaultFont", 8)).grid( row=0, column=1 ) ttk.Label(labels_frame, text=str(scale_max), font=("TkDefaultFont", 8)).grid( row=0, column=2, sticky="e" ) # Update label when scale changes def update_value_label_pathology(event=None): current_val = vars_dict[key].get() value_label.configure(text=str(current_val)) # Change color based on value and orientation if scale_orientation == "inverted": # For inverted scales (like sleep, appetite), higher is better if current_val >= scale_max * 0.7: value_label.configure(foreground="#28A745") # Green for good elif current_val >= scale_max * 0.4: value_label.configure(foreground="#FFC107") # Yellow for medium else: value_label.configure(foreground="#DC3545") # Red for bad else: # For normal scales (like depression, anxiety), lower is better if current_val <= scale_max * 0.3: value_label.configure(foreground="#28A745") # Green for good elif current_val <= scale_max * 0.6: value_label.configure(foreground="#FFC107") # Yellow for medium else: value_label.configure(foreground="#DC3545") # Red for bad scale.bind("", update_value_label_pathology) scale.bind("", update_value_label_pathology) scale.bind("", update_value_label_pathology) update_value_label_pathology() # Set initial color def _create_medicine_section( self, parent: ttk.Frame, medicine_values: dict[str, int] ) -> dict[str, tk.IntVar]: """Create medicine checkboxes dynamically.""" vars_dict = {} # Create a grid layout for medicines medicine_items = [] for medicine_key, value in medicine_values.items(): medicine = self.medicine_manager.get_medicine(medicine_key) if medicine: medicine_items.append( ( medicine_key, value, medicine.display_name, medicine.dosage_info, medicine.color, ) ) # Create medicine cards in a 2-column layout for i, (key, value, name, dose, _color) in enumerate(medicine_items): row = i // 2 col = i % 2 # Medicine card frame med_card = ttk.Frame(parent, relief="solid", borderwidth=1) med_card.grid(row=row, column=col, sticky="ew", padx=5, pady=5) parent.grid_columnconfigure(col, weight=1) vars_dict[key] = tk.IntVar(value=int(value)) # Checkbox with medicine name check_frame = ttk.Frame(med_card) check_frame.pack(fill="x", padx=10, pady=8) checkbox = ttk.Checkbutton( check_frame, text=f"{name} ({dose})", variable=vars_dict[key], style="Medicine.TCheckbutton", ) checkbox.pack(anchor="w") return vars_dict def _create_dose_tracking( self, parent: ttk.Frame, medicine_doses: dict[str, str] ) -> dict[str, Any]: """Create dose tracking interface dynamically.""" vars_dict = {} # Create notebook for organized dose tracking notebook = ttk.Notebook(parent) notebook.pack(fill="both", expand=True) for medicine_key, dose_str in medicine_doses.items(): medicine = self.medicine_manager.get_medicine(medicine_key) if not medicine: continue # Create tab for each medicine tab_frame = ttk.Frame(notebook) notebook.add(tab_frame, text=medicine.display_name) # Configure tab layout tab_frame.grid_columnconfigure(0, weight=1) # Quick dose entry section entry_frame = ttk.LabelFrame(tab_frame, text="Add New Dose", padding="10") entry_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5) entry_frame.grid_columnconfigure(0, weight=1) # Dose entry dose_entry_var = tk.StringVar() vars_dict[f"{medicine_key}_dose_entry"] = dose_entry_var dose_entry = ttk.Entry(entry_frame, textvariable=dose_entry_var, width=12) dose_entry.grid(row=0, column=0, padx=5, pady=5, sticky="w") # Quick dose buttons quick_frame = ttk.Frame(entry_frame) quick_frame.grid(row=0, column=1, padx=10, pady=5, sticky="w") # Create the dose StringVar that will be used for saving dose_string_var = tk.StringVar(value=str(dose_str)) vars_dict[f"{medicine_key}_doses"] = dose_string_var # Punch button - updated to use the StringVar properly def create_punch_callback(med_key, entry_var, dose_var): def punch_dose(): dose = entry_var.get().strip() if dose: from datetime import datetime # Format timestamp for display (12-hour format with AM/PM) timestamp = datetime.now().strftime("%I:%M %p") new_dose = f"• {timestamp} - {dose}" current_doses = dose_var.get() if current_doses and current_doses.strip(): # Check if current content is placeholder text if "No doses recorded" in current_doses: dose_var.set(new_dose) else: dose_var.set(current_doses + f"\n{new_dose}") else: dose_var.set(new_dose) entry_var.set("") return punch_dose punch_btn = ttk.Button( quick_frame, text=f"Take {medicine.display_name}", command=create_punch_callback( medicine_key, dose_entry_var, dose_string_var ), width=15, ) punch_btn.grid(row=0, column=0, padx=5) # Quick dose buttons quick_doses = self.medicine_manager.get_quick_doses(medicine_key) for i, dose in enumerate(quick_doses[:3]): # Limit to 3 quick doses def create_quick_callback(d, entry_var=dose_entry_var): return lambda: entry_var.set(d) btn = ttk.Button( quick_frame, text=f"{dose}mg", command=create_quick_callback(dose), width=8, ) btn.grid(row=0, column=i + 1, padx=2) # Dose history section history_frame = ttk.LabelFrame( tab_frame, text="Dose History (HH:MM: dose)", padding="10" ) history_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=5) history_frame.grid_columnconfigure(0, weight=1) # Dose display text area dose_text = tk.Text( history_frame, height=3, width=40, wrap=tk.WORD, font=("Consolas", 9), relief="solid", borderwidth=1, ) dose_text.grid(row=0, column=0, sticky="ew", padx=5, pady=5) # Populate with existing doses using the proper formatting method self._populate_dose_history(dose_text, dose_str) # Bind text widget to update string var - fixed closure issue def create_update_callback(text_widget, dose_var): def update_doses(*args): content = text_widget.get("1.0", tk.END).strip() dose_var.set(content) return update_doses update_callback = create_update_callback(dose_text, dose_string_var) dose_text.bind("", update_callback) dose_text.bind("", update_callback) # Also update text widget when StringVar changes (for punch button) def create_var_to_text_callback(text_widget, string_var): def update_text_from_var(*args): current_text = text_widget.get("1.0", tk.END).strip() var_content = string_var.get() if current_text != var_content: text_widget.delete("1.0", tk.END) text_widget.insert("1.0", var_content) return update_text_from_var var_to_text_callback = create_var_to_text_callback( dose_text, dose_string_var ) dose_string_var.trace("w", var_to_text_callback) # Scrollbar for dose text dose_scroll = ttk.Scrollbar( history_frame, orient="vertical", command=dose_text.yview ) dose_scroll.grid(row=0, column=1, sticky="ns") dose_text.configure(yscrollcommand=dose_scroll.set) # Store reference to text widget for save function vars_dict[f"{medicine_key}_dose_text"] = dose_text return vars_dict def _get_quick_doses(self, medicine_key: str) -> list[str]: """Get common dose amounts for quick selection.""" return self.medicine_manager.get_quick_doses(medicine_key) def _populate_dose_history(self, text_widget: tk.Text, doses_str: str) -> None: """Populate dose history text widget with formatted dose data.""" text_widget.configure(state="normal") text_widget.delete(1.0, tk.END) if not doses_str or str(doses_str) == "nan": text_widget.insert(1.0, "No doses recorded today") # Keep text widget enabled for editing return doses_str = str(doses_str) formatted_doses = [] for dose_entry in doses_str.split("|"): if ":" in dose_entry: # Split on the last colon to separate timestamp from dose parts = dose_entry.rsplit(":", 1) if len(parts) == 2: timestamp, dose = parts try: dt = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S") time_str = dt.strftime("%I:%M %p") formatted_doses.append(f"• {time_str} - {dose}") except ValueError: # Handle cases where the timestamp might be malformed formatted_doses.append(f"• {dose_entry}") else: formatted_doses.append(f"• {dose_entry}") else: formatted_doses.append(f"• {dose_entry}") if formatted_doses: text_widget.insert(1.0, "\n".join(formatted_doses)) else: text_widget.insert(1.0, "No doses recorded today") # Always keep text widget enabled for user editing def _take_dose( self, med_name: str, entry_var: tk.StringVar, med_key: str, vars_dict: dict[str, Any], ) -> None: """Handle taking a dose with feedback and state management.""" dose = entry_var.get().strip() # Get the dose text widget - this is what the save function reads from dose_text_widget = vars_dict.get(f"{med_key}_doses_text") if not dose_text_widget: self.logger.error(f"Dose text widget not found for {med_key}") return # Find the parent edit window parent_window = dose_text_widget.winfo_toplevel() if not dose: messagebox.showerror( "Error", f"Please enter a dose amount for {med_name}", parent=parent_window, ) return # Get current time and timestamp now = datetime.now() time_str = now.strftime("%I:%M %p") # Ensure text widget is enabled dose_text_widget.configure(state="normal") # Get current content from the text widget current_content = dose_text_widget.get(1.0, tk.END).strip() self.logger.debug(f"Current content before adding dose: '{current_content}'") # Create new dose entry in the display format new_dose_line = f"• {time_str} - {dose}" self.logger.debug(f"New dose line: '{new_dose_line}'") # Add the new dose to the text widget if current_content == "No doses recorded today" or not current_content: dose_text_widget.delete(1.0, tk.END) dose_text_widget.insert(1.0, new_dose_line) self.logger.debug("Added first dose") else: # Append to existing content with proper formatting updated_content = current_content + f"\n{new_dose_line}" self.logger.debug(f"Updated content: '{updated_content}'") dose_text_widget.delete(1.0, tk.END) dose_text_widget.insert(1.0, updated_content) self.logger.debug("Added subsequent dose") # Verify what's actually in the widget after insertion final_content = dose_text_widget.get(1.0, tk.END).strip() self.logger.debug(f"Final content in widget: '{final_content}'") # Clear entry field entry_var.set("") # Success feedback messagebox.showinfo( "Dose Recorded", f"{med_name} dose of {dose} recorded at {time_str}", parent=parent_window, ) def _add_edit_buttons( self, parent: ttk.Frame, vars_dict: dict[str, Any], callbacks: dict[str, Callable], edit_win: tk.Toplevel, ) -> None: """Add action buttons to edit window.""" button_frame = ttk.Frame(parent) button_frame.grid(row=999, column=0, sticky="ew", pady=(20, 0)) button_frame.grid_columnconfigure((0, 1, 2), weight=1) # Save button def save_with_data(): self.logger.debug("=== SAVE FUNCTION CALLED ===") # Get note text from Text widget note_text_widget = vars_dict.get("note_text") self.logger.debug(f"note_text_widget found: {note_text_widget is not None}") self.logger.debug(f"vars_dict keys: {list(vars_dict.keys())}") note_content = "" if note_text_widget: try: note_content = note_text_widget.get(1.0, tk.END).strip() self.logger.debug(f"Note content from widget: '{note_content}'") except Exception as e: self.logger.error(f"Error getting note from text widget: {e}") # Fallback to StringVar note_var = vars_dict.get("note") if note_var: note_content = note_var.get() self.logger.debug( f"Note content from StringVar fallback: '{note_content}'" ) else: # Fallback to StringVar if note_text widget not found note_var = vars_dict.get("note") if note_var: note_content = note_var.get() self.logger.debug(f"Note content from StringVar: '{note_content}'") else: self.logger.error("No note widget or StringVar found!") self.logger.debug(f"Final note_content: '{note_content}'") # Extract dose data dynamically from all medicines dose_data = {} medicines = self.medicine_manager.get_all_medicines() for medicine_key in medicines: dose_var_key = f"{medicine_key}_doses" dose_text_key = f"{medicine_key}_dose_text" self.logger.debug(f"Processing {medicine_key}...") # Prioritize Text widget if it exists (it has the most current data) if dose_text_key in vars_dict: # Read directly from Text widget dose_text_widget = vars_dict[dose_text_key] raw_text = dose_text_widget.get(1.0, tk.END).strip() self.logger.debug( f"Raw text from Text widget for {medicine_key}: '{raw_text}'" ) elif dose_var_key in vars_dict: # Fall back to StringVar if isinstance(vars_dict[dose_var_key], tk.StringVar): raw_text = vars_dict[dose_var_key].get().strip() elif isinstance(vars_dict[dose_var_key], tk.Text): raw_text = vars_dict[dose_var_key].get(1.0, tk.END).strip() else: raw_text = str(vars_dict[dose_var_key]).strip() self.logger.debug( f"Raw text from StringVar for {medicine_key}: '{raw_text}'" ) else: raw_text = "" self.logger.debug(f"No dose data found for {medicine_key}") if raw_text: parsed_dose = self._parse_dose_history_for_saving( raw_text, vars_dict["date"].get() ) dose_data[medicine_key] = parsed_dose self.logger.debug( f"Parsed dose for {medicine_key}: '{parsed_dose}'" ) else: dose_data[medicine_key] = "" self.logger.debug(f"Final dose_data: {dose_data}") # Build dynamic callback arguments callback_args = [edit_win, vars_dict["date"].get()] # Add pathology values pathologies = self.pathology_manager.get_all_pathologies() for pathology_key in pathologies: callback_args.append(vars_dict[pathology_key].get()) # Add medicine values medicines = self.medicine_manager.get_all_medicines() for medicine_key in medicines: callback_args.append(vars_dict[medicine_key].get()) # Add note and dose data callback_args.extend([note_content, dose_data]) self.logger.debug( f"Calling save callback with {len(callback_args)} arguments" ) callbacks["save"](*callback_args) save_btn = ttk.Button( button_frame, text="💾 Save Changes", style="Accent.TButton", command=save_with_data, ) save_btn.grid(row=0, column=0, sticky="ew", padx=(0, 5)) # Cancel button cancel_btn = ttk.Button( button_frame, text="❌ Cancel", command=edit_win.destroy ) cancel_btn.grid(row=0, column=1, sticky="ew", padx=5) # Delete button delete_btn = ttk.Button( button_frame, text="🗑️ Delete Entry", style="Danger.TButton", command=lambda: callbacks["delete"](edit_win), ) delete_btn.grid(row=0, column=2, sticky="ew", padx=(5, 0)) def _parse_dose_history_for_saving(self, text: str, date_str: str) -> str: """ Parse the user-edited dose history back into the storable format, supporting add/delete/edit. """ self.logger.debug("=== PARSING DOSE HISTORY ===") self.logger.debug(f"Input text: '{text}'") self.logger.debug(f"Date string: '{date_str}'") if not text or "No doses recorded" in text: self.logger.debug("No doses to parse, returning empty string") return "" lines = text.strip().split("\n") self.logger.debug(f"Split into {len(lines)} lines: {lines}") dose_entries = [] for line_num, line in enumerate(lines): line = line.strip() self.logger.debug(f"Processing line {line_num}: '{line}'") if not line or line.lower().startswith("no doses recorded"): self.logger.debug("Empty or placeholder line, skipping") continue # Handle bullet point format: "• HH:MM AM/PM - dose" if line.startswith("•") and " - " in line: try: content = line.lstrip("• ").strip() self.logger.debug(f"Bullet point content: '{content}'") time_part, dose_part = content.split(" - ", 1) self.logger.debug( f"Time part: '{time_part}', Dose part: '{dose_part}'" ) # Try parsing as 12-hour (with AM/PM) try: time_obj = datetime.strptime(time_part.strip(), "%I:%M %p") except ValueError: # Try 24-hour format fallback time_obj = datetime.strptime(time_part.strip(), "%H:%M") # Try different date formats try: entry_date = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: try: entry_date = datetime.strptime(date_str, "%m/%d/%Y") except ValueError: # If both fail, try ISO format entry_date = datetime.fromisoformat(date_str) full_timestamp = entry_date.replace( hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0, ) timestamp_str = full_timestamp.strftime("%Y-%m-%d %H:%M:%S") dose_entry = f"{timestamp_str}:{dose_part.strip()}" dose_entries.append(dose_entry) self.logger.debug(f"Added dose entry: '{dose_entry}'") except Exception as e: self.logger.warning( f"Could not parse dose line: '{line}'. Error: {e}" ) continue # Handle simple format: "HH:MM dose" or "HH:MM: dose" elif ":" in line and not line.startswith("•"): try: # Try to parse as "HH:MM dose" or "HH:MM: dose" if " " in line: time_part, dose_part = line.split(" ", 1) time_part = time_part.rstrip(":") # Try 24-hour format first try: time_obj = datetime.strptime(time_part, "%H:%M") except ValueError: # Try 12-hour format time_obj = datetime.strptime(time_part, "%I:%M") # Try different date formats try: entry_date = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: try: entry_date = datetime.strptime(date_str, "%m/%d/%Y") except ValueError: # If both fail, try ISO format entry_date = datetime.fromisoformat(date_str) full_timestamp = entry_date.replace( hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0, ) timestamp_str = full_timestamp.strftime("%Y-%m-%d %H:%M:%S") dose_entries.append(f"{timestamp_str}:{dose_part.strip()}") self.logger.debug( "Added simple dose entry: '%s:%s'", timestamp_str, dose_part.strip(), ) except Exception as e: self.logger.warning( f"Could not parse simple dose line: '{line}'. Error: {e}" ) continue # If user just types a dose (no time), store as-is with no timestamp elif line: self.logger.debug(f"Line with no time, storing as-is: '{line}'") dose_entries.append(line) result = "|".join(dose_entries) self.logger.debug(f"Final parsed result: '{result}'") return result def _bind_mousewheel_to_widget_tree( self, widget: tk.Widget, canvas: tk.Canvas ) -> None: """Recursively bind mouse wheel events to all widgets in the tree.""" def on_mousewheel(event): # Check if canvas is scrollable before scrolling if canvas.cget("scrollregion"): canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def on_mousewheel_linux_up(event): if canvas.cget("scrollregion"): canvas.yview_scroll(-1, "units") def on_mousewheel_linux_down(event): if canvas.cget("scrollregion"): canvas.yview_scroll(1, "units") # Bind to the widget itself try: widget.bind("", on_mousewheel) widget.bind("", on_mousewheel_linux_up) widget.bind("", on_mousewheel_linux_down) except tk.TclError: # Some widgets might not support binding pass # Recursively bind to all children try: for child in widget.winfo_children(): # Skip widgets that have their own scrolling behavior or are problematic skip_types = (tk.Text, tk.Listbox, tk.Canvas, ttk.Notebook) if not isinstance(child, skip_types): self._bind_mousewheel_to_widget_tree(child, canvas) elif isinstance(child, ttk.Notebook): # For notebooks, bind to their tab frames for tab_id in child.tabs(): tab_widget = child.nametowidget(tab_id) self._bind_mousewheel_to_widget_tree(tab_widget, canvas) except tk.TclError: # Handle potential errors when accessing children pass def _optimize_tree_scrolling(self, tree: ttk.Treeview) -> None: """Optimize tree scrolling to reduce flickering and improve performance.""" # Store scroll state to prevent unnecessary updates last_scroll_position = [0.0, 1.0] def optimized_yscrollcommand(first, last): """Optimized scroll command to reduce update frequency.""" nonlocal last_scroll_position # Only update if position significantly changed first_f, last_f = float(first), float(last) if ( abs(first_f - last_scroll_position[0]) > 0.001 or abs(last_f - last_scroll_position[1]) > 0.001 ): last_scroll_position = [first_f, last_f] # Update scrollbar position scrollbar = None for child in tree.master.winfo_children(): if isinstance(child, ttk.Scrollbar): scrollbar = child break if scrollbar: scrollbar.set(first, last) # Apply the optimized scroll command tree.configure(yscrollcommand=optimized_yscrollcommand)