@@ -1490,44 +1490,38 @@ def _build(self):
14901490 toolbar .addStretch ()
14911491 left_lay .addLayout (toolbar )
14921492
1493- selection_bar = QHBoxLayout ()
1494- selection_bar .setSpacing (4 )
1495- selection_lbl = QLabel ("Selection" )
1496- selection_lbl .setFont (QFont ("Helvetica" , 8 , QFont .Bold ))
1497- selection_bar .addWidget (selection_lbl )
1498- self ._selection_group = QButtonGroup (self )
1499- self ._selection_group .setExclusive (True )
1493+ drawing_bar = QHBoxLayout ()
1494+ drawing_bar .setSpacing (4 )
1495+ drawing_lbl = QLabel ("Draw" )
1496+ drawing_lbl .setFont (QFont ("Helvetica" , 8 , QFont .Bold ))
1497+ drawing_bar .addWidget (drawing_lbl )
1498+ self ._drawing_group = QButtonGroup (self )
1499+ self ._drawing_group .setExclusive (True )
1500+ # Keep old name for backward-compat references
1501+ self ._selection_group = self ._drawing_group
15001502 for key , label , tip in (
1501- ("none" , "Pointer" , "Pointer / no selection tool" ),
1502- ("rectangle" , "Rect." , "Rectangular area selection" ),
1503- ("ellipse" , "Ellipse" , "Elliptical area selection" ),
1504- ("polygon" , "Polygon" , "Polygon area selection; double-click to finish" ),
1505- ("line" , "Line" , "Line selection for display/status only" ),
1503+ ("pan" , "✋ Pan" , "Pan (drag to scroll)" ),
1504+ ("rectangle" , "▭ Rect" , "Rectangle ROI [R]" ),
1505+ ("ellipse" , "◯ Ellipse" , "Ellipse ROI [E]" ),
1506+ ("polygon" , "⬠ Poly" , "Polygon ROI — click vertices, double-click to close [P]" ),
1507+ ("freehand" , "〰 Free" , "Freehand ROI — drag to draw [F]" ),
1508+ ("line" , "— Line" , "Line ROI [L]" ),
1509+ ("point" , "• Point" , "Point ROI [T]" ),
15061510 ):
15071511 btn = QPushButton (label )
15081512 btn .setCheckable (True )
15091513 btn .setFixedHeight (24 )
15101514 btn .setMinimumWidth (44 )
15111515 btn .setFont (QFont ("Helvetica" , 8 ))
15121516 btn .setToolTip (tip )
1513- self ._selection_group .addButton (btn )
1514- btn .setProperty ("selection_tool " , key )
1515- if key == "none " :
1517+ self ._drawing_group .addButton (btn )
1518+ btn .setProperty ("drawing_tool " , key )
1519+ if key == "pan " :
15161520 btn .setChecked (True )
1517- selection_bar .addWidget (btn )
1518- self ._selection_group .buttonClicked .connect (self ._on_selection_tool_clicked )
1519- # Phase 4b: canvas drawing tools not yet implemented
1520- for _btn in self ._selection_group .buttons ():
1521- if (_btn .property ("selection_tool" ) or "none" ) != "none" :
1522- _btn .setEnabled (False )
1523- _btn .setToolTip (_btn .toolTip () + " (available in Phase 4b)" )
1524- clear_selection_btn = QPushButton ("Clear" )
1525- clear_selection_btn .setFont (QFont ("Helvetica" , 8 ))
1526- clear_selection_btn .setFixedHeight (24 )
1527- clear_selection_btn .clicked .connect (self ._on_clear_roi )
1528- selection_bar .addWidget (clear_selection_btn )
1529- selection_bar .addStretch ()
1530- left_lay .addLayout (selection_bar )
1521+ drawing_bar .addWidget (btn )
1522+ self ._drawing_group .buttonClicked .connect (self ._on_drawing_tool_clicked )
1523+ drawing_bar .addStretch ()
1524+ left_lay .addLayout (drawing_bar )
15311525
15321526 # Rulers scroll together with the image (placed in the same scroll
15331527 # viewport via a small grid container).
@@ -1871,6 +1865,10 @@ def _spin_row(label: str, mn: float, mx: float, init: float,
18711865 self ._zoom_lbl .pixmap_resized .connect (self ._on_pixmap_resized )
18721866 self ._zoom_lbl .context_menu_requested .connect (self ._on_image_context_menu )
18731867 self ._zoom_lbl .pixel_hovered .connect (self ._on_pixel_hovered )
1868+ self ._zoom_lbl .roi_created .connect (self ._on_canvas_roi_created )
1869+ self ._zoom_lbl .roi_move_requested .connect (self ._on_canvas_roi_move )
1870+ self ._zoom_lbl .tool_changed .connect (self ._on_canvas_tool_changed )
1871+ self ._zoom_lbl .roi_context_menu_requested .connect (self ._on_roi_canvas_context_menu )
18741872 self ._line_profile_panel .export_csv_clicked .connect (self ._on_export_line_profile_csv )
18751873
18761874 self ._status_lbl = QLabel ("" )
@@ -1943,13 +1941,62 @@ def _spin_row(label: str, mn: float, mx: float, init: float,
19431941 # ── Navigation ─────────────────────────────────────────────────────────────
19441942 def keyPressEvent (self , event ):
19451943 k = event .key ()
1944+
1945+ # ── drawing tool shortcuts ────────────────────────────────────────────
1946+ _tool_keys = {
1947+ Qt .Key_R : "rectangle" ,
1948+ Qt .Key_E : "ellipse" ,
1949+ Qt .Key_P : "polygon" ,
1950+ Qt .Key_F : "freehand" ,
1951+ Qt .Key_L : "line" ,
1952+ Qt .Key_T : "point" ,
1953+ }
1954+ if k in _tool_keys and not event .modifiers ():
1955+ self ._set_drawing_tool (_tool_keys [k ])
1956+ event .accept ()
1957+ return
1958+
1959+ # ── Escape: cancel drawing, or close dialog if idle ───────────────────
1960+ if k == Qt .Key_Escape :
1961+ canvas_tool = getattr (self ._zoom_lbl , "tool" , lambda : "pan" )()
1962+ canvas_drawing = (canvas_tool != "pan" or
1963+ self ._zoom_lbl ._draw_pts or
1964+ self ._zoom_lbl ._draw_start is not None )
1965+ if canvas_drawing :
1966+ self ._zoom_lbl .cancel_drawing ()
1967+ self ._set_drawing_tool ("pan" )
1968+ event .accept ()
1969+ return
1970+ self .accept ()
1971+ return
1972+
1973+ # Return closes the dialog (when not drawing)
1974+ if k == Qt .Key_Return :
1975+ self .accept ()
1976+ return
1977+
1978+ # ── ROI keyboard actions ──────────────────────────────────────────────
1979+ if k == Qt .Key_Delete and not event .modifiers ():
1980+ self ._delete_active_image_roi ()
1981+ event .accept ()
1982+ return
1983+
1984+ if k == Qt .Key_I and not event .modifiers ():
1985+ self ._invert_active_image_roi ()
1986+ event .accept ()
1987+ return
1988+
1989+ if Qt .Key_1 <= k <= Qt .Key_9 and not event .modifiers ():
1990+ self ._select_nth_image_roi (k - Qt .Key_0 )
1991+ event .accept ()
1992+ return
1993+
1994+ # ── arrow keys: nudge line profile or navigate ────────────────────────
19461995 if k in (Qt .Key_Left , Qt .Key_Right , Qt .Key_Up , Qt .Key_Down ):
19471996 if self ._nudge_line_profile (k ):
19481997 event .accept ()
19491998 return
1950- if k in (Qt .Key_Escape , Qt .Key_Return ):
1951- self .accept ()
1952- elif k == Qt .Key_Left :
1999+ if k == Qt .Key_Left :
19532000 self ._go_prev ()
19542001 elif k == Qt .Key_Right :
19552002 self ._go_next ()
@@ -2311,6 +2358,8 @@ def _save_image_roi_set(self) -> None:
23112358 def _on_image_roi_set_changed (self ) -> None :
23122359 self ._zoom_lbl .set_roi_set (self ._image_roi_set )
23132360 self ._save_image_roi_set ()
2361+ if hasattr (self , "_roi_dock" ):
2362+ self ._roi_dock .refresh (self ._image_roi_set )
23142363
23152364 def _on_pixel_hovered (self , col : int , row : int , val ) -> None :
23162365 if not hasattr (self , "_coord_lbl" ):
@@ -2323,6 +2372,120 @@ def _on_pixel_hovered(self, col: int, row: int, val) -> None:
23232372 unit_str = f" { unit } " if unit else ""
23242373 self ._coord_lbl .setText (f"({ col } , { row } ): { val_disp :.4g} { unit_str } " )
23252374
2375+ # ── Canvas drawing-tool callbacks ─────────────────────────────────────────
2376+
2377+ def _on_canvas_roi_created (self , roi ) -> None :
2378+ """A drawing tool completed; add the new ROI and make it active."""
2379+ if self ._image_roi_set is None :
2380+ return
2381+ self ._image_roi_set .add (roi )
2382+ self ._image_roi_set .set_active (roi .id )
2383+ self ._on_image_roi_set_changed ()
2384+ # Canvas already switched to pan internally; sync toolbar
2385+ self ._set_drawing_tool ("pan" )
2386+
2387+ def _on_canvas_roi_move (self , roi_id : str , dx : int , dy : int ) -> None :
2388+ """Active ROI was drag-moved on the canvas; translate its geometry."""
2389+ if self ._image_roi_set is None or (dx == 0 and dy == 0 ):
2390+ return
2391+ roi = self ._image_roi_set .get (roi_id )
2392+ if roi is None :
2393+ return
2394+ from probeflow .core .roi import translate as _translate_roi
2395+ new_roi = _translate_roi (roi , float (dx ), float (dy ))
2396+ self ._image_roi_set .remove (roi_id )
2397+ self ._image_roi_set .add (new_roi )
2398+ self ._image_roi_set .set_active (new_roi .id )
2399+ self ._on_image_roi_set_changed ()
2400+
2401+ def _on_canvas_tool_changed (self , kind : str ) -> None :
2402+ """Canvas emitted tool_changed (e.g. after Escape or drawing completion)."""
2403+ for btn in self ._drawing_group .buttons ():
2404+ btn .setChecked (btn .property ("drawing_tool" ) == kind )
2405+ self ._sync_line_profile_visibility (kind )
2406+ from probeflow .gui .tool_manager import _TOOL_HINTS
2407+ if hasattr (self , "_status_lbl" ):
2408+ self ._status_lbl .setText (_TOOL_HINTS .get (kind , "" ))
2409+
2410+ def _on_roi_canvas_context_menu (self , roi_id : str , global_pos ) -> None :
2411+ """Right-click on a ROI in the canvas — show a small ROI action menu."""
2412+ from PySide6 .QtWidgets import QMenu , QInputDialog
2413+ roi_set = self ._image_roi_set
2414+ roi = roi_set .get (roi_id ) if roi_set else None
2415+ if roi is None :
2416+ return
2417+ menu = QMenu (self )
2418+ act_active = menu .addAction ("Set Active" )
2419+ act_active .triggered .connect (lambda : self ._set_active_image_roi (roi_id ))
2420+ act_rename = menu .addAction ("Rename…" )
2421+ act_rename .triggered .connect (lambda : self ._rename_image_roi (roi_id ))
2422+ act_delete = menu .addAction ("Delete" )
2423+ act_delete .triggered .connect (lambda : self ._delete_image_roi (roi_id ))
2424+ act_invert = menu .addAction ("Invert" )
2425+ act_invert .triggered .connect (lambda : self ._invert_image_roi (roi_id ))
2426+ menu .exec (global_pos )
2427+
2428+ # ── ROI helper actions ────────────────────────────────────────────────────
2429+
2430+ def _set_active_image_roi (self , roi_id : str ) -> None :
2431+ if self ._image_roi_set is None :
2432+ return
2433+ self ._image_roi_set .set_active (roi_id )
2434+ self ._on_image_roi_set_changed ()
2435+
2436+ def _rename_image_roi (self , roi_id : str ) -> None :
2437+ from PySide6 .QtWidgets import QInputDialog
2438+ roi_set = self ._image_roi_set
2439+ roi = roi_set .get (roi_id ) if roi_set else None
2440+ if roi is None :
2441+ return
2442+ new_name , ok = QInputDialog .getText (self , "Rename ROI" , "New name:" , text = roi .name )
2443+ if ok and new_name .strip ():
2444+ roi .name = new_name .strip ()
2445+ self ._on_image_roi_set_changed ()
2446+
2447+ def _delete_image_roi (self , roi_id : str ) -> None :
2448+ if self ._image_roi_set is None :
2449+ return
2450+ self ._image_roi_set .remove (roi_id )
2451+ self ._on_image_roi_set_changed ()
2452+
2453+ def _delete_active_image_roi (self ) -> None :
2454+ if self ._image_roi_set is None :
2455+ return
2456+ active_id = self ._image_roi_set .active_roi_id
2457+ if active_id is not None :
2458+ self ._delete_image_roi (active_id )
2459+
2460+ def _invert_image_roi (self , roi_id : str ) -> None :
2461+ roi_set = self ._image_roi_set
2462+ roi = roi_set .get (roi_id ) if roi_set else None
2463+ if roi is None :
2464+ return
2465+ shape = self ._current_array_shape ()
2466+ if shape is None :
2467+ return
2468+ from probeflow .core import roi as _roi_module
2469+ inverted = _roi_module .invert (roi , shape )
2470+ roi_set .add (inverted )
2471+ self ._on_image_roi_set_changed ()
2472+
2473+ def _invert_active_image_roi (self ) -> None :
2474+ if self ._image_roi_set is None :
2475+ return
2476+ active_id = self ._image_roi_set .active_roi_id
2477+ if active_id is not None :
2478+ self ._invert_image_roi (active_id )
2479+
2480+ def _select_nth_image_roi (self , n : int ) -> None :
2481+ if self ._image_roi_set is None :
2482+ return
2483+ rois = list (self ._image_roi_set .rois )
2484+ if 1 <= n <= len (rois ):
2485+ roi_id = rois [n - 1 ].id
2486+ self ._image_roi_set .set_active (roi_id )
2487+ self ._on_image_roi_set_changed ()
2488+
23262489 # ── ROI operation callbacks ───────────────────────────────────────────────
23272490
23282491 def _on_roi_bg_subtract_fit (self , roi_id : str ) -> None :
@@ -2437,20 +2600,36 @@ def _current_array_shape(self) -> tuple[int, int] | None:
24372600 return None if arr is None else arr .shape
24382601
24392602 def _set_selection_tool (self , kind : str ) -> None :
2440- kind = str (kind or "none" )
2441- for btn in self ._selection_group .buttons ():
2442- if btn .property ("selection_tool" ) == kind :
2443- btn .setChecked (True )
2444- break
2445- self ._zoom_lbl .set_selection_tool (kind )
2603+ """Compat shim: delegates to _set_drawing_tool, mapping 'none' → 'pan'."""
2604+ self ._set_drawing_tool (kind if kind and kind != "none" else "pan" )
2605+
2606+ def _set_drawing_tool (self , kind : str ) -> None :
2607+ """Activate a drawing tool both on the canvas and in the toolbar."""
2608+ kind = str (kind or "pan" )
2609+ from probeflow .gui .tool_manager import TOOLS
2610+ if kind not in TOOLS :
2611+ kind = "pan"
2612+ for btn in self ._drawing_group .buttons ():
2613+ btn .setChecked (btn .property ("drawing_tool" ) == kind )
2614+ self ._zoom_lbl .set_tool (kind )
24462615 self ._sync_line_profile_visibility (kind )
2616+ from probeflow .gui .tool_manager import _TOOL_HINTS
2617+ if hasattr (self , "_status_lbl" ):
2618+ self ._status_lbl .setText (_TOOL_HINTS .get (kind , "" ))
24472619
24482620 def _on_selection_tool_clicked (self , button ) -> None :
2621+ """Compat shim kept for any lingering external references."""
2622+ self ._on_drawing_tool_clicked (button )
2623+
2624+ def _on_drawing_tool_clicked (self , button ) -> None :
24492625 if self ._set_zero_plane_btn .isChecked ():
24502626 self ._set_zero_plane_btn .setChecked (False )
2451- kind = button .property ("selection_tool " ) or "none "
2452- self ._zoom_lbl .set_selection_tool (kind )
2627+ kind = button .property ("drawing_tool " ) or "pan "
2628+ self ._zoom_lbl .set_tool (kind )
24532629 self ._sync_line_profile_visibility (kind )
2630+ from probeflow .gui .tool_manager import _TOOL_HINTS
2631+ if hasattr (self , "_status_lbl" ):
2632+ self ._status_lbl .setText (_TOOL_HINTS .get (kind , "" ))
24542633
24552634 def _sync_line_profile_visibility (self , kind : str | None = None ) -> None :
24562635 if not hasattr (self , "_line_profile_panel" ):
0 commit comments