Skip to content

Commit 78318f9

Browse files
committed
feat: arrow tool
1 parent 8a7b56b commit 78318f9

File tree

13 files changed

+465
-60
lines changed

13 files changed

+465
-60
lines changed

Src/GhostDraw/App.xaml.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
using System.Windows;
2+
using GhostDraw.Core;
3+
using GhostDraw.Managers;
4+
using GhostDraw.Services;
5+
using GhostDraw.Views;
26
using Microsoft.Extensions.DependencyInjection;
37
using Microsoft.Extensions.Logging;
48
using Serilog.Events;
5-
using GhostDraw.Services;
69
using Application = System.Windows.Application;
7-
using GhostDraw.Managers;
8-
using GhostDraw.Core;
9-
using GhostDraw.Views;
1010

1111
namespace GhostDraw;
1212

@@ -76,6 +76,7 @@ protected override void OnStartup(StartupEventArgs e)
7676
_keyboardHook.ClearCanvasPressed += OnClearCanvasPressed;
7777
_keyboardHook.PenToolPressed += OnPenToolPressed;
7878
_keyboardHook.LineToolPressed += OnLineToolPressed;
79+
_keyboardHook.ArrowToolPressed += OnArrowToolPressed;
7980
_keyboardHook.EraserToolPressed += OnEraserToolPressed;
8081
_keyboardHook.RectangleToolPressed += OnRectangleToolPressed;
8182
_keyboardHook.CircleToolPressed += OnCircleToolPressed;
@@ -290,6 +291,23 @@ private void OnLineToolPressed(object? sender, EventArgs e)
290291
}
291292
}
292293

294+
private void OnArrowToolPressed(object? sender, EventArgs e)
295+
{
296+
try
297+
{
298+
// Only switch to arrow tool if drawing mode is active
299+
if (_drawingManager?.IsDrawingMode == true)
300+
{
301+
_logger?.LogInformation("A pressed - selecting arrow tool");
302+
_drawingManager?.SetArrowTool();
303+
}
304+
}
305+
catch (Exception ex)
306+
{
307+
_exceptionHandler?.HandleException(ex, "Arrow tool handler");
308+
}
309+
}
310+
293311
private void OnEraserToolPressed(object? sender, EventArgs e)
294312
{
295313
try

Src/GhostDraw/Core/DrawTool.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,10 @@ public enum DrawTool
3131
/// <summary>
3232
/// Circle tool - click two points to draw a circle/ellipse
3333
/// </summary>
34-
Circle
34+
Circle,
35+
36+
/// <summary>
37+
/// Arrow tool - click two points to draw a line with an arrowhead
38+
/// </summary>
39+
Arrow
3540
}

Src/GhostDraw/Core/GlobalKeyboardHook.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public class GlobalKeyboardHook : IDisposable
1313
// Only keep VK_ESCAPE constant (emergency exit)
1414
private const int VK_ESCAPE = 0x1B; // 27
1515
private const int VK_DELETE = 0x2E; // 46 - 'Delete' key for clear canvas
16+
private const int VK_A = 0x41; // 65 - 'A' key for arrow tool
1617
private const int VK_L = 0x4C; // 76 - 'L' key for line tool
1718
private const int VK_P = 0x50; // 80 - 'P' key for pen tool
1819
private const int VK_E = 0x45; // 69 - 'E' key for eraser tool
@@ -37,6 +38,7 @@ public class GlobalKeyboardHook : IDisposable
3738
public event EventHandler? ClearCanvasPressed;
3839
public event EventHandler? PenToolPressed;
3940
public event EventHandler? LineToolPressed;
41+
public event EventHandler? ArrowToolPressed;
4042
public event EventHandler? EraserToolPressed;
4143
public event EventHandler? RectangleToolPressed;
4244
public event EventHandler? CircleToolPressed;
@@ -221,7 +223,7 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
221223
{
222224
_logger.LogDebug("Delete key pressed - clear canvas confirmation request");
223225
ClearCanvasPressed?.Invoke(this, EventArgs.Empty);
224-
226+
225227
// INTENTIONAL: Suppress Delete key when drawing mode is active to prevent deleting
226228
// content in underlying apps. This is different from other tool keys (P, L, E, etc.)
227229
// which are less likely to cause data loss in underlying applications.
@@ -238,6 +240,13 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
238240
LineToolPressed?.Invoke(this, EventArgs.Empty);
239241
}
240242

243+
// Check for A key press (arrow tool)
244+
if (vkCode == VK_A && isKeyDown)
245+
{
246+
_logger.LogDebug("A key pressed - arrow tool request");
247+
ArrowToolPressed?.Invoke(this, EventArgs.Empty);
248+
}
249+
241250
// Check for P key press (pen tool)
242251
if (vkCode == VK_P && isKeyDown)
243252
{

Src/GhostDraw/Core/ServiceConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public static ServiceProvider ConfigureServices()
6363
// Register drawing tools
6464
services.AddSingleton<GhostDraw.Tools.PenTool>();
6565
services.AddSingleton<GhostDraw.Tools.LineTool>();
66+
services.AddSingleton<GhostDraw.Tools.ArrowTool>();
6667
services.AddSingleton<GhostDraw.Tools.EraserTool>();
6768
services.AddSingleton<GhostDraw.Tools.RectangleTool>();
6869
services.AddSingleton<GhostDraw.Tools.CircleTool>();

Src/GhostDraw/Managers/DrawingManager.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,31 @@ public void SetLineTool()
286286
}
287287
}
288288

289+
/// <summary>
290+
/// Sets the active tool to Arrow
291+
/// </summary>
292+
public void SetArrowTool()
293+
{
294+
try
295+
{
296+
if (_overlayWindow.IsVisible)
297+
{
298+
_appSettings.SetActiveTool(DrawTool.Arrow);
299+
_overlayWindow.OnToolChanged(DrawTool.Arrow);
300+
_logger.LogInformation("Tool set to Arrow");
301+
}
302+
else
303+
{
304+
_logger.LogDebug("SetArrowTool ignored - overlay not visible");
305+
}
306+
}
307+
catch (Exception ex)
308+
{
309+
_logger.LogError(ex, "Failed to set arrow tool");
310+
// Don't re-throw - not critical
311+
}
312+
}
313+
289314
/// <summary>
290315
/// Sets the active tool to Eraser
291316
/// </summary>
@@ -395,7 +420,7 @@ public void RequestClearCanvas()
395420
if (_overlayWindow.IsVisible)
396421
{
397422
_logger.LogInformation("Requesting clear canvas confirmation (Delete key)");
398-
423+
399424
// Show confirmation modal with callbacks
400425
_overlayWindow.ShowClearCanvasConfirmation(
401426
onConfirm: () =>

Src/GhostDraw/Tools/ArrowTool.cs

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
using System.Linq;
2+
using System.Windows;
3+
using System.Windows.Controls;
4+
using System.Windows.Input;
5+
using System.Windows.Media;
6+
using System.Windows.Shapes;
7+
using Microsoft.Extensions.Logging;
8+
using Brush = System.Windows.Media.Brush;
9+
using Brushes = System.Windows.Media.Brushes;
10+
using Color = System.Windows.Media.Color;
11+
using ColorConverter = System.Windows.Media.ColorConverter;
12+
using Point = System.Windows.Point;
13+
using Vector = System.Windows.Vector;
14+
15+
namespace GhostDraw.Tools;
16+
17+
/// <summary>
18+
/// Arrow drawing tool - click two points to draw a line with an arrowhead at the end.
19+
/// </summary>
20+
public class ArrowTool(ILogger<ArrowTool> logger) : IDrawingTool
21+
{
22+
private readonly ILogger<ArrowTool> _logger = logger;
23+
private Path? _currentPath;
24+
private Point? _startPoint;
25+
private bool _isCreatingArrow;
26+
private string _currentColor = "#FF0000";
27+
private double _currentThickness = 3.0;
28+
29+
public event EventHandler<DrawingActionCompletedEventArgs>? ActionCompleted;
30+
31+
public void OnMouseDown(Point position, Canvas canvas)
32+
{
33+
if (!_isCreatingArrow)
34+
{
35+
StartNewArrow(position, canvas);
36+
}
37+
else
38+
{
39+
FinishArrow(position);
40+
}
41+
}
42+
43+
public void OnMouseMove(Point position, Canvas canvas, MouseButtonState leftButtonState)
44+
{
45+
if (_isCreatingArrow && _currentPath != null && _startPoint.HasValue)
46+
{
47+
_currentPath.Data = BuildArrowGeometry(_startPoint.Value, position, _currentThickness);
48+
}
49+
}
50+
51+
public void OnMouseUp(Point position, Canvas canvas)
52+
{
53+
// Arrow tool uses click-click, not click-drag
54+
}
55+
56+
public void OnActivated()
57+
{
58+
_logger.LogDebug("Arrow tool activated");
59+
}
60+
61+
public void OnDeactivated()
62+
{
63+
_logger.LogDebug("Arrow tool deactivated");
64+
_currentPath = null;
65+
_startPoint = null;
66+
_isCreatingArrow = false;
67+
}
68+
69+
public void OnColorChanged(string colorHex)
70+
{
71+
_currentColor = colorHex;
72+
73+
if (_currentPath != null)
74+
{
75+
var brush = CreateBrushFromHex(colorHex);
76+
_currentPath.Stroke = brush;
77+
_currentPath.Fill = brush;
78+
}
79+
80+
_logger.LogDebug("Arrow color changed to {Color}", colorHex);
81+
}
82+
83+
public void OnThicknessChanged(double thickness)
84+
{
85+
_currentThickness = thickness;
86+
87+
if (_currentPath != null)
88+
{
89+
_currentPath.StrokeThickness = thickness;
90+
91+
if (_startPoint.HasValue)
92+
{
93+
// Rebuild geometry so arrowhead scales with thickness
94+
var endPoint = GetCurrentEndPoint(_currentPath) ?? _startPoint.Value;
95+
_currentPath.Data = BuildArrowGeometry(_startPoint.Value, endPoint, thickness);
96+
}
97+
}
98+
99+
_logger.LogDebug("Arrow thickness changed to {Thickness}", thickness);
100+
}
101+
102+
public void Cancel(Canvas canvas)
103+
{
104+
if (_currentPath != null)
105+
{
106+
canvas.Children.Remove(_currentPath);
107+
_currentPath = null;
108+
_startPoint = null;
109+
_isCreatingArrow = false;
110+
_logger.LogDebug("In-progress arrow cancelled and removed");
111+
}
112+
}
113+
114+
private void StartNewArrow(Point startPoint, Canvas canvas)
115+
{
116+
_startPoint = startPoint;
117+
_isCreatingArrow = true;
118+
119+
var brush = CreateBrushFromHex(_currentColor);
120+
121+
_currentPath = new Path
122+
{
123+
Stroke = brush,
124+
Fill = brush,
125+
StrokeThickness = _currentThickness,
126+
StrokeStartLineCap = PenLineCap.Round,
127+
StrokeEndLineCap = PenLineCap.Round,
128+
StrokeLineJoin = PenLineJoin.Round,
129+
Data = BuildArrowGeometry(startPoint, startPoint, _currentThickness)
130+
};
131+
132+
canvas.Children.Add(_currentPath);
133+
_logger.LogInformation("Arrow started at ({X:F0}, {Y:F0})", startPoint.X, startPoint.Y);
134+
}
135+
136+
private void FinishArrow(Point endPoint)
137+
{
138+
if (_currentPath != null && _startPoint.HasValue)
139+
{
140+
_currentPath.Data = BuildArrowGeometry(_startPoint.Value, endPoint, _currentThickness);
141+
_logger.LogInformation("Arrow finished at ({X:F0}, {Y:F0})", endPoint.X, endPoint.Y);
142+
143+
ActionCompleted?.Invoke(this, new DrawingActionCompletedEventArgs(_currentPath));
144+
}
145+
146+
_currentPath = null;
147+
_startPoint = null;
148+
_isCreatingArrow = false;
149+
}
150+
151+
private Geometry BuildArrowGeometry(Point start, Point end, double thickness)
152+
{
153+
var group = new GeometryGroup
154+
{
155+
FillRule = FillRule.Nonzero
156+
};
157+
158+
// Shaft
159+
group.Children.Add(new LineGeometry(start, end));
160+
161+
// Arrowhead
162+
var delta = end - start;
163+
if (delta.Length < 0.001)
164+
{
165+
return group;
166+
}
167+
168+
Vector dir = delta;
169+
dir.Normalize();
170+
171+
// Perpendicular vector
172+
var perp = new Vector(-dir.Y, dir.X);
173+
174+
double arrowLength = Math.Max(12.0, thickness * 4.0);
175+
double arrowWidth = Math.Max(8.0, thickness * 3.0);
176+
177+
var tip = end;
178+
var baseCenter = end - dir * arrowLength;
179+
var left = baseCenter + perp * (arrowWidth / 2.0);
180+
var right = baseCenter - perp * (arrowWidth / 2.0);
181+
182+
var figure = new PathFigure
183+
{
184+
StartPoint = tip,
185+
IsClosed = true,
186+
IsFilled = true
187+
};
188+
figure.Segments.Add(new LineSegment(left, true));
189+
figure.Segments.Add(new LineSegment(right, true));
190+
191+
group.Children.Add(new PathGeometry(new[] { figure }));
192+
193+
return group;
194+
}
195+
196+
private Brush CreateBrushFromHex(string colorHex)
197+
{
198+
try
199+
{
200+
return new SolidColorBrush((Color)ColorConverter.ConvertFromString(colorHex));
201+
}
202+
catch (Exception ex)
203+
{
204+
_logger.LogWarning(ex, "Failed to parse brush color {Color}, using default red", colorHex);
205+
return Brushes.Red;
206+
}
207+
}
208+
209+
private static Point? GetCurrentEndPoint(Path path)
210+
{
211+
if (path.Data is GeometryGroup group)
212+
{
213+
var line = group.Children.OfType<LineGeometry>().FirstOrDefault();
214+
return line?.EndPoint;
215+
}
216+
217+
return null;
218+
}
219+
}

0 commit comments

Comments
 (0)