Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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).
Expand Down
103 changes: 95 additions & 8 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
import sys
from typing import Dict, List, Optional, Tuple

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = []
Expand Down
67 changes: 64 additions & 3 deletions negpy/desktop/view/canvas/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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())
16 changes: 16 additions & 0 deletions negpy/desktop/view/confirm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion negpy/desktop/view/keyboard_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
}
Expand Down
13 changes: 11 additions & 2 deletions negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
8 changes: 6 additions & 2 deletions negpy/desktop/view/sidebar/retouch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading