From 294ad26ce6a984836893a2a5b0381397f0542551 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 7 Dec 2025 16:00:01 -0500 Subject: [PATCH] Add GUI functionality and update documentation --- ProjectPlan.md | 15 ++- README.md | 22 ++++ pyproject.toml | 1 + src/custom_bingo/gui.py | 165 ++++++++++++++++++++++++++++++ src/custom_bingo/pdf_generator.py | 46 +++++++-- 5 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 src/custom_bingo/gui.py diff --git a/ProjectPlan.md b/ProjectPlan.md index 6a13015..1f7478d 100644 --- a/ProjectPlan.md +++ b/ProjectPlan.md @@ -28,7 +28,8 @@ CustomBingo/ │ ├── main.py │ ├── card_generator.py │ ├── spreadsheet_reader.py -│ └── pdf_generator.py +│ ├── pdf_generator.py +│ └── gui.py ├── tests/ ├── data/ ├── requirements.txt @@ -82,8 +83,15 @@ CustomBingo/ 3. Test with various input spreadsheets 4. Verify PDF output quality -### Step 8: Documentation and Polish -1. Create README.md with usage instructions +### Step 8: GUI Development +1. Create `gui.py` module with tkinter interface +2. Implement input fields for card title, column headers, spreadsheet path, and number of cards +3. Add file dialogs for source spreadsheet and output PDF location +4. Integrate with existing card generation functions +5. Add validation, status updates, and error handling + +### Step 9: Documentation and Polish +1. Update README.md to include GUI usage instructions 2. Add command-line help 3. Test on target platform (Windows PowerShell) 4. Final review and debugging @@ -94,6 +102,7 @@ CustomBingo/ 3. Customizable column headers 4. Multiple card layouts 5. Web interface option +6. Enhanced GUI features (theme customization, preview, etc.) ## Dependencies - pandas: For reading spreadsheet files diff --git a/README.md b/README.md index d05ef2d..3924b87 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ CustomBingo is a Python application that reads a spreadsheet with 5 columns (B, - Auto-fit text in grid cells with horizontal and vertical centering - Support for multi-line text in cells - Customizable number of cards to generate +- Graphical User Interface (GUI) for easy operation ## Installation @@ -58,6 +59,27 @@ uv run python src/custom_bingo/main.py -i data/ChristmasSongsBingo.csv -o output # Generate a BINGO card with custom title and sub-headers uv run python src/custom_bingo/main.py -i data/ChristmasSongsBingo.csv -o output.pdf -t "Holiday Music Bingo" -s "Traditional,Upbeat,Holy,Classic,Festive" + +## GUI Usage + +The application also includes a graphical user interface for easier operation: + +```bash +# Run the GUI application directly +python -m src.custom_bingo.gui + +# Or using the installed command (after installation) +custombingo-gui +``` + +The GUI provides input fields for: +- Card title +- 5 column headers (B, I, N, G, O) +- Source spreadsheet file +- Number of cards to generate +- Output PDF location + +Click the "Generate PDF" button to create your BINGO cards. ``` ## Input Format diff --git a/pyproject.toml b/pyproject.toml index c0e30ba..c2d3fa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dev = [ [project.scripts] custombingo = "custom_bingo.main:main" +custombingo-gui = "custom_bingo.gui:main" [tool.setuptools] packages = ["custom_bingo"] diff --git a/src/custom_bingo/gui.py b/src/custom_bingo/gui.py new file mode 100644 index 0000000..20df27a --- /dev/null +++ b/src/custom_bingo/gui.py @@ -0,0 +1,165 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import os +from custom_bingo.card_generator import generate_bingo_cards +from custom_bingo.spreadsheet_reader import read_spreadsheet +from custom_bingo.pdf_generator import export_cards_to_pdf + + +class BingoCardGeneratorUI: + def __init__(self, root): + self.root = root + self.root.title("Custom Bingo Card Generator") + self.root.geometry("600x700") + + # Create main frame with padding + main_frame = ttk.Frame(root, padding="20") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights for responsive design + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + + # Card Title + ttk.Label(main_frame, text="Card Title:").grid(row=0, column=0, sticky=tk.W, pady=5) + self.title_var = tk.StringVar(value="BINGO") + title_entry = ttk.Entry(main_frame, textvariable=self.title_var, width=50) + title_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=5, padx=(10, 0)) + + # Column Headers + ttk.Label(main_frame, text="Column Headers:").grid(row=1, column=0, sticky=tk.W, pady=5) + + header_frame = ttk.Frame(main_frame) + header_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5, padx=(10, 0)) + + # Configure column weights for the header frame + for i in range(5): + header_frame.columnconfigure(i, weight=1) + + self.header_vars = [] + columns = ['B', 'I', 'N', 'G', 'O'] + for i, col in enumerate(columns): + ttk.Label(header_frame, text=f"{col}:").grid(row=0, column=i*2, sticky=tk.W) + var = tk.StringVar(value=col) + entry = ttk.Entry(header_frame, textvariable=var, width=10) + entry.grid(row=0, column=i*2+1, sticky=(tk.W, tk.E), padx=(0, 5)) + self.header_vars.append(var) + + # Source Spreadsheet + ttk.Label(main_frame, text="Source Spreadsheet:").grid(row=2, column=0, sticky=tk.W, pady=5) + spreadsheet_frame = ttk.Frame(main_frame) + spreadsheet_frame.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=5, padx=(10, 0)) + + self.spreadsheet_var = tk.StringVar() + spreadsheet_entry = ttk.Entry(spreadsheet_frame, textvariable=self.spreadsheet_var, width=40) + spreadsheet_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) + spreadsheet_frame.columnconfigure(0, weight=1) + + browse_button = ttk.Button(spreadsheet_frame, text="Browse...", command=self.browse_spreadsheet) + browse_button.grid(row=0, column=1) + + # Number of Cards + ttk.Label(main_frame, text="Number of Cards:").grid(row=3, column=0, sticky=tk.W, pady=5) + self.number_var = tk.StringVar(value="1") + number_spinbox = ttk.Spinbox(main_frame, from_=1, to=100, textvariable=self.number_var, width=10) + number_spinbox.grid(row=3, column=1, sticky=tk.W, pady=5, padx=(10, 0)) + + # Output PDF Location + ttk.Label(main_frame, text="Output PDF:").grid(row=4, column=0, sticky=tk.W, pady=5) + output_frame = ttk.Frame(main_frame) + output_frame.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=5, padx=(10, 0)) + + self.output_var = tk.StringVar() + output_entry = ttk.Entry(output_frame, textvariable=self.output_var, width=40) + output_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) + output_frame.columnconfigure(0, weight=1) + + output_button = ttk.Button(output_frame, text="Save As...", command=self.save_pdf) + output_button.grid(row=0, column=1) + + # Generate Button + self.generate_button = ttk.Button(main_frame, text="Generate PDF", command=self.generate_pdf) + self.generate_button.grid(row=5, column=0, columnspan=2, pady=20) + + # Status label + self.status_var = tk.StringVar(value="Ready") + self.status_label = ttk.Label(main_frame, textvariable=self.status_var, foreground="blue") + self.status_label.grid(row=6, column=0, columnspan=2, pady=10) + + def browse_spreadsheet(self): + filename = filedialog.askopenfilename( + title="Select Spreadsheet", + filetypes=[ + ("Excel files", "*.xlsx *.xls"), + ("CSV files", "*.csv"), + ("All files", "*.*") + ] + ) + if filename: + self.spreadsheet_var.set(filename) + + def save_pdf(self): + filename = filedialog.asksaveasfilename( + title="Save PDF As", + defaultextension=".pdf", + filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")] + ) + if filename: + self.output_var.set(filename) + + def generate_pdf(self): + try: + # Validate inputs + title = self.title_var.get().strip() + if not title: + messagebox.showerror("Validation Error", "Card title cannot be empty.") + return + + spreadsheet_path = self.spreadsheet_var.get().strip() + if not spreadsheet_path or not os.path.exists(spreadsheet_path): + messagebox.showerror("Validation Error", "Please select a valid source spreadsheet.") + return + + output_path = self.output_var.get().strip() + if not output_path: + messagebox.showerror("Validation Error", "Please specify an output PDF location.") + return + + try: + number_of_cards = int(self.number_var.get()) + if number_of_cards < 1: + raise ValueError + except ValueError: + messagebox.showerror("Validation Error", "Number of cards must be a positive integer.") + return + + # Get column headers + sub_headers = [var.get().strip() for var in self.header_vars] + + # Update status + self.status_var.set("Processing...") + self.root.update() + + # Generate the cards + data = read_spreadsheet(spreadsheet_path) + cards = generate_bingo_cards(data, number_of_cards) + export_cards_to_pdf(cards, output_path, title=title, sub_headers=sub_headers) + + # Show success message + self.status_var.set(f"Successfully generated {number_of_cards} card(s) at {output_path}") + messagebox.showinfo("Success", f"Successfully generated {number_of_cards} BINGO card(s) at {output_path}") + + except Exception as e: + self.status_var.set(f"Error: {str(e)}") + messagebox.showerror("Error", f"An error occurred: {str(e)}") + + +def main(): + root = tk.Tk() + app = BingoCardGeneratorUI(root) + root.mainloop() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/custom_bingo/pdf_generator.py b/src/custom_bingo/pdf_generator.py index d3c2f66..6af842d 100644 --- a/src/custom_bingo/pdf_generator.py +++ b/src/custom_bingo/pdf_generator.py @@ -25,20 +25,35 @@ def wrap_text_to_fit_box(text: str, available_width: float, available_height: fl if not text: return [], max_font_size + # Import here to avoid circular imports if needed + from io import BytesIO + from reportlab.pdfgen import canvas + # Try different font sizes to find the best fit for font_size in range(max_font_size, 0, -1): - # Split the text into lines that fit the width, with even more conservative padding - lines = simpleSplit(text, font_name, font_size, available_width - 8) # Subtract 8 for more padding + # Split the text into lines that fit the width + lines = simpleSplit(text, font_name, font_size, available_width * 0.95) # Use 95% of width for safety # Check if the text height fits in the available height line_height = font_size * 1.2 # Add some spacing between lines total_height = len(lines) * line_height - if total_height <= available_height - 8: # Subtract 8 for more vertical padding - return lines, font_size + if total_height <= available_height * 0.95: # Use 95% of height for safety + # Also check that each individual line fits within the width + all_lines_fit = True + # Create a temporary canvas for text width measurement + temp_buffer = BytesIO() + temp_canvas = canvas.Canvas(temp_buffer) + for line in lines: + line_width = temp_canvas.stringWidth(line, font_name, font_size) + if line_width > available_width * 0.95: # Each line must fit within width + all_lines_fit = False + break + if all_lines_fit: + return lines, font_size # If we can't fit the text with reasonable font size, use smallest possible - lines = simpleSplit(text, font_name, 1, available_width - 8) + lines = simpleSplit(text, font_name, 1, available_width * 0.95) return lines, 1 @@ -64,20 +79,29 @@ def draw_multiline_text(c: canvas.Canvas, text: str, x: float, y: float, width: # Try different font sizes to ensure the text fits properly in both dimensions for font_size in range(original_max_font_size, 0, -1): # Split the text using the current font size - lines = simpleSplit(text, "Helvetica", font_size, width * 0.8) # Use 80% of width for safety + lines = simpleSplit(text, "Helvetica", font_size, width * 0.95) # Use 95% of width initially # Calculate actual line height including space between lines line_height = font_size * 1.2 # Include space between lines total_text_height = len(lines) * line_height - # Check if both width and height constraints are satisfied - if total_text_height <= height * 0.8: # Use 80% of height for safety - # Text fits in the box with this font size - break + # Check if the height constraint is satisfied first + if total_text_height <= height * 0.95: # Use 95% of height for safety + # Also check that each individual line fits within the width + all_lines_fit = True + for line in lines: + line_width = c.stringWidth(line, "Helvetica", font_size) + if line_width > width * 0.95: # Each line must fit within width + all_lines_fit = False + break + + if all_lines_fit: + # Text fits in the box with this font size + break else: # If even font size 1 doesn't fit, use size 1 font_size = 1 - lines = simpleSplit(text, "Helvetica", font_size, width * 0.8) + lines = simpleSplit(text, "Helvetica", font_size, width * 0.95) c.setFont("Helvetica", font_size)