diff --git a/Pinta.Resources/Icons.cs b/Pinta.Resources/Icons.cs
index 79d502a83e..1c093a09f2 100644
--- a/Pinta.Resources/Icons.cs
+++ b/Pinta.Resources/Icons.cs
@@ -206,6 +206,9 @@ public static class Icons
public const string LayerNew = "layers-add-layer-symbolic";
public const string LayerProperties = "document-properties-symbolic";
public const string LayerRotateZoom = "layers-rotate-zoom-symbolic";
+ // Bidirectional rotate cursor for the transform tools' Alt-drag rotation.
+ // Non-symbolic so the white halo + dark stroke survive (symbolic recolors to a single color).
+ public const string RotateHandle = "rotate-handle";
public const string Pinta = "com.github.PintaProject.Pinta";
diff --git a/Pinta.Resources/icons/hicolor/scalable/actions/rotate-handle-symbolic.svg b/Pinta.Resources/icons/hicolor/scalable/actions/rotate-handle-symbolic.svg
new file mode 100644
index 0000000000..19a6b1f51e
--- /dev/null
+++ b/Pinta.Resources/icons/hicolor/scalable/actions/rotate-handle-symbolic.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/Pinta.Resources/icons/hicolor/scalable/actions/rotate-handle.svg b/Pinta.Resources/icons/hicolor/scalable/actions/rotate-handle.svg
new file mode 100644
index 0000000000..4804bdd80a
--- /dev/null
+++ b/Pinta.Resources/icons/hicolor/scalable/actions/rotate-handle.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/Pinta.Tools/Handles/MoveHandle.cs b/Pinta.Tools/Handles/MoveHandle.cs
index 385f674b4a..af8e1cf3e2 100644
--- a/Pinta.Tools/Handles/MoveHandle.cs
+++ b/Pinta.Tools/Handles/MoveHandle.cs
@@ -35,7 +35,7 @@ public MoveHandle (IWorkspaceService workspace)
///
public bool Selected { get; set; } = false;
- public Gdk.Cursor Cursor { get; init; } = default_cursor;
+ public Gdk.Cursor Cursor { get; set; } = default_cursor;
///
/// Tests whether the window point is inside the handle's area.
diff --git a/Pinta.Tools/Handles/RectangleHandle.cs b/Pinta.Tools/Handles/RectangleHandle.cs
index 7468fac47a..84be2303b1 100644
--- a/Pinta.Tools/Handles/RectangleHandle.cs
+++ b/Pinta.Tools/Handles/RectangleHandle.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using Cairo;
using Pinta.Core;
namespace Pinta.Tools;
@@ -31,6 +32,10 @@ public class RectangleHandle : IToolHandle
private MoveHandle? active_handle;
private PointD? drag_start_pos;
+ // Resize cursors ordered by screen-space octant [E, SE, S, SW, W, NW, N, NE],
+ // so a direction vector can pick the glyph that matches an oriented grip (issue #4).
+ private readonly Gdk.Cursor[] direction_cursors;
+
public RectangleHandle (IWorkspaceService workspace)
{
this.workspace = workspace;
@@ -49,6 +54,47 @@ public RectangleHandle (IWorkspaceService workspace)
foreach (var handle in handles.Values)
handle.Active = true;
+
+ direction_cursors = [
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeE),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeSE),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeS),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeSW),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeW),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeNW),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeN),
+ GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeNE),
+ ];
+ }
+
+ // Each grip's cursor octant when axis-aligned, in the [E,SE,S,SW,W,NW,N,NE]
+ // order of direction_cursors. Corners sit on odd (diagonal) octants, edges on
+ // even (straight) ones — a grip keeps its family as the content rotates.
+ private static readonly Dictionary base_octant = new () {
+ { HandlePoint.Right, 0 },
+ { HandlePoint.LowerRight, 1 },
+ { HandlePoint.Down, 2 },
+ { HandlePoint.LowerLeft, 3 },
+ { HandlePoint.Left, 4 },
+ { HandlePoint.UpperLeft, 5 },
+ { HandlePoint.Up, 6 },
+ { HandlePoint.UpperRight, 7 },
+ };
+
+ ///
+ /// Picks the resize cursor for a grip rotated by
+ /// from its axis-aligned . Snaps within the grip's
+ /// own family (90° apart) so a corner always shows a diagonal glyph and an
+ /// edge a straight one, each pointing along the rotated content. Glyphs are
+ /// 180°-symmetric, so flips resolve too.
+ ///
+ private Gdk.Cursor DirectionCursor (int baseOct, double thetaDeg)
+ {
+ int parity = baseOct & 1; // 0 = straight/edge, 1 = diagonal/corner
+ double rotatedDeg = baseOct * 45.0 + thetaDeg;
+ int k = (int) Math.Round ((rotatedDeg - parity * 45.0) / 90.0);
+ int oct = ((parity + 2 * k) % 8 + 8) % 8;
+ return direction_cursors[oct];
}
#region IToolHandle Implementation
@@ -71,11 +117,36 @@ public void Draw (Gtk.Snapshot snapshot)
///
public bool InvertIfNegative { get; init; }
+ ///
+ /// Optional transform applied to the handle positions so the grips can
+ /// follow rotated / oriented content instead of an axis-aligned box
+ /// (issue #4). Null = axis-aligned. Draw and hit-testing both use the
+ /// resulting oriented s.
+ ///
+ public Matrix? Orientation { get; set; }
+
+ ///
+ /// Set the reference rectangle and its orientation in one step, so the
+ /// handle positions are recomputed once with the orientation applied.
+ ///
+ public void SetOriented (RectangleD rect, Matrix? orientation)
+ {
+ Orientation = orientation;
+ Rectangle = rect; // triggers UpdateHandlePositions with the orientation applied
+ }
+
///
/// Whether the user is currently dragging a corner of the rectangle.
///
public bool IsDragging => drag_start_pos is not null;
+ ///
+ /// Which handle is currently being dragged, if any. Lets the transform
+ /// tools apply their own aspect-ratio / center-anchored scaling.
+ ///
+ internal HandlePoint? ActiveHandlePoint
+ => active_handle is null ? null : handles.First (kvp => kvp.Value == active_handle).Key;
+
///
/// The rectangle selected by the user.
///
@@ -183,6 +254,17 @@ private void UpdateHandlePositions ()
handles[HandlePoint.Up].CanvasPosition = new PointD (center.X, start_pt.Y);
handles[HandlePoint.Right].CanvasPosition = new PointD (end_pt.X, center.Y);
handles[HandlePoint.Down].CanvasPosition = new PointD (center.X, end_pt.Y);
+
+ if (Orientation is not null) {
+ // Screen-space rotation of the content = angle of the transformed X axis.
+ PointD o0 = Orientation.TransformPoint (new PointD (0, 0));
+ PointD o1 = Orientation.TransformPoint (new PointD (1, 0));
+ double thetaDeg = Math.Atan2 (o1.Y - o0.Y, o1.X - o0.X) * 180.0 / Math.PI;
+ foreach ((HandlePoint point, MoveHandle handle) in handles) {
+ handle.CanvasPosition = Orientation.TransformPoint (handle.CanvasPosition);
+ handle.Cursor = DirectionCursor (base_octant[point], thetaDeg);
+ }
+ }
}
private void UpdateHandleUnderPoint (PointD viewPos)
diff --git a/Pinta.Tools/Tools/BaseTransformTool.cs b/Pinta.Tools/Tools/BaseTransformTool.cs
index 11d1e51832..8ab38ac3fe 100644
--- a/Pinta.Tools/Tools/BaseTransformTool.cs
+++ b/Pinta.Tools/Tools/BaseTransformTool.cs
@@ -25,6 +25,8 @@
// THE SOFTWARE.
using System;
+using System.Collections.Generic;
+using System.IO;
using Cairo;
using Pinta.Core;
@@ -41,11 +43,58 @@ public abstract class BaseTransformTool : BaseTool
private bool is_scaling = false;
private bool using_mouse = false;
+ // Drag-handle resizing of the moved/pasted content. Credit: issue #585,
+ // marcovr's allow-handle-resize branch, and cameronwhite's draft PR #1515.
+ // See docs/selection-transform-handles.md.
+ private readonly IWorkspaceService workspace;
+ private readonly IToolService tool_service;
+ private readonly RectangleHandle handle;
+ private readonly Gdk.Cursor rotate_cursor;
+ private bool is_handle_scaling = false;
+ public override IEnumerable Handles => [handle];
+
+ // Live orientation of the moved content (issue #4): maps the axis-aligned
+ // reference rect (ref_rect, captured when the selection first attaches) onto
+ // the current on-screen quad, so the resize grips stay glued to rotated
+ // content. Only ever a rotation + ref-space axis scale + translation, so it
+ // always maps ref_rect to a proper (non-sheared) oriented rectangle.
+ private readonly Matrix live = CairoExtensions.CreateIdentityMatrix ();
+ private RectangleD ref_rect;
+
+ // Nudge hint when holding arrow key for >2s (Issue #1559 extension)
+ private DateTime? nudge_start_time;
+ private uint nudge_hint_timeout_id = 0;
+ private bool nudge_hint_visible = false;
+ private string? last_nudge_hint;
+ private Gtk.Popover? nudge_popover;
+ private Gtk.Label? nudge_label;
+
///
/// Initializes a new instance of the class.
///
public BaseTransformTool (IServiceProvider services) : base (services)
{
+ workspace = services.GetService ();
+ tool_service = services.GetService ();
+ handle = new (workspace) { InvertIfNegative = true };
+ // A larger, high-contrast bidirectional rotate cursor, sized to match the
+ // resize grips' cursors. Resource SVGs load at their natural size (not the
+ // requested one), so center the hotspot on the actual texture — a fixed
+ // hotspot past the edge trips a GDK assertion.
+ // Use a non-symbolic icon (rotate-handle.svg) so GTK preserves the white
+ // halo + dark stroke. Symbolic icons get recolored to a single color and
+ // lose contrast, making the cursor invisible against some backgrounds.
+ Gdk.Texture rotate_texture = LoadRotateTexture ();
+ rotate_cursor = Gdk.Cursor.NewFromTexture (rotate_texture, rotate_texture.Width / 2, rotate_texture.Height / 2, null);
+
+ // The pasted/moved selection is often created *after* this tool is
+ // activated (see PasteAction), so OnActivated is too early to show the
+ // grips. Refresh them whenever the selection changes.
+ workspace.SelectionChanged += (_, _) => {
+ if (IsActive || tool_service.CurrentTool != this || !workspace.HasOpenDocuments)
+ return;
+ UpdateHandlesFromDocument (workspace.ActiveDocument);
+ };
}
protected override void OnMouseDown (
@@ -57,12 +106,26 @@ protected override void OnMouseDown (
original_point = e.PointDouble;
+ // Alt (or right button) rotates, and takes priority over grabbing a grip.
+ bool rotate_requested = e.MouseButton == MouseButton.Right || e.IsAltPressed;
+
+ // If the mouse is on a resize grip, scale by dragging the handle.
+ if (!rotate_requested && handle.Active && handle.BeginDrag (e.PointDouble, document.ImageSize)) {
+ is_handle_scaling = true;
+ using_mouse = true;
+ OnStartTransform (document);
+ // Grip scaling is computed in the reference frame (see ApplyRefScale).
+ source_rect = ref_rect;
+ return;
+ }
+
if (!document.Workspace.PointInCanvas (e.PointDouble))
return;
- if (e.MouseButton == MouseButton.Right)
+ if (rotate_requested) {
is_rotating = true;
- else if (e.IsControlPressed)
+ SetCursor (rotate_cursor);
+ } else if (e.IsControlPressed)
is_scaling = true;
else
is_dragging = true;
@@ -76,8 +139,291 @@ protected override void OnMouseMove (
Document document,
ToolMouseEventArgs e)
{
- if (!IsActive || !using_mouse)
+ // While a grip is being dragged, scale the content to match the handle.
+ if (is_handle_scaling) {
+ HandlePoint? active = handle.ActiveHandlePoint;
+ if (active is null)
+ return;
+
+ // Work in the reference frame: un-rotate the mouse through the live
+ // orientation so a (possibly rotated) grip drag reduces to an
+ // axis-aligned resize of ref_rect. The resize matrices built below
+ // are then re-applied through `live` in ApplyRefScale, and the grips
+ // are re-positioned in the oriented frame (never via handle.UpdateDrag,
+ // which would corrupt the reference rectangle).
+ Matrix liveInv = live.Clone ();
+ liveInv.Invert ();
+ PointD mouse = liveInv.TransformPoint (e.PointDouble);
+ PointD srcCenter = source_rect.GetCenter ();
+ bool keepAspect = e.IsShiftPressed;
+ bool fromCenter = e.IsControlPressed;
+
+ if (IsCorner (active)) {
+ // --- Corner handles: allow flipping (mirroring) ---
+ PointD opp = OppositeCorner (source_rect, active.Value);
+ PointD srcCorner = GetCornerPoint (source_rect, active.Value);
+
+ Matrix flipTransform = CairoExtensions.CreateIdentityMatrix ();
+ RectangleD newRect;
+
+ if (fromCenter) {
+ // Scale about center; flip occurs when mouse crosses center.
+ double cdx0 = srcCorner.X - srcCenter.X;
+ double cdy0 = srcCorner.Y - srcCenter.Y;
+ double cdx1 = mouse.X - srcCenter.X;
+ double cdy1 = mouse.Y - srcCenter.Y;
+
+ if (keepAspect && source_rect.Width > 0 && source_rect.Height > 0) {
+ double halfW = source_rect.Width / 2.0;
+ double halfH = source_rect.Height / 2.0;
+ if (halfW > 0 && halfH > 0) {
+ double s = Math.Max (Math.Abs (cdx1) / halfW, Math.Abs (cdy1) / halfH);
+ double signX = cdx1 < 0 ? -1 : 1;
+ double signY = cdy1 < 0 ? -1 : 1;
+ // When exactly zero, keep positive to avoid NaN.
+ cdx1 = signX * halfW * s;
+ cdy1 = signY * halfH * s;
+ }
+ }
+
+ double sx = cdx0 != 0 ? cdx1 / cdx0 : 1;
+ double sy = cdy0 != 0 ? cdy1 / cdy0 : 1;
+
+ double w = Math.Abs (cdx1) * 2;
+ double h = Math.Abs (cdy1) * 2;
+ newRect = new RectangleD (srcCenter.X - w / 2, srcCenter.Y - h / 2, w, h);
+
+ flipTransform.Translate (srcCenter.X, srcCenter.Y);
+ flipTransform.Scale (sx, sy);
+ flipTransform.Translate (-srcCenter.X, -srcCenter.Y);
+ } else {
+ // Scale about opposite corner; flip occurs when mouse crosses that corner.
+ double dx0 = srcCorner.X - opp.X;
+ double dy0 = srcCorner.Y - opp.Y;
+ double dx1 = mouse.X - opp.X;
+ double dy1 = mouse.Y - opp.Y;
+
+ if (keepAspect && source_rect.Width > 0 && source_rect.Height > 0) {
+ double s = Math.Max (Math.Abs (dx1) / source_rect.Width, Math.Abs (dy1) / source_rect.Height);
+ double signX = dx1 < 0 ? -1 : 1;
+ double signY = dy1 < 0 ? -1 : 1;
+ dx1 = signX * source_rect.Width * s;
+ dy1 = signY * source_rect.Height * s;
+ }
+
+ double sx = dx0 != 0 ? dx1 / dx0 : 1;
+ double sy = dy0 != 0 ? dy1 / dy0 : 1;
+
+ double w = Math.Abs (dx1);
+ double h = Math.Abs (dy1);
+ double rx = Math.Min (opp.X, opp.X + dx1);
+ double ry = Math.Min (opp.Y, opp.Y + dy1);
+ newRect = new RectangleD (rx, ry, w, h);
+
+ flipTransform.Translate (opp.X, opp.Y);
+ flipTransform.Scale (sx, sy);
+ flipTransform.Translate (-opp.X, -opp.Y);
+ }
+
+ _ = newRect; // grips are placed via the oriented frame, not this rect
+ ApplyRefScale (document, flipTransform);
+ } else {
+ // Edge handles: allow flipping (mirroring) as well, so user can
+ // mirror horizontally by dragging left/right past opposite edge,
+ // and vertically by dragging up/down past opposite edge.
+ Matrix edgeTransform = CairoExtensions.CreateIdentityMatrix ();
+ RectangleD edgeRect;
+ PointD edgeAnchor;
+
+ double srcW = source_rect.Width;
+ double srcH = source_rect.Height;
+
+ switch (active.Value) {
+ case HandlePoint.Left: {
+ double oppX = source_rect.X + srcW;
+ if (fromCenter) {
+ double cdx0 = source_rect.X - srcCenter.X;
+ double cdx1 = mouse.X - srcCenter.X;
+ double sx = cdx0 != 0 ? cdx1 / cdx0 : 1;
+ double w = Math.Abs (cdx1) * 2;
+ double h = srcH;
+ double sy = 1;
+ if (keepAspect && srcW > 0) {
+ h = w * srcH / srcW;
+ sy = Math.Abs (sx);
+ }
+ edgeRect = new RectangleD (srcCenter.X - w / 2, srcCenter.Y - h / 2, w, h);
+ edgeAnchor = srcCenter;
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ } else {
+ double dx0 = source_rect.X - oppX;
+ double dx1 = mouse.X - oppX;
+ double sx = dx0 != 0 ? dx1 / dx0 : 1;
+ double w = Math.Abs (dx1);
+ double h = srcH;
+ double sy = 1;
+ if (keepAspect && srcW > 0) {
+ h = w * srcH / srcW;
+ sy = Math.Abs (sx);
+ }
+ double rx = Math.Min (oppX, mouse.X);
+ double ry = keepAspect ? srcCenter.Y - h / 2 : source_rect.Y;
+ edgeRect = new RectangleD (rx, ry, w, h);
+ edgeAnchor = new PointD (oppX, srcCenter.Y);
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ }
+ break;
+ }
+ case HandlePoint.Right: {
+ double oppX = source_rect.X;
+ if (fromCenter) {
+ double cdx0 = source_rect.X + srcW - srcCenter.X;
+ double cdx1 = mouse.X - srcCenter.X;
+ double sx = cdx0 != 0 ? cdx1 / cdx0 : 1;
+ double w = Math.Abs (cdx1) * 2;
+ double h = srcH;
+ double sy = 1;
+ if (keepAspect && srcW > 0) {
+ h = w * srcH / srcW;
+ sy = Math.Abs (sx);
+ }
+ edgeRect = new RectangleD (srcCenter.X - w / 2, srcCenter.Y - h / 2, w, h);
+ edgeAnchor = srcCenter;
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ } else {
+ double dx0 = srcW;
+ double dx1 = mouse.X - oppX;
+ double sx = dx0 != 0 ? dx1 / dx0 : 1;
+ double w = Math.Abs (dx1);
+ double h = srcH;
+ double sy = 1;
+ if (keepAspect && srcW > 0) {
+ h = w * srcH / srcW;
+ sy = Math.Abs (sx);
+ }
+ double rx = Math.Min (oppX, mouse.X);
+ double ry = keepAspect ? srcCenter.Y - h / 2 : source_rect.Y;
+ edgeRect = new RectangleD (rx, ry, w, h);
+ edgeAnchor = new PointD (oppX, srcCenter.Y);
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ }
+ break;
+ }
+ case HandlePoint.Up: {
+ double oppY = source_rect.Y + srcH;
+ if (fromCenter) {
+ double cdy0 = source_rect.Y - srcCenter.Y;
+ double cdy1 = mouse.Y - srcCenter.Y;
+ double sy = cdy0 != 0 ? cdy1 / cdy0 : 1;
+ double h = Math.Abs (cdy1) * 2;
+ double w = srcW;
+ double sx = 1;
+ if (keepAspect && srcH > 0) {
+ w = h * srcW / srcH;
+ sx = Math.Abs (sy);
+ }
+ edgeRect = new RectangleD (srcCenter.X - w / 2, srcCenter.Y - h / 2, w, h);
+ edgeAnchor = srcCenter;
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ } else {
+ double dy0 = source_rect.Y - oppY;
+ double dy1 = mouse.Y - oppY;
+ double sy = dy0 != 0 ? dy1 / dy0 : 1;
+ double h = Math.Abs (dy1);
+ double w = srcW;
+ double sx = 1;
+ if (keepAspect && srcH > 0) {
+ w = h * srcW / srcH;
+ sx = Math.Abs (sy);
+ }
+ double ry = Math.Min (oppY, mouse.Y);
+ double rx = keepAspect ? srcCenter.X - w / 2 : source_rect.X;
+ edgeRect = new RectangleD (rx, ry, w, h);
+ edgeAnchor = new PointD (srcCenter.X, oppY);
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ }
+ break;
+ }
+ case HandlePoint.Down: {
+ double oppY = source_rect.Y;
+ if (fromCenter) {
+ double cdy0 = source_rect.Y + srcH - srcCenter.Y;
+ double cdy1 = mouse.Y - srcCenter.Y;
+ double sy = cdy0 != 0 ? cdy1 / cdy0 : 1;
+ double h = Math.Abs (cdy1) * 2;
+ double w = srcW;
+ double sx = 1;
+ if (keepAspect && srcH > 0) {
+ w = h * srcW / srcH;
+ sx = Math.Abs (sy);
+ }
+ edgeRect = new RectangleD (srcCenter.X - w / 2, srcCenter.Y - h / 2, w, h);
+ edgeAnchor = srcCenter;
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ } else {
+ double dy0 = srcH;
+ double dy1 = mouse.Y - oppY;
+ double sy = dy0 != 0 ? dy1 / dy0 : 1;
+ double h = Math.Abs (dy1);
+ double w = srcW;
+ double sx = 1;
+ if (keepAspect && srcH > 0) {
+ w = h * srcW / srcH;
+ sx = Math.Abs (sy);
+ }
+ double ry = Math.Min (oppY, mouse.Y);
+ double rx = keepAspect ? srcCenter.X - w / 2 : source_rect.X;
+ edgeRect = new RectangleD (rx, ry, w, h);
+ edgeAnchor = new PointD (srcCenter.X, oppY);
+ edgeTransform.Translate (edgeAnchor.X, edgeAnchor.Y);
+ edgeTransform.Scale (sx, sy);
+ edgeTransform.Translate (-edgeAnchor.X, -edgeAnchor.Y);
+ }
+ break;
+ }
+ default: {
+ // Unreachable (all 8 HandlePoints handled above); kept as a guard.
+ RectangleD to = ComputeEdgeRect (source_rect, ref_rect, active.Value, keepAspect, fromCenter);
+ ApplyRefScale (document, ComputeScaleTransform (source_rect, to));
+ return;
+ }
+ }
+
+ _ = edgeRect; // grips are placed via the oriented frame, not this rect
+ ApplyRefScale (document, edgeTransform);
+ }
return;
+ }
+
+ if (!IsActive || !using_mouse) {
+ if (!using_mouse && handle.Active) {
+ Gdk.Cursor? gripCursor = handle.GetCursorAtPoint (e.WindowPoint);
+ // Alt rotates; show the rotate cursor. Otherwise show the grip's
+ // resize cursor when hovering one.
+ SetCursor (e.IsAltPressed ? rotate_cursor : gripCursor ?? DefaultCursor);
+ // Hint the modifier keys when hovering a grip.
+ UpdateHandleHint (gripCursor is not null);
+ }
+ return;
+ }
+
+ // Keep rotate cursor visible while actively rotating.
+ if (is_rotating)
+ SetCursor (rotate_cursor);
bool constrain = e.IsShiftPressed;
@@ -127,6 +473,9 @@ protected override void OnMouseMove (
}
OnUpdateTransform (document, transform);
+
+ // Keep the grips glued to the moved/rotated content in the oriented frame.
+ RefreshOrientedHandles (document);
}
protected override void OnMouseUp (
@@ -136,7 +485,15 @@ protected override void OnMouseUp (
if (!IsActive || !using_mouse)
return;
- OnFinishTransform (document, transform);
+ if (is_handle_scaling)
+ handle.EndDrag ();
+
+ // For handle scaling we already computed the (possibly flipped) transform
+ // in OnMouseMove and stored it in `transform`. Using ComputeScaleTransform
+ // from the positive bounding rect would lose the sign and thus the flip.
+ Matrix final = transform;
+
+ OnFinishTransform (document, final);
}
protected override bool OnKeyDown (
@@ -146,26 +503,81 @@ protected override bool OnKeyDown (
if (using_mouse) // Don't handle the arrow keys while already interacting via the mouse.
return base.OnKeyDown (document, e);
+ // Determine if this is an arrow key for nudging.
+ bool isArrow = e.Key.Value is Gdk.Constants.KEY_Left or Gdk.Constants.KEY_Right
+ or Gdk.Constants.KEY_Up or Gdk.Constants.KEY_Down;
+
+ if (!isArrow)
+ return base.OnKeyDown (document, e);
+
+ // Track nudge hold duration for hint (2 seconds).
+ if (nudge_start_time is null) {
+ nudge_start_time = DateTime.UtcNow;
+ // Schedule a one-shot timeout to show hint after 2s even if no key repeat.
+ if (nudge_hint_timeout_id == 0) {
+ Document docForHint = document;
+ nudge_hint_timeout_id = GLib.Functions.TimeoutAdd (0, 2000, () => {
+ nudge_hint_timeout_id = 0;
+ if (nudge_start_time is not null && IsActive && !using_mouse) {
+ ShowNudgeHint (docForHint);
+ }
+ return false;
+ });
+ }
+ } else {
+ // If we've been holding for >2s, ensure hint is visible and updated.
+ if ((DateTime.UtcNow - nudge_start_time.Value).TotalSeconds >= 2.0) {
+ ShowNudgeHint (document);
+ }
+ }
+
+ // Compute step amounts:
+ // - 1px base
+ // - Shift: 10px (Paint.NET parity, Issue #1559)
+ // - Ctrl: 10% of canvas size (user request: based on canvas size)
+ // - Ctrl+Shift: 20% of canvas size
double dx = 0.0;
double dy = 0.0;
- double coeff = e.IsControlPressed ? 10.0 : 1.0;
+
+ bool isCtrl = e.IsControlPressed;
+ bool isShift = e.IsShiftPressed;
+
+ int canvasW = document.ImageSize.Width;
+ int canvasH = document.ImageSize.Height;
+
+ // 10% and 20% of canvas, with sensible minimums so Ctrl is distinguishable.
+ int ctrl10X = Math.Max (10, (int) Math.Round (canvasW * 0.10));
+ int ctrl10Y = Math.Max (10, (int) Math.Round (canvasH * 0.10));
+ int ctrl20X = Math.Max (20, (int) Math.Round (canvasW * 0.20));
+ int ctrl20Y = Math.Max (20, (int) Math.Round (canvasH * 0.20));
+
+ double stepX, stepY;
+
+ if (isCtrl && isShift) {
+ stepX = ctrl20X;
+ stepY = ctrl20Y;
+ } else if (isCtrl) {
+ stepX = ctrl10X;
+ stepY = ctrl10Y;
+ } else if (isShift) {
+ stepX = stepY = 10.0;
+ } else {
+ stepX = stepY = 1.0;
+ }
switch (e.Key.Value) {
case Gdk.Constants.KEY_Left:
- dx = -coeff;
+ dx = -stepX;
break;
case Gdk.Constants.KEY_Right:
- dx = coeff;
+ dx = stepX;
break;
case Gdk.Constants.KEY_Up:
- dy = -coeff;
+ dy = -stepY;
break;
case Gdk.Constants.KEY_Down:
- dy = coeff;
+ dy = stepY;
break;
- default:
- // Otherwise, let the key be handled elsewhere.
- return base.OnKeyDown (document, e);
}
if (!IsActive) {
@@ -176,6 +588,14 @@ protected override bool OnKeyDown (
transform.Translate (dx, dy);
OnUpdateTransform (document, transform);
+ // Keep handles glued to the nudged content in realtime (issue #1).
+ RefreshOrientedHandles (document);
+
+ // If hint is already visible, refresh its position/content (e.g., ctrl px changes).
+ if (nudge_hint_visible) {
+ ShowNudgeHint (document);
+ }
+
return true;
}
@@ -183,6 +603,12 @@ protected override bool OnKeyUp (
Document document,
ToolKeyEventArgs e)
{
+ // Clear nudge hint state when arrow key is released.
+ if (e.Key.Value is Gdk.Constants.KEY_Left or Gdk.Constants.KEY_Right
+ or Gdk.Constants.KEY_Up or Gdk.Constants.KEY_Down) {
+ ClearNudgeState ();
+ }
+
if (IsActive && !using_mouse)
OnFinishTransform (document, transform);
@@ -209,10 +635,552 @@ protected virtual void OnFinishTransform (
is_dragging = false;
is_rotating = false;
is_scaling = false;
+ is_handle_scaling = false;
using_mouse = false;
+
+ // Clear any nudge hint when transform finishes.
+ ClearNudgeState ();
+
+ // Commit this gesture into the live orientation and snap the grips onto
+ // it, so the next gesture (and the drawn handles) stay in the oriented
+ // frame instead of resetting to an axis-aligned box (issue #4).
+ live.Multiply (transform);
+ if (document.Selection.Visible)
+ handle.SetOriented (ref_rect, live.Clone ());
+ else
+ handle.Active = false;
+ document.Workspace.Invalidate ();
+
+ // Restore cursor after a rotate/scale gesture
+ SetCursor (DefaultCursor);
+ }
+
+ protected override void OnActivated (Document? document)
+ {
+ base.OnActivated (document);
+ UpdateHandlesFromDocument (document);
+ }
+
+ protected override void OnDeactivated (Document? document, BaseTool? newTool)
+ {
+ base.OnDeactivated (document, newTool);
+ handle.Active = false;
+ UpdateHandleHint (false);
+ ClearNudgeState ();
+
+ // Fully detach popover to avoid holding parent reference.
+ if (nudge_popover is not null) {
+ try {
+ nudge_popover.Popdown ();
+ nudge_popover.Unparent ();
+ } catch { }
+ nudge_popover = null;
+ nudge_label = null;
+ }
+ }
+
+ ///
+ /// Position the resize grips on the current selection's bounding box, and
+ /// only show them when there is a visible selection to transform.
+ /// A fresh document carries an invisible full-canvas select-all
+ /// (ResetSelectionPaths), so gating on SelectionPolygons.Count
+ /// would wrongly show grips on the default layer; paste sets Visible.
+ ///
+ private void UpdateHandlesFromDocument (Document? document)
+ {
+ bool hasSelection = document is not null && document.Selection.Visible;
+
+ if (!hasSelection) {
+ handle.Active = false;
+ return;
+ }
+
+ // Derive the grips from the selection's own polygon. It is part of
+ // document.Selection, so history saves/restores it, and it outlines the
+ // transformed content exactly (a rotate maps the polygon corners the same
+ // way it maps the pixels). A rotated rectangular selection is a 4-corner
+ // quad; map an axis-aligned reference rect onto it so the existing
+ // draw/hit-test/scale code (which works in ref space) keeps functioning.
+ // Non-rectangular selections fall back to the axis-aligned bounding box.
+ if (TryGetOrientedQuad (document!, out RectangleD refRect, out Matrix orientation)) {
+ ref_rect = refRect;
+ live.InitMatrix (orientation);
+ } else {
+ ref_rect = GetSourceRectangle (document!);
+ live.InitIdentity ();
+ }
+ handle.Active = true;
+ handle.SetOriented (ref_rect, live.Clone ());
+ document!.Workspace.Invalidate ();
+ }
+
+ ///
+ /// Re-position the grips on the moved/rotated content during a non-grip
+ /// gesture (body drag, rotate, nudge). The content transform for the whole
+ /// gesture is transform; the grips follow at live · transform.
+ ///
+ private void RefreshOrientedHandles (Document document)
+ {
+ Matrix disp = live.Clone ();
+ disp.Multiply (transform);
+ handle.SetOriented (ref_rect, disp);
+ document.Workspace.Invalidate ();
+ }
+
+ ///
+ /// Apply a resize computed in the reference frame ( maps
+ /// ref_rect onto the resized rect) to the actual content, mapped through the
+ /// live orientation so rotated content scales along its own axes:
+ /// g = live · s · live⁻¹. The grips are drawn at the candidate
+ /// post-drag orientation (committed on mouse-up in OnFinishTransform).
+ ///
+ private void ApplyRefScale (Document document, Matrix s)
+ {
+ Matrix liveInv = live.Clone ();
+ liveInv.Invert ();
+
+ Matrix g = liveInv; // apply live⁻¹ first,
+ g.Multiply (s); // then the ref-space resize,
+ g.Multiply (live); // then re-apply live.
+
+ transform.InitMatrix (g);
+ OnUpdateTransform (document, transform);
+
+ Matrix disp = live.Clone ();
+ disp.Multiply (g);
+ handle.SetOriented (ref_rect, disp);
+ document.Workspace.Invalidate ();
+ }
+
+ ///
+ /// Show/clear a canvas tooltip explaining the modifier keys while the
+ /// cursor is over a resize grip.
+ ///
+ private void UpdateHandleHint (bool overGrip)
+ {
+ if (!workspace.HasOpenDocuments)
+ return;
+
+ if (nudge_hint_visible)
+ return;
+
+ string? hint = overGrip
+ // Translators: hint shown when hovering a selection resize handle. Now lists shortcuts vertically.
+ ? Translations.GetString ("Drag to resize\nShift: keep aspect ratio\nCtrl+drag: scale from center\nAlt-drag: rotate")
+ : null;
+
+ Gtk.Widget canvas = workspace.ActiveWorkspace.Canvas;
+ if (canvas.TooltipText != hint)
+ canvas.SetTooltipText (hint);
+ }
+
+ ///
+ /// Show a hint about arrow-key nudging after holding for 2 seconds.
+ /// Similar UI to the tool menu button's hint popovers (issue #1559).
+ /// Anchored to the lower-right of the nudged area so it appears near the
+ /// content even when the mouse is elsewhere (keyboard-only use).
+ ///
+ private void ShowNudgeHint (Document document)
+ {
+ if (!workspace.HasOpenDocuments)
+ return;
+
+ int w = document.ImageSize.Width;
+ int h = document.ImageSize.Height;
+ int ctrl10X = Math.Max (10, (int) Math.Round (w * 0.10));
+ int ctrl10Y = Math.Max (10, (int) Math.Round (h * 0.10));
+ int ctrl20X = Math.Max (20, (int) Math.Round (w * 0.20));
+ int ctrl20Y = Math.Max (20, (int) Math.Round (h * 0.20));
+
+ // List shortcuts vertically per user request (instead of dot-separated).
+ string template = Translations.GetString (
+ "Arrow: 1px\nShift+Arrow: 10px\nCtrl+Arrow: 10% of canvas ({0}×{1}px)\nCtrl+Shift+Arrow: 20% of canvas ({2}×{3}px)");
+
+ string hint;
+ try {
+ hint = string.Format (template, ctrl10X, ctrl10Y, ctrl20X, ctrl20Y);
+ } catch {
+ string fallback = Translations.GetString (
+ "Arrow: 1px\nShift+Arrow: 10px\nCtrl+Arrow: 10% of canvas\nCtrl+Shift+Arrow: 20% of canvas");
+ hint = fallback;
+ }
+
+ var activeWs = workspace.ActiveWorkspace;
+ Gtk.Widget canvas = activeWs.Canvas;
+
+ // Determine lower-right of the nudged area in canvas coordinates.
+ RectangleD rect;
+ if (handle.Active) {
+ rect = handle.Rectangle;
+ } else {
+ // Fallback to current selection bounds.
+ try {
+ rect = GetSourceRectangle (document);
+ } catch {
+ rect = document.Selection.GetBounds ();
+ }
+ }
+
+ PointD lowerRightCanvas = new (rect.X + rect.Width, rect.Y + rect.Height);
+ // Anchor to the oriented lower-right when the content is rotated (issue #4).
+ if (handle.Orientation is not null)
+ lowerRightCanvas = handle.Orientation.TransformPoint (lowerRightCanvas);
+ PointD lowerRightView = activeWs.CanvasPointToView (lowerRightCanvas);
+
+ // Create or reuse popover + label.
+ if (nudge_popover is null) {
+ nudge_popover = Gtk.Popover.New ();
+ nudge_popover.Autohide = false;
+ nudge_popover.Position = Gtk.PositionType.Bottom;
+ nudge_popover.SetParent (canvas);
+ nudge_label = Gtk.Label.New (hint);
+ nudge_label.Wrap = true;
+ nudge_label.MaxWidthChars = 60;
+ nudge_popover.SetChild (nudge_label);
+ } else {
+ if (nudge_label is not null)
+ nudge_label.SetText (hint);
+ // Re-parent if canvas changed.
+ if (nudge_popover.GetParent () != canvas) {
+ nudge_popover.Unparent ();
+ nudge_popover.SetParent (canvas);
+ }
+ }
+
+ // Anchor popover to lower-right of the nudge area.
+ // If mouse is elsewhere, popover still appears near content (fixes issue #2).
+ Gdk.Rectangle pointing = new () {
+ X = (int) Math.Clamp (lowerRightView.X, 0, 10000),
+ Y = (int) Math.Clamp (lowerRightView.Y, 0, 10000),
+ Width = 1,
+ Height = 1
+ };
+ nudge_popover.PointingTo = pointing;
+
+ // Also set tooltip as fallback for accessibility / hover.
+ canvas.SetTooltipText (hint);
+
+ nudge_popover.Popup ();
+ last_nudge_hint = hint;
+ nudge_hint_visible = true;
+ }
+
+ private void HideNudgeHint ()
+ {
+ if (!nudge_hint_visible && nudge_popover is null)
+ return;
+
+ if (workspace.HasOpenDocuments) {
+ try {
+ Gtk.Widget canvas = workspace.ActiveWorkspace.Canvas;
+ if (last_nudge_hint is not null && canvas.TooltipText == last_nudge_hint) {
+ canvas.SetTooltipText (null);
+ } else if (canvas.TooltipText is not null && nudge_hint_visible) {
+ if (canvas.TooltipText.Contains ("Arrow:") || canvas.TooltipText.Contains ("Shift+Arrow"))
+ canvas.SetTooltipText (null);
+ }
+ } catch {
+ // Workspace may be disposed.
+ }
+ }
+
+ if (nudge_popover is not null) {
+ try {
+ nudge_popover.Popdown ();
+ } catch {
+ // Ignore if already closed.
+ }
+ }
+
+ nudge_hint_visible = false;
+ last_nudge_hint = null;
+ }
+
+ private void ClearNudgeState ()
+ {
+ if (nudge_hint_timeout_id != 0) {
+ GLib.Functions.SourceRemove (nudge_hint_timeout_id);
+ nudge_hint_timeout_id = 0;
+ }
+
+ nudge_start_time = null;
+ HideNudgeHint ();
+ }
+
+ ///
+ /// Build the transform that maps the rectangle onto
+ /// . Because the dragged handle keeps the opposite corner
+ /// fixed, this scales relative to that corner rather than the center.
+ ///
+ private Matrix ComputeScaleTransform (RectangleD from, RectangleD to)
+ {
+ double sx = from.Width != 0 ? to.Width / from.Width : 1.0;
+ double sy = from.Height != 0 ? to.Height / from.Height : 1.0;
+
+ transform.InitIdentity ();
+ transform.Translate (to.X, to.Y);
+ transform.Scale (sx, sy);
+ transform.Translate (-from.X, -from.Y);
+ return transform;
+ }
+
+ ///
+ /// If the selection is a rotated rectangle (a 4-corner quad), returns the
+ /// axis-aligned reference rect (0,0,w,h) and the
+ /// matrix that maps it onto that quad. The quad comes straight from the
+ /// selection polygon, so it matches the on-screen content at any history step.
+ /// Returns false for non-rectangular selections (caller uses the bbox).
+ ///
+ private static bool TryGetOrientedQuad (Document document, out RectangleD refRect, out Matrix orientation)
+ {
+ refRect = default;
+ orientation = CairoExtensions.CreateIdentityMatrix ();
+
+ var polys = document.Selection.SelectionPolygons;
+ // A rectangle is 4 corners (some paths repeat the first point to close).
+ if (polys.Count != 1 || (polys[0].Count != 4 && polys[0].Count != 5))
+ return false;
+
+ // p0 and its two adjacent corners p1 (width edge) and p3 (height edge).
+ PointD p0 = new (polys[0][0].X, polys[0][0].Y);
+ PointD p1 = new (polys[0][1].X, polys[0][1].Y);
+ PointD p3 = new (polys[0][3].X, polys[0][3].Y);
+
+ PointD widthVec = p1 - p0;
+ PointD heightVec = p3 - p0;
+ double w = Math.Sqrt (widthVec.X * widthVec.X + widthVec.Y * widthVec.Y);
+ double h = Math.Sqrt (heightVec.X * heightVec.X + heightVec.Y * heightVec.Y);
+ if (w < 1e-6 || h < 1e-6)
+ return false;
+
+ PointD ex = new (widthVec.X / w, widthVec.Y / w);
+ PointD ey = new (heightVec.X / h, heightVec.Y / h);
+
+ // Reject non-orthogonal quads (sheared/arbitrary polygons); we only model
+ // rotated + flipped rectangles. SelectionPolygons stores integer points
+ // (DocumentSelection.Transform truncates to IntPoint), so a genuinely
+ // rotated rectangle carries up to ~1px error per corner — scale the
+ // tolerance with edge length instead of demanding exact orthogonality.
+ double dot = ex.X * ey.X + ex.Y * ey.Y;
+ double tol = Math.Min (0.1, 4.0 * (1.0 / w + 1.0 / h));
+ if (Math.Abs (dot) > tol)
+ return false;
+
+ double theta = Math.Atan2 (ex.Y, ex.X);
+ double det = ex.X * ey.Y - ex.Y * ey.X; // ex × ey; <0 = mirrored
+
+ Matrix m = CairoExtensions.CreateIdentityMatrix ();
+ m.Translate (p0.X, p0.Y);
+ m.Rotate (theta);
+ if (det < 0)
+ m.Scale (1, -1); // height edge is mirrored relative to a pure rotation
+
+ refRect = new RectangleD (0, 0, w, h);
+ orientation = m;
+ return true;
+ }
+
+ private static bool IsCorner (HandlePoint? p)
+ => p is HandlePoint.UpperLeft or HandlePoint.UpperRight
+ or HandlePoint.LowerLeft or HandlePoint.LowerRight;
+
+ private static PointD ClampToImage (PointD p, Document document)
+ => new (
+ Math.Round (Math.Clamp (p.X, 0, document.ImageSize.Width)),
+ Math.Round (Math.Clamp (p.Y, 0, document.ImageSize.Height)));
+
+ ///
+ /// The corner of diagonally opposite the dragged one;
+ /// this corner stays fixed while the dragged corner follows the mouse.
+ ///
+ private static PointD OppositeCorner (RectangleD s, HandlePoint dragged) => dragged switch {
+ HandlePoint.UpperLeft => new (s.Right, s.Bottom),
+ HandlePoint.UpperRight => new (s.Left, s.Bottom),
+ HandlePoint.LowerLeft => new (s.Right, s.Top),
+ HandlePoint.LowerRight => new (s.Left, s.Top),
+ _ => s.GetCenter (),
+ };
+
+ private static PointD GetCornerPoint (RectangleD s, HandlePoint dragged) => dragged switch {
+ HandlePoint.UpperLeft => new (s.X, s.Y),
+ HandlePoint.UpperRight => new (s.X + s.Width, s.Y),
+ HandlePoint.LowerLeft => new (s.X, s.Y + s.Height),
+ HandlePoint.LowerRight => new (s.X + s.Width, s.Y + s.Height),
+ HandlePoint.Left => new (s.X, s.GetCenter ().Y),
+ HandlePoint.Right => new (s.X + s.Width, s.GetCenter ().Y),
+ HandlePoint.Up => new (s.GetCenter ().X, s.Y),
+ HandlePoint.Down => new (s.GetCenter ().X, s.Y + s.Height),
+ _ => s.GetCenter (),
+ };
+
+ ///
+ /// Target rectangle for a corner drag. Shift keeps the pasted content's
+ /// original width:height ratio (not a square); Ctrl anchors the scale to
+ /// the center instead of the opposite corner.
+ ///
+ private static RectangleD ComputeCornerRect (RectangleD source, PointD mouse, HandlePoint dragged, bool keepAspect, bool fromCenter)
+ {
+ PointD anchor = OppositeCorner (source, dragged);
+ double dx = mouse.X - anchor.X;
+ double dy = mouse.Y - anchor.Y;
+
+ if (keepAspect && source.Width > 0 && source.Height > 0) {
+ // Scale both axes by the same factor, following whichever axis was dragged further.
+ double s = Math.Max (Math.Abs (dx) / source.Width, Math.Abs (dy) / source.Height);
+ dx = (dx < 0 ? -1 : 1) * source.Width * s;
+ dy = (dy < 0 ? -1 : 1) * source.Height * s;
+ }
+
+ RectangleD rect = RectangleD.FromPoints (anchor, new PointD (anchor.X + dx, anchor.Y + dy), true);
+ return fromCenter ? CenterAnchored (source, rect) : rect;
+ }
+
+ ///
+ /// Target rectangle for an edge drag. The dragged edge changes one dimension;
+ /// Shift derives the other dimension from the pasted content's aspect ratio
+ /// (expanding/contracting symmetrically about the perpendicular centerline),
+ /// and Ctrl anchors the whole scale to the center.
+ ///
+ private static RectangleD ComputeEdgeRect (RectangleD source, RectangleD dragged, HandlePoint edge, bool keepAspect, bool fromCenter)
+ {
+ RectangleD rect = dragged;
+
+ if (keepAspect && source.Width > 0 && source.Height > 0) {
+ PointD c = source.GetCenter ();
+ if (edge is HandlePoint.Left or HandlePoint.Right) {
+ // Width was dragged; derive height, centered vertically.
+ double height = dragged.Width * source.Height / source.Width;
+ rect = new RectangleD (new PointD (dragged.X, c.Y - height / 2), dragged.Width, height);
+ } else {
+ // Up/Down: height was dragged; derive width, centered horizontally.
+ double width = dragged.Height * source.Width / source.Height;
+ rect = new RectangleD (new PointD (c.X - width / 2, dragged.Y), width, dragged.Height);
+ }
+ }
+
+ return fromCenter ? CenterAnchored (source, rect) : rect;
+ }
+
+ ///
+ /// Re-centers on the source's center, keeping its size,
+ /// so scaling grows symmetrically about the center (Ctrl behavior).
+ ///
+ private static RectangleD CenterAnchored (RectangleD source, RectangleD rect)
+ {
+ PointD c = source.GetCenter ();
+ return new RectangleD (new PointD (c.X - rect.Width / 2, c.Y - rect.Height / 2), rect.Width, rect.Height);
+ }
+
+ ///
+ /// Load the rotate cursor texture with white halo + black outline preserved.
+ /// Tries direct file load first (bypasses Gtk IconTheme recoloring), then
+ /// Resources.GetIcon, then a manual Cairo fallback.
+ ///
+ private static Gdk.Texture LoadRotateTexture ()
+ {
+ // Try direct file load from the installed icons directory — this preserves
+ // the white halo + black stroke, unlike the symbolic IconTheme path.
+ string data_dir = Pinta.Core.SystemManager.GetDataRootDirectory ();
+ string[] candidates = [
+ System.IO.Path.Combine (data_dir, "icons", "hicolor", "scalable", "actions", "rotate-handle.svg"),
+ System.IO.Path.Combine (data_dir, "icons", "hicolor", "scalable", "actions", "rotate-handle-symbolic.svg"),
+ ];
+
+ foreach (string path in candidates) {
+ try {
+ if (!File.Exists (path))
+ continue;
+ byte[] data = File.ReadAllBytes (path);
+ GLib.Bytes bytes = GLib.Bytes.New (data);
+ Gdk.Texture tex = Gdk.Texture.NewFromBytes (bytes);
+ if (tex.Width > 0 && tex.Height > 0)
+ return tex;
+ } catch {
+ // fall through
+ }
+ }
+
+ // Fallback to themed icon (may be recolored but better than nothing).
+ try {
+ Gdk.Texture themed = Pinta.Resources.ResourceLoader.GetIcon (Pinta.Resources.Icons.RotateHandle, 28);
+ if (themed.Width > 0)
+ return themed;
+ } catch {
+ }
+
+ // Final fallback: draw a high-contrast rotate icon via Cairo (white halo + black arc).
+ return CreateFallbackRotateTexture (32);
+ }
+
+ private static Gdk.Texture CreateFallbackRotateTexture (int size)
+ {
+ using ImageSurface surf = new (Format.Argb32, size, size);
+ using Context g = new (surf);
+ g.Antialias = Antialias.Subpixel;
+ g.LineCap = LineCap.Round;
+ g.LineJoin = LineJoin.Round;
+
+ // Clear transparent.
+ g.Operator = Operator.Source;
+ g.SetSourceRgba (0, 0, 0, 0);
+ g.Paint ();
+ g.Operator = Operator.Over;
+
+ double cx = size / 2.0;
+ double cy = size / 2.0;
+ double radius = size * 0.35; // ~11 at 32px, similar to 8 at 24px
+ double gap_deg = 35.0;
+ double start_rad = gap_deg * Math.PI / 180.0;
+ double end_rad = (360.0 - gap_deg) * Math.PI / 180.0;
+
+ // Helper to stroke arc + arrowheads.
+ void StrokeArc (double r, double width, double[] color)
+ {
+ g.LineWidth = width;
+ g.SetSourceRgb (color[0], color[1], color[2]);
+ // Arc (Cairo angles: 0 = +X, positive clockwise because Y down, but we use math)
+ g.Arc (cx, cy, r, start_rad, end_rad);
+ g.Stroke ();
+
+ // Arrowheads — small V at each end, tangent to circle.
+ // Compute end points
+ double sx = cx + r * Math.Cos (start_rad);
+ double sy = cy + r * Math.Sin (start_rad);
+ double ex = cx + r * Math.Cos (end_rad);
+ double ey = cy + r * Math.Sin (end_rad);
+
+ // Tangent directions (perpendicular to radius). For a clockwise arc,
+ // tangent at start is roughly upwards-ish, at end downwards-ish.
+ // Approximate arrowhead by drawing two short lines.
+ double arrow_len = size * 0.18;
+ double arrow_angle = 25.0 * Math.PI / 180.0;
+
+ // Start arrow — tangent roughly -90deg from radius
+ double t1 = start_rad - Math.PI / 2;
+ g.MoveTo (sx, sy);
+ g.LineTo (sx + arrow_len * Math.Cos (t1 + arrow_angle), sy + arrow_len * Math.Sin (t1 + arrow_angle));
+ g.MoveTo (sx, sy);
+ g.LineTo (sx + arrow_len * Math.Cos (t1 - arrow_angle), sy + arrow_len * Math.Sin (t1 - arrow_angle));
+ g.Stroke ();
+
+ // End arrow — tangent +90deg
+ double t2 = end_rad + Math.PI / 2;
+ g.MoveTo (ex, ey);
+ g.LineTo (ex + arrow_len * Math.Cos (t2 + arrow_angle), ey + arrow_len * Math.Sin (t2 + arrow_angle));
+ g.MoveTo (ex, ey);
+ g.LineTo (ex + arrow_len * Math.Cos (t2 - arrow_angle), ey + arrow_len * Math.Sin (t2 - arrow_angle));
+ g.Stroke ();
+ }
+
+ // White halo
+ StrokeArc (radius, size * 0.16, [1, 1, 1]);
+ // Black arc
+ StrokeArc (radius, size * 0.09, [0.1, 0.1, 0.1]);
+
+ return Gdk.Texture.NewForPixbuf (Gdk.Functions.PixbufGetFromSurface (surf, 0, 0, surf.Width, surf.Height)!);
}
private bool IsActive
- => is_dragging || is_rotating || is_scaling;
+ => is_dragging || is_rotating || is_scaling || is_handle_scaling;
}