From 8993e8a645319cc067967915f2555ee591aa468e Mon Sep 17 00:00:00 2001 From: Li <175231748+linkmodo@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:36:14 -0400 Subject: [PATCH 1/2] fix: heal/scratch tools are mutually exclusive; suspend across tab switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activating the Heal tool while the Scratch tool was on (or vice versa) left both buttons highlighted: the tool-sync pass never refreshed the retouch sidebar, so the stale button kept its checked state even though only one tool was live. Sync the retouch (and colour, same latent WB-picker bug) sidebars on tool change. Navigating away from the tab hosting the Retouch section now suspends an active heal/scratch tool — canvas clicks stop placing heals while the tool's controls are out of sight — and returning to the tab restores the suspended tool and its overlay. Nothing is restored if no tool was active when leaving, or if another tool was picked up in the meantime. --- negpy/desktop/controller.py | 28 +++++++ negpy/desktop/view/canvas/overlay.py | 77 ++++++++++++++++++++ negpy/desktop/view/canvas/widget.py | 67 ++++++++++++++++- negpy/desktop/view/confirm.py | 16 ++++ negpy/desktop/view/keyboard_shortcuts.py | 12 ++- negpy/desktop/view/sidebar/controls_panel.py | 5 ++ negpy/desktop/view/sidebar/right_panel.py | 21 ++++++ 7 files changed, 222 insertions(+), 4 deletions(-) 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..5a958ec6 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) @@ -892,6 +915,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 +1184,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..daaff2b7 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 + + pix = qta.icon("fa5s.pen-nib", color="white").pixmap(22, 22) + # The nib tip sits at the glyph's bottom-left; hotspot there so + # points land where the pen visually touches. + _scratch_pen_cursor = QCursor(pix, 3, 19) + 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..a541aa16 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -750,3 +750,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/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: From 3841122a5a61ffd77c040ce2df26e46e26056d3d Mon Sep 17 00:00:00 2001 From: Li <175231748+linkmodo@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:36:14 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20heal/scratch=20tool=20interaction?= =?UTF-8?q?=20=E2=80=94=20pen=20cursor,=20tool=20menus,=20point=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working the heal/scratch tools previously meant round-tripping to the sidebar for every correction. Make the tools self-contained on the canvas: - The scratch tool shows a pen-nib cursor (rotated to read like a pointer, nib tip up-left, hotspot on the tip) so click-points line-drawing mode is visible at a glance. - Right-click opens a tool menu instead of the general settings menu. Heal: Undo Last Heal, Clear All Heals. Scratch adds Confirm Scratch (same as double-click/Enter) and Undo Last Point. Right-clicking a placed heal offers Delete This Heal, so a bad patch can be picked off directly instead of unwinding newer heals step by step (hit-test over the stroke band / spot radius the overlay draws). - Backspace steps back one click-point of the in-progress scratch polyline. - Ctrl+Z while a heal tool is active undoes the last placed heal; outside the tools it stays the normal edit undo. - Clear All Heals (sidebar button and menus) asks for confirmation — unlike single-heal undo it is not step-recoverable. - Tool placements are now left-click only, so right-click no longer drops a stray scratch point or heal on its way to the context menu. - Manual heals/scratches now count toward the Retouch section's modified badge, so the Finish tab's edited dot lights for healed images (manual_heal_strokes was missing from the count — both tools commit there; manual_dust_spots is the legacy list). Also fix the placed-heal overlay drawing offset from where the user actually clicked/drew: the raw->screen inverse uv-grid lookup snapped to the centres of a ~100-bucket decimated grid (up to half a bucket off — several to tens of pixels depending on preview size, magnified by zoom), while the heal itself used the exact forward lookup. A full-resolution refinement pass over the winning bucket's neighbourhood pins the outline to sub-pixel of the healed spot at any zoom, HQ or not. --- negpy/desktop/view/canvas/overlay.py | 26 ++++++++++++++------ negpy/desktop/view/canvas/widget.py | 8 +++--- negpy/desktop/view/sidebar/controls_panel.py | 8 ++++-- negpy/desktop/view/sidebar/retouch.py | 8 ++++-- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 5a958ec6..f693db18 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -510,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) @@ -524,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(), diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index daaff2b7..2561a1a0 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -50,10 +50,10 @@ def _cursor_for_tool(mode: ToolMode) -> QCursor | Qt.CursorShape: if _scratch_pen_cursor is None: import qtawesome as qta - pix = qta.icon("fa5s.pen-nib", color="white").pixmap(22, 22) - # The nib tip sits at the glyph's bottom-left; hotspot there so - # points land where the pen visually touches. - _scratch_pen_cursor = QCursor(pix, 3, 19) + # 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) diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index a541aa16..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 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)