diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 9e2c26b..58fa9ba 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -81,6 +81,23 @@ When adding new features:
5. **Update settings UI** - Add configuration options when appropriate
6. **Document in code** - Add XML comments for public APIs
+## Icon Usage in WPF
+
+**IMPORTANT**: When adding icons or symbols to WPF UI:
+
+- **Always use Segoe MDL2 Assets font** instead of Unicode symbols
+- Set `FontFamily="Segoe MDL2 Assets"` on TextBlock/TextBox controls
+- Use hex codes like `` for chevron-right, `` for chevron-up, etc.
+- **Never use plain Unicode characters** like ?, ?, ? - they display as `?` in the app
+- See [Segoe MDL2 Assets icons list](https://learn.microsoft.com/en-us/windows/apps/design/style/segoe-ui-symbol-font)
+
+Common icons used in GhostDraw:
+- `` - ChevronRight (?)
+- `` - ChevronUp (?)
+- `` - ChevronDown (?)
+- `` - Delete (??)
+- `` - CheckMark (?)
+
## Resources
- [Low-Level Keyboard Hook Documentation](https://learn.microsoft.com/en-us/windows/win32/winmsg/lowlevelkeyboardproc)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0c53296..de0e1fa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,8 +3,8 @@ name: CI Build
on:
push:
branches: [main]
- pull_request:
- branches: [main]
+ # pull_request:
+ # branches: [main]
permissions:
contents: write # Required for creating draft releases
diff --git a/GhostDraw.sln b/GhostDraw.sln
index 5b5b8ee..2ff61e2 100644
--- a/GhostDraw.sln
+++ b/GhostDraw.sln
@@ -7,7 +7,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GhostDraw", "Src\GhostDraw\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GhostDraw.Tests", "Tests\GhostDraw.Tests\GhostDraw.Tests.csproj", "{2C665F98-F0C5-0A3F-9461-BB8A1A593CE2}"
EndProject
-Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "GhostDraw.Installer", "Installer\GhostDraw.Installer.wixproj", "{E1C8B89D-3F4E-4A6F-9C8E-5D7A8B9C0D1E}"
+Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "GhostDraw.Installer", "Installer\GhostDraw.Installer.wixproj", "{E1C8B89D-3F4E-4A6F-9C8E-5D7A8B9C0D1E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
@@ -23,6 +23,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{02EA
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{611A7057-6E33-46CA-BF31-21DA1B6765CF}"
ProjectSection(SolutionItems) = preProject
+ docs\Settings.png = docs\Settings.png
docs\TODO.md = docs\TODO.md
EndProjectSection
EndProject
diff --git a/README.md b/README.md
index 301f5ff..4761601 100644
--- a/README.md
+++ b/README.md
@@ -1,146 +1,291 @@
-# GhostDraw
+
-A lightweight Windows desktop application that allows you to draw directly on your screen using a simple keyboard hotkey and mouse input.
+# 👻 GhostDraw
-## Features
+### Draw on Your Screen, Anywhere, Anytime
-- **Global Hotkey**: Toggle drawing mode with `Ctrl+Alt+D`
-- **Fullscreen Overlay**: Draw on top of any application
-- **System Tray Integration**: Runs quietly in the background
-- **Emergency Exit**: Press `ESC` to quickly hide the overlay
-- **Transparent Drawing**: Overlay is completely transparent when not actively drawing
+[](https://dotnet.microsoft.com/)
+[](https://www.microsoft.com/windows)
+[](LICENSE)
+[](https://github.com/RuntimeRascal/ghost-draw/releases)
-## Requirements
+**GhostDraw** is a lightweight, cyberpunk-themed Windows desktop application that lets you draw directly on your screen with a simple keyboard hotkey. Perfect for presentations, tutorials, collaboration, or just having fun!
-- Windows 10 or later
-- .NET 8 Runtime
+[**Download Latest Release**](https://github.com/RuntimeRascal/ghost-draw/releases) | [**Report Bug**](https://github.com/RuntimeRascal/ghost-draw/issues) | [**Request Feature**](https://github.com/RuntimeRascal/ghost-draw/issues)
-## Installation
+
+
-1. Download the latest release from the [Releases](https://github.com/RuntimeRascal/ghost-draw/releases) page
-2. Extract the ZIP file to a folder of your choice
-3. Run `GhostDraw.exe`
-4. The application will start minimized to the system tray
+
-## Usage
+---
+
+## ✨ Features
+
+### 🎨 **Drawing Tools**
+- **Customizable Color Palette** - Create your own color collection and cycle through them while drawing
+- **Variable Brush Thickness** - Adjust brush size from 1-100px with configurable min/max ranges
+- **Smooth Drawing** - High-performance rendering for fluid strokes
+- **Mouse Wheel Control** - Change brush thickness on-the-fly while drawing
+
+### ⌨️ **Hotkey System**
+- **Global Hotkey** - Activate drawing mode from any application
+- **Customizable Shortcuts** - Define your own key combinations
+- **Two Draw Modes**:
+ - **Toggle Mode** - Press once to start, press again to stop
+ - **Hold Mode** - Draw only while holding the hotkey
+
+### 🖥️ **User Experience**
+- **Transparent Overlay** - Draw on top of any application without blocking input
+- **System Tray Integration** - Runs quietly in the background
+- **Emergency Exit** - Press `ESC` to instantly hide the overlay
+- **Right-Click Color Cycling** - Quickly switch between your palette colors
+- **Position-Numbered Palette** - Easily organize and reorder your favorite colors
+
+### 🛡️ **Safety & Stability**
+- **Fail-Safe Design** - Won't lock you out of your system if it crashes
+- **Fast Input Processing** - All hooks complete in < 5ms for responsive system
+- **Graceful Error Handling** - Protected critical paths ensure stability
+- **Proper Resource Cleanup** - Memory and hooks always released properly
+
+---
+
+## 📸 Screenshots
+
+### Settings Window
+
+
+Customize every aspect of GhostDraw with the intuitive settings panel:
+- Color palette management with add/remove/reorder
+- Brush thickness range configuration
+- Hotkey customization
+- Drawing mode selection
+- Logging level control
+
+### Active Drawing Mode
+
+
+
+### Color Palette Cycling
+
+
+
+### System Tray Integration
+
+
+
+---
+
+## 🚀 Getting Started
+
+### Requirements
+
+- **Operating System**: Windows 10 or later
+- **.NET Runtime**: [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) (automatically included in releases)
+
+### Installation
+
+1. **Download** the latest release from the [Releases page](https://github.com/RuntimeRascal/ghost-draw/releases)
+2. **Extract** the ZIP file to your preferred location (e.g., `C:\Program Files\GhostDraw`)
+3. **Run** `GhostDraw.exe`
+4. The application will start and **minimize to the system tray** 👻
+
+> **💡 Tip**: Create a shortcut in your Startup folder to launch GhostDraw automatically when Windows starts!
+
+---
+
+## 🎯 How to Use
+
+### Basic Drawing
+
+1. **Activate Drawing Mode**
+ Press your configured hotkey (default: `Ctrl+Alt+D`)
+
+2. **Start Drawing**
+ Click and drag with your **left mouse button** to draw
+
+3. **Cycle Colors**
+ **Right-click** while drawing to switch to the next color in your palette
+
+4. **Adjust Thickness**
+ Scroll the **mouse wheel** while drawing to change brush size
-1. **Start Drawing**: Press `Ctrl+Alt+D` to activate the drawing overlay
-2. **Draw**: Click and drag with your mouse to draw on the screen
-3. **Exit Drawing Mode**: Press `Ctrl+Alt+D` again or press `ESC`
-4. **Exit Application**: Right-click the system tray icon and select "Exit"
+5. **Exit Drawing Mode**
+ - Press the hotkey again (toggle mode)
+ - Press `ESC` for emergency exit
+ - Release the hotkey (hold mode)
-## Building from Source
+### Customizing Your Palette
-### Prerequisites
+1. **Open Settings**
+ Right-click the system tray icon → **Settings**
-- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
-- Visual Studio 2022 (recommended) or any .NET-compatible IDE
+2. **Edit Palette Colors**
+ Click to expand the color palette section
-### Build Steps
+3. **Add Colors**
+ Click **+ ADD COLOR** and choose your color
-```bash
-# Clone the repository
-git clone https://github.com/RuntimeRascal/ghost-draw.git
-cd ghost-draw
+4. **Reorder Colors**
+ Use the **⬆ Up** and **⬇ Down** buttons to arrange colors
+ (Press ⬆ on first item to move it to end, ⬇ on last to move to start)
-# Navigate to the source directory
-cd src
+5. **Remove Colors**
+ Click the **🗑️ Delete** button (minimum 1 color required)
-# Restore dependencies
-dotnet restore
+6. **Select Active Color**
+ Click any color swatch to set it as your active brush color
+ (Indicated by pink border and ✓ checkmark)
-# Build the project
-dotnet build --configuration Release
+### Configuring Hotkeys
-# Run the application
-dotnet run --configuration Release
+1. **Open Settings** → Navigate to **HOTKEY** section
+2. **Click** the hotkey input field
+3. **Press** your desired key combination
+4. **Save & Close** to apply changes
+
+> **⚠️ Note**: Some key combinations may conflict with system shortcuts or other applications.
+
+### Drawing Modes
+
+- **Lock Mode (Toggle)**: Press hotkey once to start drawing, press again to stop
+- **Hold Mode**: Drawing is only active while the hotkey is held down
+
+Change modes in **Settings** → **MODE** section
+
+---
+
+## ⚙️ Configuration
+
+All settings are automatically saved to:
+`%LOCALAPPDATA%\GhostDraw\settings.json`
+
+### Default Settings
+
+```json
+{
+ "activeBrush": "#FFFFFF",
+ "brushThickness": 3,
+ "minBrushThickness": 1,
+ "maxBrushThickness": 20,
+ "hotkeyVirtualKeys": [162, 164, 68],
+ "lockDrawingMode": false,
+ "colorPalette": [
+ "#FF0000",
+ "#00FF00",
+ "#0000FF",
+ "#FFFF00",
+ "#FF00FF",
+ "#00FFFF",
+ "#FFFFFF",
+ "#000000",
+ "#FFA500",
+ "#800080"
+ ]
+}
```
-## Architecture
+---
+
+## 🏗️ Architecture
-GhostDraw is built using modern .NET practices:
+GhostDraw is built with modern .NET practices and WPF:
+### Technology Stack
- **WPF** - UI framework and overlay rendering
-- **Global Windows Hooks** - Keyboard and mouse input capture
+- **Global Windows Hooks** - Low-level keyboard/mouse capture
- **Dependency Injection** - Microsoft.Extensions.DependencyInjection
- **Structured Logging** - Serilog + Microsoft.Extensions.Logging
- **.NET 8** - Latest LTS framework
-### Project Structure
+### Design Principles
-```
-ghost-draw/
-??? src/ # Main application source code
-? ??? GhostDraw.csproj # Project file
-? ??? ... # Application code
-??? tests/ # Unit and integration tests
-??? docs/ # Additional documentation
-??? .github/ # GitHub configuration
-? ??? copilot-instructions.md # AI assistant guidelines
-??? README.md # This file
-```
+✅ **Safety First** - User must never be locked out of their system
+✅ **Fast Hooks** - All hook callbacks complete in < 5ms
+✅ **Graceful Failure** - Exceptions are caught, logged, and handled
+✅ **Clean Resources** - Hooks and resources always released on exit
+✅ **Structured Logging** - Comprehensive diagnostics without performance impact
-## Safety & Stability
+---
-GhostDraw intercepts global keyboard and mouse input. The application is designed with safety as the top priority:
+## 🔧 Troubleshooting
-- **Fail-Safe Design**: Crashes won't lock you out of your system
-- **Emergency Exit**: ESC key always hides the overlay
-- **Fast Hook Processing**: All input hooks complete in < 5ms
-- **Graceful Error Handling**: All critical paths are protected with try-catch blocks
-- **Proper Cleanup**: Resources are always released on exit
+### Drawing overlay doesn't appear
+- Check if the hotkey is conflicting with another application
+- Try changing the hotkey in Settings
+- Ensure .NET 8 runtime is installed
-## Contributing
+### Application won't start
+- Right-click `GhostDraw.exe` → **Run as administrator**
+- Check Windows Event Viewer for crash logs
+- Review logs in `%LOCALAPPDATA%\GhostDraw\logs\`
-Contributions are welcome! Please read the [Copilot Instructions](.github/copilot-instructions.md) for detailed guidelines on:
+### Drawing is laggy or slow
+- Reduce brush thickness range (lower max value)
+- Close other resource-intensive applications
+- Check CPU usage in Task Manager
-- Safety requirements
-- Code style and conventions
-- Architecture patterns
-- Testing considerations
+### Hotkey doesn't work
+- Verify the key combination isn't used by Windows or other apps
+- Try a different key combination
+- Restart GhostDraw after changing hotkeys
-### Development Guidelines
+---
-1. **Safety First**: Never compromise user system stability
-2. **Test Thoroughly**: Especially edge cases (multi-monitor, high DPI, rapid input)
-3. **Log Appropriately**: Use structured logging with proper levels
-4. **Handle Errors**: Catch and log exceptions gracefully
-5. **Keep It Fast**: Hook callbacks must be lightning fast (< 5ms)
+## 📋 Logging
-## Roadmap
+GhostDraw uses structured logging with configurable levels:
-Future features under consideration:
+- **Verbose** - Everything (very noisy)
+- **Debug** - Detailed diagnostic information
+- **Information** - General application flow (default)
+- **Warning** - Unexpected but recoverable situations
+- **Error** - Errors that don't crash the app
+- **Fatal** - Critical errors requiring restart
-- [ ] Brush customization (color, thickness, opacity)
-- [ ] Stroke persistence (save/load drawings)
-- [ ] Undo/redo functionality
-- [ ] Screenshot integration
-- [ ] Multiple drawing tools (pen, highlighter, eraser)
-- [ ] Shape tools (line, rectangle, circle)
+Logs are stored in: `%LOCALAPPDATA%\GhostDraw\logs\`
-## Known Issues
+Access logs via **Settings** → **LOGS** → **OPEN FOLDER**
-- None currently reported
+---
+
+## 🤝 Contributing
+
+We welcome contributions! Whether it's:
+- 🐛 Bug reports
+- 💡 Feature suggestions
+- 📝 Documentation improvements
+- 💻 Code contributions
+
+Please check out our [Contributing Guidelines](CONTRIBUTING.md) to get started.
+
+---
-## License
+## 📄 License
-[Specify your license here - e.g., MIT, GPL, etc.]
+This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
-## Acknowledgments
+---
+
+## 🙏 Acknowledgments
-Built with:
-- [WPF](https://github.com/dotnet/wpf) - UI framework
-- [Serilog](https://serilog.net/) - Logging library
-- [Microsoft.Extensions.DependencyInjection](https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection) - DI container
+- **Segoe MDL2 Assets** - Microsoft's icon font
+- **Serilog** - Flexible logging framework
+- **WPF Community** - Inspiration and best practices
-## Support
+---
-If you encounter any issues or have questions:
+## 📬 Contact
-1. Check the [Issues](https://github.com/RuntimeRascal/ghost-draw/issues) page
-2. Create a new issue with detailed information
-3. Include logs from `%LOCALAPPDATA%\GhostDraw\logs\` if applicable
+- **Issues**: [GitHub Issues](https://github.com/RuntimeRascal/ghost-draw/issues)
+- **Discussions**: [GitHub Discussions](https://github.com/RuntimeRascal/ghost-draw/discussions)
---
-**?? Important**: This application uses global keyboard and mouse hooks. Use responsibly and ensure you understand the [safety guidelines](.github/copilot-instructions.md) if modifying the code.
+
+
+**Made with 💜 for creators, presenters, and anyone who loves drawing on their screen**
+
+⭐ **Star this repo if you find it useful!** ⭐
+
+
diff --git a/Src/GhostDraw/App.xaml b/Src/GhostDraw/App.xaml
index 241cb7e..7fe57f1 100644
--- a/Src/GhostDraw/App.xaml
+++ b/Src/GhostDraw/App.xaml
@@ -3,6 +3,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:GhostDraw">
-
+
+
+
+
+
diff --git a/Src/GhostDraw/App.xaml.cs b/Src/GhostDraw/App.xaml.cs
index b402f62..7d05451 100644
--- a/Src/GhostDraw/App.xaml.cs
+++ b/Src/GhostDraw/App.xaml.cs
@@ -60,7 +60,7 @@ protected override void OnStartup(StartupEventArgs e)
// Log loaded settings
var settings = _appSettings.CurrentSettings;
_logger.LogInformation("Loaded settings - Color: {Color}, Thickness: {Thickness}, Hotkey: {Hotkey}, LockMode: {LockMode}",
- settings.BrushColor, settings.BrushThickness,
+ settings.ActiveBrush, settings.BrushThickness,
settings.HotkeyDisplayName,
settings.LockDrawingMode);
@@ -115,9 +115,7 @@ protected override void OnStartup(StartupEventArgs e)
try
{
_logger?.LogDebug("Opening settings window");
- var logger = _serviceProvider!.GetRequiredService>();
- var loggerFactory = _serviceProvider!.GetRequiredService();
- var settingsWindow = new SettingsWindow(_loggingSettings!, _appSettings!, logger, loggerFactory);
+ var settingsWindow = _serviceProvider!.GetRequiredService();
settingsWindow.ShowDialog();
}
catch (Exception ex)
@@ -185,11 +183,14 @@ protected override void OnStartup(StartupEventArgs e)
}
}
- private void UpdateLogLevelMenuChecks(ToolStripMenuItem logLevelMenu, LogEventLevel selectedLevel)
+ private static void UpdateLogLevelMenuChecks(ToolStripMenuItem logLevelMenu, LogEventLevel selectedLevel)
{
foreach (ToolStripMenuItem item in logLevelMenu.DropDownItems)
{
- item.Checked = item.Text.StartsWith(selectedLevel.ToString());
+ if (item.Text != null)
+ {
+ item.Checked = item.Text.StartsWith(selectedLevel.ToString());
+ }
}
}
diff --git a/Src/GhostDraw/Core/AppSettings.cs b/Src/GhostDraw/Core/AppSettings.cs
index 8a35e98..4cb3fe1 100644
--- a/Src/GhostDraw/Core/AppSettings.cs
+++ b/Src/GhostDraw/Core/AppSettings.cs
@@ -8,10 +8,10 @@ namespace GhostDraw.Core;
public class AppSettings
{
///
- /// Brush color in hex format (e.g., "#FF0000" for red)
+ /// Active brush color from the palette in hex format (e.g., "#FF0000" for red)
///
- [JsonPropertyName("brushColor")]
- public string BrushColor { get; set; } = "#FF0000";
+ [JsonPropertyName("activeBrush")]
+ public string ActiveBrush { get; set; } = "#FF0000";
///
/// Brush thickness in pixels
@@ -82,7 +82,7 @@ public AppSettings Clone()
{
return new AppSettings
{
- BrushColor = BrushColor,
+ ActiveBrush = ActiveBrush,
BrushThickness = BrushThickness,
MinBrushThickness = MinBrushThickness,
MaxBrushThickness = MaxBrushThickness,
diff --git a/Src/GhostDraw/Core/ServiceConfiguration.cs b/Src/GhostDraw/Core/ServiceConfiguration.cs
index 73867f6..5e7fa7d 100644
--- a/Src/GhostDraw/Core/ServiceConfiguration.cs
+++ b/Src/GhostDraw/Core/ServiceConfiguration.cs
@@ -7,6 +7,7 @@
using GhostDraw.Managers;
using GhostDraw.Helpers;
using GhostDraw.Views;
+using GhostDraw.ViewModels;
namespace GhostDraw.Core;
@@ -61,6 +62,10 @@ public static ServiceProvider ConfigureServices()
// Register GlobalExceptionHandler AFTER its dependencies
services.AddSingleton();
+ // Register ViewModels and Views
+ services.AddTransient();
+ services.AddTransient();
+
_serviceProvider = services.BuildServiceProvider();
// Load saved log level from settings
diff --git a/Src/GhostDraw/Services/AppSettingsService.cs b/Src/GhostDraw/Services/AppSettingsService.cs
index f476575..b9b3629 100644
--- a/Src/GhostDraw/Services/AppSettingsService.cs
+++ b/Src/GhostDraw/Services/AppSettingsService.cs
@@ -20,6 +20,7 @@ public class AppSettingsService
public event EventHandler? LockDrawingModeChanged;
public event EventHandler<(double min, double max)>? BrushThicknessRangeChanged;
public event EventHandler>? HotkeyChanged;
+ public event EventHandler>? ColorPaletteChanged;
public AppSettingsService(ILogger logger)
{
@@ -53,13 +54,21 @@ private AppSettings LoadSettings()
{
_logger.LogInformation("Loading settings from {Path}", _settingsFilePath);
string json = File.ReadAllText(_settingsFilePath);
+
+ // Try to deserialize as current format
var settings = JsonSerializer.Deserialize(json);
if (settings != null)
{
+ // Migrate old settings format if needed
+ settings = MigrateSettings(json, settings);
+
_logger.LogInformation("Settings loaded successfully");
- _logger.LogDebug("Brush Color: {Color}, Thickness: {Thickness}, Hotkey: {Hotkey}",
- settings.BrushColor, settings.BrushThickness, settings.HotkeyDisplayName);
+ _logger.LogDebug("Active Brush: {Color}, Thickness: {Thickness}, Hotkey: {Hotkey}",
+ settings.ActiveBrush, settings.BrushThickness, settings.HotkeyDisplayName);
+
+ // Save migrated settings to update file format
+ SaveSettings(settings);
return settings;
}
}
@@ -76,6 +85,40 @@ private AppSettings LoadSettings()
}
}
+ ///
+ /// Migrates settings from old format to new format
+ ///
+ private AppSettings MigrateSettings(string json, AppSettings settings)
+ {
+ try
+ {
+ // Parse as JsonDocument to check for old properties
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Migrate old "brushColor" to new "activeBrush"
+ if (root.TryGetProperty("brushColor", out var brushColorProp))
+ {
+ var oldBrushColor = brushColorProp.GetString();
+ if (!string.IsNullOrEmpty(oldBrushColor))
+ {
+ settings.ActiveBrush = oldBrushColor;
+ _logger.LogInformation("Migrated 'brushColor' to 'activeBrush': {Color}", oldBrushColor);
+ }
+ }
+
+ // Future migrations can be added here
+ // Example: if (root.TryGetProperty("oldProperty", out var oldProp)) { ... }
+
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to migrate settings, using loaded values as-is");
+ }
+
+ return settings;
+ }
+
///
/// Saves settings to disk
///
@@ -102,12 +145,12 @@ private void SaveSettings(AppSettings settings)
}
///
- /// Updates brush color and persists to disk
+ /// Updates active brush color and persists to disk
///
- public void SetBrushColor(string colorHex)
+ public void SetActiveBrush(string colorHex)
{
- _logger.LogInformation("Setting brush color to {Color}", colorHex);
- _currentSettings.BrushColor = colorHex;
+ _logger.LogInformation("Setting active brush to {Color}", colorHex);
+ _currentSettings.ActiveBrush = colorHex;
SaveSettings(_currentSettings);
// Raise event to notify UI
@@ -195,14 +238,14 @@ public void SetHotkey(List virtualKeys)
///
public string GetNextColor()
{
- var currentIndex = _currentSettings.ColorPalette.IndexOf(_currentSettings.BrushColor);
+ var currentIndex = _currentSettings.ColorPalette.IndexOf(_currentSettings.ActiveBrush);
var nextIndex = (currentIndex + 1) % _currentSettings.ColorPalette.Count;
var nextColor = _currentSettings.ColorPalette[nextIndex];
_logger.LogDebug("Cycling color from {CurrentColor} to {NextColor}",
- _currentSettings.BrushColor, nextColor);
+ _currentSettings.ActiveBrush, nextColor);
- SetBrushColor(nextColor);
+ SetActiveBrush(nextColor);
return nextColor;
}
@@ -216,6 +259,69 @@ public void SetLogLevel(string logLevel)
SaveSettings(_currentSettings);
}
+ ///
+ /// Adds a color to the palette
+ ///
+ public void AddColorToPalette(string colorHex)
+ {
+ if (string.IsNullOrWhiteSpace(colorHex))
+ {
+ _logger.LogWarning("Attempted to add invalid color to palette");
+ return;
+ }
+
+ if (_currentSettings.ColorPalette.Contains(colorHex))
+ {
+ _logger.LogDebug("Color {Color} already exists in palette", colorHex);
+ return;
+ }
+
+ _logger.LogInformation("Adding color {Color} to palette", colorHex);
+ _currentSettings.ColorPalette.Add(colorHex);
+ SaveSettings(_currentSettings);
+ ColorPaletteChanged?.Invoke(this, new List(_currentSettings.ColorPalette));
+ }
+
+ ///
+ /// Removes a color from the palette
+ ///
+ public void RemoveColorFromPalette(string colorHex)
+ {
+ if (_currentSettings.ColorPalette.Count <= 1)
+ {
+ _logger.LogWarning("Cannot remove last color from palette");
+ return;
+ }
+
+ if (!_currentSettings.ColorPalette.Contains(colorHex))
+ {
+ _logger.LogDebug("Color {Color} not found in palette", colorHex);
+ return;
+ }
+
+ _logger.LogInformation("Removing color {Color} from palette", colorHex);
+ _currentSettings.ColorPalette.Remove(colorHex);
+ SaveSettings(_currentSettings);
+ ColorPaletteChanged?.Invoke(this, new List(_currentSettings.ColorPalette));
+ }
+
+ ///
+ /// Updates the entire color palette
+ ///
+ public void SetColorPalette(List colors)
+ {
+ if (colors == null || colors.Count == 0)
+ {
+ _logger.LogWarning("Cannot set empty color palette");
+ return;
+ }
+
+ _logger.LogInformation("Setting color palette with {Count} colors", colors.Count);
+ _currentSettings.ColorPalette = new List(colors);
+ SaveSettings(_currentSettings);
+ ColorPaletteChanged?.Invoke(this, new List(_currentSettings.ColorPalette));
+ }
+
///
/// Resets all settings to default values
///
diff --git a/Src/GhostDraw/Styles/CyberpunkStyles.xaml b/Src/GhostDraw/Styles/CyberpunkStyles.xaml
new file mode 100644
index 0000000..d4dd3eb
--- /dev/null
+++ b/Src/GhostDraw/Styles/CyberpunkStyles.xaml
@@ -0,0 +1,337 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/GhostDraw/ViewModels/SettingsViewModel.cs b/Src/GhostDraw/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..00b37ca
--- /dev/null
+++ b/Src/GhostDraw/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,37 @@
+using Microsoft.Extensions.Logging;
+using GhostDraw.Services;
+
+namespace GhostDraw.ViewModels;
+
+///
+/// ViewModel for the SettingsWindow that aggregates all required services.
+/// This enables proper MVVM pattern with dependency injection while keeping
+/// UserControls visible in the XAML designer.
+///
+public class SettingsViewModel
+{
+ ///
+ /// Service for managing application settings (brush, hotkey, mode, etc.)
+ ///
+ public AppSettingsService AppSettings { get; }
+
+ ///
+ /// Service for managing logging configuration
+ ///
+ public LoggingSettingsService LoggingSettings { get; }
+
+ ///
+ /// Factory for creating loggers for child controls
+ ///
+ public ILoggerFactory LoggerFactory { get; }
+
+ public SettingsViewModel(
+ AppSettingsService appSettings,
+ LoggingSettingsService loggingSettings,
+ ILoggerFactory loggerFactory)
+ {
+ AppSettings = appSettings;
+ LoggingSettings = loggingSettings;
+ LoggerFactory = loggerFactory;
+ }
+}
diff --git a/Src/GhostDraw/Views/OverlayWindow.xaml.cs b/Src/GhostDraw/Views/OverlayWindow.xaml.cs
index 886faad..839a6a0 100644
--- a/Src/GhostDraw/Views/OverlayWindow.xaml.cs
+++ b/Src/GhostDraw/Views/OverlayWindow.xaml.cs
@@ -80,8 +80,8 @@ private void UpdateCursor()
try
{
var settings = _appSettings.CurrentSettings;
- this.Cursor = _cursorHelper.CreateColoredPencilCursor(settings.BrushColor);
- _logger.LogDebug("Updated cursor with color {Color}", settings.BrushColor);
+ this.Cursor = _cursorHelper.CreateColoredPencilCursor(settings.ActiveBrush);
+ _logger.LogDebug("Updated cursor with color {Color}", settings.ActiveBrush);
}
catch (Exception ex)
{
@@ -184,11 +184,11 @@ private void StartNewStroke(System.Windows.Point startPoint)
try
{
strokeBrush = new SolidColorBrush(
- (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(settings.BrushColor));
+ (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(settings.ActiveBrush));
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to parse brush color {Color}, using default red", settings.BrushColor);
+ _logger.LogWarning(ex, "Failed to parse brush color {Color}, using default red", settings.ActiveBrush);
strokeBrush = System.Windows.Media.Brushes.Red;
}
@@ -204,7 +204,7 @@ private void StartNewStroke(System.Windows.Point startPoint)
_currentStroke.Points.Add(startPoint);
DrawingCanvas.Children.Add(_currentStroke);
_logger.LogDebug("Stroke added to canvas with color {Color} and thickness {Thickness}, total strokes: {StrokeCount}",
- settings.BrushColor, settings.BrushThickness, DrawingCanvas.Children.Count);
+ settings.ActiveBrush, settings.BrushThickness, DrawingCanvas.Children.Count);
}
private void AddPointToStroke(System.Windows.Point point)
diff --git a/Src/GhostDraw/Views/SettingsWindow.xaml b/Src/GhostDraw/Views/SettingsWindow.xaml
index bf2e7f4..dfc8b1a 100644
--- a/Src/GhostDraw/Views/SettingsWindow.xaml
+++ b/Src/GhostDraw/Views/SettingsWindow.xaml
@@ -1,6 +1,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -386,6 +56,7 @@
+
@@ -418,7 +89,7 @@
Orientation="Horizontal"
Margin="24,0,0,0"
VerticalAlignment="Center">
-
-
+
+ Margin="28,12,28,12">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/GhostDraw/Views/UserControls/DrawingSettingsControl.xaml.cs b/Src/GhostDraw/Views/UserControls/DrawingSettingsControl.xaml.cs
new file mode 100644
index 0000000..eb9a751
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/DrawingSettingsControl.xaml.cs
@@ -0,0 +1,565 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Effects;
+using GhostDraw.Services;
+using WpfUserControl = System.Windows.Controls.UserControl;
+using WpfColor = System.Windows.Media.Color;
+using WpfBrush = System.Windows.Media.Brushes;
+using WpfColorConverter = System.Windows.Media.ColorConverter;
+
+namespace GhostDraw.Views.UserControls;
+
+public partial class DrawingSettingsControl : WpfUserControl
+{
+ private int _updateNestingLevel = 0;
+
+ // DependencyProperty for AppSettings to enable XAML binding
+ public static readonly DependencyProperty AppSettingsProperty =
+ DependencyProperty.Register(
+ nameof(AppSettings),
+ typeof(AppSettingsService),
+ typeof(DrawingSettingsControl),
+ new PropertyMetadata(null, OnAppSettingsChanged));
+
+ public AppSettingsService? AppSettings
+ {
+ get => (AppSettingsService?)GetValue(AppSettingsProperty);
+ set => SetValue(AppSettingsProperty, value);
+ }
+
+ private static void OnAppSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is DrawingSettingsControl control && e.NewValue is AppSettingsService appSettings)
+ {
+ control.Initialize(appSettings);
+ }
+ }
+
+ public DrawingSettingsControl()
+ {
+ InitializeComponent();
+ }
+
+ private void Initialize(AppSettingsService appSettings)
+ {
+ // Load initial settings
+ LoadSettings(appSettings);
+
+ // Subscribe to settings change events
+ appSettings.BrushColorChanged += OnBrushColorChanged;
+ appSettings.BrushThicknessChanged += OnBrushThicknessChanged;
+ appSettings.BrushThicknessRangeChanged += OnBrushThicknessRangeChanged;
+ appSettings.ColorPaletteChanged += OnColorPaletteChanged;
+
+ // Unsubscribe when unloaded
+ Unloaded += (s, e) => UnsubscribeFromEvents(appSettings);
+ }
+
+ private void UnsubscribeFromEvents(AppSettingsService appSettings)
+ {
+ appSettings.BrushColorChanged -= OnBrushColorChanged;
+ appSettings.BrushThicknessChanged -= OnBrushThicknessChanged;
+ appSettings.BrushThicknessRangeChanged -= OnBrushThicknessRangeChanged;
+ appSettings.ColorPaletteChanged -= OnColorPaletteChanged;
+ }
+
+ private void OnBrushColorChanged(object? sender, string colorHex)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ UpdateActiveColorIndicators();
+ });
+ }
+
+ private void OnBrushThicknessChanged(object? sender, double thickness)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ _updateNestingLevel++;
+ try
+ {
+ if (ThicknessSlider != null && ThicknessValueText != null)
+ {
+ ThicknessSlider.Value = thickness;
+ ThicknessValueText.Text = $"{thickness:F0} px";
+ }
+ }
+ finally
+ {
+ _updateNestingLevel--;
+ }
+ });
+ }
+
+ private void OnBrushThicknessRangeChanged(object? sender, (double min, double max) range)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ _updateNestingLevel++;
+ try
+ {
+ if (MinThicknessTextBox == null || MaxThicknessTextBox == null || ThicknessSlider == null)
+ return;
+
+ MinThicknessTextBox.Text = range.min.ToString("F0");
+ MaxThicknessTextBox.Text = range.max.ToString("F0");
+
+ var currentValue = ThicknessSlider.Value;
+ ThicknessSlider.Minimum = range.min;
+ ThicknessSlider.Maximum = range.max;
+
+ if (currentValue < range.min)
+ ThicknessSlider.Value = range.min;
+ else if (currentValue > range.max)
+ ThicknessSlider.Value = range.max;
+ else
+ ThicknessSlider.Value = currentValue;
+ }
+ finally
+ {
+ _updateNestingLevel--;
+ }
+ });
+ }
+
+ private void OnColorPaletteChanged(object? sender, List palette)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ LoadColorPalette(palette);
+ });
+ }
+
+ private void LoadColorPalette(List palette)
+ {
+ PaletteColorsItemsControl.ItemsSource = new List(palette);
+ }
+
+ private void PaletteColorsItemsControl_Loaded(object sender, RoutedEventArgs e)
+ {
+ // Update indicators after ItemsControl is fully loaded
+ // Use multiple priority levels to ensure it happens
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ UpdateActiveColorIndicators();
+ }), System.Windows.Threading.DispatcherPriority.ContextIdle);
+
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ UpdateActiveColorIndicators();
+ }), System.Windows.Threading.DispatcherPriority.ApplicationIdle);
+ }
+
+ private void ColorSquare_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (sender is Border border && border.Tag is string colorHex)
+ {
+ AppSettings.SetActiveBrush(colorHex);
+
+ // Update immediately since visual tree is already loaded
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ UpdateActiveColorIndicators();
+ }), System.Windows.Threading.DispatcherPriority.Normal);
+ }
+ }
+
+ private void UpdateActiveColorIndicators()
+ {
+ if (AppSettings == null) return;
+
+ var activeColor = AppSettings.CurrentSettings.ActiveBrush;
+
+ // Try StackPanel first (default for ItemsControl without ItemsPanel specified)
+ var itemsPanel = FindVisualChild(PaletteColorsItemsControl);
+ if (itemsPanel == null)
+ {
+ // Try again after a short delay if visual tree isn't ready
+ Dispatcher.BeginInvoke(new Action(() =>
+ {
+ UpdateActiveColorIndicatorsInternal(activeColor);
+ }), System.Windows.Threading.DispatcherPriority.Loaded);
+ return;
+ }
+
+ UpdateActiveColorIndicatorsInternal(activeColor);
+ }
+
+ private void UpdateActiveColorIndicatorsInternal(string activeColor)
+ {
+ // Try to find StackPanel (default ItemsControl panel)
+ var itemsPanel = FindVisualChild(PaletteColorsItemsControl);
+ if (itemsPanel == null)
+ return;
+
+ int position = 1;
+ foreach (var child in itemsPanel.Children)
+ {
+ // WPF wraps DataTemplate items in ContentPresenter, so we need to look inside
+ Grid? grid = null;
+
+ if (child is ContentPresenter contentPresenter)
+ {
+ // Find the Grid inside the ContentPresenter
+ grid = FindVisualChild(contentPresenter);
+ }
+ else if (child is Grid directGrid)
+ {
+ grid = directGrid;
+ }
+
+ if (grid != null && grid.Children.Count > 0 && grid.Children[0] is Border colorBorder)
+ {
+ var colorHex = colorBorder.Tag as string;
+ var isActive = colorHex == activeColor;
+
+ // Update border style for active color
+ colorBorder.BorderBrush = isActive
+ ? new SolidColorBrush((WpfColor)WpfColorConverter.ConvertFromString("#FF0080"))
+ : new SolidColorBrush((WpfColor)WpfColorConverter.ConvertFromString("#00FFFF"));
+ colorBorder.BorderThickness = isActive ? new Thickness(3) : new Thickness(2);
+
+ // Update position number and checkmark inside the Border's Grid
+ if (VisualTreeHelper.GetChildrenCount(colorBorder) > 0)
+ {
+ var innerGrid = VisualTreeHelper.GetChild(colorBorder, 0);
+ if (innerGrid is Grid contentGrid)
+ {
+ // Find position number badge (first child - Border containing TextBlock)
+ if (contentGrid.Children.Count > 0 && contentGrid.Children[0] is Border badge)
+ {
+ var positionText = FindVisualChild(badge);
+ if (positionText != null)
+ {
+ positionText.Text = position.ToString();
+ }
+ }
+
+ // Find checkmark (second child - Viewbox containing TextBlock)
+ if (contentGrid.Children.Count > 1 && contentGrid.Children[1] is Viewbox vb && vb.Child is TextBlock checkmark)
+ {
+ checkmark.Visibility = isActive ? Visibility.Visible : Visibility.Collapsed;
+ }
+ }
+ }
+
+ // Replace the drop shadow effect with a new one (can't modify frozen effects)
+ var shadowColor = isActive
+ ? (WpfColor)WpfColorConverter.ConvertFromString("#FF0080")
+ : (WpfColor)WpfColorConverter.ConvertFromString("#00FFFF");
+ var shadowOpacity = isActive ? 0.8 : 0.4;
+
+ colorBorder.Effect = new DropShadowEffect
+ {
+ Color = shadowColor,
+ Opacity = shadowOpacity,
+ BlurRadius = 8,
+ ShadowDepth = 0
+ };
+
+ position++;
+ }
+ }
+ }
+
+ // Helper method to find visual child
+ private static T? FindVisualChild(DependencyObject parent) where T : DependencyObject
+ {
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
+ {
+ var child = VisualTreeHelper.GetChild(parent, i);
+ if (child is T typedChild)
+ return typedChild;
+
+ var result = FindVisualChild(child);
+ if (result != null)
+ return result;
+ }
+ return null;
+ }
+
+ private void AddColorButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ var colorDialog = new System.Windows.Forms.ColorDialog
+ {
+ FullOpen = true,
+ Color = System.Drawing.Color.White
+ };
+
+ if (colorDialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
+ {
+ var color = colorDialog.Color;
+ string hexColor = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
+ AppSettings.AddColorToPalette(hexColor);
+ }
+ }
+
+ private void RemoveColorButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (sender is System.Windows.Controls.Button button && button.Tag is string colorHex)
+ {
+ // Show confirmation dialog
+ var result = System.Windows.MessageBox.Show(
+ $"Remove color {colorHex} from palette?",
+ "Remove Color",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result == MessageBoxResult.Yes)
+ {
+ AppSettings.RemoveColorFromPalette(colorHex);
+ }
+ }
+ }
+
+ private void MoveColorUpButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (sender is System.Windows.Controls.Button button && button.Tag is string colorHex)
+ {
+ var palette = new List(AppSettings.CurrentSettings.ColorPalette);
+ var currentIndex = palette.IndexOf(colorHex);
+
+ if (currentIndex < 0)
+ return;
+
+ // Remove from current position
+ palette.RemoveAt(currentIndex);
+
+ // If at the top, wrap to bottom
+ if (currentIndex == 0)
+ {
+ palette.Add(colorHex);
+ }
+ else
+ {
+ // Move up one position
+ palette.Insert(currentIndex - 1, colorHex);
+ }
+
+ AppSettings.SetColorPalette(palette);
+ }
+ }
+
+ private void MoveColorDownButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (sender is System.Windows.Controls.Button button && button.Tag is string colorHex)
+ {
+ var palette = new List(AppSettings.CurrentSettings.ColorPalette);
+ var currentIndex = palette.IndexOf(colorHex);
+
+ if (currentIndex < 0)
+ return;
+
+ // Remove from current position
+ palette.RemoveAt(currentIndex);
+
+ // If at the bottom, wrap to top
+ if (currentIndex >= palette.Count)
+ {
+ palette.Insert(0, colorHex);
+ }
+ else
+ {
+ // Move down one position
+ palette.Insert(currentIndex + 1, colorHex);
+ }
+
+ AppSettings.SetColorPalette(palette);
+ }
+ }
+
+ private void LoadSettings(AppSettingsService appSettings)
+ {
+ var settings = appSettings.CurrentSettings;
+ _updateNestingLevel++;
+
+ try
+ {
+ // Load color palette
+ LoadColorPalette(settings.ColorPalette);
+
+ // Load min/max range
+ MinThicknessTextBox.Text = settings.MinBrushThickness.ToString("F0");
+ MaxThicknessTextBox.Text = settings.MaxBrushThickness.ToString("F0");
+
+ // Load brush thickness and slider
+ ThicknessSlider.Minimum = settings.MinBrushThickness;
+ ThicknessSlider.Maximum = settings.MaxBrushThickness;
+ ThicknessSlider.Value = settings.BrushThickness;
+ ThicknessValueText.Text = $"{settings.BrushThickness:F0} px";
+ }
+ finally
+ {
+ _updateNestingLevel--;
+ }
+ }
+
+ private void ThicknessSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
+ {
+ if (ThicknessValueText != null && _updateNestingLevel == 0 && AppSettings != null)
+ {
+ double value = e.NewValue;
+ ThicknessValueText.Text = $"{value:F0} px";
+ AppSettings.SetBrushThickness(value);
+ }
+ }
+
+ private void NumericTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
+ {
+ e.Handled = !int.TryParse(e.Text, out _);
+ }
+
+ private void MinUpButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (double.TryParse(MinThicknessTextBox.Text, out double value))
+ {
+ var maxValue = AppSettings.CurrentSettings.MaxBrushThickness;
+ if (value < maxValue - 1)
+ MinThicknessTextBox.Text = (value + 1).ToString("F0");
+ }
+ }
+
+ private void MinDownButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (double.TryParse(MinThicknessTextBox.Text, out double value))
+ {
+ if (value > 1)
+ MinThicknessTextBox.Text = (value - 1).ToString("F0");
+ }
+ }
+
+ private void MaxUpButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (double.TryParse(MaxThicknessTextBox.Text, out double value))
+ {
+ if (value < 100)
+ MaxThicknessTextBox.Text = (value + 1).ToString("F0");
+ }
+ }
+
+ private void MaxDownButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ if (double.TryParse(MaxThicknessTextBox.Text, out double value))
+ {
+ var minValue = AppSettings.CurrentSettings.MinBrushThickness;
+ if (value > minValue + 1)
+ MaxThicknessTextBox.Text = (value - 1).ToString("F0");
+ }
+ }
+
+ private void MinThicknessTextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (MinThicknessTextBox == null || MaxThicknessTextBox == null || ThicknessSlider == null || AppSettings == null)
+ return;
+
+ if (_updateNestingLevel > 0)
+ return;
+
+ if (double.TryParse(MinThicknessTextBox.Text, out double minValue) && minValue > 0)
+ {
+ var settings = AppSettings.CurrentSettings;
+ double maxValue = settings.MaxBrushThickness;
+
+ if (double.TryParse(MaxThicknessTextBox.Text, out double parsedMax))
+ maxValue = parsedMax;
+
+ if (minValue < maxValue)
+ {
+ double preservedValue = ThicknessSlider.Value;
+ _updateNestingLevel++;
+ try
+ {
+ AppSettings.SetBrushThicknessRange(minValue, maxValue);
+ ThicknessSlider.Minimum = minValue;
+ ThicknessSlider.Maximum = maxValue;
+
+ if (preservedValue < minValue)
+ {
+ ThicknessSlider.Value = minValue;
+ AppSettings.SetBrushThickness(minValue);
+ }
+ else if (preservedValue > maxValue)
+ {
+ ThicknessSlider.Value = maxValue;
+ AppSettings.SetBrushThickness(maxValue);
+ }
+ else
+ {
+ ThicknessSlider.Value = preservedValue;
+ }
+ }
+ finally
+ {
+ _updateNestingLevel--;
+ }
+ }
+ }
+ }
+
+ private void MaxThicknessTextBox_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (MinThicknessTextBox == null || MaxThicknessTextBox == null || ThicknessSlider == null || AppSettings == null)
+ return;
+
+ if (_updateNestingLevel > 0)
+ return;
+
+ if (double.TryParse(MaxThicknessTextBox.Text, out double maxValue) && maxValue > 0)
+ {
+ var settings = AppSettings.CurrentSettings;
+ double minValue = settings.MinBrushThickness;
+
+ if (double.TryParse(MinThicknessTextBox.Text, out double parsedMin))
+ minValue = parsedMin;
+
+ if (maxValue > minValue)
+ {
+ double preservedValue = ThicknessSlider.Value;
+ _updateNestingLevel++;
+ try
+ {
+ AppSettings.SetBrushThicknessRange(minValue, maxValue);
+ ThicknessSlider.Minimum = minValue;
+ ThicknessSlider.Maximum = maxValue;
+
+ if (preservedValue < minValue)
+ {
+ ThicknessSlider.Value = minValue;
+ AppSettings.SetBrushThickness(minValue);
+ }
+ else if (preservedValue > maxValue)
+ {
+ ThicknessSlider.Value = maxValue;
+ AppSettings.SetBrushThickness(maxValue);
+ }
+ else
+ {
+ ThicknessSlider.Value = preservedValue;
+ }
+ }
+ finally
+ {
+ _updateNestingLevel--;
+ }
+ }
+ }
+ }
+}
diff --git a/Src/GhostDraw/Views/UserControls/HotkeySettingsControl.xaml b/Src/GhostDraw/Views/UserControls/HotkeySettingsControl.xaml
new file mode 100644
index 0000000..f0e2cb8
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/HotkeySettingsControl.xaml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/GhostDraw/Views/UserControls/HotkeySettingsControl.xaml.cs b/Src/GhostDraw/Views/UserControls/HotkeySettingsControl.xaml.cs
new file mode 100644
index 0000000..6664b09
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/HotkeySettingsControl.xaml.cs
@@ -0,0 +1,266 @@
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Media;
+using Microsoft.Extensions.Logging;
+using GhostDraw.Core;
+using GhostDraw.Services;
+using WpfUserControl = System.Windows.Controls.UserControl;
+using WpfColor = System.Windows.Media.Color;
+using WpfColorConverter = System.Windows.Media.ColorConverter;
+using WpfMessageBox = System.Windows.MessageBox;
+
+namespace GhostDraw.Views.UserControls;
+
+public partial class HotkeySettingsControl : WpfUserControl
+{
+ private ILogger? _logger;
+ private readonly HashSet _recordedKeys = [];
+ private GlobalKeyboardHook? _recorderHook;
+
+ // DependencyProperty for AppSettings
+ public static readonly DependencyProperty AppSettingsProperty =
+ DependencyProperty.Register(
+ nameof(AppSettings),
+ typeof(AppSettingsService),
+ typeof(HotkeySettingsControl),
+ new PropertyMetadata(null, OnAppSettingsChanged));
+
+ public AppSettingsService? AppSettings
+ {
+ get => (AppSettingsService?)GetValue(AppSettingsProperty);
+ set => SetValue(AppSettingsProperty, value);
+ }
+
+ // DependencyProperty for LoggerFactory
+ public static readonly DependencyProperty LoggerFactoryProperty =
+ DependencyProperty.Register(
+ nameof(LoggerFactory),
+ typeof(ILoggerFactory),
+ typeof(HotkeySettingsControl),
+ new PropertyMetadata(null, OnLoggerFactoryChanged));
+
+ public ILoggerFactory? LoggerFactory
+ {
+ get => (ILoggerFactory?)GetValue(LoggerFactoryProperty);
+ set => SetValue(LoggerFactoryProperty, value);
+ }
+
+ private static void OnAppSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is HotkeySettingsControl control && e.NewValue is AppSettingsService appSettings)
+ {
+ control.Initialize(appSettings);
+ }
+ }
+
+ private static void OnLoggerFactoryChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is HotkeySettingsControl control && e.NewValue is ILoggerFactory loggerFactory)
+ {
+ control._logger = loggerFactory.CreateLogger();
+ }
+ }
+
+ public HotkeySettingsControl()
+ {
+ InitializeComponent();
+ }
+
+ private void Initialize(AppSettingsService appSettings)
+ {
+ LoadSettings(appSettings);
+ }
+
+ private void LoadSettings(AppSettingsService appSettings)
+ {
+ var settings = appSettings.CurrentSettings;
+ CurrentHotkeyText.Text = settings.HotkeyDisplayName;
+ }
+
+ private void RecordButton_Click(object sender, RoutedEventArgs e)
+ {
+ StartRecording();
+ }
+
+ private void StartRecording()
+ {
+ if (LoggerFactory == null || AppSettings == null) return;
+
+ _recordedKeys.Clear();
+
+ // Show recorder UI
+ RecorderBox.Visibility = Visibility.Visible;
+ RecordButton.Visibility = Visibility.Collapsed;
+ CancelRecordButton.Visibility = Visibility.Visible;
+ RecorderStatusText.Foreground = new SolidColorBrush((WpfColor)WpfColorConverter.ConvertFromString("#FF0080"));
+ RecorderStatusText.Text = "RECORDING... Press your hotkey combination";
+ RecorderPreviewText.Text = "Waiting for keys...";
+
+ // Create temporary hook for recording
+ var hookLogger = LoggerFactory.CreateLogger();
+ _recorderHook = new GlobalKeyboardHook(hookLogger);
+ _recorderHook.KeyPressed += OnRecorderKeyPressed;
+ _recorderHook.KeyReleased += OnRecorderKeyReleased;
+ _recorderHook.EscapePressed += OnRecorderEscape;
+ _recorderHook.Start();
+
+ _logger?.LogInformation("Started hotkey recording");
+ }
+
+ private void OnRecorderKeyPressed(object? sender, GlobalKeyboardHook.KeyEventArgs e)
+ {
+ _recordedKeys.Add(e.VirtualKeyCode);
+ RecorderPreviewText.Text = Helpers.VirtualKeyHelper.GetCombinationDisplayName([.. _recordedKeys]);
+ _logger?.LogDebug("Recorded key: VK {VK} ({Name})", e.VirtualKeyCode, Helpers.VirtualKeyHelper.GetFriendlyName(e.VirtualKeyCode));
+ }
+
+ private async void OnRecorderKeyReleased(object? sender, GlobalKeyboardHook.KeyEventArgs e)
+ {
+ _logger?.LogDebug("Key released: VK {VK}, Recorded keys count: {Count}", e.VirtualKeyCode, _recordedKeys.Count);
+
+ if (_recordedKeys.Count > 0)
+ {
+ await Task.Delay(50);
+
+ if (!IsAnyKeyPressed())
+ {
+ _logger?.LogInformation("All keys released, stopping recording with {Count} keys", _recordedKeys.Count);
+ StopRecording(accepted: true);
+ }
+ }
+ }
+
+ private void OnRecorderEscape(object? sender, EventArgs e)
+ {
+ StopRecording(accepted: false);
+ }
+
+ private void StopRecording(bool accepted)
+ {
+ _recorderHook?.Stop();
+ _recorderHook?.Dispose();
+ _recorderHook = null;
+
+ if (accepted && ValidateRecordedKeys())
+ {
+ RecorderStatusText.Text = "Hotkey captured! Click APPLY to save";
+ RecorderStatusText.Foreground = new SolidColorBrush((WpfColor)WpfColorConverter.ConvertFromString("#00FF00"));
+
+ CancelRecordButton.Visibility = Visibility.Collapsed;
+ RecordButton.Visibility = Visibility.Visible;
+ RecordButton.Content = "RECORD AGAIN";
+ ApplyHotkeyButton.Visibility = Visibility.Visible;
+
+ _logger?.LogInformation("Hotkey recorded successfully: {Hotkey}", Helpers.VirtualKeyHelper.GetCombinationDisplayName([.. _recordedKeys]));
+ }
+ else
+ {
+ RecorderBox.Visibility = Visibility.Collapsed;
+ RecordButton.Visibility = Visibility.Visible;
+ RecordButton.Content = "RECORD NEW HOTKEY";
+ CancelRecordButton.Visibility = Visibility.Collapsed;
+ ApplyHotkeyButton.Visibility = Visibility.Collapsed;
+
+ _logger?.LogInformation("Hotkey recording cancelled");
+ }
+ }
+
+ private bool ValidateRecordedKeys()
+ {
+ if (_recordedKeys.Count < 2)
+ {
+ WpfMessageBox.Show(
+ "Hotkey must include at least one modifier (Ctrl, Alt, Shift, Win) and one regular key.",
+ "Invalid Hotkey",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ return false;
+ }
+
+ if (!_recordedKeys.Any(vk => Helpers.VirtualKeyHelper.IsModifierKey(vk)))
+ {
+ WpfMessageBox.Show(
+ "Hotkey must include at least one modifier key (Ctrl, Alt, Shift, Win).",
+ "Invalid Hotkey",
+ MessageBoxButton.OK,
+ MessageBoxImage.Warning);
+ return false;
+ }
+
+ if (IsReservedCombo([.. _recordedKeys]))
+ {
+ var result = WpfMessageBox.Show(
+ $"{Helpers.VirtualKeyHelper.GetCombinationDisplayName([.. _recordedKeys])} is a system hotkey that may not work reliably.\n\nDo you want to use it anyway?",
+ "System Hotkey Warning",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Warning);
+
+ return result == MessageBoxResult.Yes;
+ }
+
+ return true;
+ }
+
+ private static bool IsReservedCombo(List vks)
+ {
+ var hasCtrl = vks.Any(vk => vk is 0xA2 or 0xA3);
+ var hasAlt = vks.Any(vk => vk is 0xA4 or 0xA5);
+ var hasWin = vks.Any(vk => vk is 0x5B or 0x5C);
+
+ if (hasCtrl && hasAlt && vks.Contains(0x2E)) return true;
+ if (hasWin && vks.Contains(0x4C)) return true;
+ if (hasAlt && vks.Contains(0x73)) return true;
+ if (hasAlt && vks.Contains(0x09)) return true;
+
+ return false;
+ }
+
+ private bool IsAnyKeyPressed()
+ {
+ foreach (var vk in _recordedKeys)
+ {
+ short keyState = GetAsyncKeyState(vk);
+ bool isPressed = (keyState & 0x8000) != 0;
+ _logger?.LogTrace("VK {VK} state: {State} (raw: {Raw})", vk, isPressed ? "PRESSED" : "released", keyState);
+
+ if (isPressed)
+ {
+ _logger?.LogDebug("Key VK {VK} is still pressed", vk);
+ return true;
+ }
+ }
+ _logger?.LogDebug("All keys released");
+ return false;
+ }
+
+ [DllImport("user32.dll")]
+ private static extern short GetAsyncKeyState(int vKey);
+
+ private void ApplyHotkey_Click(object sender, RoutedEventArgs e)
+ {
+ if (AppSettings == null) return;
+
+ AppSettings.SetHotkey([.. _recordedKeys]);
+ CurrentHotkeyText.Text = RecorderPreviewText.Text;
+
+ RecorderBox.Visibility = Visibility.Collapsed;
+ RecordButton.Visibility = Visibility.Visible;
+ RecordButton.Content = "RECORD NEW HOTKEY";
+ ApplyHotkeyButton.Visibility = Visibility.Collapsed;
+
+ WpfMessageBox.Show(
+ "Hotkey updated successfully!\n\nChanges take effect immediately.",
+ "Success",
+ MessageBoxButton.OK,
+ MessageBoxImage.Information);
+
+ _logger?.LogInformation("Hotkey updated to: {Hotkey}", AppSettings.CurrentSettings.HotkeyDisplayName);
+ }
+
+ private void CancelRecord_Click(object sender, RoutedEventArgs e)
+ {
+ StopRecording(accepted: false);
+ }
+}
diff --git a/Src/GhostDraw/Views/UserControls/LoggingSettingsControl.xaml b/Src/GhostDraw/Views/UserControls/LoggingSettingsControl.xaml
new file mode 100644
index 0000000..ebe2b45
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/LoggingSettingsControl.xaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/GhostDraw/Views/UserControls/LoggingSettingsControl.xaml.cs b/Src/GhostDraw/Views/UserControls/LoggingSettingsControl.xaml.cs
new file mode 100644
index 0000000..46a5fed
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/LoggingSettingsControl.xaml.cs
@@ -0,0 +1,110 @@
+using System.Windows;
+using System.Windows.Controls;
+using Serilog.Events;
+using GhostDraw.Services;
+using WpfUserControl = System.Windows.Controls.UserControl;
+
+namespace GhostDraw.Views.UserControls;
+
+public partial class LoggingSettingsControl : WpfUserControl
+{
+ // DependencyProperty for LoggingSettings
+ public static readonly DependencyProperty LoggingSettingsProperty =
+ DependencyProperty.Register(
+ nameof(LoggingSettings),
+ typeof(LoggingSettingsService),
+ typeof(LoggingSettingsControl),
+ new PropertyMetadata(null, OnLoggingSettingsChanged));
+
+ public LoggingSettingsService? LoggingSettings
+ {
+ get => (LoggingSettingsService?)GetValue(LoggingSettingsProperty);
+ set => SetValue(LoggingSettingsProperty, value);
+ }
+
+ private static void OnLoggingSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is LoggingSettingsControl control && e.NewValue is LoggingSettingsService loggingSettings)
+ {
+ control.LoadSettings(loggingSettings);
+ }
+ }
+
+ public LoggingSettingsControl()
+ {
+ InitializeComponent();
+ }
+
+ private void LoadSettings(LoggingSettingsService loggingSettings)
+ {
+ foreach (var level in LoggingSettingsService.GetAvailableLogLevels())
+ {
+ LogLevelComboBox.Items.Add(new LogLevelItem
+ {
+ Level = level,
+ DisplayName = LoggingSettingsService.GetLogLevelDisplayName(level)
+ });
+ }
+
+ var currentLevel = loggingSettings.CurrentLevel;
+ foreach (LogLevelItem item in LogLevelComboBox.Items)
+ {
+ if (item.Level == currentLevel)
+ {
+ LogLevelComboBox.SelectedItem = item;
+ break;
+ }
+ }
+
+ UpdateLogLevelDescription(currentLevel);
+ }
+
+ private void LogLevelComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (LogLevelComboBox.SelectedItem is LogLevelItem selectedItem && LoggingSettings != null)
+ {
+ LoggingSettings.SetLogLevel(selectedItem.Level);
+ UpdateLogLevelDescription(selectedItem.Level);
+ }
+ }
+
+ private void UpdateLogLevelDescription(LogEventLevel level)
+ {
+ LogLevelDescription.Text = level switch
+ {
+ LogEventLevel.Verbose => "Shows all logs including very detailed trace information. Use for deep debugging.",
+ LogEventLevel.Debug => "Shows detailed debugging information. Good for troubleshooting issues.",
+ LogEventLevel.Information => "Shows normal operational messages. Recommended for regular use.",
+ LogEventLevel.Warning => "Shows only warnings and errors. Minimal logging.",
+ LogEventLevel.Error => "Shows only errors and critical failures.",
+ LogEventLevel.Fatal => "Shows only critical failures that cause application termination.",
+ _ => string.Empty
+ };
+ }
+
+ private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (LoggingSettings == null) return;
+
+ try
+ {
+ System.Diagnostics.Process.Start("explorer.exe", LoggingSettings.GetLogDirectory());
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show(
+ $"Failed to open log folder:\n\n{ex.Message}",
+ "Error",
+ MessageBoxButton.OK,
+ MessageBoxImage.Error);
+ }
+ }
+
+ private class LogLevelItem
+ {
+ public LogEventLevel Level { get; set; }
+ public string DisplayName { get; set; } = string.Empty;
+
+ public override string ToString() => DisplayName;
+ }
+}
diff --git a/Src/GhostDraw/Views/UserControls/ModeSettingsControl.xaml b/Src/GhostDraw/Views/UserControls/ModeSettingsControl.xaml
new file mode 100644
index 0000000..609a882
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/ModeSettingsControl.xaml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Src/GhostDraw/Views/UserControls/ModeSettingsControl.xaml.cs b/Src/GhostDraw/Views/UserControls/ModeSettingsControl.xaml.cs
new file mode 100644
index 0000000..8dc6c34
--- /dev/null
+++ b/Src/GhostDraw/Views/UserControls/ModeSettingsControl.xaml.cs
@@ -0,0 +1,75 @@
+using System.Windows;
+using GhostDraw.Services;
+using WpfUserControl = System.Windows.Controls.UserControl;
+
+namespace GhostDraw.Views.UserControls;
+
+public partial class ModeSettingsControl : WpfUserControl
+{
+ private int _updateNestingLevel = 0;
+
+ // DependencyProperty for AppSettings
+ public static readonly DependencyProperty AppSettingsProperty =
+ DependencyProperty.Register(
+ nameof(AppSettings),
+ typeof(AppSettingsService),
+ typeof(ModeSettingsControl),
+ new PropertyMetadata(null, OnAppSettingsChanged));
+
+ public AppSettingsService? AppSettings
+ {
+ get => (AppSettingsService?)GetValue(AppSettingsProperty);
+ set => SetValue(AppSettingsProperty, value);
+ }
+
+ private static void OnAppSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is ModeSettingsControl control && e.NewValue is AppSettingsService appSettings)
+ {
+ control.Initialize(appSettings);
+ }
+ }
+
+ public ModeSettingsControl()
+ {
+ InitializeComponent();
+ }
+
+ private void Initialize(AppSettingsService appSettings)
+ {
+ LoadSettings(appSettings);
+ appSettings.LockDrawingModeChanged += OnLockDrawingModeChanged;
+ Unloaded += (s, e) => appSettings.LockDrawingModeChanged -= OnLockDrawingModeChanged;
+ }
+
+ private void OnLockDrawingModeChanged(object? sender, bool isLocked)
+ {
+ Dispatcher.Invoke(() =>
+ {
+ _updateNestingLevel++;
+ try
+ {
+ if (LockModeCheckBox != null)
+ LockModeCheckBox.IsChecked = isLocked;
+ }
+ finally
+ {
+ _updateNestingLevel--;
+ }
+ });
+ }
+
+ private void LoadSettings(AppSettingsService appSettings)
+ {
+ var settings = appSettings.CurrentSettings;
+ LockModeCheckBox.IsChecked = settings.LockDrawingMode;
+ }
+
+ private void LockModeCheckBox_Changed(object sender, RoutedEventArgs e)
+ {
+ if (_updateNestingLevel == 0 && LockModeCheckBox.IsChecked.HasValue && AppSettings != null)
+ {
+ AppSettings.SetLockDrawingMode(LockModeCheckBox.IsChecked.Value);
+ }
+ }
+}
diff --git a/Tests/GhostDraw.Tests/AppSettingsServiceTests.cs b/Tests/GhostDraw.Tests/AppSettingsServiceTests.cs
index dafc70a..1af4f15 100644
--- a/Tests/GhostDraw.Tests/AppSettingsServiceTests.cs
+++ b/Tests/GhostDraw.Tests/AppSettingsServiceTests.cs
@@ -86,7 +86,7 @@ public void AppSettingsService_ShouldCreateWithDefaultSettings()
// Assert
var settings = service.CurrentSettings;
Assert.NotNull(settings);
- Assert.Equal("#FF0000", settings.BrushColor);
+ Assert.Equal("#FF0000", settings.ActiveBrush);
Assert.Equal(3.0, settings.BrushThickness);
Assert.False(settings.LockDrawingMode);
}
@@ -100,27 +100,27 @@ public void CurrentSettings_ShouldReturnClonedCopy()
// Act
var settings1 = service.CurrentSettings;
var settings2 = service.CurrentSettings;
- settings1.BrushColor = "#FFFFFF";
+ settings1.ActiveBrush = "#FFFFFF";
// Assert - Verify they are different instances
Assert.NotSame(settings1, settings2);
- Assert.Equal("#FF0000", settings2.BrushColor); // settings2 should still have default
+ Assert.Equal("#FF0000", settings2.ActiveBrush); // settings2 should still have default
}
[Theory]
[InlineData("#FF0000")]
[InlineData("#00FF00")]
[InlineData("#0000FF")]
- public void SetBrushColor_ShouldUpdateColor(string color)
+ public void SetActiveBrush_ShouldUpdateColor(string color)
{
// Arrange
var service = CreateService();
// Act
- service.SetBrushColor(color);
+ service.SetActiveBrush(color);
// Assert
- Assert.Equal(color, service.CurrentSettings.BrushColor);
+ Assert.Equal(color, service.CurrentSettings.ActiveBrush);
}
[Theory]
@@ -173,7 +173,7 @@ public void GetNextColor_ShouldCycleThroughPalette()
{
// Arrange
var service = CreateService();
- var firstColor = service.CurrentSettings.BrushColor;
+ var firstColor = service.CurrentSettings.ActiveBrush;
var palette = service.CurrentSettings.ColorPalette;
// Act
@@ -182,7 +182,7 @@ public void GetNextColor_ShouldCycleThroughPalette()
// Assert
var expectedIndex = (palette.IndexOf(firstColor) + 1) % palette.Count;
Assert.Equal(palette[expectedIndex], nextColor);
- Assert.Equal(nextColor, service.CurrentSettings.BrushColor);
+ Assert.Equal(nextColor, service.CurrentSettings.ActiveBrush);
}
[Fact]
@@ -193,7 +193,7 @@ public void GetNextColor_ShouldWrapAroundAtEnd()
var palette = service.CurrentSettings.ColorPalette;
// Set to last color in palette
- service.SetBrushColor(palette[palette.Count - 1]);
+ service.SetActiveBrush(palette[palette.Count - 1]);
// Act
var nextColor = service.GetNextColor();
@@ -207,7 +207,7 @@ public void ResetToDefaults_ShouldRestoreDefaultSettings()
{
// Arrange
var service = CreateService();
- service.SetBrushColor("#123456");
+ service.SetActiveBrush("#123456");
service.SetBrushThickness(15.0);
service.SetLockDrawingMode(true);
@@ -216,7 +216,7 @@ public void ResetToDefaults_ShouldRestoreDefaultSettings()
// Assert
var settings = service.CurrentSettings;
- Assert.Equal("#FF0000", settings.BrushColor);
+ Assert.Equal("#FF0000", settings.ActiveBrush);
Assert.Equal(3.0, settings.BrushThickness);
Assert.False(settings.LockDrawingMode);
}
@@ -383,13 +383,13 @@ public void AppSettings_MinMaxThicknessShouldSerializeToJson()
}
[Fact]
- public void SetBrushColor_ShouldTriggerLogging()
+ public void SetActiveBrush_ShouldTriggerLogging()
{
// Arrange
var service = CreateService();
// Act
- service.SetBrushColor("#ABCDEF");
+ service.SetActiveBrush("#ABCDEF");
// Assert - Verify logger was called (using Moq verification)
_mockLogger.Verify(
@@ -410,7 +410,7 @@ public void GetNextColor_WithColorNotInPalette_ShouldStillCycle()
var palette = service.CurrentSettings.ColorPalette;
// Set to a color not in the palette
- service.SetBrushColor("#999999");
+ service.SetActiveBrush("#999999");
// Act
var nextColor = service.GetNextColor();
diff --git a/Tests/GhostDraw.Tests/AppSettingsTests.cs b/Tests/GhostDraw.Tests/AppSettingsTests.cs
index 130858e..ce06df4 100644
--- a/Tests/GhostDraw.Tests/AppSettingsTests.cs
+++ b/Tests/GhostDraw.Tests/AppSettingsTests.cs
@@ -11,7 +11,7 @@ public void AppSettings_ShouldHaveDefaultValues()
var settings = new AppSettings();
// Assert
- Assert.Equal("#FF0000", settings.BrushColor);
+ Assert.Equal("#FF0000", settings.ActiveBrush);
Assert.Equal(3.0, settings.BrushThickness);
Assert.Equal(1.0, settings.MinBrushThickness);
Assert.Equal(20.0, settings.MaxBrushThickness);
@@ -46,20 +46,20 @@ public void AppSettings_Clone_ShouldCreateDeepCopy()
// Arrange
var original = new AppSettings
{
- BrushColor = "#0000FF",
+ ActiveBrush = "#0000FF",
BrushThickness = 5.0,
LockDrawingMode = true
};
// Act
var clone = original.Clone();
- clone.BrushColor = "#00FF00";
+ clone.ActiveBrush = "#00FF00";
clone.BrushThickness = 10.0;
// Assert
- Assert.Equal("#0000FF", original.BrushColor);
+ Assert.Equal("#0000FF", original.ActiveBrush);
Assert.Equal(5.0, original.BrushThickness);
- Assert.Equal("#00FF00", clone.BrushColor);
+ Assert.Equal("#00FF00", clone.ActiveBrush);
Assert.Equal(10.0, clone.BrushThickness);
}
@@ -105,16 +105,16 @@ public void AppSettings_BrushThicknessRange_ShouldAcceptValidValues(double min,
[InlineData("#0000FF")]
[InlineData("#FFFFFF")]
[InlineData("#000000")]
- public void AppSettings_BrushColor_ShouldAcceptValidHexColors(string color)
+ public void AppSettings_ActiveBrush_ShouldAcceptValidHexColors(string color)
{
// Arrange & Act
var settings = new AppSettings
{
- BrushColor = color
+ ActiveBrush = color
};
// Assert
- Assert.Equal(color, settings.BrushColor);
+ Assert.Equal(color, settings.ActiveBrush);
}
[Theory]
diff --git a/docs/Settings.png b/docs/Settings.png
new file mode 100644
index 0000000..d018481
Binary files /dev/null and b/docs/Settings.png differ
diff --git a/docs/TODO.md b/docs/TODO.md
index 8c73c25..00bd610 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -25,3 +25,13 @@
- [X] Installer should be able to uninstall the app cleanly removing all files and registry entries.
- [X] Installer should be able to update the app to a new version if run again and a current version is already installed.
- [ ] Create github actions workflow to build and package the installer. And publish to realeases on github.
+
+
+
+
+## Settings
+Seams like my settings file settings sometimes get overwritten. I set the key combo to Ctrl + Shift + x but sometimes it gets reset to Ctrl + Shift + D.
+
+Create a default settings json file that we use as defaults for non set settings and for re-creating the settings file if it is corrupted.
+
+On startup load settings, if there is an error loading settings, show user via toast or message dialog that the settings file is corupted and will be re-recreated with defaults. Update the settings file content with default settings.
diff --git a/package.json b/package.json
index ccdba0e..c63c093 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"format": "dotnet format GhostDraw.sln",
"format:check": "dotnet format GhostDraw.sln --verify-no-changes",
"version:check": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File Scripts/Update-Version.ps1 -UpdateProjFiles -Bump patch -DryRun",
- "version:tag": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File Scripts/Update-Version.ps1 -CreateTag -Bump patch -DryRun"
+ "version:tag": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File Scripts/Update-Version.ps1 -CreateTag -PushTag -Bump patch -DryRun"
},
"engines": {
"node": ">=18.0.0",
diff --git a/tests/GhostDraw.Tests/AppSettingsServiceTests.cs b/tests/GhostDraw.Tests/AppSettingsServiceTests.cs
deleted file mode 100644
index dafc70a..0000000
--- a/tests/GhostDraw.Tests/AppSettingsServiceTests.cs
+++ /dev/null
@@ -1,439 +0,0 @@
-using Microsoft.Extensions.Logging;
-using Moq;
-using System.Text.Json;
-using GhostDraw.Services;
-using GhostDraw.Core;
-
-namespace GhostDraw.Tests;
-
-public class AppSettingsServiceTests : IDisposable
-{
- private readonly string _testSettingsPath;
- private readonly string _testDirectory;
- private readonly Mock> _mockLogger;
- private readonly string _actualSettingsPath;
-
- public AppSettingsServiceTests()
- {
- _mockLogger = new Mock>();
-
- // Create a temporary directory for test settings
- _testDirectory = Path.Combine(Path.GetTempPath(), $"GhostDrawTests_{Guid.NewGuid()}");
- Directory.CreateDirectory(_testDirectory);
- _testSettingsPath = Path.Combine(_testDirectory, "settings.json");
-
- // Determine the actual settings path used by AppSettingsService
- string appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- string settingsDirectory = Path.Combine(appData, "GhostDraw");
- _actualSettingsPath = Path.Combine(settingsDirectory, "settings.json");
-
- // Clean up any existing settings file before tests to ensure clean state
- CleanupSettingsFile();
- }
-
- public void Dispose()
- {
- // Cleanup test directory
- if (Directory.Exists(_testDirectory))
- {
- try
- {
- Directory.Delete(_testDirectory, true);
- }
- catch
- {
- // Ignore cleanup errors
- }
- }
-
- // Also cleanup the actual settings file to prevent pollution between test runs
- CleanupSettingsFile();
- }
-
- ///
- /// Deletes the actual settings file to ensure tests start with a clean slate
- ///
- private void CleanupSettingsFile()
- {
- try
- {
- if (File.Exists(_actualSettingsPath))
- {
- File.Delete(_actualSettingsPath);
- }
- }
- catch
- {
- // Ignore if file is locked or doesn't exist
- }
- }
-
- ///
- /// Helper to create a testable service by using the actual LocalApplicationData,
- /// but we'll verify behavior through public API
- ///
- private AppSettingsService CreateService()
- {
- return new AppSettingsService(_mockLogger.Object);
- }
-
- [Fact]
- public void AppSettingsService_ShouldCreateWithDefaultSettings()
- {
- // Arrange & Act
- var service = CreateService();
-
- // Assert
- var settings = service.CurrentSettings;
- Assert.NotNull(settings);
- Assert.Equal("#FF0000", settings.BrushColor);
- Assert.Equal(3.0, settings.BrushThickness);
- Assert.False(settings.LockDrawingMode);
- }
-
- [Fact]
- public void CurrentSettings_ShouldReturnClonedCopy()
- {
- // Arrange
- var service = CreateService();
-
- // Act
- var settings1 = service.CurrentSettings;
- var settings2 = service.CurrentSettings;
- settings1.BrushColor = "#FFFFFF";
-
- // Assert - Verify they are different instances
- Assert.NotSame(settings1, settings2);
- Assert.Equal("#FF0000", settings2.BrushColor); // settings2 should still have default
- }
-
- [Theory]
- [InlineData("#FF0000")]
- [InlineData("#00FF00")]
- [InlineData("#0000FF")]
- public void SetBrushColor_ShouldUpdateColor(string color)
- {
- // Arrange
- var service = CreateService();
-
- // Act
- service.SetBrushColor(color);
-
- // Assert
- Assert.Equal(color, service.CurrentSettings.BrushColor);
- }
-
- [Theory]
- [InlineData(1.0)]
- [InlineData(5.0)]
- [InlineData(10.0)]
- [InlineData(20.0)]
- public void SetBrushThickness_ShouldUpdateThickness(double thickness)
- {
- // Arrange
- var service = CreateService();
-
- // Act
- service.SetBrushThickness(thickness);
-
- // Assert
- Assert.Equal(thickness, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void SetBrushThickness_ShouldClampBelowMinimum()
- {
- // Arrange
- var service = CreateService();
- var minThickness = service.CurrentSettings.MinBrushThickness;
-
- // Act
- service.SetBrushThickness(minThickness - 5.0);
-
- // Assert
- Assert.Equal(minThickness, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void SetBrushThickness_ShouldClampAboveMaximum()
- {
- // Arrange
- var service = CreateService();
- var maxThickness = service.CurrentSettings.MaxBrushThickness;
-
- // Act
- service.SetBrushThickness(maxThickness + 10.0);
-
- // Assert
- Assert.Equal(maxThickness, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void GetNextColor_ShouldCycleThroughPalette()
- {
- // Arrange
- var service = CreateService();
- var firstColor = service.CurrentSettings.BrushColor;
- var palette = service.CurrentSettings.ColorPalette;
-
- // Act
- var nextColor = service.GetNextColor();
-
- // Assert
- var expectedIndex = (palette.IndexOf(firstColor) + 1) % palette.Count;
- Assert.Equal(palette[expectedIndex], nextColor);
- Assert.Equal(nextColor, service.CurrentSettings.BrushColor);
- }
-
- [Fact]
- public void GetNextColor_ShouldWrapAroundAtEnd()
- {
- // Arrange
- var service = CreateService();
- var palette = service.CurrentSettings.ColorPalette;
-
- // Set to last color in palette
- service.SetBrushColor(palette[palette.Count - 1]);
-
- // Act
- var nextColor = service.GetNextColor();
-
- // Assert - Should wrap to first color
- Assert.Equal(palette[0], nextColor);
- }
-
- [Fact]
- public void ResetToDefaults_ShouldRestoreDefaultSettings()
- {
- // Arrange
- var service = CreateService();
- service.SetBrushColor("#123456");
- service.SetBrushThickness(15.0);
- service.SetLockDrawingMode(true);
-
- // Act
- service.ResetToDefaults();
-
- // Assert
- var settings = service.CurrentSettings;
- Assert.Equal("#FF0000", settings.BrushColor);
- Assert.Equal(3.0, settings.BrushThickness);
- Assert.False(settings.LockDrawingMode);
- }
-
- [Theory]
- [InlineData(new int[] { 0xA2, 0xA4, 0x44 })] // Ctrl + Alt + D
- [InlineData(new int[] { 0xA2, 0xA0, 0x46 })] // Ctrl + Shift + F
- [InlineData(new int[] { 0xA4, 0xA0, 0x58 })] // Alt + Shift + X
- public void SetHotkey_ShouldUpdateHotkeyConfiguration(int[] vkArray)
- {
- // Arrange
- var service = CreateService();
- var vks = new List(vkArray);
-
- // Act
- service.SetHotkey(vks);
-
- // Assert
- var settings = service.CurrentSettings;
- Assert.Equal(vks, settings.HotkeyVirtualKeys);
- }
-
- [Theory]
- [InlineData(true)]
- [InlineData(false)]
- public void SetLockDrawingMode_ShouldUpdateLockMode(bool lockMode)
- {
- // Arrange
- var service = CreateService();
-
- // Act
- service.SetLockDrawingMode(lockMode);
-
- // Assert
- Assert.Equal(lockMode, service.CurrentSettings.LockDrawingMode);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldUpdateMinAndMax()
- {
- // Arrange
- var service = CreateService();
- double newMin = 2.0;
- double newMax = 25.0;
-
- // Act
- service.SetBrushThicknessRange(newMin, newMax);
-
- // Assert
- var settings = service.CurrentSettings;
- Assert.Equal(newMin, settings.MinBrushThickness);
- Assert.Equal(newMax, settings.MaxBrushThickness);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldAdjustCurrentThicknessIfBelowMin()
- {
- // Arrange
- var service = CreateService();
- service.SetBrushThickness(3.0);
-
- // Act - Set new range where min is above current thickness
- service.SetBrushThicknessRange(5.0, 20.0);
-
- // Assert
- Assert.Equal(5.0, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldAdjustCurrentThicknessIfAboveMax()
- {
- // Arrange
- var service = CreateService();
- service.SetBrushThickness(15.0);
-
- // Act - Set new range where max is below current thickness
- service.SetBrushThicknessRange(1.0, 10.0);
-
- // Assert
- Assert.Equal(10.0, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldPersistMinAndMax()
- {
- // Arrange
- var service = new AppSettingsService(_mockLogger.Object);
-
- // Act
- service.SetBrushThicknessRange(5, 30);
-
- // Assert - values should be set in memory
- Assert.Equal(5, service.CurrentSettings.MinBrushThickness);
- Assert.Equal(30, service.CurrentSettings.MaxBrushThickness);
-
- // Create a new service instance to verify persistence
- var newService = new AppSettingsService(_mockLogger.Object);
- Assert.Equal(5, newService.CurrentSettings.MinBrushThickness);
- Assert.Equal(30, newService.CurrentSettings.MaxBrushThickness);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldAdjustCurrentThicknessIfOutOfRange()
- {
- // Arrange
- var service = new AppSettingsService(_mockLogger.Object);
- service.SetBrushThickness(15);
-
- // Act - set range that excludes current value
- service.SetBrushThicknessRange(1, 10);
-
- // Assert - current thickness should be adjusted to max
- Assert.Equal(10, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldNotAdjustCurrentThicknessIfInRange()
- {
- // Arrange
- var service = new AppSettingsService(_mockLogger.Object);
- service.SetBrushThickness(8);
-
- // Act - set range that includes current value
- service.SetBrushThicknessRange(5, 30);
-
- // Assert - current thickness should remain unchanged
- Assert.Equal(8, service.CurrentSettings.BrushThickness);
- }
-
- [Fact]
- public void SetBrushThicknessRange_ShouldRaiseEvent()
- {
- // Arrange
- var service = new AppSettingsService(_mockLogger.Object);
- (double min, double max) eventData = (0, 0);
- service.BrushThicknessRangeChanged += (sender, data) => eventData = data;
-
- // Act
- service.SetBrushThicknessRange(2, 50);
-
- // Assert
- Assert.Equal(2, eventData.min);
- Assert.Equal(50, eventData.max);
- }
-
- [Fact]
- public void AppSettings_MinMaxThicknessShouldSerializeToJson()
- {
- // Arrange
- var settings = new AppSettings
- {
- MinBrushThickness = 3,
- MaxBrushThickness = 40
- };
-
- // Act
- var json = JsonSerializer.Serialize(settings);
- var deserialized = JsonSerializer.Deserialize(json);
-
- // Assert
- Assert.NotNull(deserialized);
- Assert.Equal(3, deserialized.MinBrushThickness);
- Assert.Equal(40, deserialized.MaxBrushThickness);
- }
-
- [Fact]
- public void SetBrushColor_ShouldTriggerLogging()
- {
- // Arrange
- var service = CreateService();
-
- // Act
- service.SetBrushColor("#ABCDEF");
-
- // Assert - Verify logger was called (using Moq verification)
- _mockLogger.Verify(
- x => x.Log(
- LogLevel.Information,
- It.IsAny(),
- It.Is((v, t) => v.ToString()!.Contains("#ABCDEF")),
- It.IsAny(),
- It.IsAny>()),
- Times.AtLeastOnce);
- }
-
- [Fact]
- public void GetNextColor_WithColorNotInPalette_ShouldStillCycle()
- {
- // Arrange
- var service = CreateService();
- var palette = service.CurrentSettings.ColorPalette;
-
- // Set to a color not in the palette
- service.SetBrushColor("#999999");
-
- // Act
- var nextColor = service.GetNextColor();
-
- // Assert - Should default to first color (index -1 + 1 = 0)
- Assert.Equal(palette[0], nextColor);
- }
-
- [Fact]
- public void SetHotkey_ShouldPersistHotkeyConfiguration()
- {
- // Arrange
- var service = new AppSettingsService(_mockLogger.Object);
- var vks = new List { 0xA0, 0x46 }; // Shift + F
-
- // Act
- service.SetHotkey(vks);
-
- // Assert - values should be set in memory
- Assert.Equal(vks, service.CurrentSettings.HotkeyVirtualKeys);
-
- // Create a new service instance to verify persistence
- var newService = new AppSettingsService(_mockLogger.Object);
- Assert.Equal(vks, newService.CurrentSettings.HotkeyVirtualKeys);
- }
-}
diff --git a/tests/GhostDraw.Tests/AppSettingsTests.cs b/tests/GhostDraw.Tests/AppSettingsTests.cs
deleted file mode 100644
index 130858e..0000000
--- a/tests/GhostDraw.Tests/AppSettingsTests.cs
+++ /dev/null
@@ -1,134 +0,0 @@
-using GhostDraw.Core;
-
-namespace GhostDraw.Tests;
-
-public class AppSettingsTests
-{
- [Fact]
- public void AppSettings_ShouldHaveDefaultValues()
- {
- // Arrange & Act
- var settings = new AppSettings();
-
- // Assert
- Assert.Equal("#FF0000", settings.BrushColor);
- Assert.Equal(3.0, settings.BrushThickness);
- Assert.Equal(1.0, settings.MinBrushThickness);
- Assert.Equal(20.0, settings.MaxBrushThickness);
- Assert.Equal(new List { 0xA2, 0xA4, 0x44 }, settings.HotkeyVirtualKeys); // Ctrl+Alt+D
- Assert.Equal("Ctrl + Alt + D", settings.HotkeyDisplayName);
- Assert.False(settings.LockDrawingMode);
- Assert.Equal(10, settings.ColorPalette.Count);
- }
-
- [Fact]
- public void AppSettings_ColorPalette_ShouldContainExpectedColors()
- {
- // Arrange & Act
- var settings = new AppSettings();
-
- // Assert
- Assert.Contains("#FF0000", settings.ColorPalette); // Red
- Assert.Contains("#00FF00", settings.ColorPalette); // Green
- Assert.Contains("#0000FF", settings.ColorPalette); // Blue
- Assert.Contains("#FFFF00", settings.ColorPalette); // Yellow
- Assert.Contains("#FF00FF", settings.ColorPalette); // Magenta
- Assert.Contains("#00FFFF", settings.ColorPalette); // Cyan
- Assert.Contains("#FFFFFF", settings.ColorPalette); // White
- Assert.Contains("#000000", settings.ColorPalette); // Black
- Assert.Contains("#FFA500", settings.ColorPalette); // Orange
- Assert.Contains("#800080", settings.ColorPalette); // Purple
- }
-
- [Fact]
- public void AppSettings_Clone_ShouldCreateDeepCopy()
- {
- // Arrange
- var original = new AppSettings
- {
- BrushColor = "#0000FF",
- BrushThickness = 5.0,
- LockDrawingMode = true
- };
-
- // Act
- var clone = original.Clone();
- clone.BrushColor = "#00FF00";
- clone.BrushThickness = 10.0;
-
- // Assert
- Assert.Equal("#0000FF", original.BrushColor);
- Assert.Equal(5.0, original.BrushThickness);
- Assert.Equal("#00FF00", clone.BrushColor);
- Assert.Equal(10.0, clone.BrushThickness);
- }
-
- [Fact]
- public void AppSettings_Clone_ShouldCopyColorPalette()
- {
- // Arrange
- var original = new AppSettings();
- original.ColorPalette.Add("#123456");
-
- // Act
- var clone = original.Clone();
- clone.ColorPalette.Add("#ABCDEF");
-
- // Assert
- Assert.Equal(11, original.ColorPalette.Count);
- Assert.Equal(12, clone.ColorPalette.Count);
- Assert.Contains("#123456", original.ColorPalette);
- Assert.DoesNotContain("#ABCDEF", original.ColorPalette);
- }
-
- [Theory]
- [InlineData(1.0, 20.0)]
- [InlineData(0.5, 50.0)]
- [InlineData(5.0, 15.0)]
- public void AppSettings_BrushThicknessRange_ShouldAcceptValidValues(double min, double max)
- {
- // Arrange & Act
- var settings = new AppSettings
- {
- MinBrushThickness = min,
- MaxBrushThickness = max
- };
-
- // Assert
- Assert.Equal(min, settings.MinBrushThickness);
- Assert.Equal(max, settings.MaxBrushThickness);
- }
-
- [Theory]
- [InlineData("#FF0000")]
- [InlineData("#00FF00")]
- [InlineData("#0000FF")]
- [InlineData("#FFFFFF")]
- [InlineData("#000000")]
- public void AppSettings_BrushColor_ShouldAcceptValidHexColors(string color)
- {
- // Arrange & Act
- var settings = new AppSettings
- {
- BrushColor = color
- };
-
- // Assert
- Assert.Equal(color, settings.BrushColor);
- }
-
- [Theory]
- [InlineData(true)]
- [InlineData(false)]
- public void AppSettings_LockDrawingMode_ShouldAcceptBooleanValues(bool lockMode)
- {
- // Arrange & Act
- var settings = new AppSettings
- {
- LockDrawingMode = lockMode
- };
-
- // Assert
- Assert.Equal(lockMode, settings.LockDrawingMode);
- }
-}
diff --git a/tests/GhostDraw.Tests/GhostDraw.Tests.csproj b/tests/GhostDraw.Tests/GhostDraw.Tests.csproj
deleted file mode 100644
index b5bf0bb..0000000
--- a/tests/GhostDraw.Tests/GhostDraw.Tests.csproj
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
- net8.0-windows
- enable
- enable
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-