diff --git a/requirements.txt b/requirements.txt index 5b693c6..b655691 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,9 @@ azure-ai-evaluation>=1.0.0 openpyxl pandas +# UI components +tksheet>=7.5.0 + # Web automation for link checking and HTTP requests playwright requests>=2.28.0 diff --git a/src/ui/spreadsheet_view.py b/src/ui/spreadsheet_view.py index bfdc402..e3cd031 100644 --- a/src/ui/spreadsheet_view.py +++ b/src/ui/spreadsheet_view.py @@ -1,8 +1,8 @@ -"""Spreadsheet view using tkinter Treeview for single Excel sheet rendering.""" +"""Spreadsheet view using tksheet for single Excel sheet rendering with automatic text wrapping.""" import tkinter as tk -from tkinter import ttk from typing import Optional +from tksheet import Sheet from utils.data_types import SheetData, CellState import logging @@ -10,7 +10,7 @@ class SpreadsheetView: - """Visual representation of a single Excel sheet using tkinter Treeview.""" + """Visual representation of a single Excel sheet using tksheet with automatic text wrapping.""" # Cell background colors for different states COLOR_PENDING = "#FFFFFF" # White @@ -34,153 +34,119 @@ def __init__(self, parent: tk.Widget, sheet_data: SheetData): """ self.parent = parent self.sheet_data = sheet_data - self.treeview: Optional[ttk.Treeview] = None - self.row_ids: list[str] = [] - self.scrollbar_v: Optional[ttk.Scrollbar] = None - self.scrollbar_h: Optional[ttk.Scrollbar] = None + self.sheet: Optional[Sheet] = None + self.frame: Optional[tk.Frame] = None - def render(self) -> ttk.Treeview: - """Create and return configured Treeview widget. + def render(self) -> Sheet: + """Create and return configured tksheet widget. Returns: - Configured Treeview widget ready for display + Configured Sheet widget ready for display """ - # Create frame to hold treeview and scrollbars - frame = ttk.Frame(self.parent) - - # Create Treeview with columns - self.treeview = ttk.Treeview( - frame, - columns=('question', 'response'), - show='headings', - selectmode='none' + # Create frame to hold sheet + self.frame = tk.Frame(self.parent) + + # Create Sheet widget with better configuration + self.sheet = Sheet( + self.frame, + headers=["Question", "Response"], + header_height=35, + default_row_height=30, # Initial row height (will auto-resize) + show_top_left=False, + show_row_index=False, + show_x_scrollbar=True, + show_y_scrollbar=True, + auto_resize_columns=True, # Allow columns to resize + auto_resize_rows=True, # KEY: Auto-resize rows for content + empty_horizontal=0, + empty_vertical=0, + page_up_down_select_row=True, + expand_sheet_if_paste_too_big=True, + arrow_key_down_right_scroll_page=False, + displayed_columns=[0, 1], # Show both columns + all_columns_displayed=True, + index_width=0, # No row index ) - # Configure column headings - self.treeview.heading('question', text='Question') - self.treeview.heading('response', text='Response') - - # Configure column widths and properties - self.treeview.column('question', width=400, minwidth=200, anchor='w') - self.treeview.column('response', width=600, minwidth=300, anchor='w') - - # Configure row height to accommodate multi-line text better - style = ttk.Style() - style.configure("Treeview", rowheight=60) # Increase row height from default ~20 to 60 + # Enable features + self.sheet.enable_bindings( + "single_select", + "column_select", + "column_width_resize", + "double_click_column_resize", + "copy", + "rc_select", + ) - # Configure borders and styling - use a combination of approaches - style.configure("Treeview", - background="white", - fieldbackground="white", - selectbackground="#e6f3ff", - selectforeground="black") + # Set initial column widths (will auto-resize based on content) + self.sheet.column_width(column=0, width=400) # Question column + self.sheet.column_width(column=1, width=620) # Response column - # Configure header styling with visible borders - style.configure("Treeview.Heading", - background="#f0f0f0", - foreground="black", - relief="solid", - borderwidth=1) + # Configure text wrapping and alignment + self.sheet.set_options( + wrap_text=True, # Enable text wrapping + align="w", # Left align text + header_align="center", # Center align headers + auto_resize_default_row_index=True, + ) - # Map different states for better visual separation - style.map("Treeview", - background=[('selected', '#e6f3ff')], - foreground=[('selected', 'black')]) + # Populate with data + self._populate_data() - # Configure Treeview to show lines between items - self.treeview.configure(show='tree headings') # Show both tree lines and headings + # Pack the sheet to fill available space + self.sheet.pack(fill="both", expand=True) + self.frame.pack(fill="both", expand=True) - # Re-configure to show headings only but with better styling - self.treeview.configure(show='headings') + logger.debug(f"Rendered tksheet view for sheet '{self.sheet_data.sheet_name}' with {len(self.sheet_data.questions)} questions") - # Configure cell state tags for styling - self._configure_cell_tags() + return self.sheet + + def _populate_data(self) -> None: + """Populate sheet with questions and responses.""" + data = [] - # Add scrollbars - self._add_scrollbars(frame) + for row_idx, question in enumerate(self.sheet_data.questions): + state = self.sheet_data.cell_states[row_idx] + answer = self.sheet_data.answers[row_idx] + + response_text = self._get_response_text(state, answer, agent_name=None) + data.append([question, response_text]) - # Insert all rows from sheet data - self._populate_rows() + # Set the data + self.sheet.set_sheet_data(data) - # Pack components - self.treeview.grid(row=0, column=0, sticky='nsew') - self.scrollbar_v.grid(row=0, column=1, sticky='ns') - self.scrollbar_h.grid(row=1, column=0, sticky='ew') + # Apply cell colors based on state + for row_idx in range(len(self.sheet_data.questions)): + self._update_row_color(row_idx) - # Configure grid weights for resizing - frame.grid_rowconfigure(0, weight=1) - frame.grid_columnconfigure(0, weight=1) + logger.debug(f"Populated tksheet with {len(data)} rows") + + def _update_row_color(self, row_index: int) -> None: + """Update the background color of a row based on its state. - # Pack frame in parent - frame.pack(fill=tk.BOTH, expand=True) + Args: + row_index: Zero-based row index + """ + if row_index < 0 or row_index >= len(self.sheet_data.cell_states): + return - logger.debug(f"Rendered spreadsheet view for sheet '{self.sheet_data.sheet_name}' with {len(self.sheet_data.questions)} questions") + state = self.sheet_data.cell_states[row_index] - return self.treeview - - def _configure_cell_tags(self) -> None: - """Configure Treeview tags for different cell states.""" - # Configure cell state colors with alternating backgrounds for better separation - self.treeview.tag_configure('pending', background=self.COLOR_PENDING) - self.treeview.tag_configure('working', background=self.COLOR_WORKING) - self.treeview.tag_configure('completed', background=self.COLOR_COMPLETED) - - # Add alternating row colors for better visual separation - self.treeview.tag_configure('odd_row', background='#f9f9f9') - self.treeview.tag_configure('even_row', background='#ffffff') - - # Working state variants with alternating backgrounds - self.treeview.tag_configure('working_odd', background='#FFB6C1') # Pink - self.treeview.tag_configure('working_even', background='#FFC0CB') # Light pink - - # Completed state variants with alternating backgrounds - self.treeview.tag_configure('completed_odd', background='#90EE90') # Light green - self.treeview.tag_configure('completed_even', background='#98FB98') # Pale green - - # Text color for all states (default black) - for tag in ['pending', 'working', 'completed', 'odd_row', 'even_row', - 'working_odd', 'working_even', 'completed_odd', 'completed_even']: - self.treeview.tag_configure(tag, foreground='#000000') - - def _add_scrollbars(self, frame: ttk.Frame) -> None: - """Add vertical and horizontal scrollbars to the treeview.""" - # Vertical scrollbar - self.scrollbar_v = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.treeview.yview) - self.treeview.configure(yscrollcommand=self.scrollbar_v.set) - - # Horizontal scrollbar - self.scrollbar_h = ttk.Scrollbar(frame, orient=tk.HORIZONTAL, command=self.treeview.xview) - self.treeview.configure(xscrollcommand=self.scrollbar_h.set) - - def _populate_rows(self) -> None: - """Populate treeview with all questions from sheet data.""" - self.row_ids.clear() - - for row_idx, question in enumerate(self.sheet_data.questions): - state = self.sheet_data.cell_states[row_idx] - answer = self.sheet_data.answers[row_idx] - - response_text = self._get_response_text(state, answer or "", agent_name=None) - - # Use alternating row colors with state-specific variants - is_odd = (row_idx % 2) == 1 - - if state == CellState.WORKING: - tag = 'working_odd' if is_odd else 'working_even' - elif state == CellState.COMPLETED: - tag = 'completed_odd' if is_odd else 'completed_even' - else: # PENDING - tag = 'odd_row' if is_odd else 'even_row' - - row_id = self.treeview.insert( - '', - 'end', - values=(question, response_text), - tags=(tag,) - ) - self.row_ids.append(row_id) - - logger.debug(f"Populated {len(self.row_ids)} rows in treeview with alternating colors") + # Determine color based on state + if state == CellState.WORKING: + color = self.COLOR_WORKING + elif state == CellState.COMPLETED: + color = self.COLOR_COMPLETED + else: # PENDING + color = self.COLOR_PENDING + + # Apply color to both cells in the row + self.sheet.highlight_rows( + rows=[row_index], + bg=color, + fg="black", + redraw=True + ) def update_cell( self, @@ -197,45 +163,33 @@ def update_cell( answer: Answer text (required for COMPLETED state) agent_name: Name of the currently active agent (for WORKING state) """ - if row_index < 0 or row_index >= len(self.row_ids): - logger.warning(f"Invalid row_index: {row_index} (valid range: 0-{len(self.row_ids)-1})") + if row_index < 0 or row_index >= len(self.sheet_data.questions): + logger.warning(f"Invalid row_index: {row_index} (valid range: 0-{len(self.sheet_data.questions)-1})") return - if not self.treeview: - logger.error("Cannot update cell: treeview not initialized") + if not self.sheet: + logger.error("Cannot update cell: sheet not initialized") return - row_id = self.row_ids[row_index] - question = self.sheet_data.questions[row_index] - response_text = self._get_response_text(state, answer or "", agent_name) - - # Use alternating row colors with state-specific variants - is_odd = (row_index % 2) == 1 - - if state == CellState.WORKING: - tag = 'working_odd' if is_odd else 'working_even' - elif state == CellState.COMPLETED: - tag = 'completed_odd' if is_odd else 'completed_even' - else: # PENDING - tag = 'odd_row' if is_odd else 'even_row' - - # Update the treeview item - self.treeview.item( - row_id, - values=(question, response_text), - tags=(tag,) - ) - # Update sheet data to stay in sync self.sheet_data.cell_states[row_index] = state if answer and state == CellState.COMPLETED: self.sheet_data.answers[row_index] = answer + # Get response text + response_text = self._get_response_text(state, answer or "", agent_name) + + # Update the response cell (column 1) + self.sheet.set_cell_data(row_index, 1, value=response_text, redraw=True) + + # Update row color + self._update_row_color(row_index) + # Auto-scroll to keep active cell visible if state == CellState.WORKING: self._auto_scroll_to_row(row_index) - logger.debug(f"Updated cell [{row_index}] to {state.value} with alternating color") + logger.debug(f"Updated cell [{row_index}] to {state.value}") def _get_response_text(self, state: CellState, answer: str, agent_name: Optional[str] = None) -> str: """Get display text for response cell based on state. @@ -264,40 +218,24 @@ def _auto_scroll_to_row(self, row_index: int) -> None: Args: row_index: Row to scroll to """ - if not self.treeview or row_index >= len(self.row_ids): + if not self.sheet or row_index >= len(self.sheet_data.questions): return try: - # Get the item ID for the row - item_id = self.row_ids[row_index] - - # Calculate scroll position to center the row - total_rows = len(self.row_ids) - if total_rows > 0: - # Scroll to position that shows the row with some context - target_position = max(0, (row_index - 3) / total_rows) - self.treeview.yview_moveto(target_position) - - # Ensure the specific item is visible - self.treeview.see(item_id) - + # Use tksheet's see method to make the row visible + self.sheet.see(row=row_index, column=0, keep_yscroll=False, keep_xscroll=True) logger.debug(f"Auto-scrolled to row {row_index}") - except Exception as e: logger.warning(f"Failed to auto-scroll to row {row_index}: {e}") def refresh(self) -> None: """Redraw entire view from current sheet_data.""" - if not self.treeview: - logger.warning("Cannot refresh: treeview not initialized") + if not self.sheet: + logger.warning("Cannot refresh: sheet not initialized") return - # Clear existing items - for item in self.treeview.get_children(): - self.treeview.delete(item) - # Repopulate with current data - self._populate_rows() + self._populate_data() logger.debug(f"Refreshed spreadsheet view for sheet '{self.sheet_data.sheet_name}'") @@ -307,29 +245,20 @@ def get_visible_row_range(self) -> tuple[int, int]: Returns: Tuple of (first_visible_row, last_visible_row) indices """ - if not self.treeview or not self.row_ids: + if not self.sheet: return (0, 0) try: - # Get first and last visible items - visible_items = [] - for item_id in self.row_ids: - bbox = self.treeview.bbox(item_id) - if bbox: # Item is visible - visible_items.append(item_id) - - if not visible_items: - return (0, 0) - - # Find indices of first and last visible items - first_visible = self.row_ids.index(visible_items[0]) - last_visible = self.row_ids.index(visible_items[-1]) - - return (first_visible, last_visible) - + # Get visible rows from tksheet + visible = self.sheet.get_currently_visible() + if visible: + first_row = visible[0] + last_row = visible[1] + return (first_row, last_row) + return (0, len(self.sheet_data.questions) - 1) except Exception as e: logger.warning(f"Failed to get visible row range: {e}") - return (0, len(self.row_ids) - 1) + return (0, len(self.sheet_data.questions) - 1) def select_row(self, row_index: int) -> None: """Select and highlight a specific row. @@ -337,19 +266,17 @@ def select_row(self, row_index: int) -> None: Args: row_index: Zero-based row index to select """ - if not self.treeview or row_index < 0 or row_index >= len(self.row_ids): + if not self.sheet or row_index < 0 or row_index >= len(self.sheet_data.questions): return - # Clear existing selection - self.treeview.selection_remove(self.treeview.selection()) - - # Select the specified row - item_id = self.row_ids[row_index] - self.treeview.selection_add(item_id) - self.treeview.focus(item_id) - - # Ensure it's visible - self._auto_scroll_to_row(row_index) + try: + # Select the row + self.sheet.select_row(row_index, redraw=True) + + # Ensure it's visible + self._auto_scroll_to_row(row_index) + except Exception as e: + logger.warning(f"Failed to select row {row_index}: {e}") def get_row_count(self) -> int: """Get the total number of rows in the view. @@ -361,17 +288,12 @@ def get_row_count(self) -> int: def destroy(self) -> None: """Clean up the view and its resources.""" - if self.treeview: - self.treeview.destroy() - self.treeview = None - - if self.scrollbar_v: - self.scrollbar_v.destroy() - self.scrollbar_v = None + if self.sheet: + self.sheet.destroy() + self.sheet = None - if self.scrollbar_h: - self.scrollbar_h.destroy() - self.scrollbar_h = None + if self.frame: + self.frame.destroy() + self.frame = None - self.row_ids.clear() - logger.debug(f"Destroyed spreadsheet view for sheet '{self.sheet_data.sheet_name}'") \ No newline at end of file + logger.debug(f"Destroyed spreadsheet view for sheet '{self.sheet_data.sheet_name}'") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9512604 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""pytest configuration for the test suite.""" + +import sys +from pathlib import Path + +# Add the src directory to the Python path for imports +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) diff --git a/tests/unit/test_spreadsheet_text_wrapping.py b/tests/unit/test_spreadsheet_text_wrapping.py new file mode 100644 index 0000000..d511255 --- /dev/null +++ b/tests/unit/test_spreadsheet_text_wrapping.py @@ -0,0 +1,140 @@ +"""Unit tests for spreadsheet view text wrapping functionality.""" + +import pytest +import tkinter as tk +from tkinter import ttk +from ui.spreadsheet_view import SpreadsheetView +from utils.data_types import SheetData, CellState + + +class TestSpreadsheetTextWrapping: + """Test text wrapping in SpreadsheetView.""" + + @pytest.fixture + def root_window(self): + """Create a root tkinter window for testing.""" + root = tk.Tk() + yield root + root.destroy() + + @pytest.fixture + def sample_sheet_data(self): + """Create sample sheet data for testing.""" + sheet_data = SheetData( + sheet_name="Test Sheet", + sheet_index=0, + questions=[ + "Short question?", + "This is a much longer question that should wrap to multiple lines when displayed in the spreadsheet view to ensure proper formatting", + "Medium length question about testing?" + ], + answers=["", "", ""], + cell_states=[ + CellState.PENDING, + CellState.PENDING, + CellState.PENDING + ] + ) + return sheet_data + + @pytest.fixture + def spreadsheet_view(self, root_window, sample_sheet_data): + """Create a SpreadsheetView instance.""" + return SpreadsheetView(root_window, sample_sheet_data) + + def test_wrap_text_short(self, spreadsheet_view): + """Test wrapping short text that doesn't need wrapping.""" + text = "Short text" + result = spreadsheet_view._wrap_text(text, 80, 5) + assert result == "Short text" + assert len(result.split('\n')) == 1 + + def test_wrap_text_medium(self, spreadsheet_view): + """Test wrapping medium text to multiple lines.""" + text = "Microsoft Azure is a cloud computing platform and set of services offered by Microsoft. It provides a wide range of services." + result = spreadsheet_view._wrap_text(text, 80, 5) + lines = result.split('\n') + assert len(lines) >= 2 + assert len(lines) <= 5 + # Verify no line exceeds the width significantly + for line in lines: + assert len(line) <= 85 # Allow some tolerance for word boundaries + + def test_wrap_text_long_truncate(self, spreadsheet_view): + """Test wrapping very long text truncates at max lines.""" + text = " ".join(["This is a very long sentence that will need multiple lines."] * 10) + result = spreadsheet_view._wrap_text(text, 80, 5) + lines = result.split('\n') + assert len(lines) == 5 + # Last line should end with ellipsis + assert lines[-1].endswith('...') + + def test_wrap_text_empty(self, spreadsheet_view): + """Test wrapping empty text.""" + result = spreadsheet_view._wrap_text("", 80, 5) + assert result == "" + + def test_wrap_text_with_newlines(self, spreadsheet_view): + """Test wrapping text that already contains newlines.""" + text = "Line 1\nLine 2\nLine 3" + result = spreadsheet_view._wrap_text(text, 80, 5) + lines = result.split('\n') + assert len(lines) == 3 + assert "Line 1" in lines[0] + assert "Line 2" in lines[1] + assert "Line 3" in lines[2] + + def test_get_response_text_pending(self, spreadsheet_view): + """Test getting response text for pending state.""" + result = spreadsheet_view._get_response_text(CellState.PENDING, "") + assert result == "" + + def test_get_response_text_working(self, spreadsheet_view): + """Test getting response text for working state.""" + result = spreadsheet_view._get_response_text(CellState.WORKING, "") + assert result == "Working..." + + def test_get_response_text_completed_short(self, spreadsheet_view): + """Test getting response text for completed state with short answer.""" + answer = "Short answer" + result = spreadsheet_view._get_response_text(CellState.COMPLETED, answer) + assert result == "Short answer" + + def test_get_response_text_completed_long(self, spreadsheet_view): + """Test getting response text for completed state with long answer.""" + answer = "Microsoft Azure is a cloud computing platform and set of services offered by Microsoft. " * 5 + result = spreadsheet_view._get_response_text(CellState.COMPLETED, answer) + lines = result.split('\n') + # Should wrap to multiple lines + assert len(lines) >= 2 + # Should not exceed max lines + assert len(lines) <= 5 + + def test_constants_defined(self, spreadsheet_view): + """Test that wrapping constants are properly defined.""" + assert hasattr(spreadsheet_view, 'MAX_LINES_PER_CELL') + assert hasattr(spreadsheet_view, 'CHARS_PER_LINE_RESPONSE') + assert hasattr(spreadsheet_view, 'CHARS_PER_LINE_QUESTION') + assert spreadsheet_view.MAX_LINES_PER_CELL == 5 + assert spreadsheet_view.CHARS_PER_LINE_RESPONSE == 80 + assert spreadsheet_view.CHARS_PER_LINE_QUESTION == 50 + + def test_wrap_text_very_small_width(self, spreadsheet_view): + """Test wrapping with very small width doesn't cause errors.""" + text = "Test text that is longer than expected" + # Test with width smaller than 3 (edge case) + result = spreadsheet_view._wrap_text(text, 2, 5) + # Should handle gracefully without errors + assert isinstance(result, str) + + def test_row_height_configured(self, root_window, sample_sheet_data): + """Test that row height is properly configured for multi-line text.""" + view = SpreadsheetView(root_window, sample_sheet_data) + treeview = view.render() + + # Get the style and check row height + style = ttk.Style() + rowheight = style.lookup("Treeview", "rowheight") + + # Should be set to accommodate 5 lines (110 pixels) + assert rowheight == 110