Files
thechart/src/ui_manager.py
William Valentin b7c01bc373
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
Refactor method names for clarity and consistency across the application
- Renamed `initialize_csv` to `_initialize_csv_file` in `DataManager` for better clarity.
- Updated method calls in `GraphManager` from `_create_toggle_controls` to `_create_chart_toggles` and `_on_toggle_changed` to `_handle_toggle_changed`.
- Changed method names in `MedTrackerApp` from `on_closing` to `handle_window_closing`, `add_entry` to `add_new_entry`, and `load_data` to `refresh_data_display`.
- Adjusted corresponding test method names in `TestMedTrackerApp` to reflect the new method names.
- Updated `UIManager` method names from `setup_icon` to `setup_application_icon` and adjusted related tests accordingly.
2025-07-30 12:32:17 -07:00

1182 lines
43 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
class UIManager:
"""Handle UI creation and management for the application."""
def __init__(self, root: tk.Tk, logger: logging.Logger) -> None:
self.root: tk.Tk = root
self.logger: logging.Logger = logger
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 symptoms
symptom_vars: dict[str, tk.IntVar] = {
"depression": tk.IntVar(value=0),
"anxiety": tk.IntVar(value=0),
"sleep": tk.IntVar(value=0),
"appetite": tk.IntVar(value=0),
}
# Create scales for symptoms
symptom_labels: list[tuple[str, str]] = [
("Depression (0-10):", "depression"),
("Anxiety (0-10):", "anxiety"),
("Sleep Quality (0-10):", "sleep"),
("Appetite (0-10):", "appetite"),
]
for idx, (label, var_name) in enumerate(symptom_labels):
ttk.Label(input_frame, text=label).grid(
row=idx, column=0, sticky="w", padx=5, pady=2
)
ttk.Scale(
input_frame,
from_=0,
to=10,
orient=tk.HORIZONTAL,
variable=symptom_vars[var_name],
).grid(row=idx, column=1, sticky="ew")
# Medicine tracking section (simplified)
ttk.Label(input_frame, text="Treatment:").grid(
row=4, column=0, sticky="w", padx=5, pady=2
)
medicine_frame = ttk.LabelFrame(input_frame, text="Medicine")
medicine_frame.grid(row=4, column=1, padx=0, pady=10, sticky="nsew")
medicine_frame.grid_columnconfigure(0, weight=1)
# Store medicine variables (checkboxes only)
medicine_vars: dict[str, tuple[tk.IntVar, str]] = {
"bupropion": (tk.IntVar(value=0), "Bupropion 150/300 mg"),
"hydroxyzine": (tk.IntVar(value=0), "Hydroxyzine 25mg"),
"gabapentin": (tk.IntVar(value=0), "Gabapentin 100mg"),
"propranolol": (tk.IntVar(value=0), "Propranolol 10mg"),
"quetiapine": (tk.IntVar(value=0), "Quetiapine 25mg"),
}
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
note_var: tk.StringVar = tk.StringVar()
date_var: tk.StringVar = tk.StringVar()
ttk.Label(input_frame, text="Note:").grid(
row=5, column=0, sticky="w", padx=5, pady=2
)
ttk.Entry(input_frame, textvariable=note_var).grid(
row=5, column=1, sticky="ew", padx=5, pady=2
)
ttk.Label(input_frame, text="Date (mm/dd/yyyy):").grid(
row=6, column=0, sticky="w", padx=5, pady=2
)
ttk.Entry(input_frame, textvariable=date_var, justify="center").grid(
row=6, 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,
"symptom_vars": symptom_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)
columns: list[str] = [
"Date",
"Depression",
"Anxiety",
"Sleep",
"Appetite",
"Bupropion",
"Hydroxyzine",
"Gabapentin",
"Propranolol",
"Quetiapine",
"Note",
]
tree: ttk.Treeview = ttk.Treeview(table_frame, columns=columns, show="headings")
col_labels: list[str] = [
"Date",
"Depression",
"Anxiety",
"Sleep",
"Appetite",
"Bupropion 150/300 mg",
"Hydroxyzine 25mg",
"Gabapentin 100mg",
"Propranolol 10mg",
"Quetiapine 25mg",
"Note",
]
for col, label in zip(columns, col_labels, strict=False):
tree.heading(col, text=label)
col_settings: list[tuple[str, int, str]] = [
("Date", 80, "center"),
("Depression", 80, "center"),
("Anxiety", 80, "center"),
("Sleep", 80, "center"),
("Appetite", 80, "center"),
("Bupropion", 120, "center"),
("Hydroxyzine", 120, "center"),
("Gabapentin", 120, "center"),
("Propranolol", 120, "center"),
("Quetiapine", 120, "center"),
("Note", 300, "w"),
]
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_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 - handle both old and new CSV formats
if len(values) == 10:
# Old format: date, dep, anx, slp, app, bup, hydro, gaba, prop, note
date, dep, anx, slp, app, bup, hydro, gaba, prop, note = values
bup_doses, hydro_doses, gaba_doses, prop_doses, quet_doses = (
"",
"",
"",
"",
"",
)
quet = 0
elif len(values) == 14:
# Old new format with dose tracking (without quetiapine)
(
date,
dep,
anx,
slp,
app,
bup,
bup_doses,
hydro,
hydro_doses,
gaba,
gaba_doses,
prop,
prop_doses,
note,
) = values
quet, quet_doses = 0, ""
elif len(values) == 16:
# New format with quetiapine and dose tracking
(
date,
dep,
anx,
slp,
app,
bup,
bup_doses,
hydro,
hydro_doses,
gaba,
gaba_doses,
prop,
prop_doses,
quet,
quet_doses,
note,
) = values
else:
# Fallback for unexpected format
self.logger.warning(f"Unexpected number of values in edit: {len(values)}")
# Pad with default values
values_list = list(values) + [""] * (16 - len(values))
(
date,
dep,
anx,
slp,
app,
bup,
bup_doses,
hydro,
hydro_doses,
gaba,
gaba_doses,
prop,
prop_doses,
quet,
quet_doses,
note,
) = values_list[:16]
# Create improved UI sections
vars_dict = self._create_edit_ui(
main_container,
date,
dep,
anx,
slp,
app,
bup,
hydro,
gaba,
prop,
quet,
note,
{
"bupropion": bup_doses,
"hydroxyzine": hydro_doses,
"gabapentin": gaba_doses,
"propranolol": prop_doses,
"quetiapine": quet_doses,
},
)
# 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,
dep: int,
anx: int,
slp: int,
app: int,
bup: int,
hydro: int,
gaba: int,
prop: int,
quet: int,
note: str,
dose_data: dict[str, str],
) -> dict[str, Any]:
"""Create UI layout for edit window with organized sections."""
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
# Symptoms section
symptoms_frame = ttk.LabelFrame(
parent, text="Daily Symptoms (0-10 scale)", padding="15"
)
symptoms_frame.grid(row=row, column=0, sticky="ew", pady=(0, 15))
symptoms_frame.grid_columnconfigure(1, weight=1)
# Create symptom scales with better layout
symptoms = [
("Depression", "depression", dep),
("Anxiety", "anxiety", anx),
("Sleep Quality", "sleep", slp),
("Appetite", "appetite", app),
]
for i, (label, key, value) in enumerate(symptoms):
self._create_symptom_scale(symptoms_frame, i, label, 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 with better styling
med_vars = self._create_medicine_section(
meds_frame, bup, hydro, gaba, prop, quet
)
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, dose_data)
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, wrap=tk.WORD, font=("TkDefaultFont", 10)
)
note_text.grid(row=0, column=0, sticky="ew")
note_text.insert(1.0, str(note))
vars_dict["note_text"] = note_text
# Add scrollbar for notes
note_scroll = ttk.Scrollbar(
notes_frame, orient="vertical", command=note_text.yview
)
note_scroll.grid(row=0, column=1, sticky="ns")
note_text.configure(yscrollcommand=note_scroll.set)
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_medicine_section(
self, parent: ttk.Frame, bup: int, hydro: int, gaba: int, prop: int, quet: int
) -> dict[str, tk.IntVar]:
"""Create medicine checkboxes with organized layout."""
vars_dict = {}
# Create a grid layout for medicines
medicines = [
("bupropion", bup, "Bupropion", "150/300 mg", "#E8F4FD"),
("hydroxyzine", hydro, "Hydroxyzine", "25 mg", "#FFF2E8"),
("gabapentin", gaba, "Gabapentin", "100 mg", "#F0F8E8"),
("propranolol", prop, "Propranolol", "10 mg", "#FCE8F3"),
("quetiapine", quet, "Quetiapine", "25 mg", "#E8F0FF"),
]
# Create medicine cards in a 2-column layout
for i, (key, value, name, dose, _bg_color) in enumerate(medicines):
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, dose_data: dict[str, str]
) -> dict[str, Any]:
"""Create dose tracking interface."""
vars_dict = {}
# Create notebook for organized dose tracking
notebook = ttk.Notebook(parent)
notebook.pack(fill="both", expand=True)
medicines = [
("bupropion", "Bupropion"),
("hydroxyzine", "Hydroxyzine"),
("gabapentin", "Gabapentin"),
("propranolol", "Propranolol"),
("quetiapine", "Quetiapine"),
]
for med_key, med_name in medicines:
# Create tab for each medicine
tab_frame = ttk.Frame(notebook)
notebook.add(tab_frame, text=med_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(1, weight=1)
ttk.Label(entry_frame, text="Dose amount:").grid(
row=0, column=0, sticky="w"
)
dose_entry_var = tk.StringVar()
vars_dict[f"{med_key}_entry_var"] = dose_entry_var
dose_entry = ttk.Entry(entry_frame, textvariable=dose_entry_var, width=15)
dose_entry.grid(row=0, column=1, sticky="w", padx=(10, 10))
# Quick dose buttons
quick_frame = ttk.Frame(entry_frame)
quick_frame.grid(row=0, column=2, sticky="w")
# Common dose amounts (customize per medicine)
quick_doses = self._get_quick_doses(med_key)
for i, dose in enumerate(quick_doses):
ttk.Button(
quick_frame,
text=dose,
width=8,
command=lambda d=dose, var=dose_entry_var: var.set(d),
).grid(row=0, column=i, padx=2)
# Take dose button
def create_take_dose_command(med_name, entry_var, med_key):
def take_dose():
self._take_dose(med_name, entry_var, med_key, vars_dict)
return take_dose
take_button = ttk.Button(
entry_frame,
text=f"Take {med_name}",
style="Accent.TButton",
command=create_take_dose_command(med_name, dose_entry_var, med_key),
)
take_button.grid(row=1, column=0, columnspan=3, pady=(10, 0), sticky="ew")
# Dose history section
history_frame = ttk.LabelFrame(
tab_frame, text="Today's Doses", padding="10"
)
history_frame.grid(row=1, column=0, sticky="ew", padx=10, pady=5)
history_frame.grid_columnconfigure(0, weight=1)
# Dose history display with fixed height to prevent excessive expansion
dose_text = tk.Text(
history_frame,
height=4, # Reduced height to fit better in scrollable window
wrap=tk.WORD,
font=("Consolas", 10),
state="normal", # Start enabled
)
dose_text.grid(row=0, column=0, sticky="ew")
# Store raw dose string in a variable
doses_str = dose_data.get(med_key, "")
dose_str_var = tk.StringVar(value=doses_str)
vars_dict[f"{med_key}_doses_str"] = dose_str_var
# Populate with existing doses
self._populate_dose_history(dose_text, dose_str_var.get())
vars_dict[f"{med_key}_doses_text"] = dose_text
# Scrollbar for dose history
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)
return vars_dict
def _get_quick_doses(self, medicine_key: str) -> list[str]:
"""Get common dose amounts for quick selection."""
dose_map = {
"bupropion": ["150", "300"],
"hydroxyzine": ["25", "50"],
"gabapentin": ["100", "300", "600"],
"propranolol": ["10", "20", "40"],
"quetiapine": ["25", "50", "100"],
}
return dose_map.get(medicine_key, ["25", "50"])
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:
timestamp, dose = dose_entry.split(":", 1)
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}")
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")
note_content = ""
if note_text_widget:
note_content = note_text_widget.get(1.0, tk.END).strip()
# Extract dose data from the editable text widgets
dose_data = {}
medicine_list = [
"bupropion",
"hydroxyzine",
"gabapentin",
"propranolol",
"quetiapine",
]
for medicine in medicine_list:
dose_text_key = f"{medicine}_doses_text"
self.logger.debug(f"Processing {medicine}...")
if dose_text_key in vars_dict and isinstance(
vars_dict[dose_text_key], tk.Text
):
raw_text = vars_dict[dose_text_key].get(1.0, tk.END).strip()
self.logger.debug(f"Raw text for {medicine}: '{raw_text}'")
parsed_dose = self._parse_dose_history_for_saving(
raw_text, vars_dict["date"].get()
)
dose_data[medicine] = parsed_dose
self.logger.debug(f"Parsed dose for {medicine}: '{parsed_dose}'")
else:
self.logger.debug(f"No text widget found for {medicine}")
dose_data[medicine] = ""
self.logger.debug(f"Final dose_data: {dose_data}")
callbacks["save"](
edit_win,
vars_dict["date"].get(),
vars_dict["depression"].get(),
vars_dict["anxiety"].get(),
vars_dict["sleep"].get(),
vars_dict["appetite"].get(),
vars_dict["bupropion"].get(),
vars_dict["hydroxyzine"].get(),
vars_dict["gabapentin"].get(),
vars_dict["propranolol"].get(),
vars_dict["quetiapine"].get(),
note_content,
dose_data,
)
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")
entry_date = datetime.strptime(date_str, "%m/%d/%Y")
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")
entry_date = datetime.strptime(date_str, "%m/%d/%Y")
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