From ec142fd638514a367f5ef9aa1914dae8266663fc Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sat, 6 Dec 2025 08:14:30 -0700 Subject: [PATCH] Initial commit: Word Search Generator application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Interactive GUI for generating and playing word search puzzles - Drag-to-select word highlighting functionality - PDF export capability - Auto-resizing window to fit puzzle dimensions - Duplicate word prevention - 8-directional word placement (horizontal, vertical, diagonal) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 44 +++ word_search_generator.py | 585 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 629 insertions(+) create mode 100644 .gitignore create mode 100644 word_search_generator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64ed49f --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS specific files +.DS_Store +Thumbs.db + +# Generated PDF files (optional - uncomment if you don't want to track PDFs) +# *.pdf diff --git a/word_search_generator.py b/word_search_generator.py new file mode 100644 index 0000000..fb1a790 --- /dev/null +++ b/word_search_generator.py @@ -0,0 +1,585 @@ +import tkinter as tk +from tkinter import ttk, scrolledtext, messagebox, filedialog +import random +import string +from reportlab.lib.pagesizes import letter as LETTER_SIZE +from reportlab.pdfgen import canvas as pdf_canvas +from reportlab.lib.units import inch + + +class WordSearchGenerator: + def __init__(self, width, height): + self.width = width + self.height = height + self.grid = [['' for _ in range(width)] for _ in range(height)] + self.placed_words = [] + + def place_word(self, word): + """Try to place a word in the grid in a random direction.""" + word = word.upper() + directions = [ + (0, 1), # horizontal right + (1, 0), # vertical down + (1, 1), # diagonal down-right + (0, -1), # horizontal left + (-1, 0), # vertical up + (-1, -1), # diagonal up-left + (1, -1), # diagonal down-left + (-1, 1), # diagonal up-right + ] + + random.shuffle(directions) + + for _ in range(100): # Try 100 random positions + row = random.randint(0, self.height - 1) + col = random.randint(0, self.width - 1) + + for dr, dc in directions: + if self._can_place_word(word, row, col, dr, dc): + self._place_word_at(word, row, col, dr, dc) + return True + + return False + + def _can_place_word(self, word, row, col, dr, dc): + """Check if a word can be placed at the given position and direction.""" + positions = [] + for i, char in enumerate(word): + r = row + i * dr + c = col + i * dc + + # Check bounds + if r < 0 or r >= self.height or c < 0 or c >= self.width: + return False + + # Check if cell is empty or has the same letter + if self.grid[r][c] != '' and self.grid[r][c] != char: + return False + + positions.append((r, c)) + + # Check if this exact word already exists at these positions + for placed_word in self.placed_words: + if set(placed_word['positions']) == set(positions): + return False + + return True + + def _place_word_at(self, word, row, col, dr, dc): + """Place a word at the given position and direction.""" + positions = [] + for i, char in enumerate(word): + r = row + i * dr + c = col + i * dc + self.grid[r][c] = char + positions.append((r, c)) + + self.placed_words.append({ + 'word': word, + 'positions': positions, + 'start': (row, col), + 'direction': (dr, dc) + }) + + def fill_empty_cells(self): + """Fill empty cells with random letters.""" + for r in range(self.height): + for c in range(self.width): + if self.grid[r][c] == '': + self.grid[r][c] = random.choice(string.ascii_uppercase) + + def get_grid_string(self): + """Return the grid as a formatted string.""" + return '\n'.join([' '.join(row) for row in self.grid]) + + def get_word_list_string(self): + """Return the list of successfully placed words.""" + return '\n'.join([word['word'] for word in self.placed_words]) + + +class WordSearchApp: + def __init__(self, root): + self.root = root + self.root.title("Word Search Generator & Player") + self.root.geometry("1000x700") + + # Game state + self.word_search = None + self.grid_canvas = None + self.cell_size = 30 + self.cell_rects = {} # (row, col) -> canvas rectangle id + self.cell_texts = {} # (row, col) -> canvas text id + self.selected_cells = [] + self.found_words = set() + self.word_labels = {} + self.is_dragging = False + + # Create main frame + main_frame = ttk.Frame(root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + # Configure grid weights + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + main_frame.rowconfigure(2, weight=1) + + # Input section + input_frame = ttk.LabelFrame(main_frame, text="Input", padding="10") + input_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10)) + + # Dimensions + ttk.Label(input_frame, text="Width (X):").grid(row=0, column=0, sticky=tk.W) + self.width_var = tk.StringVar(value="12") + ttk.Entry(input_frame, textvariable=self.width_var, width=10).grid(row=0, column=1, sticky=tk.W, padx=(5, 20)) + + ttk.Label(input_frame, text="Height (Y):").grid(row=0, column=2, sticky=tk.W) + self.height_var = tk.StringVar(value="12") + ttk.Entry(input_frame, textvariable=self.height_var, width=10).grid(row=0, column=3, sticky=tk.W, padx=(5, 20)) + + # Words input + ttk.Label(input_frame, text="Words (one per line):").grid(row=1, column=0, columnspan=4, sticky=tk.W, pady=(10, 5)) + self.words_text = scrolledtext.ScrolledText(input_frame, width=40, height=6) + self.words_text.grid(row=2, column=0, columnspan=4, sticky=(tk.W, tk.E), pady=(0, 10)) + self.words_text.insert('1.0', 'PYTHON\nGUI\nWORDSEARCH\nGAME\nPUZZLE\nFUN') + + # Buttons + button_frame = ttk.Frame(input_frame) + button_frame.grid(row=3, column=0, columnspan=2, pady=10, sticky=tk.W) + ttk.Button(button_frame, text="Generate & Play", command=self.generate_word_search).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Clear Selection", command=self.clear_selection).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Export to PDF", command=self.export_to_pdf).pack(side=tk.LEFT, padx=5) + + # Rules section - place to the right of buttons + rules_frame = ttk.LabelFrame(input_frame, text="How to Play", padding="5") + rules_frame.grid(row=3, column=2, columnspan=2, rowspan=1, sticky=(tk.W, tk.E, tk.N), padx=(20, 0)) + rules_text = ( + "• Words: horizontal, vertical, diagonal\n" + "• Can go forwards or backwards\n" + "• Click and drag to select • Green = found!" + ) + ttk.Label(rules_frame, text=rules_text, justify=tk.LEFT, font=('TkDefaultFont', 9)).pack(anchor=tk.W) + + # Game section (grid on left, word list on right) + game_frame = ttk.LabelFrame(main_frame, text="Play Word Search", padding="10") + game_frame.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S)) + game_frame.columnconfigure(0, weight=3) + game_frame.columnconfigure(1, weight=1) + game_frame.rowconfigure(0, weight=1) + + # Grid frame + self.grid_frame = ttk.Frame(game_frame) + self.grid_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 10)) + + # Word list frame + word_list_frame = ttk.LabelFrame(game_frame, text="Words to Find", padding="10") + word_list_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) + word_list_frame.rowconfigure(0, weight=1) + word_list_frame.columnconfigure(0, weight=1) + + # Scrollable word list + self.word_list_canvas = tk.Canvas(word_list_frame, width=150) + scrollbar = ttk.Scrollbar(word_list_frame, orient="vertical", command=self.word_list_canvas.yview) + self.word_list_inner = ttk.Frame(self.word_list_canvas) + + self.word_list_canvas.configure(yscrollcommand=scrollbar.set) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.word_list_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.word_list_canvas.create_window((0, 0), window=self.word_list_inner, anchor="nw") + + self.word_list_inner.bind("", lambda e: self.word_list_canvas.configure(scrollregion=self.word_list_canvas.bbox("all"))) + + def clear_selection(self): + """Clear the current selection.""" + if not self.grid_canvas: + return + for row, col in self.selected_cells: + if (row, col) not in self._get_all_found_positions(): + rect_id = self.cell_rects.get((row, col)) + if rect_id: + self.grid_canvas.itemconfig(rect_id, fill='white') + self.selected_cells = [] + + def _get_all_found_positions(self): + """Get all positions that are part of found words.""" + positions = set() + if self.word_search: + for word_info in self.word_search.placed_words: + if word_info['word'] in self.found_words: + positions.update(word_info['positions']) + return positions + + def get_cell_from_coords(self, x, y): + """Get cell (row, col) from canvas coordinates.""" + if not self.word_search: + return None + + col = x // self.cell_size + row = y // self.cell_size + + height = len(self.word_search.grid) + width = len(self.word_search.grid[0]) if height > 0 else 0 + + if 0 <= row < height and 0 <= col < width: + return (row, col) + return None + + def on_canvas_press(self, event): + """Handle mouse button press on canvas.""" + cell = self.get_cell_from_coords(event.x, event.y) + if cell is None: + return + + self.is_dragging = True + self.clear_selection() + row, col = cell + self.selected_cells.append((row, col)) + if (row, col) not in self._get_all_found_positions(): + rect_id = self.cell_rects.get((row, col)) + if rect_id: + self.grid_canvas.itemconfig(rect_id, fill='lightblue') + + def on_canvas_drag(self, event): + """Handle mouse drag on canvas.""" + if not self.is_dragging: + return + + cell = self.get_cell_from_coords(event.x, event.y) + if cell is None: + return + + row, col = cell + + # Only add if not already selected and forms a valid line + if (row, col) not in self.selected_cells: + # Check if this continues a valid line + temp_cells = self.selected_cells + [(row, col)] + if self._is_valid_line_list(temp_cells): + self.selected_cells.append((row, col)) + if (row, col) not in self._get_all_found_positions(): + rect_id = self.cell_rects.get((row, col)) + if rect_id: + self.grid_canvas.itemconfig(rect_id, fill='lightblue') + + def on_canvas_release(self, event): + """Handle mouse button release.""" + self.is_dragging = False + self.check_for_word() + + def check_for_word(self): + """Check if the selected cells form a valid word.""" + if len(self.selected_cells) < 2: + return + + # Sort cells to form a line + if not self._is_valid_line(): + return + + # Get the word formed by selected cells + selected_word = ''.join([self.word_search.grid[r][c] for r, c in self.selected_cells]) + + # Check if it matches any unfound word (forward or backward) + for word_info in self.word_search.placed_words: + word = word_info['word'] + if word in self.found_words: + continue + + positions = word_info['positions'] + if (set(self.selected_cells) == set(positions) or + set(self.selected_cells) == set(reversed(positions))): + # Found a word! + self.mark_word_found(word, positions) + return + + def _is_valid_line(self): + """Check if selected cells form a straight line.""" + return self._is_valid_line_list(self.selected_cells) + + def _is_valid_line_list(self, cells): + """Check if a list of cells forms a straight line.""" + if len(cells) < 2: + return True + + # Get direction from first two cells + first = cells[0] + second = cells[1] + + dr = second[0] - first[0] + dc = second[1] - first[1] + + # Normalize direction + if dr != 0: + dr = dr // abs(dr) + if dc != 0: + dc = dc // abs(dc) + + # Check if all cells follow the same direction + for i in range(len(cells) - 1): + curr = cells[i] + next_cell = cells[i + 1] + expected_next = (curr[0] + dr, curr[1] + dc) + if next_cell != expected_next: + return False + + return True + + def mark_word_found(self, word, positions): + """Mark a word as found.""" + self.found_words.add(word) + + # Highlight the found word in green + for row, col in positions: + rect_id = self.cell_rects.get((row, col)) + if rect_id: + self.grid_canvas.itemconfig(rect_id, fill='lightgreen') + + # Strike through the word in the list + if word in self.word_labels: + self.word_labels[word].config(font=('TkDefaultFont', 10, 'overstrike')) + + # Clear selection + self.selected_cells = [] + + # Check if all words are found + if len(self.found_words) == len(self.word_search.placed_words): + messagebox.showinfo("Congratulations!", "You found all the words!") + + def create_grid_ui(self): + """Create the interactive grid UI using Canvas.""" + # Clear existing grid + for widget in self.grid_frame.winfo_children(): + widget.destroy() + + self.cell_rects = {} + self.cell_texts = {} + + height = len(self.word_search.grid) + width = len(self.word_search.grid[0]) if height > 0 else 0 + + # Create canvas + canvas_width = width * self.cell_size + canvas_height = height * self.cell_size + self.grid_canvas = tk.Canvas(self.grid_frame, width=canvas_width, height=canvas_height, + bg='white', highlightthickness=1, highlightbackground='gray') + self.grid_canvas.pack() + + # Draw grid cells + for row in range(height): + for col in range(width): + x1 = col * self.cell_size + y1 = row * self.cell_size + x2 = x1 + self.cell_size + y2 = y1 + self.cell_size + + # Create rectangle + rect_id = self.grid_canvas.create_rectangle(x1, y1, x2, y2, + fill='white', + outline='gray', + width=1) + self.cell_rects[(row, col)] = rect_id + + # Create text + letter = self.word_search.grid[row][col] + text_x = x1 + self.cell_size // 2 + text_y = y1 + self.cell_size // 2 + text_id = self.grid_canvas.create_text(text_x, text_y, + text=letter, + font=('Courier', 14, 'bold'), + fill='black') + self.cell_texts[(row, col)] = text_id + + # Bind mouse events to canvas + self.grid_canvas.bind('', self.on_canvas_press) + self.grid_canvas.bind('', self.on_canvas_drag) + self.grid_canvas.bind('', self.on_canvas_release) + + def update_word_list(self): + """Update the word list display.""" + # Clear existing words + for widget in self.word_list_inner.winfo_children(): + widget.destroy() + + self.word_labels = {} + + # Add each word + for i, word_info in enumerate(self.word_search.placed_words): + word = word_info['word'] + label = tk.Label(self.word_list_inner, text=word, font=('TkDefaultFont', 10), + anchor='w', padx=10, pady=5) + label.pack(fill=tk.X) + self.word_labels[word] = label + + def generate_word_search(self): + """Generate the word search puzzle.""" + try: + width = int(self.width_var.get()) + height = int(self.height_var.get()) + + if width < 5 or width > 25 or height < 5 or height > 25: + messagebox.showerror("Error", "Dimensions must be between 5 and 25") + return + + # Get words + words_input = self.words_text.get('1.0', tk.END).strip() + words = [word.strip() for word in words_input.split('\n') if word.strip()] + + if not words: + messagebox.showerror("Error", "Please enter at least one word") + return + + # Create word search + ws = WordSearchGenerator(width, height) + + failed_words = [] + for word in words: + if len(word) > max(width, height): + failed_words.append(f"{word} (too long)") + elif not ws.place_word(word): + failed_words.append(f"{word} (couldn't fit)") + + ws.fill_empty_cells() + + if len(ws.placed_words) == 0: + messagebox.showerror("Error", "No words could be placed. Try increasing grid size.") + return + + # Store the word search + self.word_search = ws + self.found_words = set() + self.selected_cells = [] + + # Create the interactive UI + self.create_grid_ui() + self.update_word_list() + + # Resize window to fit puzzle + self.resize_window_for_puzzle(width, height) + + if failed_words: + messagebox.showwarning("Warning", + f"{len(failed_words)} word(s) couldn't be placed. Try increasing grid size or removing some words.") + + except ValueError: + messagebox.showerror("Error", "Please enter valid numbers for dimensions") + + def resize_window_for_puzzle(self, grid_width, grid_height): + """Resize the window to fit the puzzle comfortably.""" + # Calculate required size for puzzle grid + puzzle_width = grid_width * self.cell_size + puzzle_height = grid_height * self.cell_size + + # Add generous padding for UI elements: + # - Input section height: ~280px + # - Right panel (word list) width: ~280px + # - Frame padding, borders, and extra space: ~120px width, ~120px height + total_width = puzzle_width + 280 + 120 + total_height = puzzle_height + 280 + 120 + + # Set minimum based on puzzle size to ensure it fits + min_width = max(900, total_width) + min_height = max(700, total_height) + max_width = 1800 + max_height = 1200 + + # Clamp to maximum bounds only + final_width = min(max_width, min_width) + final_height = min(max_height, min_height) + + # Resize the window + self.root.geometry(f"{final_width}x{final_height}") + + # Update the window to apply changes + self.root.update_idletasks() + + def export_to_pdf(self): + """Export the current word search puzzle to a PDF file.""" + if not self.word_search: + messagebox.showerror("Error", "Please generate a word search puzzle first") + return + + # Ask user for save location + file_path = filedialog.asksaveasfilename( + defaultextension=".pdf", + filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")], + title="Save Word Search as PDF" + ) + + if not file_path: + return + + try: + # Create PDF + pdf = pdf_canvas.Canvas(file_path, pagesize=LETTER_SIZE) + page_width, page_height = LETTER_SIZE + + # Title + pdf.setFont("Helvetica-Bold", 20) + pdf.drawCentredString(page_width / 2, page_height - 0.75 * inch, "Word Search Puzzle") + + # Grid setup + height = len(self.word_search.grid) + width = len(self.word_search.grid[0]) if height > 0 else 0 + + # Calculate cell size to fit on page + max_grid_width = 6.5 * inch + max_grid_height = 6.5 * inch + cell_size = min(max_grid_width / width, max_grid_height / height, 0.4 * inch) + + # Center the grid + grid_width = width * cell_size + grid_height = height * cell_size + start_x = (page_width - grid_width) / 2 + start_y = page_height - 2 * inch + + # Draw grid + pdf.setFont("Courier-Bold", int(cell_size * 0.6)) + for row in range(height): + for col in range(width): + x = start_x + col * cell_size + y = start_y - row * cell_size + + # Draw letter centered in cell (no border) + letter = self.word_search.grid[row][col] + text_x = x + cell_size / 2 + text_y = y - cell_size / 2 - cell_size * 0.15 + pdf.drawCentredString(text_x, text_y, letter) + + # Word list + word_list_y = start_y - grid_height - 0.5 * inch + pdf.setFont("Helvetica-Bold", 12) + pdf.drawString(1 * inch, word_list_y, "Words to Find:") + + pdf.setFont("Helvetica", 10) + words = [word_info['word'] for word_info in self.word_search.placed_words] + + # Arrange words in columns + words_per_column = 10 + col_width = 1.5 * inch + current_y = word_list_y - 0.25 * inch + + for i, word in enumerate(words): + col_num = i // words_per_column + row_num = i % words_per_column + + x = 1 * inch + col_num * col_width + y = current_y - row_num * 0.2 * inch + + pdf.drawString(x, y, f"• {word}") + + # Save PDF + pdf.save() + + messagebox.showinfo("Success", f"Word search exported to:\n{file_path}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to export PDF:\n{str(e)}") + + +def main(): + root = tk.Tk() + app = WordSearchApp(root) + root.mainloop() + + +if __name__ == "__main__": + main()