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
16 changes: 16 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from negpy.features.exposure.models import ExposureConfig
from negpy.features.finish.models import FinishConfig
from negpy.features.geometry.logic import apply_fine_rotation, detect_closest_aspect_ratio
from negpy.features.geometry.models import FINE_ROTATION_LIMIT
from negpy.features.lab.models import LabConfig
from negpy.features.local.models import LocalAdjustmentsConfig
from negpy.features.process.models import ProcessMode, invalidate_local_bounds
Expand Down Expand Up @@ -816,6 +817,21 @@ def handle_crop_rotation_changed(self, angle: float, persist: bool) -> None:
else:
self._render_debounce.start()

def handle_straighten_completed(self, delta_deg: float) -> None:
"""Applies the straighten tool's measured correction on top of the current
fine rotation and closes the tool (one-shot, like a Lightroom straighten
line). ``delta_deg`` is stored-convention (positive = CCW on screen) and
display-space, so it composes additively under flips/90° turns."""
if self.state.active_tool != ToolMode.STRAIGHTEN:
return
current = self.state.config.geometry.fine_rotation
new_angle = float(np.clip(current + delta_deg, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT))
new_geo = replace(self.state.config.geometry, fine_rotation=new_angle)
self.session.update_config(replace(self.state.config, geometry=new_geo), persist=True)
self.rotation_guide_requested.emit()
self.set_active_tool(ToolMode.NONE)
self.request_render()

def confirm_manual_crop(self) -> None:
"""Close the crop tool (committing the current rect) — invoked by a double-click
inside the crop box so the user needn't return to the Crop button."""
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class ToolMode(Enum):
SCRATCH_PICK = auto()
LOCAL_DRAW = auto()
ANALYSIS_DRAW = auto()
STRAIGHTEN = auto()


@dataclass
Expand Down
82 changes: 80 additions & 2 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 All @@ -11,7 +12,7 @@
from negpy.desktop.session import AppState, ToolMode
from negpy.desktop.view.canvas.crop_guides import CropGuide, guide_shapes
from negpy.desktop.view.styles.theme import THEME
from negpy.features.geometry.logic import rotation_drag_angle, translate_manual_crop_rect
from negpy.features.geometry.logic import rotation_drag_angle, straighten_delta_degrees, translate_manual_crop_rect
from negpy.features.local.logic import _rasterise_mask
from negpy.kernel.system.config import APP_CONFIG

Expand Down Expand Up @@ -64,6 +65,7 @@ class CanvasOverlay(QWidget):
lasso_completed = pyqtSignal(list)
scratch_completed = pyqtSignal(list)
local_mask_selected = pyqtSignal(int)
straighten_completed = pyqtSignal(float) # fine-rotation delta, stored convention (CCW+)

def __init__(self, state: AppState, parent=None):
super().__init__(parent)
Expand Down Expand Up @@ -110,6 +112,10 @@ def __init__(self, state: AppState, parent=None):
self._local_mask_screen_polys: List[List[QPointF]] = []
self._mask_img_cache: Dict[tuple, QImage] = {}

# Straighten tool: reference-line drag (press -> drag -> release applies).
self._straighten_p1: Optional[QPointF] = None
self._straighten_p2: Optional[QPointF] = None

self.zoom_level: float = 1.0
self.pan_x: float = 0.0
self.pan_y: float = 0.0
Expand Down Expand Up @@ -192,6 +198,9 @@ def set_tool_mode(self, mode: ToolMode) -> None:
self._lasso_drawing = False
if mode != ToolMode.SCRATCH_PICK:
self._scratch_pts = []
if mode != ToolMode.STRAIGHTEN:
self._straighten_p1 = None
self._straighten_p2 = None
self.update()

def _end_crop_drag(self) -> None:
Expand Down Expand Up @@ -220,6 +229,10 @@ def _cancel_lasso(self) -> None:
elif self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts:
self._scratch_pts = []
self.update()
elif self._tool_mode == ToolMode.STRAIGHTEN and self._straighten_p1 is not None:
self._straighten_p1 = None
self._straighten_p2 = None
self.update()

def update_buffer(
self,
Expand Down Expand Up @@ -354,6 +367,8 @@ def _draw_ui(self, painter: QPainter) -> None:
self._draw_placed_heals(painter)
if self._tool_mode == ToolMode.SCRATCH_PICK:
self._draw_scratch_in_progress(painter)
if self._tool_mode == ToolMode.STRAIGHTEN:
self._draw_straighten_line(painter)

if self._rotation_grid_visible:
self._draw_rotation_grid(painter, visible_rect)
Expand Down Expand Up @@ -442,6 +457,36 @@ def _draw_scratch_in_progress(self, painter: QPainter) -> None:
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(self._scratch_pts[0], 3.0, 3.0)

def _draw_straighten_line(self, painter: QPainter) -> None:
"""Reference line being dragged with the straighten tool, plus a badge
previewing the correction (display convention: positive = clockwise)."""
if self._straighten_p1 is None or self._straighten_p2 is None:
return
p1, p2 = self._straighten_p1, self._straighten_p2

pen = QPen(Qt.GlobalColor.white, 1.5, Qt.PenStyle.SolidLine)
pen.setCosmetic(True)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawLine(p1, p2)
painter.setBrush(QColor(255, 255, 255, 200))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(p1, 3.0, 3.0)
painter.drawEllipse(p2, 3.0, 3.0)

dx, dy = p2.x() - p1.x(), p2.y() - p1.y()
if math.hypot(dx, dy) < 8.0:
return
delta = straighten_delta_degrees(dx, dy)
vertical = abs(abs(math.degrees(math.atan2(dy, dx))) - 90.0) < 45.0
label = f"{'Plumb' if vertical else 'Level'} {-delta:+.2f}°"
mid = QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0)
badge = QRectF(mid.x() - 52, mid.y() - 26, 104, 22)
painter.setBrush(QColor(0, 0, 0, 170))
painter.drawRoundedRect(badge, 4, 4)
painter.setPen(QColor(THEME.accent_primary))
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, label)

def _draw_placed_heals(self, painter: QPainter) -> None:
"""Thin outlines of committed heals (strokes + legacy spots) while a retouch tool is active."""
conf = self.state.config.retouch
Expand Down Expand Up @@ -764,7 +809,9 @@ def _draw_rotation_handles(self, painter: QPainter, corners: Dict[str, QPointF])
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(badge, 4, 4)
painter.setPen(QColor(THEME.accent_primary))
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, f"{self._rotate_current:+.2f}°")
# Badge shows the display convention (positive = clockwise on screen),
# matching the Fine Rotation slider; _rotate_current is stored-convention.
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, f"{-self._rotate_current:+.2f}°")

def _analysis_rect_screen(self) -> Optional[QRectF]:
"""Screen rect for the current analysis region, or None if unset."""
Expand Down Expand Up @@ -904,6 +951,16 @@ def mousePressEvent(self, event: QMouseEvent) -> None:
event.accept()
return

if self._tool_mode == ToolMode.STRAIGHTEN:
# Left-click draws the reference line; other buttons pass through.
if event.button() == Qt.MouseButton.LeftButton:
if self._view_rect.contains(event.position()):
self._straighten_p1 = event.position()
self._straighten_p2 = event.position()
self.update()
event.accept()
return

if self._tool_mode == ToolMode.ANALYSIS_DRAW:
self._start_analysis_drag(event.position())
self.update()
Expand Down Expand Up @@ -1059,6 +1116,15 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:
event.accept()
return

if self._tool_mode == ToolMode.STRAIGHTEN and self._straighten_p1 is not None:
self._straighten_p2 = QPointF(
float(np.clip(event.position().x(), self._view_rect.left(), self._view_rect.right())),
float(np.clip(event.position().y(), self._view_rect.top(), self._view_rect.bottom())),
)
self.update()
event.accept()
return

if self._crop_drag_mode == "draw" and self._crop_draw_p1 is not None:
mx = np.clip(event.position().x(), self._view_rect.left(), self._view_rect.right())
my = np.clip(event.position().y(), self._view_rect.top(), self._view_rect.bottom())
Expand Down Expand Up @@ -1181,6 +1247,18 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
event.accept()
return

if self._tool_mode == ToolMode.STRAIGHTEN and self._straighten_p1 is not None:
p1, p2 = self._straighten_p1, self._straighten_p2 or self._straighten_p1
self._straighten_p1 = None
self._straighten_p2 = None
dx, dy = p2.x() - p1.x(), p2.y() - p1.y()
# Ignore accidental clicks — a reference line needs some length.
if math.hypot(dx, dy) >= 8.0:
self.straighten_completed.emit(straighten_delta_degrees(dx, dy))
self.update()
event.accept()
return

if self._crop_drag_mode == "rotate":
if self._rotate_current is not None:
self.crop_rotation_changed.emit(self._rotate_current, True)
Expand Down
3 changes: 3 additions & 0 deletions negpy/desktop/view/canvas/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def clamp_canvas_zoom_level(zoom: float) -> float:
ToolMode.DUST_PICK: Qt.CursorShape.BlankCursor,
ToolMode.LOCAL_DRAW: Qt.CursorShape.CrossCursor,
ToolMode.ANALYSIS_DRAW: Qt.CursorShape.CrossCursor,
ToolMode.STRAIGHTEN: Qt.CursorShape.CrossCursor,
}
# Do not apply more than this many notch-equivalents in a single event (huge flings).
_WHEEL_MAX_NOTCHES = 4.0
Expand Down Expand Up @@ -85,6 +86,7 @@ class ImageCanvas(QWidget):
cursor_left_canvas = pyqtSignal()
lasso_completed = pyqtSignal(list)
scratch_completed = pyqtSignal(list)
straighten_completed = pyqtSignal(float)
local_mask_selected = pyqtSignal(int)

def __init__(self, state: AppState, parent=None):
Expand Down Expand Up @@ -135,6 +137,7 @@ def __init__(self, state: AppState, parent=None):
self.overlay.cursor_left.connect(self.cursor_left_canvas.emit)
self.overlay.lasso_completed.connect(self.lasso_completed.emit)
self.overlay.scratch_completed.connect(self.scratch_completed.emit)
self.overlay.straighten_completed.connect(self.straighten_completed.emit)
self.overlay.local_mask_selected.connect(self.local_mask_selected.emit)

self.hud = CanvasHud(self)
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/keyboard_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]:
"lock_bounds_toggle": lambda: controls.process_sidebar.lock_bounds_btn.toggle(),
"pick_wb": lambda: controls.colour_sidebar.pick_wb_btn.toggle(),
"manual_crop": lambda: controls.geometry_sidebar.manual_crop_btn.toggle(),
"straighten": lambda: controls.geometry_sidebar.straighten_btn.toggle(),
"crop_guide_next": lambda: controls.geometry_sidebar.cycle_guide(),
"crop_guide_orient": controller.cycle_crop_guide_orientation,
"auto_crop": lambda: controls.geometry_sidebar.reset_crop_btn.toggle(),
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ def _connect_signals(self) -> None:
self.canvas.analysis_confirmed.connect(self.controller.confirm_analysis_region)
self.canvas.lasso_completed.connect(self.controller.handle_lasso_completed)
self.canvas.scratch_completed.connect(self.controller.handle_heal_stroke_completed)
self.canvas.straighten_completed.connect(self.controller.handle_straighten_completed)
self.canvas.local_mask_selected.connect(self.controller.select_local_mask)

self.controller.export_progress.connect(self._on_export_progress)
Expand Down
5 changes: 3 additions & 2 deletions negpy/desktop/view/shortcut_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ class ShortcutEntry:
"flip_v": ShortcutEntry("V", "Flip vertical", "Geometry"),
"offset_dec": ShortcutEntry("Z", "Crop offset down", "Geometry"),
"offset_inc": ShortcutEntry("X", "Crop offset up", "Geometry"),
"fine_rot_dec": ShortcutEntry("Alt+Shift+R", "Fine rotation down", "Geometry"),
"fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation up", "Geometry"),
"fine_rot_dec": ShortcutEntry("Alt+Shift+R", "Fine rotation counter-clockwise", "Geometry"),
"fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation clockwise", "Geometry"),
"straighten": ShortcutEntry("L", "Toggle straighten line tool", "Geometry"),
"pick_wb": ShortcutEntry("Shift+W", "Toggle WB picker", "Tools"),
"manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"),
"crop_guide_next": ShortcutEntry("O", "Next crop guide overlay", "Geometry"),
Expand Down
10 changes: 9 additions & 1 deletion negpy/desktop/view/sidebar/controls_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,14 @@ def apply_shortcut_tooltips(self) -> None:
"manual_crop",
)
)
geo.straighten_btn.setToolTip(
tooltip_with_shortcut(
"Straighten with a reference line — draw along the horizon or a vertical edge "
"(a building, a door frame) and the image rotates to make it level or plumb. "
"Applies once per line; Esc cancels an in-progress line",
"straighten",
)
)
geo.offset_slider.setToolTip(
tooltip_with_shortcut(
"Insets the auto-crop border from the detected film edge. Positive = trim more; negative = bleed outside",
Expand All @@ -439,7 +447,7 @@ def apply_shortcut_tooltips(self) -> None:
)
geo.fine_rot_slider.setToolTip(
tooltip_with_shortcut(
"Sub-degree rotation correction for tilted scans, applied after auto-crop",
"Sub-degree rotation correction for tilted scans: positive turns clockwise, negative counter-clockwise",
["fine_rot_inc", "fine_rot_dec"],
)
)
Expand Down
40 changes: 34 additions & 6 deletions negpy/desktop/view/sidebar/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,31 @@ def _init_ui(self) -> None:

self.layout.addWidget(section_subheader("ALIGNMENT"))

self.fine_rot_slider = CompactSlider("Fine Rotation", -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT, conf.fine_rotation, unit="°")
align_row = QHBoxLayout()
self.straighten_btn = self._tool_toggle(
"fa5s.ruler",
"",
tooltip_with_shortcut(
"Straighten with a reference line — draw along the horizon or a vertical edge "
"(a building, a door frame) and the image rotates to make it level or plumb. "
"Applies once per line; Esc cancels an in-progress line",
"straighten",
),
)
self.straighten_btn.setFixedWidth(36)

# Slider shows the photographer's convention — positive = clockwise on screen.
# Internally geometry.fine_rotation keeps the cv2/warp convention (positive =
# counter-clockwise, flip-independent because flips apply before fine rotation),
# so saved edits keep their meaning: display = -stored at this boundary.
self.fine_rot_slider = CompactSlider("Fine Rotation", -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT, -conf.fine_rotation, unit="°")
self.fine_rot_slider.setToolTip(
"Fine-tunes rotation to correct tilt (degrees). For quick rotation, drag the round handles outside the crop box in the Crop tool."
"Fine-tunes rotation to correct tilt (degrees): positive turns clockwise, negative counter-clockwise. "
"For quick rotation, drag the round handles outside the crop box in the Crop tool."
)
self.layout.addWidget(self.fine_rot_slider)
align_row.addWidget(self.straighten_btn, 0)
align_row.addWidget(self.fine_rot_slider, 1)
self.layout.addLayout(align_row)

def cycle_guide(self) -> None:
self.guide_combo.setCurrentIndex((self.guide_combo.currentIndex() + 1) % self.guide_combo.count())
Expand All @@ -159,12 +179,15 @@ def _connect_signals(self) -> None:
)
self.offset_slider.valueCommitted.connect(self._on_offset_committed)

self.straighten_btn.toggled.connect(self._on_straighten_toggled)

# Display convention is CW-positive; negate crossing into the stored convention.
self.fine_rot_slider.valueChanged.connect(
lambda v: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, fine_rotation=v)
lambda v: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, fine_rotation=-v)
)
self.fine_rot_slider.valueChanged.connect(lambda _v: self.controller.show_rotation_guide())
self.fine_rot_slider.valueCommitted.connect(
lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=v)
lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=-v)
)

def _on_ratio_changed(self, ratio: str) -> None:
Expand Down Expand Up @@ -197,6 +220,9 @@ def _on_offset_committed(self, v: float) -> None:
def _on_manual_crop_toggled(self, checked: bool) -> None:
self.controller.set_active_tool(ToolMode.CROP_MANUAL if checked else ToolMode.NONE)

def _on_straighten_toggled(self, checked: bool) -> None:
self.controller.set_active_tool(ToolMode.STRAIGHTEN if checked else ToolMode.NONE)

def _on_auto_crop_toggled(self, checked: bool) -> None:
if checked:
self.controller.apply_auto_crop()
Expand All @@ -214,9 +240,10 @@ def sync_ui(self) -> None:
self.mode_combo.setCurrentIndex(self.mode_combo.findData(conf.autocrop_mode))

self.offset_slider.setValue(float(conf.autocrop_offset))
self.fine_rot_slider.setValue(conf.fine_rotation)
self.fine_rot_slider.setValue(-conf.fine_rotation)

self.manual_crop_btn.setChecked(self.state.active_tool == ToolMode.CROP_MANUAL)
self.straighten_btn.setChecked(self.state.active_tool == ToolMode.STRAIGHTEN)
self.reset_crop_btn.setChecked(conf.auto_crop_enabled)
self.manual_crop_btn.set_crop_active(conf.manual_crop_rect is not None)
self.reset_crop_btn.set_crop_active(conf.auto_crop_enabled)
Expand All @@ -232,4 +259,5 @@ def block_signals(self, blocked: bool) -> None:
self.offset_slider.blockSignals(blocked)
self.fine_rot_slider.blockSignals(blocked)
self.manual_crop_btn.blockSignals(blocked)
self.straighten_btn.blockSignals(blocked)
self.reset_crop_btn.blockSignals(blocked)
13 changes: 7 additions & 6 deletions negpy/desktop/view/widgets/shortcuts_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,18 @@ def _init_ui(self) -> None:

actions = QHBoxLayout()
customize_btn = QPushButton("Customize")
customize_btn.setStyleSheet(
f"font-size: 12px; padding: 6px 20px; background: transparent; color: {THEME.text_primary}; border: 1px solid {THEME.border_primary}; border-radius: 3px;"
)
# Padding-only override: a widget-level `background:` would beat the app
# stylesheet in every state and kill the global hover/pressed feedback.
customize_btn.setStyleSheet("font-size: 12px; padding: 6px 20px;")
customize_btn.clicked.connect(self._customize)
actions.addWidget(customize_btn)
actions.addStretch()

close_btn = QPushButton("Close")
close_btn.setStyleSheet(
f"font-size: 12px; padding: 6px 20px; background: {THEME.accent_primary}; color: white; border: none; border-radius: 3px;"
)
close_btn.setStyleSheet("font-size: 12px; padding: 6px 20px;")
# Primary action styling from the app stylesheet (accent fill with its own
# hover/pressed shades), same as the Export panel's call-to-action.
close_btn.setProperty("primary", True)
close_btn.clicked.connect(self.accept)
actions.addWidget(close_btn)
root.addLayout(actions)
Expand Down
Loading
Loading