diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 9283091f..4cbcf6a5 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -926,6 +926,15 @@ def save_current_edits(self) -> None: self._update_thumbnail_from_state(force_readback=True) def clear_retouch(self) -> None: + from negpy.desktop.view.confirm import confirm_clear_heals + + conf = self.state.config.retouch + count = len(conf.manual_dust_spots) + len(conf.manual_heal_strokes) + if count == 0: + return + # Wiping every heal is not step-recoverable like single-heal undo — confirm. + if not confirm_clear_heals(None, count): + return self.session.update_config( replace( self.state.config, @@ -934,6 +943,25 @@ def clear_retouch(self) -> None: ) self.request_render() + def delete_heal(self, kind: str, index: int) -> None: + """Removes one placed heal by identity ("stroke"/"spot", index) — lets the + user pick off a bad patch directly instead of unwinding newer heals first.""" + strokes = list(self.state.config.retouch.manual_heal_strokes) + spots = list(self.state.config.retouch.manual_dust_spots) + if kind == "stroke" and 0 <= index < len(strokes): + strokes.pop(index) + elif kind == "spot" and 0 <= index < len(spots): + spots.pop(index) + else: + return + self.session.update_config( + replace( + self.state.config, + retouch=replace(self.state.config.retouch, manual_dust_spots=spots, manual_heal_strokes=strokes), + ) + ) + self.request_render() + def undo_last_retouch(self) -> None: """ Removes the most recently added heal (strokes first, then legacy spots). diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index bc47f799..f693db18 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -1,3 +1,4 @@ +import math import sys from typing import Dict, List, Optional, Tuple @@ -31,6 +32,23 @@ def grid_interior_fractions(divisions: int) -> List[float]: return [i / divisions for i in range(1, divisions)] +def _distance_to_polyline(pos: QPointF, pts: List[QPointF]) -> float: + """Shortest screen distance from `pos` to a polyline (a single point counts).""" + if not pts: + return float("inf") + if len(pts) == 1: + return math.hypot(pos.x() - pts[0].x(), pos.y() - pts[0].y()) + best = float("inf") + for a, b in zip(pts, pts[1:]): + abx, aby = b.x() - a.x(), b.y() - a.y() + apx, apy = pos.x() - a.x(), pos.y() - a.y() + denom = abx * abx + aby * aby + t = 0.0 if denom <= 1e-12 else max(0.0, min(1.0, (apx * abx + apy * aby) / denom)) + cx, cy = a.x() + t * abx, a.y() + t * aby + best = min(best, math.hypot(pos.x() - cx, pos.y() - cy)) + return best + + def feathered_mask_image(local_pts: List[Tuple[float, float]], w: int, h: int, sigma_px: float, color: QColor, max_alpha: int) -> QImage: """Tinted premultiplied-alpha QImage of a feathered polygon. @@ -143,6 +161,11 @@ def __init__(self, state: AppState, parent=None): sc.setContext(Qt.ShortcutContext.WidgetShortcut) sc.activated.connect(self._finish_draw_if_active) + # Backspace steps back one click-point of the in-progress scratch polyline. + self._backspace_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Backspace), self) + self._backspace_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) + self._backspace_shortcut.activated.connect(self.undo_last_scratch_point) + if sys.platform == "win32": self.setAttribute(Qt.WidgetAttribute.WA_StaticContents, False) @@ -487,12 +510,12 @@ def _raw_to_screen(self, rx: float, ry: float, uv_grid: np.ndarray, buckets: int """ Inverse UV-grid lookup: raw-normalised (0-1) -> screen position. - Downsamples the grid before the nearest-neighbour search so this stays - fast even for large preview buffers. `buckets` trades precision for - speed: the default is fine for one-shot mask-vertex rendering, but - anything redrawn continuously while being dragged (e.g. crop handles) - needs a much finer grid or the on-screen point visibly snaps between - buckets instead of tracking the cursor. + Two-stage nearest-neighbour: a coarse pass over a `buckets`-decimated grid + locates the neighbourhood cheaply, then a full-resolution pass over that + bucket's window pins the exact pixel. The coarse pass alone snapped results + to bucket centres (± step/2 grid pixels ≈ 3-20px depending on preview size, + magnified by zoom) — enough to draw a heal outline entirely off the healed + spot even though the heal itself landed exactly where clicked. """ h_uv, w_uv = uv_grid.shape[:2] step = max(1, h_uv // buckets) @@ -501,8 +524,18 @@ def _raw_to_screen(self, rx: float, ry: float, uv_grid: np.ndarray, buckets: int idx = int(np.argmin(dist)) h_s, w_s = small.shape[:2] vy, vx = divmod(idx, w_s) - nx = min((vx * step + step // 2) / w_uv, 1.0) - ny = min((vy * step + step // 2) / h_uv, 1.0) + + # Refine: exact search across the coarse cell and its neighbours. + py, px = vy * step, vx * step + y0, y1 = max(0, py - step), min(h_uv, py + step + 1) + x0, x1 = max(0, px - step), min(w_uv, px + step + 1) + window = uv_grid[y0:y1, x0:x1] + wdist = (window[..., 0] - rx) ** 2 + (window[..., 1] - ry) ** 2 + widx = int(np.argmin(wdist)) + wy, wx = divmod(widx, window.shape[1]) + + nx = min((x0 + wx + 0.5) / w_uv, 1.0) + ny = min((y0 + wy + 0.5) / h_uv, 1.0) return QPointF( self._view_rect.x() + nx * self._view_rect.width(), self._view_rect.y() + ny * self._view_rect.height(), @@ -892,6 +925,12 @@ def mousePressEvent(self, event: QMouseEvent) -> None: event.accept() return + # Tool placements are left-click only: right-click must fall through to the + # context menu without dropping a lasso vertex, scratch point, or heal. + if event.button() != Qt.MouseButton.LeftButton: + super().mousePressEvent(event) + return + if self._tool_mode == ToolMode.LOCAL_DRAW: self._handle_lasso_press(event.position()) event.accept() @@ -1155,6 +1194,54 @@ def _finish_draw_if_active(self) -> None: elif self._tool_mode == ToolMode.LOCAL_DRAW and self._lasso_drawing and len(self._lasso_pts) >= 3: self._finish_lasso() + def has_scratch_points(self) -> bool: + return bool(self._scratch_pts) + + def confirm_scratch(self) -> None: + """Commit the in-progress scratch polyline (same as double-click / Enter).""" + if self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: + self._finish_scratch() + + def undo_last_scratch_point(self) -> None: + """Step back one click-point of the in-progress scratch polyline.""" + if self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: + self._scratch_pts.pop() + self.update() + + def heal_hit_test(self, pos: QPointF) -> Optional[Tuple[str, int]]: + """Placed heal under `pos`, as ("stroke"|"spot", index), or None. + + Mirrors the geometry `_draw_placed_heals` renders: raw-normalized points + mapped to screen through the uv grid, hit within the brush band radius + (plus a small slop so thin strokes stay clickable). + """ + conf = self.state.config.retouch + if not (conf.manual_heal_strokes or conf.manual_dust_spots): + return None + with self.state.metrics_lock: + uv_grid = self.state.last_metrics.get("uv_grid") + if uv_grid is None: + return None + + slop = 4.0 + best: Optional[Tuple[str, int]] = None + best_dist = float("inf") + for i, (points, size, _dx, _dy) in enumerate(conf.manual_heal_strokes): + screen_pts = [self._raw_to_screen(px, py, uv_grid) for px, py in points] + radius = max(2.0, self._brush_screen_radius(size)) + slop + d = _distance_to_polyline(pos, screen_pts) + if d <= radius and d < best_dist: + best = ("stroke", i) + best_dist = d + for i, (rx, ry, size) in enumerate(conf.manual_dust_spots): + center = self._raw_to_screen(rx, ry, uv_grid) + radius = max(2.0, self._brush_screen_radius(size)) + slop + d = math.hypot(pos.x() - center.x(), pos.y() - center.y()) + if d <= radius and d < best_dist: + best = ("spot", i) + best_dist = d + return best + def _finish_scratch(self) -> None: pts = self._scratch_pts self._scratch_pts = [] diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 627d0f98..2561a1a0 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -2,7 +2,7 @@ import math import sys from PyQt6.QtWidgets import QStackedLayout, QMenu, QWidget, QPinchGesture, QGestureEvent -from PyQt6.QtGui import QMouseEvent, QNativeGestureEvent, QPainter, QColor, QWheelEvent +from PyQt6.QtGui import QCursor, QMouseEvent, QNativeGestureEvent, QPainter, QColor, QWheelEvent from PyQt6.QtCore import QEvent, pyqtSignal, Qt, QPointF from negpy.desktop.session import ToolMode, AppState from negpy.desktop.view.canvas.gpu_widget import GPUCanvasWidget @@ -37,6 +37,27 @@ def clamp_canvas_zoom_level(zoom: float) -> float: ToolMode.LOCAL_DRAW: Qt.CursorShape.CrossCursor, ToolMode.ANALYSIS_DRAW: Qt.CursorShape.CrossCursor, } + +_scratch_pen_cursor: Optional[QCursor] = None + + +def _cursor_for_tool(mode: ToolMode) -> QCursor | Qt.CursorShape: + """Cursor for a tool mode. The scratch tool gets a pen-nib cursor so it's + obvious the canvas is in click-points line-drawing mode (built lazily — + QCursor pixmaps need a live QGuiApplication).""" + if mode == ToolMode.SCRATCH_PICK: + global _scratch_pen_cursor + if _scratch_pen_cursor is None: + import qtawesome as qta + + # Rotated 90°: the glyph's nib swings from bottom-left to top-left, + # tail to lower-right — reads like a normal pointer, tip up-left. + pix = qta.icon("fa5s.pen-nib", color="white", rotated=90).pixmap(18, 18) + _scratch_pen_cursor = QCursor(pix, 2, 2) + return _scratch_pen_cursor + return _TOOL_CURSORS.get(mode, Qt.CursorShape.ArrowCursor) + + # Do not apply more than this many notch-equivalents in a single event (huge flings). _WHEEL_MAX_NOTCHES = 4.0 @@ -171,11 +192,11 @@ def _raise_floating_widgets(self) -> None: self._floating_toolbar.raise_() def set_tool_mode(self, mode: ToolMode) -> None: - self.setCursor(_TOOL_CURSORS.get(mode, Qt.CursorShape.ArrowCursor)) + self.setCursor(_cursor_for_tool(mode)) self.overlay.set_tool_mode(mode) def reset_tool_cursor(self) -> None: - self.setCursor(_TOOL_CURSORS.get(self.state.active_tool, Qt.CursorShape.ArrowCursor)) + self.setCursor(_cursor_for_tool(self.state.active_tool)) def set_controller(self, controller: "AppController") -> None: self._controller = controller @@ -497,6 +518,12 @@ def contextMenuEvent(self, event) -> None: event.ignore() return + # While a heal tool is live, the menu serves that tool — the general + # settings menu would be noise mid-retouch. + if self.state.active_tool in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK): + self._exec_retouch_menu(event) + return + menu = QMenu(self) act_wb = menu.addAction("Pick WB Shift+W") act_wb.triggered.connect(lambda: self._controller.set_active_tool(ToolMode.WB_PICK)) # type: ignore[union-attr] @@ -514,3 +541,37 @@ def contextMenuEvent(self, event) -> None: act_reset = menu.addAction("Reset View") act_reset.triggered.connect(self.fit_to_window) menu.exec(event.globalPos()) + + def _exec_retouch_menu(self, event) -> None: + """Context menu while the heal or scratch tool is active.""" + controller = self._controller + assert controller is not None + conf = self.state.config.retouch + num_heals = len(conf.manual_dust_spots) + len(conf.manual_heal_strokes) + pos = QPointF(event.pos()) + + menu = QMenu(self) + + if self.state.active_tool == ToolMode.SCRATCH_PICK: + act_confirm = menu.addAction("Confirm Scratch Enter") + act_confirm.triggered.connect(self.overlay.confirm_scratch) + act_confirm.setEnabled(self.overlay.has_scratch_points()) + act_point = menu.addAction("Undo Last Point Backspace") + act_point.triggered.connect(self.overlay.undo_last_scratch_point) + act_point.setEnabled(self.overlay.has_scratch_points()) + menu.addSeparator() + + hit = self.overlay.heal_hit_test(pos) + if hit is not None: + kind, index = hit + act_delete = menu.addAction("Delete This Heal") + act_delete.triggered.connect(lambda _=False, k=kind, i=index: controller.delete_heal(k, i)) + menu.addSeparator() + + act_undo = menu.addAction("Undo Last Heal Ctrl+Z") + act_undo.triggered.connect(controller.undo_last_retouch) + act_undo.setEnabled(num_heals > 0) + act_clear = menu.addAction("Clear All Heals…") + act_clear.triggered.connect(controller.clear_retouch) + act_clear.setEnabled(num_heals > 0) + menu.exec(event.globalPos()) diff --git a/negpy/desktop/view/confirm.py b/negpy/desktop/view/confirm.py index e4099889..092f5a45 100644 --- a/negpy/desktop/view/confirm.py +++ b/negpy/desktop/view/confirm.py @@ -27,3 +27,19 @@ def confirm_unload(parent, *, clear_all: bool = False, count: int = 1) -> bool: box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) box.setDefaultButton(QMessageBox.StandardButton.Yes) return box.exec() == QMessageBox.StandardButton.Yes + + +def confirm_clear_heals(parent, count: int) -> bool: + """Ask before wiping every manual heal/scratch on the frame. + + Unlike single-heal undo this is not step-recoverable, so gate it like the + session Clear All. Enter confirms (default button); Esc cancels. + """ + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Question) + box.setWindowTitle("Clear All Heals") + box.setText(f"Remove all {count} manual heal{'s' if count != 1 else ''} from this image?") + box.setInformativeText("Every heal and scratch repair placed on this frame will be removed.") + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) + box.setDefaultButton(QMessageBox.StandardButton.Yes) + return box.exec() == QMessageBox.StandardButton.Yes diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 35785f39..24871349 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -2,9 +2,19 @@ from PyQt6.QtGui import QKeySequence, QShortcut +from negpy.desktop.session import ToolMode from negpy.desktop.view.shortcut_registry import REGISTRY, load_bindings, save_bindings, set_current_bindings +def _context_undo(controller) -> None: + """Ctrl+Z targets what the user is working on: while a heal/scratch tool is + active it removes the last placed heal; otherwise it's the normal edit undo.""" + if controller.session.state.active_tool in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK): + controller.undo_last_retouch() + else: + controller.session.undo() + + def _show_shortcuts(window) -> None: from negpy.desktop.view.widgets.shortcuts_overlay import ShortcutsOverlay @@ -68,7 +78,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "copy": controller.session.copy_settings, "copy_with_bounds": controller.session.copy_settings_with_bounds, "paste": controller.session.paste_settings, - "undo": controller.session.undo, + "undo": lambda: _context_undo(controller), "redo": controller.session.redo, "show_shortcuts": lambda: _show_shortcuts(self.window), } diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index a0342f7c..ac03787f 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -532,7 +532,8 @@ def apply_shortcut_tooltips(self) -> None: ret.pick_dust_btn.setToolTip( tooltip_with_shortcut( - "Toggle manual heal brush — click dust spots in the preview to paint them out one at a time", + "Toggle manual heal brush — click dust spots in the preview to paint them out one at a time. " + "Right-click an existing heal overlay to delete it", "pick_dust", ) ) @@ -721,7 +722,10 @@ def _sync_modified_dots(self) -> None: ) ret = cfg.retouch - retouch_count = int(ret.dust_remove) + len(ret.manual_dust_spots) + # Heal-tool clicks and scratch polylines both commit into manual_heal_strokes + # (manual_dust_spots is the legacy list), so count them or the Finish tab's + # edited dot never lights for healed images. + retouch_count = int(ret.dust_remove) + len(ret.manual_dust_spots) + len(ret.manual_heal_strokes) _fin = _DEFAULT_FINISH fin = cfg.finish @@ -750,3 +754,8 @@ def _sync_tool_buttons(self) -> None: self.geometry_sidebar.sync_ui() self.local_sidebar.sync_ui() self.process_sidebar.sync_ui() + # Retouch hosts two tool toggles (heal + scratch); without this sync, + # activating one left the other highlighted as if both were live. The + # colour sidebar's WB picker had the same latent stale-check bug. + self.retouch_sidebar.sync_ui() + self.colour_sidebar.sync_ui() diff --git a/negpy/desktop/view/sidebar/retouch.py b/negpy/desktop/view/sidebar/retouch.py index 24e00a3c..c59e916b 100644 --- a/negpy/desktop/view/sidebar/retouch.py +++ b/negpy/desktop/view/sidebar/retouch.py @@ -36,12 +36,16 @@ def _init_ui(self) -> None: self.pick_dust_btn = self._tool_toggle( "fa5s.eye-dropper", "Heal Tool", - tooltip_with_shortcut("Toggle heal tool — click a dust spot to heal it", "pick_dust"), + tooltip_with_shortcut( + "Toggle heal tool — click a dust spot to heal it. Right-click an existing heal overlay to delete it", + "pick_dust", + ), ) self.pick_scratch_btn = self._tool_toggle( "fa5s.pen-nib", "Scratch Tool", - "Heal a scratch or hair: click points along it, double-click or Enter to finish, Esc cancels", + "Heal a scratch or hair: click points along it, double-click or Enter to finish, Esc cancels. " + "Backspace deletes the last entered point; right-click an existing scratch overlay to delete it", ) buttons_row.addWidget(self.auto_dust_btn) diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index f66829d6..2e02d2a7 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -36,6 +36,8 @@ class RightPanel(QWidget): def __init__(self, controller: AppController): super().__init__() self.controller = controller + # Heal/scratch tool suspended by leaving the Retouch tab; restored on return. + self._suspended_retouch_tool = None self._init_ui() self._connect_signals() @@ -265,6 +267,25 @@ def _switch_tab(self, index: int) -> None: btn.setChecked(i == index) self._refresh_tab_icons() + # The heal/scratch tools live on the tab hosting the Retouch section; + # navigating to another tab suspends the active one so clicks on the + # canvas don't keep placing heals with their controls out of sight. + # Returning to the tab restores the suspended tool (and its overlay) — + # unless nothing was active when the user left, or another tool has + # been picked up in the meantime. + from negpy.desktop.session import ToolMode + + state = self.controller.session.state + retouch_tab = self._section_tab_index.get("retouch_section") + if index != retouch_tab: + if state.active_tool in (ToolMode.DUST_PICK, ToolMode.SCRATCH_PICK): + self._suspended_retouch_tool = state.active_tool + self.controller.cancel_active_tool() + else: + if self._suspended_retouch_tool is not None and state.active_tool == ToolMode.NONE: + self.controller.set_active_tool(self._suspended_retouch_tool) + self._suspended_retouch_tool = None + # Trigger device detection + gating refresh when the Scan tab is selected — it now # hosts both the SANE scanner and the RGB-Scan capture as collapsible sections. if index == self._scan_index: