Skip to content

Commit 8b5a06c

Browse files
committed
Add a new RectangleHandle control, extracted from the selection tool
- The goal is to allow this to be used in the Move Selected Pixels tool for scaling the selection in a more intuitive manner - The Select tool's behaviour should be unchanged, except for one minor difference. If the rectangle is inverted (e.g. dragging the bottom right corner before the top left corner), after a mouse up the rectangle resets itself to no longer be inverted so that the mouse cursors for each handle point in the expected direction - Add an option to `RectangleD.FromPoints()` to control the behaviour when the end point is before the start point. This can be clamped to a zero size rectangle (which will be used for the transform tools) rather than inverting the rectangle Bug: #585
1 parent 066d391 commit 8b5a06c

File tree

4 files changed

+368
-256
lines changed

4 files changed

+368
-256
lines changed

Pinta.Core/Classes/Rectangle.cs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,27 @@ double bottom
5151
bottom - top + 1);
5252

5353
/// <summary>
54-
/// Creates a rectangle with positive width & height from the provided points.
54+
/// Creates a rectangle from the provided points.
5555
/// Note that the second point will be the bottom right corner of the rectangle,
5656
/// and the pixel is not inside the rectangle itself.
57+
/// <param name="invertIfNegative">
58+
/// Flips the start and end points if necessary to produce a rectangle with positive width and height.
59+
/// Otherwise, a negative width or height is clamped to zero.
60+
/// </param>
5761
/// </summary>
58-
public static RectangleD FromPoints (in PointD a, in PointD b)
62+
public static RectangleD FromPoints (in PointD a, in PointD b, bool invertIfNegative = true)
5963
{
60-
double x1 = Math.Min (a.X, b.X);
61-
double y1 = Math.Min (a.Y, b.Y);
62-
double x2 = Math.Max (a.X, b.X);
63-
double y2 = Math.Max (a.Y, b.Y);
64-
return new (x1, y1, x2 - x1, y2 - y1);
64+
if (invertIfNegative) {
65+
double x1 = Math.Min (a.X, b.X);
66+
double y1 = Math.Min (a.Y, b.Y);
67+
double x2 = Math.Max (a.X, b.X);
68+
double y2 = Math.Max (a.Y, b.Y);
69+
return new (x1, y1, x2 - x1, y2 - y1);
70+
} else {
71+
double width = Math.Max (0.0, b.Y - a.X);
72+
double height = Math.Max (0.0, b.Y - a.Y);
73+
return new (a.X, a.Y, width, height);
74+
}
6575
}
6676

6777
public static RectangleD Zero { get; } = new (0d, 0d, 0d, 0d);
@@ -102,9 +112,18 @@ public readonly bool ContainsPoint (double x, double y)
102112
public readonly bool ContainsPoint (in PointD point)
103113
=> ContainsPoint (point.X, point.Y);
104114

115+
/// <summary>
116+
/// Position of the rectangle's top left corner.
117+
/// </summary>
105118
public readonly PointD Location ()
106119
=> new (X, Y);
107120

121+
/// <summary>
122+
/// Position of the rectangle's bottom right corner.
123+
/// </summary>
124+
public readonly PointD EndLocation ()
125+
=> new (X + Width, Y + Height);
126+
108127
public readonly PointD GetCenter ()
109128
=> new (X + 0.5 * Width, Y + 0.5 * Height);
110129

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
using System;
2+
using System.Collections.Immutable;
3+
using System.Linq;
4+
using Pinta.Core;
5+
6+
namespace Pinta.Tools;
7+
8+
/// <summary>
9+
/// A handle for specifying a rectangular region.
10+
/// </summary>
11+
public class RectangleHandle : IToolHandle
12+
{
13+
private PointD start_pt;
14+
private PointD end_pt;
15+
private Size image_size;
16+
private readonly ImmutableArray<MoveHandle> handles;
17+
private MoveHandle? active_handle;
18+
private PointD? drag_start_pos;
19+
20+
public RectangleHandle ()
21+
{
22+
handles = [
23+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeNW) },
24+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeSW) },
25+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeNE) },
26+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeSE) },
27+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeW) },
28+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeN) },
29+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeE) },
30+
new (){ Cursor = GdkExtensions.CursorFromName (Resources.StandardCursors.ResizeS) },
31+
];
32+
33+
foreach (var handle in handles)
34+
handle.Active = true;
35+
}
36+
37+
#region IToolHandle Implementation
38+
public bool Active { get; set; }
39+
40+
public void Draw (Gtk.Snapshot snapshot)
41+
{
42+
foreach (MoveHandle handle in handles)
43+
handle.Draw (snapshot);
44+
}
45+
#endregion
46+
47+
/// <summary>
48+
/// If enabled, dragging the end point before the start point
49+
/// flips the points to produce a valid rectangle, rather than
50+
/// clamping to an empty rectangle.
51+
/// </summary>
52+
public bool InvertIfNegative { get; init; }
53+
54+
/// <summary>
55+
/// Whether the user is currently dragging a corner of the rectangle.
56+
/// </summary>
57+
public bool IsDragging => drag_start_pos is not null;
58+
59+
/// <summary>
60+
/// The rectangle selected by the user.
61+
/// </summary>
62+
public RectangleD Rectangle {
63+
get => RectangleD.FromPoints (start_pt, end_pt, InvertIfNegative);
64+
set {
65+
start_pt = value.Location ();
66+
end_pt = value.EndLocation ();
67+
UpdateHandlePositions ();
68+
}
69+
}
70+
71+
/// <summary>
72+
/// Begins a drag operation if the mouse position is on top of a handle.
73+
/// Mouse movements are clamped to fall within the specified image size.
74+
/// </summary>
75+
public bool BeginDrag (in PointD canvasPos, in Size imageSize)
76+
{
77+
if (IsDragging)
78+
return false;
79+
80+
image_size = imageSize;
81+
82+
PointD viewPos = PintaCore.Workspace.CanvasPointToView (canvasPos);
83+
UpdateHandleUnderPoint (viewPos);
84+
85+
if (active_handle is null)
86+
return false;
87+
88+
drag_start_pos = viewPos;
89+
return true;
90+
}
91+
92+
/// <summary>
93+
/// Updates the rectangle as the mouse is moved.
94+
/// </summary>
95+
/// <returns>The region to redraw with InvalidateWindowRect()</returns>
96+
public RectangleI UpdateDrag (PointD canvasPos, bool shiftPressed)
97+
{
98+
if (!IsDragging || active_handle is null)
99+
throw new InvalidOperationException ("Drag operation has not been started!");
100+
101+
// Clamp mouse position to the image size.
102+
canvasPos = new PointD (
103+
Math.Round (Math.Clamp (canvasPos.X, 0, image_size.Width)),
104+
Math.Round (Math.Clamp (canvasPos.Y, 0, image_size.Height)));
105+
106+
RectangleI dirty = ComputeInvalidateRect ();
107+
108+
int activeHandleIndex = handles.IndexOf (active_handle);
109+
MoveActiveHandle (activeHandleIndex, canvasPos.X, canvasPos.Y, shiftPressed);
110+
UpdateHandlePositions ();
111+
112+
dirty = dirty.Union (ComputeInvalidateRect ());
113+
return dirty;
114+
}
115+
116+
/// <summary>
117+
/// If a drag operation is active, returns whether the mouse has actually moved.
118+
/// This can be used to distinguish a "click" from a "click and drag".
119+
/// </summary>
120+
public bool HasDragged (PointD canvasPos)
121+
{
122+
if (drag_start_pos is null)
123+
throw new InvalidOperationException ("Drag operation has not been started!");
124+
125+
PointD viewPos = PintaCore.Workspace.CanvasPointToView (canvasPos);
126+
return drag_start_pos.Value.Distance (viewPos) > 1;
127+
}
128+
129+
/// <summary>
130+
/// Finishes a drag operation.
131+
/// </summary>
132+
public void EndDrag ()
133+
{
134+
if (drag_start_pos is null)
135+
throw new InvalidOperationException ("Drag operation has not been started!");
136+
137+
image_size = Size.Empty;
138+
active_handle = null;
139+
drag_start_pos = null;
140+
141+
// If the rectangle was inverted, fix inverted start/end points.
142+
RectangleD rect = Rectangle;
143+
start_pt = rect.Location ();
144+
end_pt = rect.EndLocation ();
145+
}
146+
147+
/// <summary>
148+
/// The cursor to display, if the cursor is over a corner of the rectangle.
149+
/// </summary>
150+
public Gdk.Cursor? GetCursorAtPoint (PointD viewPos)
151+
=> handles.FirstOrDefault (c => c.ContainsPoint (viewPos))?.Cursor;
152+
153+
private void UpdateHandlePositions ()
154+
{
155+
RectangleD rect = Rectangle;
156+
PointD center = rect.GetCenter ();
157+
handles[0].CanvasPosition = new PointD (rect.Left, rect.Top);
158+
handles[1].CanvasPosition = new PointD (rect.Left, rect.Bottom);
159+
handles[2].CanvasPosition = new PointD (rect.Right, rect.Top);
160+
handles[3].CanvasPosition = new PointD (rect.Right, rect.Bottom);
161+
handles[4].CanvasPosition = new PointD (rect.Left, center.Y);
162+
handles[5].CanvasPosition = new PointD (center.X, rect.Top);
163+
handles[6].CanvasPosition = new PointD (rect.Right, center.Y);
164+
handles[7].CanvasPosition = new PointD (center.X, rect.Bottom);
165+
}
166+
167+
private void UpdateHandleUnderPoint (PointD viewPos)
168+
{
169+
active_handle = handles.FirstOrDefault (c => c.ContainsPoint (viewPos));
170+
171+
// If the rectangle is empty (e.g. starting a new drag), all the handles are
172+
// at the same position so pick the bottom right corner.
173+
RectangleD rect = Rectangle;
174+
if (active_handle is not null && rect is { Width: 0.0, Height: 0.0 })
175+
active_handle = handles[3];
176+
}
177+
178+
private void MoveActiveHandle (int handle, double x, double y, bool shiftPressed)
179+
{
180+
// Update the rectangle's size depending on which handle was dragged.
181+
switch (handle) {
182+
case 0:
183+
start_pt = new (x, y);
184+
185+
if (!shiftPressed) return;
186+
187+
start_pt =
188+
(end_pt.X - start_pt.X <= end_pt.Y - start_pt.Y)
189+
? (start_pt with { X = end_pt.X - end_pt.Y + start_pt.Y })
190+
: (start_pt with { Y = end_pt.Y - end_pt.X + start_pt.X });
191+
192+
return;
193+
194+
case 1:
195+
start_pt = start_pt with { X = x };
196+
end_pt = end_pt with { Y = y };
197+
198+
if (!shiftPressed) return;
199+
200+
if (end_pt.X - start_pt.X <= end_pt.Y - start_pt.Y)
201+
start_pt = start_pt with { X = end_pt.X - end_pt.Y + start_pt.Y };
202+
else
203+
end_pt = end_pt with { Y = start_pt.Y + end_pt.X - start_pt.X };
204+
205+
return;
206+
207+
case 2:
208+
end_pt = end_pt with { X = x };
209+
start_pt = start_pt with { Y = y };
210+
211+
if (!shiftPressed) return;
212+
213+
if (end_pt.X - start_pt.X <= end_pt.Y - start_pt.Y)
214+
end_pt = end_pt with { X = start_pt.X + end_pt.Y - start_pt.Y };
215+
else
216+
start_pt = start_pt with { Y = end_pt.Y - end_pt.X + start_pt.X };
217+
218+
return;
219+
220+
case 3:
221+
end_pt = new (x, y);
222+
223+
if (!shiftPressed)
224+
return;
225+
226+
if (end_pt.X - start_pt.X <= end_pt.Y - start_pt.Y)
227+
end_pt = end_pt with { X = start_pt.X + end_pt.Y - start_pt.Y };
228+
else
229+
end_pt = end_pt with { Y = start_pt.Y + end_pt.X - start_pt.X };
230+
231+
return;
232+
233+
case 4:
234+
start_pt = start_pt with { X = x };
235+
236+
if (!shiftPressed) return;
237+
238+
double d4 = end_pt.X - start_pt.X;
239+
start_pt = start_pt with { Y = (start_pt.Y + end_pt.Y - d4) / 2 };
240+
end_pt = end_pt with { Y = (start_pt.Y + end_pt.Y + d4) / 2 };
241+
242+
return;
243+
244+
case 5:
245+
start_pt = start_pt with { Y = y };
246+
247+
if (!shiftPressed) return;
248+
249+
double d5 = end_pt.Y - start_pt.Y;
250+
start_pt = start_pt with { X = (start_pt.X + end_pt.X - d5) / 2 };
251+
end_pt = end_pt with { X = (start_pt.X + end_pt.X + d5) / 2 };
252+
253+
return;
254+
255+
case 6:
256+
end_pt = end_pt with { X = x };
257+
258+
if (!shiftPressed) return;
259+
260+
double d6 = end_pt.X - start_pt.X;
261+
start_pt = start_pt with { Y = (start_pt.Y + end_pt.Y - d6) / 2 };
262+
end_pt = end_pt with { Y = (start_pt.Y + end_pt.Y + d6) / 2 };
263+
264+
return;
265+
266+
case 7:
267+
end_pt = end_pt with { Y = y };
268+
269+
if (!shiftPressed) return;
270+
271+
double d7 = end_pt.Y - start_pt.Y;
272+
start_pt = start_pt with { X = (start_pt.X + end_pt.X - d7) / 2 };
273+
end_pt = end_pt with { X = (start_pt.X + end_pt.X + d7) / 2 };
274+
275+
return;
276+
277+
default:
278+
throw new ArgumentOutOfRangeException (nameof (handle));
279+
}
280+
}
281+
282+
/// <summary>
283+
/// Bounding rectangle to use with InvalidateWindowRect() when triggering a redraw.
284+
/// </summary>
285+
private RectangleI ComputeInvalidateRect ()
286+
=> MoveHandle.UnionInvalidateRects (handles);
287+
}

0 commit comments

Comments
 (0)