Files
thechart/src/ui_manager.py
T

1444 lines
56 KiB
Python

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
from PIL import Image, ImageTk
from medicine_manager import MedicineManager
from pathology_manager import PathologyManager
class UIManager:
"""Handle UI creation and management for the application."""
def __init__(
self,
root: tk.Tk,
logger: logging.Logger,
medicine_manager: MedicineManager,
pathology_manager: PathologyManager,
) -> None:
self.root: tk.Tk = root
self.logger: logging.Logger = logger
self.medicine_manager = medicine_manager
self.pathology_manager = pathology_manager
# 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
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")
main_container.grid(row=1, 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
canvas = tk.Canvas(main_container, highlightthickness=0)
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>", configure_scroll_region)
canvas.bind("<Configure>", configure_canvas_width)
# Bind mouse wheel events to canvas and main container
canvas.bind("<MouseWheel>", on_mousewheel) # Windows/Linux
canvas.bind("<Button-4>", on_mousewheel_linux_up) # Linux
canvas.bind("<Button-5>", on_mousewheel_linux_down) # Linux
main_container.bind("<MouseWheel>", on_mousewheel) # Windows/Linux
main_container.bind("<Button-4>", on_mousewheel_linux_up) # Linux
main_container.bind("<Button-5>", 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("<Enter>", on_mouse_enter)
canvas.bind("<Enter>", 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")
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_name, (var, text)) in enumerate(medicine_vars.items()):
# Just checkbox for medicine taken
ttk.Checkbutton(medicine_frame, text=text, variable=var).grid(
row=idx, column=0, sticky="w", padx=5, pady=2
)
# 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).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").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
return {
"frame": main_container,
"pathology_vars": pathology_vars,
"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)"
)
table_frame.grid(row=1, 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")
for col, label in zip(columns, col_labels, strict=False):
tree.heading(col, text=label)
for col, width, anchor in col_settings:
tree.column(col, width=width, anchor=anchor)
tree.pack(side="left", fill="both", expand=True)
# Add scrollbar
scrollbar = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=scrollbar.set)
scrollbar.pack(side="right", fill="y")
return {"frame": table_frame, "tree": tree}
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")
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:
ttk.Button(
button_frame,
text=btn_config["text"],
command=btn_config["command"],
).pack(
side="left",
padx=5,
fill=btn_config.get("fill", None),
expand=btn_config.get("expand", False),
)
return button_frame
def create_status_bar(self, parent_frame: tk.Widget) -> tk.Frame:
"""Create and configure the status bar at the bottom of the application."""
# Create the status bar frame
self.status_bar = tk.Frame(parent_frame, relief=tk.SUNKEN, bd=1)
self.status_bar.grid(row=2, 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,
)
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,
)
self.file_info_label.pack(side=tk.RIGHT)
return self.status_bar
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) -> 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
"""
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:
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 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>", configure_scroll_region)
canvas.bind("<Configure>", configure_canvas_width)
# Bind mouse wheel events to canvas and edit window
canvas.bind("<MouseWheel>", on_mousewheel) # Windows/Linux
canvas.bind("<Button-4>", on_mousewheel_linux_up) # Linux
canvas.bind("<Button-5>", on_mousewheel_linux_down) # Linux
edit_win.bind("<MouseWheel>", on_mousewheel) # Windows/Linux
edit_win.bind("<Button-4>", on_mousewheel_linux_up) # Linux
edit_win.bind("<Button-5>", 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("<Enter>", on_mouse_enter)
canvas.bind("<Enter>", on_mouse_enter)
# Unpack values dynamically
# Expected format: date, pathology1, pathology2, ...,
# medicine1, medicine1_doses, medicine2, medicine2_doses, ..., note
# Parse values dynamically
values_list = list(values)
# 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):
# 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("<KeyRelease>", update_note)
note_text.bind("<FocusOut>", 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("<Motion>", update_value_label)
scale.bind("<ButtonRelease-1>", update_value_label)
scale.bind("<KeyRelease>", 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,
)
scale.grid(row=0, column=1, sticky="ew")
# 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("<Motion>", update_value_label_pathology)
scale.bind("<ButtonRelease-1>", update_value_label_pathology)
scale.bind("<KeyRelease>", 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
timestamp = datetime.now().strftime("%H:%M")
new_dose = f"{timestamp}: {dose}"
current_doses = dose_var.get()
if current_doses and current_doses.strip():
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("<KeyRelease>", update_callback)
dose_text.bind("<FocusOut>", 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("<MouseWheel>", on_mousewheel)
widget.bind("<Button-4>", on_mousewheel_linux_up)
widget.bind("<Button-5>", 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