diff --git a/.gitignore b/.gitignore index 10a1877..641a484 100644 --- a/.gitignore +++ b/.gitignore @@ -228,14 +228,23 @@ excel/ backups/ data/ .venv/ +node_modules/ -# Extracted data files (can be regenerated) -extracted_data/*.json +# Extracted/derived data (can be regenerated) +extracted_data/ + +# Analysis/report outputs +diff_reports/ # Test output files test_output/ test_results/ # Backup directory (already moved files there) -# backups/ \ No newline at end of file +# backups/ + +# Ignore top-level Markdown and text payloads not meant for version control +# Keep docs/*.md and tests fixtures tracked; only ignore root-level files +/AGENTS.md +/*.txt diff --git a/config_manager.py b/config_manager.py index 24e10ce..21d98d2 100644 --- a/config_manager.py +++ b/config_manager.py @@ -22,6 +22,12 @@ def _load_config_unlocked(self): settings.setdefault("debug_mode_enabled", False) # Add the new setting for the frozen column's header settings.setdefault("show_row_numbers_on_frozen_column", False) + # Undo/Redo depth configuration + settings.setdefault("undo_stack_max_size", 10) + # Crosshair guides configuration + settings.setdefault("crosshair_enabled", True) + settings.setdefault("crosshair_thickness", 1) + settings.setdefault("crosshair_hover_enabled", True) # Add the new setting for freezing the first row #settings.setdefault("freeze_first_row_enabled", False) config["settings"] = settings @@ -47,4 +53,4 @@ def set_setting(self, key, value): if "settings" not in self.config: self.config["settings"] = {} self.config["settings"][key] = value - self.save_config() \ No newline at end of file + self.save_config() diff --git a/custom_widgets.py b/custom_widgets.py index 496a1bf..d0a0afd 100644 --- a/custom_widgets.py +++ b/custom_widgets.py @@ -1,8 +1,10 @@ -from PyQt6.QtWidgets import (QTableView, QStyledItemDelegate, QMenu, QApplication, - QHeaderView, QStyle, QAbstractItemView) -from PyQt6.QtCore import (Qt, pyqtSignal, QItemSelection, QItemSelectionModel, - QTimer) -from PyQt6.QtGui import QAction, QKeySequence, QStandardItemModel, QStandardItem +from PyQt6.QtWidgets import (QTableView, QStyledItemDelegate, QMenu, QApplication, + QHeaderView, QStyle, QAbstractItemView, QInputDialog, QMessageBox) +from PyQt6.QtCore import (Qt, pyqtSignal, QItemSelection, QItemSelectionModel, + QTimer, QEvent) +from PyQt6.QtGui import (QAction, QKeySequence, QStandardItemModel, QStandardItem, + QPainter, QPen, QColor) +from config_manager import ConfigManager as AppConfigManager class CustomHeaderView(QHeaderView): rightClicked = pyqtSignal(int) @@ -13,6 +15,12 @@ def __init__(self, orientation, parent=None): self.customContextMenuRequested.connect(self.show_context_menu) self._is_selecting = False self._start_section = -1 + # Crosshair highlighting for active section + self._highlighted_section = -1 + self._show_crosshair_guides = True + self._crosshair_color = QColor(0, 120, 215, 60) # light accent fill + self._crosshair_border = QColor(0, 120, 215, 180) + self._crosshair_border_width = 1 def show_context_menu(self, position): logical_index = self.logicalIndexAt(position) @@ -62,6 +70,43 @@ def mouseReleaseEvent(self, event): return super().mouseReleaseEvent(event) + # --- Crosshair helpers --- + def setCrosshairGuidesEnabled(self, enabled: bool): + self._show_crosshair_guides = bool(enabled) + self.viewport().update() + + def setHighlightedSection(self, section_index: int): + self._highlighted_section = section_index if section_index is not None else -1 + self.viewport().update() + + def setCrosshairStyle(self, fill_color: QColor = None, border_color: QColor = None, border_width: int = None): + if fill_color is not None: + self._crosshair_color = fill_color + if border_color is not None: + self._crosshair_border = border_color + if border_width is not None: + self._crosshair_border_width = int(border_width) + self.viewport().update() + + def paintSection(self, painter, rect, logicalIndex): + super().paintSection(painter, rect, logicalIndex) + if not self._show_crosshair_guides: + return + if logicalIndex != self._highlighted_section or not rect.isValid(): + return + # Light fill to make the active column/row name pop + painter.save() + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(self._crosshair_color) + painter.drawRect(rect) + # Subtle border to increase contrast + pen = QPen(self._crosshair_border) + pen.setWidth(self._crosshair_border_width) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(rect.adjusted(0, 0, -1, -1)) + painter.restore() + class NoHoverDelegate(QStyledItemDelegate): def paint(self, painter, option, index): opt = option @@ -69,9 +114,26 @@ def paint(self, painter, option, index): opt.state &= ~QStyle.StateFlag.State_MouseOver super().paint(painter, opt, index) -class ConfigManager: - def get_setting(self, key, default): - return default +class OverlayTableView(QTableView): + """Lightweight QTableView that allows drawing an overlay after normal paint. + Used for frozen panes so crosshair lines render reliably on top. + """ + def __init__(self, parent=None): + super().__init__(parent) + self._overlay_callback = None + + def setOverlayCallback(self, callback): + self._overlay_callback = callback + + def paintEvent(self, event): + super().paintEvent(event) + if callable(self._overlay_callback): + try: + painter = QPainter(self.viewport()) + self._overlay_callback(painter) + painter.end() + except Exception: + pass class CleanTableView(QTableView): # --- Signals remain unchanged --- @@ -82,21 +144,38 @@ class CleanTableView(QTableView): select_all_requested = pyqtSignal() insert_row_above_requested = pyqtSignal() insert_row_below_requested = pyqtSignal() + insert_rows_above_requested = pyqtSignal(int) + insert_rows_below_requested = pyqtSignal(int) delete_row_requested = pyqtSignal() insert_column_left_requested = pyqtSignal() insert_column_right_requested = pyqtSignal() + insert_columns_left_requested = pyqtSignal(int) + insert_columns_right_requested = pyqtSignal(int) delete_column_requested = pyqtSignal() math_action_requested = pyqtSignal(str) + conditional_math_requested = pyqtSignal(object) select_column_requested = pyqtSignal(int) select_row_requested = pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) - self.config_manager = ConfigManager() + self.config_manager = AppConfigManager() self.setItemDelegate(NoHoverDelegate()) + # Improve edit UX: double-click or edit key starts edit, Enter commits + self.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.EditKeyPressed) + + # Crosshair guides settings + self._show_crosshair_guides = True + self._crosshair_color = QColor(0, 120, 215, 180) # Windows accent-like + self._crosshair_width = 1 + self._current_row = -1 + self._current_col = -1 + self._hover_row = -1 + self._hover_col = -1 + self._hover_enabled = True # --- Frozen Column View Setup --- - self.frozen_column_view = QTableView(self) + self.frozen_column_view = OverlayTableView(self) self.frozen_column_view.setItemDelegate(NoHoverDelegate()) self.frozen_column_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.frozen_column_view.verticalHeader().hide() # Hide vertical header; main view's suffices @@ -107,7 +186,7 @@ def __init__(self, parent=None): self.frozen_column_view.setVisible(False) # --- Frozen Row View Setup --- - self.frozen_row_view = QTableView(self) + self.frozen_row_view = OverlayTableView(self) self.frozen_row_view.setItemDelegate(NoHoverDelegate()) self.frozen_row_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.frozen_row_view.verticalHeader().hide() @@ -144,11 +223,22 @@ def __init__(self, parent=None): self.setSelectionBehavior(QTableView.SelectionBehavior.SelectItems) self.setSelectionMode(QTableView.SelectionMode.ExtendedSelection) - + # Connect signals for column/row selection self.select_column_requested.connect(self.select_column) self.select_row_requested.connect(self.select_row) + # Track current cell for crosshair guides + # Will be fully connected when a model is set + self._selection_conn_made = False + + # Enable hover tracking across all viewports + try: + self.viewport().setMouseTracking(True) + self.viewport().installEventFilter(self) + except Exception: + pass + def setModel(self, model): super().setModel(model) if model: @@ -156,6 +246,65 @@ def setModel(self, model): self.frozen_column_view.setSelectionModel(self.selectionModel()) self.frozen_row_view.setModel(model) self.frozen_row_view.setSelectionModel(self.selectionModel()) + # Hook current index changes for crosshair + if not self._selection_conn_made: + try: + self.selectionModel().currentChanged.connect(self._on_current_changed) + self._selection_conn_made = True + except Exception: + pass + # Ensure hover tracking is set for frozen views + try: + self.frozen_column_view.viewport().setMouseTracking(True) + self.frozen_column_view.viewport().installEventFilter(self) + except Exception: + pass + try: + self.frozen_row_view.viewport().setMouseTracking(True) + self.frozen_row_view.viewport().installEventFilter(self) + except Exception: + pass + # Overlay callbacks to draw crosshair lines reliably on frozen panes + def draw_frozen_col_overlay(p: QPainter): + if not self._show_crosshair_guides: + return + eff_row = self._hover_row if self._hover_row >= 0 else self._current_row + if self.model() is None or eff_row < 0: + return + try: + idx_left = self.model().index(eff_row, 0) + rect_left = self.frozen_column_view.visualRect(idx_left) + if not rect_left.isValid(): + return + pen2 = QPen(self._crosshair_color) + pen2.setWidth(self._crosshair_width) + p.setPen(pen2) + y_left = rect_left.top() + p.drawLine(0, y_left, self.frozen_column_view.viewport().width(), y_left) + except Exception: + pass + + def draw_frozen_row_overlay(p: QPainter): + if not self._show_crosshair_guides: + return + eff_col = self._hover_col if self._hover_col >= 0 else self._current_col + if self.model() is None or eff_col < 0: + return + try: + idx_top = self.model().index(0, eff_col) + rect_top = self.frozen_row_view.visualRect(idx_top) + if not rect_top.isValid(): + return + pen3 = QPen(self._crosshair_color) + pen3.setWidth(self._crosshair_width) + p.setPen(pen3) + x_top = rect_top.left() + p.drawLine(x_top, 0, x_top, self.frozen_row_view.viewport().height()) + except Exception: + pass + + self.frozen_column_view.setOverlayCallback(draw_frozen_col_overlay) + self.frozen_row_view.setOverlayCallback(draw_frozen_row_overlay) def set_first_column_frozen(self, frozen: bool): if self.model() is None or self.model().columnCount() == 0: @@ -191,6 +340,200 @@ def set_first_row_frozen(self, frozen: bool): for row in range(1, self.model().rowCount()): self.frozen_row_view.setRowHidden(row, True) self.frozen_row_view.setRowHidden(0, False) + + # --- Crosshair guides public API --- + def setCrosshairGuidesEnabled(self, enabled: bool): + self._show_crosshair_guides = bool(enabled) + # Propagate to headers + for hv in (self.horizontalHeader(), self.verticalHeader(), + getattr(self.frozen_column_view, 'horizontalHeader', lambda: None)() or None, + getattr(self.frozen_row_view, 'horizontalHeader', lambda: None)() or None): + if isinstance(hv, CustomHeaderView): + hv.setCrosshairGuidesEnabled(self._show_crosshair_guides) + self.viewport().update() + self.frozen_column_view.viewport().update() + self.frozen_row_view.viewport().update() + + def crosshairGuidesEnabled(self) -> bool: + return self._show_crosshair_guides + + def setCrosshairColor(self, color: QColor): + self._crosshair_color = color + self.viewport().update() + self.frozen_column_view.viewport().update() + self.frozen_row_view.viewport().update() + + def setCrosshairWidth(self, width: int): + self._crosshair_width = max(1, int(width)) + self.viewport().update() + self.frozen_column_view.viewport().update() + self.frozen_row_view.viewport().update() + + def setCrosshairHoverEnabled(self, enabled: bool): + self._hover_enabled = bool(enabled) + if not self._hover_enabled: + self._hover_row = -1 + self._hover_col = -1 + self._on_current_changed(self.currentIndex(), None) + else: + # Trigger a repaint to pick up the current hover if any + self.viewport().update() + self.frozen_column_view.viewport().update() + self.frozen_row_view.viewport().update() + + # --- Crosshair core logic --- + def _on_current_changed(self, current, previous): + if not current or not current.isValid(): + self._current_row = -1 + self._current_col = -1 + else: + self._current_row = current.row() + self._current_col = current.column() + # Update header highlights + try: + eff_col = self._hover_col if self._hover_col >= 0 else self._current_col + eff_row = self._hover_row if self._hover_row >= 0 else self._current_row + if isinstance(self.horizontalHeader(), CustomHeaderView): + self.horizontalHeader().setHighlightedSection(eff_col) + if isinstance(self.verticalHeader(), CustomHeaderView): + self.verticalHeader().setHighlightedSection(eff_row) + # Frozen mirrors + h_frozen = self.frozen_column_view.horizontalHeader() + if isinstance(h_frozen, CustomHeaderView): + h_frozen.setHighlightedSection(eff_col) + h_frozen_row = self.frozen_row_view.horizontalHeader() + if isinstance(h_frozen_row, CustomHeaderView): + h_frozen_row.setHighlightedSection(eff_col) + except Exception: + pass + # Repaint overlays + self.viewport().update() + self.frozen_column_view.viewport().update() + self.frozen_row_view.viewport().update() + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseMove and self._show_crosshair_guides and self._hover_enabled: + try: + if obj is self.viewport(): + idx = self.indexAt(event.pos()) + elif obj is self.frozen_column_view.viewport(): + idx = self.frozen_column_view.indexAt(event.pos()) + elif obj is self.frozen_row_view.viewport(): + idx = self.frozen_row_view.indexAt(event.pos()) + else: + return super().eventFilter(obj, event) + + if idx and idx.isValid(): + # Determine effective hover row/col per source viewport + if obj is self.viewport(): + row = idx.row() + col = idx.column() + elif obj is self.frozen_column_view.viewport(): + row = idx.row() + # Column is first frozen column (0) + col = 0 + else: # frozen_row_view + # Row is first frozen row (0); take actual column + row = 0 + col = idx.column() + self._hover_row = row + self._hover_col = col + # Update headers to reflect hover + try: + if isinstance(self.horizontalHeader(), CustomHeaderView): + self.horizontalHeader().setHighlightedSection(self._hover_col) + if isinstance(self.verticalHeader(), CustomHeaderView): + self.verticalHeader().setHighlightedSection(self._hover_row) + hf = self.frozen_column_view.horizontalHeader() + if isinstance(hf, CustomHeaderView): + hf.setHighlightedSection(self._hover_col) + hfr = self.frozen_row_view.horizontalHeader() + if isinstance(hfr, CustomHeaderView): + hfr.setHighlightedSection(self._hover_col) + except Exception: + pass + # Repaint + self.viewport().update() + self.frozen_column_view.viewport().update() + self.frozen_row_view.viewport().update() + except Exception: + pass + elif event.type() == QEvent.Type.Leave: + # Clear hover; fall back to current selection + self._hover_row = -1 + self._hover_col = -1 + self._on_current_changed(self.currentIndex(), None) + return super().eventFilter(obj, event) + + def paintEvent(self, event): + super().paintEvent(event) + if not self._show_crosshair_guides: + return + # Determine effective row/col from hover if available, else current selection + eff_row = self._hover_row if self._hover_row >= 0 else self._current_row + eff_col = self._hover_col if self._hover_col >= 0 else self._current_col + if self.model() is None or eff_row < 0 or eff_col < 0: + return + # Draw crosshair lines aligned to the active cell + try: + model = self.model() + # Main view overlay + idx = model.index(eff_row, eff_col) + rect = self.visualRect(idx) + if rect.isValid(): + p = QPainter(self.viewport()) + pen = QPen(self._crosshair_color) + pen.setWidth(self._crosshair_width) + p.setPen(pen) + # Vertical line across main viewport + x = rect.left() + p.drawLine(x, 0, x, self.viewport().height()) + # Horizontal line across main viewport + y = rect.top() + p.drawLine(0, y, self.viewport().width(), y) + p.end() + + # Frozen overlays are handled by OverlayTableView callbacks + + # 3) Extend crosshair into headers + try: + # Horizontal header (column names) - draw vertical line at column + hh = self.horizontalHeader() + if hh and hh.isVisible(): + xh = hh.sectionViewportPosition(eff_col) + if xh >= 0: + ph = QPainter(hh.viewport()) + penh = QPen(self._crosshair_color) + penh.setWidth(self._crosshair_width) + ph.setPen(penh) + ph.drawLine(xh, 0, xh, hh.viewport().height()) + ph.end() + # Vertical header (row numbers) - draw horizontal line at row + vh = self.verticalHeader() + if vh and vh.isVisible(): + yv = vh.sectionViewportPosition(eff_row) + if yv >= 0: + pv = QPainter(vh.viewport()) + penv = QPen(self._crosshair_color) + penv.setWidth(self._crosshair_width) + pv.setPen(penv) + pv.drawLine(0, yv, vh.viewport().width(), yv) + pv.end() + # Frozen row view header (top header for all columns) - vertical line + fr_hh = self.frozen_row_view.horizontalHeader() + if fr_hh and fr_hh.isVisible(): + xfh = fr_hh.sectionViewportPosition(eff_col) + if xfh >= 0: + pfh = QPainter(fr_hh.viewport()) + penfh = QPen(self._crosshair_color) + penfh.setWidth(self._crosshair_width) + pfh.setPen(penfh) + pfh.drawLine(xfh, 0, xfh, fr_hh.viewport().height()) + pfh.end() + except Exception: + pass + except Exception: + pass # Initial sync of row height and all column widths self.frozen_row_view.setRowHeight(0, self.rowHeight(0)) for col in range(self.model().columnCount()): @@ -288,6 +631,15 @@ def select_row(self, row: int): self.selectionModel().select(selection, QItemSelectionModel.SelectionFlag.Select) def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + # Commit current editor if any + if self.state() == QAbstractItemView.State.EditingState: + self.closePersistentEditor(self.currentIndex()) + # Move down to mimic spreadsheet feel (optional) + current = self.currentIndex() + if current.isValid() and current.row() + 1 < (self.model().rowCount() if self.model() else 0): + self.setCurrentIndex(self.model().index(current.row() + 1, current.column())) + return if event.key() == Qt.Key.Key_R and (event.modifiers() & Qt.KeyboardModifier.ControlModifier): current_index = self.currentIndex() if current_index.isValid(): @@ -348,6 +700,10 @@ def show_context_menu(self, position): decrement_action = QAction("Decrement by 1", self) decrement_action.triggered.connect(lambda: self.math_action_requested.emit("decrement")) math_menu.addAction(decrement_action) + math_menu.addSeparator() + conditional_action = QAction("Conditional…", self) + conditional_action.triggered.connect(self._prompt_conditional_math) + math_menu.addAction(conditional_action) menu.addSeparator() insert_row_above_action = QAction("Insert Row &Above", self) insert_row_above_action.triggered.connect(self.insert_row_above_requested.emit) @@ -355,6 +711,12 @@ def show_context_menu(self, position): insert_row_below_action = QAction("Insert Row &Below", self) insert_row_below_action.triggered.connect(self.insert_row_below_requested.emit) menu.addAction(insert_row_below_action) + insert_rows_above_action = QAction("Insert &Rows Above…", self) + insert_rows_above_action.triggered.connect(lambda: self._prompt_insert_rows(True)) + menu.addAction(insert_rows_above_action) + insert_rows_below_action = QAction("Insert R&ows Below…", self) + insert_rows_below_action.triggered.connect(lambda: self._prompt_insert_rows(False)) + menu.addAction(insert_rows_below_action) delete_row_action = QAction("&Delete Row", self) delete_row_action.triggered.connect(self.delete_row_requested.emit) menu.addAction(delete_row_action) @@ -365,6 +727,12 @@ def show_context_menu(self, position): insert_column_right_action = QAction("Insert Column &Right", self) insert_column_right_action.triggered.connect(self.insert_column_right_requested.emit) menu.addAction(insert_column_right_action) + insert_columns_left_action = QAction("Insert C&olumns Left…", self) + insert_columns_left_action.triggered.connect(lambda: self._prompt_insert_columns(True)) + menu.addAction(insert_columns_left_action) + insert_columns_right_action = QAction("Insert Co&lumns Right…", self) + insert_columns_right_action.triggered.connect(lambda: self._prompt_insert_columns(False)) + menu.addAction(insert_columns_right_action) delete_column_action = QAction("Delete &Column", self) delete_column_action.triggered.connect(self.delete_column_requested.emit) menu.addAction(delete_column_action) @@ -386,14 +754,115 @@ def show_context_menu(self, position): except (ValueError, TypeError): can_do_math = False break - math_menu.setEnabled(can_do_math) + # Enable/disable only simple math actions based on numeric selection; keep Conditional always enabled + multiply_action.setEnabled(can_do_math) + divide_action.setEnabled(can_do_math) + add_action.setEnabled(can_do_math) + subtract_action.setEnabled(can_do_math) + increment_action.setEnabled(can_do_math) + decrement_action.setEnabled(can_do_math) has_valid_selection = index.isValid() select_row_action.setEnabled(has_valid_selection) select_column_action.setEnabled(has_valid_selection) insert_row_above_action.setEnabled(has_valid_selection) insert_row_below_action.setEnabled(has_valid_selection) + insert_rows_above_action.setEnabled(has_valid_selection) + insert_rows_below_action.setEnabled(has_valid_selection) delete_row_action.setEnabled(has_valid_selection) insert_column_left_action.setEnabled(has_valid_selection) insert_column_right_action.setEnabled(has_valid_selection) + insert_columns_left_action.setEnabled(has_valid_selection) + insert_columns_right_action.setEnabled(has_valid_selection) delete_column_action.setEnabled(has_valid_selection) - menu.exec(self.mapToGlobal(position)) \ No newline at end of file + menu.exec(self.mapToGlobal(position)) + + def _prompt_insert_rows(self, above: bool): + count, ok = QInputDialog.getInt(self, "Insert Rows", "Number of rows:", 5, 1, 10000, 1) + if not ok: + return + if above: + self.insert_rows_above_requested.emit(count) + else: + self.insert_rows_below_requested.emit(count) + + def _prompt_insert_columns(self, left: bool): + count, ok = QInputDialog.getInt(self, "Insert Columns", "Number of columns:", 2, 1, 500, 1) + if not ok: + return + if left: + self.insert_columns_left_requested.emit(count) + else: + self.insert_columns_right_requested.emit(count) + + def _prompt_conditional_math(self): + # 1) Condition input + text, ok = QInputDialog.getText(self, "Conditional Math", "Condition (e.g., primeevil == 1):") + if not ok or not text.strip(): + return + condition_str = text.strip() + + # 2) Operation choice + operations = ["multiply", "divide", "add", "subtract", "increment", "decrement"] + op, ok = QInputDialog.getItem(self, "Operation", "Choose operation:", operations, 0, False) + if not ok: + return + + operand = None + if op not in ("increment", "decrement"): + val, ok = QInputDialog.getDouble(self, op.capitalize(), f"Enter value to {op}:") + if not ok: + return + operand = val + + # 3) Target columns + selected_cols = sorted({idx.column() for idx in self.selectedIndexes()}) + use_sel = False + if selected_cols: + reply = QMessageBox.question(self, "Target Columns", "Apply to currently selected columns?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes) + use_sel = reply == QMessageBox.StandardButton.Yes + + target_columns = [] + if use_sel: + target_columns = selected_cols + else: + # Build comma-separated prompt of columns + model = self.model() + # Prefer header items; fallback to headerData with DisplayRole + headers = [] + for c in range(model.columnCount()): + header_item = model.horizontalHeaderItem(c) + if header_item is not None and header_item.text() is not None: + headers.append(header_item.text()) + else: + try: + headers.append(str(model.headerData(c, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole))) + except Exception: + headers.append(str(c)) + default_cols = ", ".join([h for h in headers if isinstance(h, str)][:2]) + cols_str, ok = QInputDialog.getText(self, "Target Columns", "Column names (comma separated):", text=default_cols) + if not ok or not cols_str.strip(): + return + requested = [c.strip() for c in cols_str.split(',') if c.strip()] + name_to_index = {str(headers[i]): i for i in range(len(headers))} + missing = [c for c in requested if c not in name_to_index] + if missing: + QMessageBox.warning(self, "Unknown Columns", f"Columns not found: {', '.join(missing)}") + return + target_columns = [name_to_index[c] for c in requested] + + payload = { + "condition": condition_str, + "operation": op, + "operand": operand, + "target_columns": target_columns, + } + # Minimal debug output to help trace selection -> payload mapping + try: + from config_manager import ConfigManager as _Cfg + if _Cfg().get_setting("debug_mode_enabled", False): + print("[DEBUG] Conditional prompt payload:", payload) + except Exception: + pass + self.conditional_math_requested.emit(payload) diff --git a/ui.py b/ui.py index e9bbe81..9825426 100644 --- a/ui.py +++ b/ui.py @@ -11,6 +11,7 @@ import shutil from datetime import datetime import json +import re class ComboBoxDelegate(QStyledItemDelegate): def __init__(self, parent=None, items=None): @@ -409,6 +410,24 @@ def __init__(self, parent=None): self.debug_mode_checkbox.setChecked(self.config_manager.get_setting("debug_mode_enabled", False)) layout.addWidget(self.debug_mode_checkbox) + # Crosshair guides settings + self.crosshair_enabled_checkbox = QCheckBox("Enable crosshair guides (row/column lines)") + self.crosshair_enabled_checkbox.setChecked(self.config_manager.get_setting("crosshair_enabled", True)) + layout.addWidget(self.crosshair_enabled_checkbox) + + self.crosshair_hover_checkbox = QCheckBox("Crosshair follows mouse hover") + self.crosshair_hover_checkbox.setChecked(self.config_manager.get_setting("crosshair_hover_enabled", True)) + layout.addWidget(self.crosshair_hover_checkbox) + + crosshair_row = QHBoxLayout() + crosshair_row.addWidget(QLabel("Crosshair thickness (px):")) + self.crosshair_thickness_input = QLineEdit(str(self.config_manager.get_setting("crosshair_thickness", 1))) + self.crosshair_thickness_input.setPlaceholderText("e.g. 1-6") + self.crosshair_thickness_input.setMaximumWidth(80) + crosshair_row.addWidget(self.crosshair_thickness_input) + crosshair_row.addStretch(1) + layout.addLayout(crosshair_row) + button_layout = QHBoxLayout() self.save_button = QPushButton("Save") self.save_button.clicked.connect(self.save_settings) @@ -429,6 +448,15 @@ def save_settings(self): # Save the new setting #self.config_manager.set_setting("freeze_first_row_enabled", self.freeze_first_row_checkbox.isChecked()) self.config_manager.set_setting("debug_mode_enabled", self.debug_mode_checkbox.isChecked()) + # Crosshair settings + self.config_manager.set_setting("crosshair_enabled", self.crosshair_enabled_checkbox.isChecked()) + self.config_manager.set_setting("crosshair_hover_enabled", self.crosshair_hover_checkbox.isChecked()) + try: + thickness = int(self.crosshair_thickness_input.text()) + except Exception: + thickness = 1 + thickness = max(1, min(6, thickness)) + self.config_manager.set_setting("crosshair_thickness", thickness) self.accept() class EditorWindow(QMainWindow, Ui_MainWindow): @@ -446,6 +474,20 @@ def __init__(self, parent=None): self.create_menus() self.apply_initial_settings() self.update_window_title() + # Honour configurable undo depth + try: + self.undo_stack.max_size = int(self.config_manager.get_setting("undo_stack_max_size", 10) or 10) + except Exception: + self.undo_stack.max_size = 10 + # Enable drag-and-drop opening of .txt files + self.setAcceptDrops(True) + + def debug_print(self, *args): + try: + if self.config_manager.get_setting("debug_mode_enabled", False): + print("[DEBUG]", *args) + except Exception: + pass def create_menus(self): # File Menu @@ -533,6 +575,30 @@ def apply_initial_settings(self): #self.freeze_row_action.setChecked(freeze_row) #self.tableView.set_first_row_frozen(freeze_row) + # Crosshair guides + crosshair_enabled = self.config_manager.get_setting("crosshair_enabled", True) + self.tableView.setCrosshairGuidesEnabled(bool(crosshair_enabled)) + crosshair_hover = self.config_manager.get_setting("crosshair_hover_enabled", True) + try: + self.tableView.setCrosshairHoverEnabled(bool(crosshair_hover)) + except Exception: + pass + try: + crosshair_width = int(self.config_manager.get_setting("crosshair_thickness", 1) or 1) + except Exception: + crosshair_width = 1 + self.tableView.setCrosshairWidth(max(1, min(6, crosshair_width))) + # Also sync header outline thickness on custom headers + try: + hh = self.tableView.horizontalHeader() + vh = self.tableView.verticalHeader() + if hasattr(hh, 'setCrosshairStyle'): + hh.setCrosshairStyle(border_width=max(1, min(6, crosshair_width))) + if hasattr(vh, 'setCrosshairStyle'): + vh.setCrosshairStyle(border_width=max(1, min(6, crosshair_width))) + except Exception: + pass + def toggle_freeze_first_column(self, frozen): """Handles the 'Freeze First Column' menu action.""" if self.data_frame is None or self.data_frame.shape[1] == 0: @@ -557,45 +623,66 @@ def toggle_freeze_first_row(self, frozen): def open_file(self): file_path, _ = QFileDialog.getOpenFileName(self, "Open .txt file", "", "Text Files (*.txt)") if file_path: - - # Auto-detect file type and encoding silently - file_type, confidence, binding_key = detect_file_type(file_path) - detected_encoding = auto_detect_encoding(file_path) - - # Try to find and apply binding if detected - binding_manager = get_binding_manager() - - # Force refresh if no bindings loaded (happens after directory reorganization) - if len(binding_manager.get_all_bindings()) == 0: - print(" No metadata loaded, forcing refresh...") - binding_manager.refresh_bindings() - - applied_binding = None - - if binding_key and confidence in ['high', 'medium']: - # Create dynamic binding for the detected file type and current file - applied_binding = binding_manager.create_dynamic_binding(binding_key, file_path) - - # Print detection results to console for debugging - print(f"Auto-detection results:") - print(f" File: {os.path.basename(file_path)}") - print(f" Type: {file_type} (confidence: {confidence})") - print(f" Encoding: {detected_encoding}") - if applied_binding: - print(f" Applied binding: {applied_binding.base_name}") - - # Load the file directly - self.current_file_path = file_path - self.current_binding = applied_binding - - data = open_txt_file(file_path) - if data is not None: - print(f"File loaded successfully! Type: {file_type}, Encoding: {detected_encoding}") - self.data_frame = data - self.display_data_in_table() - self.update_window_title() - else: - QMessageBox.warning(self, "Load Error", "Failed to load file!") + self._load_file_path(file_path) + + def _load_file_path(self, file_path: str): + """Load a specific .txt file path using standard detection and binding logic.""" + if not file_path: + return + # Auto-detect file type and encoding silently + file_type, confidence, binding_key = detect_file_type(file_path) + detected_encoding = auto_detect_encoding(file_path) + # Try to find and apply binding if detected + binding_manager = get_binding_manager() + # Force refresh if no bindings loaded (happens after directory reorganization) + if len(binding_manager.get_all_bindings()) == 0: + print(" No metadata loaded, forcing refresh...") + binding_manager.refresh_bindings() + applied_binding = None + if binding_key and confidence in ['high', 'medium']: + applied_binding = binding_manager.create_dynamic_binding(binding_key, file_path) + # Print detection results to console for debugging + print("Auto-detection results:") + print(f" File: {os.path.basename(file_path)}") + print(f" Type: {file_type} (confidence: {confidence})") + print(f" Encoding: {detected_encoding}") + if applied_binding: + print(f" Applied binding: {applied_binding.base_name}") + # Load the file directly + self.current_file_path = file_path + self.current_binding = applied_binding + data = open_txt_file(file_path) + if data is not None: + print(f"File loaded successfully! Type: {file_type}, Encoding: {detected_encoding}") + self.data_frame = data + self.display_data_in_table() + self.update_window_title() + else: + QMessageBox.warning(self, "Load Error", "Failed to load file!") + + # Drag-and-drop support + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + # Accept if any of the URLs is a .txt file + for url in event.mimeData().urls(): + if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'): + event.acceptProposedAction() + return + event.ignore() + + def dropEvent(self, event): + handled = False + for url in event.mimeData().urls(): + local = url.toLocalFile() + if url.isLocalFile() and local.lower().endswith('.txt'): + self._load_file_path(local) + handled = True + # Load only first file for now + break + if handled: + event.acceptProposedAction() + else: + event.ignore() def show_binding_info(self, binding): print(f"Applied binding: {binding.base_name}") @@ -838,11 +925,16 @@ def connect_context_menu_signals(self): self.tableView.select_all_requested.connect(self.select_all) self.tableView.insert_row_above_requested.connect(self.insert_row_above) self.tableView.insert_row_below_requested.connect(self.insert_row_below) + self.tableView.insert_rows_above_requested.connect(self.insert_rows_above) + self.tableView.insert_rows_below_requested.connect(self.insert_rows_below) self.tableView.delete_row_requested.connect(self.delete_row) self.tableView.insert_column_left_requested.connect(self.insert_column_left) self.tableView.insert_column_right_requested.connect(self.insert_column_right) + self.tableView.insert_columns_left_requested.connect(self.insert_columns_left) + self.tableView.insert_columns_right_requested.connect(self.insert_columns_right) self.tableView.delete_column_requested.connect(self.delete_column) self.tableView.math_action_requested.connect(self.handle_math_action) + self.tableView.conditional_math_requested.connect(self.handle_conditional_math) self.tableView.select_column_requested.connect(self.select_column) def copy_selection(self): @@ -994,6 +1086,24 @@ def _insert_row_at_position(self, row_position): after = self.data_frame.iloc[row_position:] self.data_frame = pd.concat([before, new_df_row, after], ignore_index=True) print(f"Inserted new row at position {row_position}") + + def insert_rows_above(self, count: int): + current_index = self.tableView.currentIndex() + if not current_index.isValid(): + return + target_row = current_index.row() + for i in range(count): + self._insert_row_at_position(target_row) + print(f"Inserted {count} rows above {target_row}") + + def insert_rows_below(self, count: int): + current_index = self.tableView.currentIndex() + if not current_index.isValid(): + return + start = current_index.row() + 1 + for i in range(count): + self._insert_row_at_position(start + i) + print(f"Inserted {count} rows below {current_index.row()}") def delete_row(self): selection = self.tableView.selectionModel().selectedIndexes() @@ -1068,6 +1178,50 @@ def _insert_column_at_position(self, col_position): new_data[col_name] = self.data_frame[old_col_name] self.data_frame = pd.DataFrame(new_data) print(f"Inserted new column '{column_name}' at position {col_position}") + + def _insert_n_columns(self, col_position: int, count: int): + for i in range(count): + # Auto-name columns; user can rename later + from PyQt6.QtWidgets import QInputDialog + column_name = f"NewColumn{col_position + i}" + model = self.tableView.model() + if not model: + return + model.insertColumn(col_position + i) + model.setHorizontalHeaderItem(col_position + i, QStandardItem(column_name)) + for row in range(model.rowCount()): + model.setItem(row, col_position + i, QStandardItem("")) + if self.data_frame is not None: + import pandas as pd + columns = list(self.data_frame.columns) + columns.insert(col_position + i, column_name) + new_data = {} + for j, col_name in enumerate(columns): + if j < col_position + i: + old_col_name = list(self.data_frame.columns)[j] + new_data[col_name] = self.data_frame[old_col_name] + elif j == col_position + i: + new_data[col_name] = [None] * len(self.data_frame) + else: + old_col_name = list(self.data_frame.columns)[j - 1] + new_data[col_name] = self.data_frame[old_col_name] + self.data_frame = pd.DataFrame(new_data) + + def insert_columns_left(self, count: int): + current_index = self.tableView.currentIndex() + if not current_index.isValid(): + return + target_col = current_index.column() + self._insert_n_columns(target_col, count) + print(f"Inserted {count} columns left of {target_col}") + + def insert_columns_right(self, count: int): + current_index = self.tableView.currentIndex() + if not current_index.isValid(): + return + target_col = current_index.column() + 1 + self._insert_n_columns(target_col, count) + print(f"Inserted {count} columns right of {current_index.column()}") def delete_column(self): current_index = self.tableView.currentIndex() @@ -1135,4 +1289,131 @@ def handle_math_action(self, action): except (ValueError, TypeError): pass finally: - self._tracking_changes = True \ No newline at end of file + self._tracking_changes = True + + def handle_conditional_math(self, payload): + condition_str = payload.get("condition", "").strip() + operation = payload.get("operation") + operand = payload.get("operand") + target_columns = payload.get("target_columns", []) + + if self.data_frame is None or not condition_str or not operation: + return + + df = self.data_frame + self.debug_print("Conditional math invoked:", { + "condition": condition_str, + "operation": operation, + "operand": operand, + "target_columns_indices": target_columns, + "target_columns_names": [df.columns[i] for i in target_columns if i < len(df.columns)], + }) + # Build a boolean mask like "primeevil == 1" ensuring a proper boolean Series aligned to df + try: + maybe_mask = df.eval(condition_str) + if isinstance(maybe_mask, pd.Series) and maybe_mask.dtype == bool and maybe_mask.index.equals(df.index): + mask = maybe_mask + else: + # Try query; convert resulting index to aligned boolean Series + queried = df.query(condition_str) + mask = df.index.isin(queried.index) + except Exception: + QMessageBox.warning(self, "Invalid Condition", f"Could not evaluate condition: {condition_str}") + return + + if not isinstance(mask, (pd.Series, pd.Index)): + QMessageBox.warning(self, "Invalid Condition", "Condition did not produce a boolean mask.") + return + if isinstance(mask, pd.Index): + mask = df.index.isin(mask) + mask = pd.Series(mask, index=df.index).fillna(False) + self.debug_print("Mask computed:", { + "dtype": str(mask.dtype), + "true_count": int(mask.sum()), + "total_rows": int(len(mask)), + }) + + # If no rows matched, try numeric coercion of referenced columns as a fallback + if int(mask.sum()) == 0: + tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]*", condition_str) + referenced_cols = [t for t in tokens if t in df.columns] + local_coerced = {name: pd.to_numeric(df[name], errors='coerce') for name in referenced_cols} + try: + coerced_mask = pd.eval(condition_str, engine='python', local_dict=local_coerced) + if isinstance(coerced_mask, (pd.Series, list)): + coerced_mask = pd.Series(coerced_mask, index=df.index) + coerced_mask = coerced_mask.astype(bool).fillna(False) + self.debug_print("Coerced mask computed:", { + "referenced_cols": referenced_cols, + "true_count": int(coerced_mask.sum()), + }) + mask = coerced_mask + except Exception as e: + self.debug_print("Coerced evaluation failed:", str(e)) + + if not target_columns: + return + + # For each target column, coerce numeric and apply + self._tracking_changes = False + try: + for col_idx in target_columns: + if col_idx >= len(df.columns): + continue + col_name = df.columns[col_idx] + # Operate only on numeric values; non-numeric remain unchanged + numeric_series = pd.to_numeric(df[col_name], errors='coerce') + sel = mask.fillna(False) + current_values = numeric_series.where(sel) + self.debug_print(f"Applying '{operation}' to column", { + "column_index": int(col_idx), + "column_name": str(col_name), + "selected_non_na": int(current_values.notna().sum()), + }) + + if operation == "increment": + new_values = current_values + 1 + elif operation == "decrement": + new_values = current_values - 1 + elif operation == "add": + new_values = current_values + float(operand) + elif operation == "subtract": + new_values = current_values - float(operand) + elif operation == "multiply": + new_values = current_values * float(operand) + elif operation == "divide": + try: + val = float(operand) + except Exception: + QMessageBox.warning(self, "Invalid Operand", "Operand must be numeric.") + return + if val == 0: + QMessageBox.warning(self, "Invalid Operand", "Cannot divide by zero.") + return + new_values = current_values / val + else: + continue + + # Round to nearest integer like other math ops + new_values = new_values.round().astype('Int64') + df.loc[sel, col_name] = new_values.astype(object) + self.debug_print("Applied operation stats:", { + "updated_rows": int(sel.sum()), + "example_before_after": str(list(zip( + current_values.dropna().head(3).astype(int).tolist(), + new_values.dropna().head(3).astype(int).tolist() + ))) + }) + + # Reflect changes in the view model + model = self.tableView.model() + for row_idx in df.index[sel]: + model_item = model.item(int(row_idx), int(col_idx)) + if model_item is not None: + val = df.at[row_idx, col_name] + try: + model_item.setText(str(int(val))) + except Exception: + model_item.setText(str(val)) + finally: + self._tracking_changes = True