Skip to content

Commit dffd482

Browse files
Merge pull request #37 from RuntimeRascal/feat/add-text-tool
Feat/add text tool
2 parents 65b5f23 + 2a9a537 commit dffd482

31 files changed

+2821
-757
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to GhostDraw will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v1.0.17
9+
10+
### Added
11+
- **Text Tool**
12+
- Added a lock-mode-only Text tool (hotkey `T`) with multi-line entry and outside-click commit; text respects brush color/size and stays non-editable after commit.
13+
- Hardened input safety during text sessions: only activation hotkey, `F1`, and `ESC` remain active; other GhostDraw shortcuts aren’t triggered or suppressed while typing.
14+
- Integrated text into history/undo/clear and eraser; multi-monitor overlays record committed text with proper overlay IDs.
15+
16+
### Changed
17+
- Help modal now show's tool usage instruction and is re-styled.
18+
19+
### Fixed
20+
- 2 hotkey presses required to activate draw mode after startup.
21+
- Ghost drawings briefly appearing on re-activations.
822

923
## v1.0.16
1024

Installer/GhostDraw.Installer.wixproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project Sdk="WixToolset.Sdk/4.0.5">
22
<PropertyGroup>
3-
<Version Condition="'$(Version)' == ''">1.0.16</Version>
3+
<Version Condition="'$(Version)' == ''">1.0.17</Version>
44
<OutputName>GhostDrawSetup-$(Version)</OutputName>
55
<OutputType>Package</OutputType>
66
<Platform>x64</Platform>

Src/GhostDraw/App.xaml.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ protected override void OnStartup(StartupEventArgs e)
8080
_keyboardHook.EraserToolPressed += OnEraserToolPressed;
8181
_keyboardHook.RectangleToolPressed += OnRectangleToolPressed;
8282
_keyboardHook.CircleToolPressed += OnCircleToolPressed;
83+
_keyboardHook.TextToolPressed += OnTextToolPressed;
8384
_keyboardHook.HelpPressed += OnHelpPressed;
8485
_keyboardHook.ScreenshotFullPressed += OnScreenshotFullPressed;
8586
_keyboardHook.UndoPressed += OnUndoPressed;
@@ -359,6 +360,23 @@ private void OnCircleToolPressed(object? sender, EventArgs e)
359360
}
360361
}
361362

363+
private void OnTextToolPressed(object? sender, EventArgs e)
364+
{
365+
try
366+
{
367+
// Only switch to text tool if drawing mode is active
368+
if (_drawingManager?.IsDrawingMode == true)
369+
{
370+
_logger?.LogInformation("T pressed - selecting text tool");
371+
_drawingManager?.SetTextTool();
372+
}
373+
}
374+
catch (Exception ex)
375+
{
376+
_exceptionHandler?.HandleException(ex, "Text tool handler");
377+
}
378+
}
379+
362380
private void OnHelpPressed(object? sender, EventArgs e)
363381
{
364382
try

Src/GhostDraw/Core/DrawTool.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,10 @@ public enum DrawTool
3636
/// <summary>
3737
/// Arrow tool - click two points to draw a line with an arrowhead
3838
/// </summary>
39-
Arrow
39+
Arrow,
40+
41+
/// <summary>
42+
/// Text tool - click to start typing, click outside to commit
43+
/// </summary>
44+
Text
4045
}

Src/GhostDraw/Core/GlobalKeyboardHook.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public class GlobalKeyboardHook : IDisposable
1919
private const int VK_E = 0x45; // 69 - 'E' key for eraser tool
2020
private const int VK_U = 0x55; // 85 - 'U' key for rectangle tool
2121
private const int VK_C = 0x43; // 67 - 'C' key for circle tool
22+
private const int VK_T = 0x54; // 84 - 'T' key for text tool
2223
private const int VK_F1 = 0x70; // 112 - 'F1' key for help
2324
private const int VK_S = 0x53; // 83 - 'S' key for screenshot (Ctrl+S only)
2425
private const int VK_Z = 0x5A; // 90 - 'Z' key for undo (Ctrl+Z only)
@@ -42,6 +43,7 @@ public class GlobalKeyboardHook : IDisposable
4243
public event EventHandler? EraserToolPressed;
4344
public event EventHandler? RectangleToolPressed;
4445
public event EventHandler? CircleToolPressed;
46+
public event EventHandler? TextToolPressed;
4547
public event EventHandler? HelpPressed;
4648
public event EventHandler? ScreenshotFullPressed;
4749
public event EventHandler? UndoPressed;
@@ -59,6 +61,9 @@ public class GlobalKeyboardHook : IDisposable
5961
// Drawing mode state - used to determine if we should suppress keys
6062
private volatile bool _isDrawingModeActive = false;
6163

64+
// Text entry session state - when active, GhostDraw must not steal/suppress typing keys
65+
private volatile bool _isTextSessionActive = false;
66+
6267
public GlobalKeyboardHook(ILogger<GlobalKeyboardHook> logger)
6368
{
6469
_logger = logger;
@@ -218,6 +223,24 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
218223
EscapePressed?.Invoke(this, EventArgs.Empty);
219224
}
220225

226+
// While a text session is active, do not fire tool/action shortcuts
227+
// and do not suppress keys needed for typing. Still allow:
228+
// - ESC (emergency exit)
229+
// - F1 (help)
230+
// - activation hotkey (HotkeyPressed/Released)
231+
if (_isTextSessionActive)
232+
{
233+
if (vkCode == VK_F1 && isKeyDown)
234+
{
235+
_logger.LogDebug("F1 key pressed - help request (text session active)");
236+
HelpPressed?.Invoke(this, EventArgs.Empty);
237+
}
238+
239+
// Skip the rest of GhostDraw key handling (Delete/Ctrl+Z/Ctrl+S/tool keys)
240+
// so normal typing works inside the overlay text box.
241+
goto TrackHotkeyState;
242+
}
243+
221244
// Check for Delete key press (clear canvas - only when drawing mode is active)
222245
if ((vkCode == VK_DELETE) && isKeyDown && _isDrawingModeActive)
223246
{
@@ -275,6 +298,13 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
275298
CircleToolPressed?.Invoke(this, EventArgs.Empty);
276299
}
277300

301+
// Check for T key press (text tool)
302+
if (vkCode == VK_T && isKeyDown)
303+
{
304+
_logger.LogDebug("T key pressed - text tool request");
305+
TextToolPressed?.Invoke(this, EventArgs.Empty);
306+
}
307+
278308
// Check for F1 key press (help)
279309
if (vkCode == VK_F1 && isKeyDown)
280310
{
@@ -320,6 +350,7 @@ private nint HookCallback(int nCode, nint wParam, nint lParam)
320350
}
321351

322352
// Track hotkey state
353+
TrackHotkeyState:
323354
if (_hotkeyVKs.Contains(vkCode))
324355
{
325356
_keyStates[vkCode] = isKeyDown;
@@ -402,6 +433,26 @@ public void SetDrawingModeActive(bool isActive)
402433
}
403434
}
404435

436+
/// <summary>
437+
/// Sets whether a text entry session is active. When active, GhostDraw won't trigger
438+
/// most shortcuts (tool keys, clear, undo, screenshot) and won't suppress typing keys.
439+
/// </summary>
440+
public void SetTextSessionActive(bool isActive)
441+
{
442+
_isTextSessionActive = isActive;
443+
_logger.LogInformation("Text session active: {IsActive}", isActive);
444+
}
445+
446+
// Internal helpers for unit tests
447+
public static bool ShouldSuppressDelete(bool isKeyDown, bool isDrawingModeActive, bool isTextSessionActive)
448+
=> isKeyDown && isDrawingModeActive && !isTextSessionActive;
449+
450+
public static bool ShouldSuppressCtrlS(bool isKeyDown, bool isControlPressed, bool isDrawingModeActive, bool isTextSessionActive)
451+
=> isKeyDown && isControlPressed && isDrawingModeActive && !isTextSessionActive;
452+
453+
public static bool ShouldSuppressCtrlZ(bool isKeyDown, bool isControlPressed, bool isDrawingModeActive, bool isTextSessionActive)
454+
=> isKeyDown && isControlPressed && isDrawingModeActive && !isTextSessionActive;
455+
405456
// P/Invoke declarations
406457
private delegate nint LowLevelKeyboardProc(int nCode, nint wParam, nint lParam);
407458

Src/GhostDraw/Core/ServiceConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public static ServiceProvider ConfigureServices()
6767
services.AddTransient<GhostDraw.Tools.EraserTool>();
6868
services.AddTransient<GhostDraw.Tools.RectangleTool>();
6969
services.AddTransient<GhostDraw.Tools.CircleTool>();
70+
services.AddTransient<GhostDraw.Tools.TextTool>();
7071

7172
services.AddTransient<OverlayWindow>();
7273
services.AddSingleton<MultiOverlayWindowOrchestrator>();

Src/GhostDraw/GhostDraw.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<UseWPF>true</UseWPF>
99
<UseWindowsForms>true</UseWindowsForms>
1010
<ApplicationIcon>Assets\favicon.ico</ApplicationIcon>
11-
<Version>1.0.16</Version>
11+
<Version>1.0.17</Version>
1212
<EnableWindowsTargeting>true</EnableWindowsTargeting>
1313
</PropertyGroup>
1414

Src/GhostDraw/Managers/DrawingManager.cs

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Threading.Tasks;
2+
using System.Windows.Threading;
13
using GhostDraw.Core;
24
using GhostDraw.Services;
35
using GhostDraw.Views;
@@ -12,6 +14,7 @@ public class DrawingManager
1214
private readonly AppSettingsService _appSettings;
1315
private readonly ScreenshotService _screenshotService;
1416
private readonly GlobalKeyboardHook _keyboardHook;
17+
private readonly Dispatcher _dispatcher;
1518
private bool _isDrawingLocked = false;
1619

1720
// Delay in milliseconds before re-showing overlay after opening snipping tool
@@ -28,15 +31,23 @@ public DrawingManager(ILogger<DrawingManager> logger, IOverlayWindow overlayWind
2831
_appSettings = appSettings;
2932
_screenshotService = screenshotService;
3033
_keyboardHook = keyboardHook;
34+
_dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
3135

32-
// Initialize lock mode state from saved settings
33-
_isDrawingLocked = _appSettings.CurrentSettings.LockDrawingMode;
36+
// LockDrawingMode controls behavior (toggle vs hold) but the locked state should start off
37+
_isDrawingLocked = false;
3438

35-
_logger.LogDebug("DrawingManager initialized - LockDrawingMode={LockMode}", _isDrawingLocked);
39+
_logger.LogDebug("DrawingManager initialized - LockDrawingModeBehavior={LockMode} StartingLocked={IsLocked}",
40+
_appSettings.CurrentSettings.LockDrawingMode, _isDrawingLocked);
3641
}
3742

3843
public void EnableDrawing()
3944
{
45+
if (!_dispatcher.CheckAccess())
46+
{
47+
_dispatcher.BeginInvoke(new Action(EnableDrawing));
48+
return;
49+
}
50+
4051
try
4152
{
4253
var settings = _appSettings.CurrentSettings;
@@ -49,7 +60,7 @@ public void EnableDrawing()
4960
_logger.LogInformation("Toggling drawing mode OFF (was locked)");
5061
_isDrawingLocked = false;
5162
_overlayWindow.DisableDrawing();
52-
_overlayWindow.Hide();
63+
ScheduleOverlayHide();
5364

5465
// Notify hook that drawing mode is inactive
5566
_keyboardHook.SetDrawingModeActive(false);
@@ -90,7 +101,7 @@ public void EnableDrawing()
90101
try
91102
{
92103
_overlayWindow.DisableDrawing();
93-
_overlayWindow.Hide();
104+
ScheduleOverlayHide();
94105
_isDrawingLocked = false;
95106
_keyboardHook.SetDrawingModeActive(false);
96107
}
@@ -104,6 +115,12 @@ public void EnableDrawing()
104115

105116
public void DisableDrawing()
106117
{
118+
if (!_dispatcher.CheckAccess())
119+
{
120+
_dispatcher.BeginInvoke(new Action(DisableDrawing));
121+
return;
122+
}
123+
107124
try
108125
{
109126
var settings = _appSettings.CurrentSettings;
@@ -120,7 +137,7 @@ public void DisableDrawing()
120137
// Hold mode: disable when hotkey is released
121138
_logger.LogInformation("Disabling drawing mode (hold released)");
122139
_overlayWindow.DisableDrawing();
123-
_overlayWindow.Hide();
140+
ScheduleOverlayHide();
124141

125142
// Notify hook that drawing mode is inactive
126143
_keyboardHook.SetDrawingModeActive(false);
@@ -146,6 +163,12 @@ public void DisableDrawing()
146163

147164
public void ForceDisableDrawing()
148165
{
166+
if (!_dispatcher.CheckAccess())
167+
{
168+
_dispatcher.BeginInvoke(new Action(ForceDisableDrawing));
169+
return;
170+
}
171+
149172
try
150173
{
151174
_logger.LogInformation("ESC pressed - checking help visibility");
@@ -159,7 +182,7 @@ public void ForceDisableDrawing()
159182
_logger.LogDebug("Force disabling drawing mode");
160183
_isDrawingLocked = false;
161184
_overlayWindow.DisableDrawing();
162-
_overlayWindow.Hide();
185+
ScheduleOverlayHide();
163186

164187
// Notify hook that drawing mode is inactive
165188
_keyboardHook.SetDrawingModeActive(false);
@@ -194,12 +217,18 @@ public void ForceDisableDrawing()
194217
public void DisableDrawingMode()
195218
{
196219
// Public method for emergency state reset
220+
if (!_dispatcher.CheckAccess())
221+
{
222+
_dispatcher.BeginInvoke(new Action(DisableDrawingMode));
223+
return;
224+
}
225+
197226
try
198227
{
199228
_logger.LogWarning("DisableDrawingMode called (likely from emergency reset)");
200229
_isDrawingLocked = false;
201230
_overlayWindow.DisableDrawing();
202-
_overlayWindow.Hide();
231+
ScheduleOverlayHide();
203232

204233
// Notify hook that drawing mode is inactive
205234
_keyboardHook.SetDrawingModeActive(false);
@@ -211,6 +240,31 @@ public void DisableDrawingMode()
211240
}
212241
}
213242

243+
private void ScheduleOverlayHide()
244+
{
245+
// Allow the cleared canvas to render before hiding to avoid ghosting when reactivating.
246+
if (_overlayWindow is OverlayWindow or MultiOverlayWindowOrchestrator)
247+
{
248+
_dispatcher.InvokeAsync(async () =>
249+
{
250+
try
251+
{
252+
await Task.Delay(30); // ~2 frames at 60fps
253+
_overlayWindow.Hide();
254+
}
255+
catch (Exception ex)
256+
{
257+
_logger.LogDebug(ex, "Delayed overlay hide failed (safe to ignore)");
258+
}
259+
}, DispatcherPriority.Background);
260+
}
261+
else
262+
{
263+
// Tests use mock overlay implementations; hide synchronously so verifications succeed.
264+
_overlayWindow.Hide();
265+
}
266+
}
267+
214268
/// <summary>
215269
/// Toggles between Pen and Line drawing tools
216270
/// </summary>
@@ -386,6 +440,31 @@ public void SetCircleTool()
386440
}
387441
}
388442

443+
/// <summary>
444+
/// Sets the active tool to Text
445+
/// </summary>
446+
public void SetTextTool()
447+
{
448+
try
449+
{
450+
if (_overlayWindow.IsVisible)
451+
{
452+
_appSettings.SetActiveTool(DrawTool.Text);
453+
_overlayWindow.OnToolChanged(DrawTool.Text);
454+
_logger.LogInformation("Tool set to Text");
455+
}
456+
else
457+
{
458+
_logger.LogDebug("SetTextTool ignored - overlay not visible");
459+
}
460+
}
461+
catch (Exception ex)
462+
{
463+
_logger.LogError(ex, "Failed to set text tool");
464+
// Don't re-throw - not critical
465+
}
466+
}
467+
389468
/// <summary>
390469
/// Toggles the help popup with keyboard shortcuts
391470
/// </summary>

Src/GhostDraw/Tools/EraserTool.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,22 @@ private void EraseAtPosition(Point position, Canvas canvas)
219219
_logger.LogDebug(ex, "Failed to compute Path bounds for erasing");
220220
}
221221
}
222+
else if (element is TextBlock textBlock)
223+
{
224+
double left = Canvas.GetLeft(textBlock);
225+
double top = Canvas.GetTop(textBlock);
226+
double width = textBlock.ActualWidth > 0 ? textBlock.ActualWidth : textBlock.Width;
227+
double height = textBlock.ActualHeight > 0 ? textBlock.ActualHeight : textBlock.FontSize * 1.5;
228+
229+
if (!double.IsNaN(left) && !double.IsNaN(top) && width > 0 && height > 0)
230+
{
231+
Rect textRect = new Rect(left, top, width, height);
232+
if (eraserRect.IntersectsWith(textRect))
233+
{
234+
shouldErase = true;
235+
}
236+
}
237+
}
222238

223239
if (shouldErase)
224240
{

0 commit comments

Comments
 (0)