diff --git a/CLAUDE.md b/CLAUDE.md index 60dceef..baeee9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build / Run -The project is a WPF app targeting `net8.0-windows`. `EnableWindowsTargeting=true` is set in the csproj so it can be restored/built on non-Windows agents, but it can only be **run** on Windows. +The project is a WPF app targeting `net10.0-windows`. `EnableWindowsTargeting=true` is set in the csproj so it can be restored/built on non-Windows agents, but it can only be **run** on Windows. ```powershell dotnet restore MoneyShot/MoneyShot.csproj @@ -22,6 +22,17 @@ The base version lives in `MoneyShot/MoneyShot.csproj` (``, `(() => _service.SaveToFile(null!, path)); + } + + [Fact] + public void SaveToFile_NestedUnderSystemDirectory_IsRejected() + { + var system = Environment.GetFolderPath(Environment.SpecialFolder.System); + var path = Path.Combine(system, "drivers", "moneyshot-test.png"); + Assert.Throws(() => _service.SaveToFile(null!, path)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SaveToFile_EmptyPath_IsRejected(string path) + { + Assert.Throws(() => _service.SaveToFile(null!, path)); + } + + [Fact] + public void SaveToFile_BareDirectoryPath_IsRejected() + { + Assert.Throws(() => _service.SaveToFile(null!, Path.GetTempPath())); + } + + [Fact] + public void SaveToFile_UserWritablePath_PassesValidation() + { + // With a valid path, validation must NOT throw ArgumentException; the failure (if any) + // comes from the null image and is wrapped as InvalidOperationException. + var path = Path.Combine(Path.GetTempPath(), "moneyshot-test.png"); + var ex = Record.Exception(() => _service.SaveToFile(null!, path)); + Assert.IsType(ex); + } + + [Theory] + [InlineData("PNG", ".png")] + [InlineData("JPG", ".jpg")] + [InlineData("BMP", ".bmp")] + public void GenerateFileName_UsesLowercaseExtension(string format, string expectedExtension) + { + var name = _service.GenerateFileName(format); + Assert.StartsWith("Screenshot_", name); + Assert.EndsWith(expectedExtension, name); + } +} diff --git a/MoneyShot.Tests/SettingsServiceTests.cs b/MoneyShot.Tests/SettingsServiceTests.cs index f2d494b..be1118d 100644 --- a/MoneyShot.Tests/SettingsServiceTests.cs +++ b/MoneyShot.Tests/SettingsServiceTests.cs @@ -10,6 +10,19 @@ public class SettingsServiceTests private static readonly string MyPictures = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); + [Theory] + [InlineData(-5, 0)] + [InlineData(0, 0)] + [InlineData(50, 50)] + [InlineData(500, 500)] + [InlineData(100_000, 500)] + public void HistoryRetentionCount_IsClampedToUiRange(int input, int expected) + { + var settings = new AppSettings { HistoryRetentionCount = input }; + var sanitized = SettingsService.ValidateAndSanitizeSettings(settings); + Assert.Equal(expected, sanitized.HistoryRetentionCount); + } + [Fact] public void EmptyDefaultSavePath_FallsBackToMyPictures() { diff --git a/MoneyShot/App.xaml b/MoneyShot/App.xaml index 0455290..cbc54fe 100644 --- a/MoneyShot/App.xaml +++ b/MoneyShot/App.xaml @@ -1,9 +1,20 @@ - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - + + + + + - - - - - - - - - - - - - - + + + + + + + + - - - - - + + + + + - - - - - - - - - - - - - - - - - + + + + + - - - + + + + + - + - - - + + + + + + - - - - + + + + + + + diff --git a/MoneyShot/MainWindow.xaml.cs b/MoneyShot/MainWindow.xaml.cs index 4c6446d..32b8c66 100644 --- a/MoneyShot/MainWindow.xaml.cs +++ b/MoneyShot/MainWindow.xaml.cs @@ -135,44 +135,47 @@ private async Task CheckForUpdatesAsync(bool showUpToDateMessage, bool showError private void PopulateMonitorButtons() { var screens = _screenshotService.GetAllScreens(); - if (screens.Count > 1) + if (screens.Count <= 1) return; + + var label = new TextBlock + { + Text = "Individual monitors", + FontSize = 12, + Foreground = (Brush)FindResource("Cocoa.TextSecondaryBrush"), + Margin = new Thickness(0, 10, 0, 6) + }; + MonitorButtonsPanel.Children.Add(label); + + var subtleStyle = (Style)FindResource("SubtleButton"); + var monitorIcon = (Geometry)FindResource("Icon.Monitor"); + var iconStyle = (Style)FindResource("ToolIcon"); + for (int i = 0; i < screens.Count; i++) { - var separator = new Separator + var screenIndex = i; + var screen = screens[i]; + var isPrimary = screen.Primary ? " · primary" : string.Empty; + var hotkeyHint = screenIndex < MaxMonitorHotkeys ? $"Ctrl+Shift+{screenIndex + 1}" : null; + + var content = new StackPanel { Orientation = System.Windows.Controls.Orientation.Horizontal }; + content.Children.Add(new System.Windows.Shapes.Path { Style = iconStyle, Data = monitorIcon }); + content.Children.Add(new TextBlock { - Margin = new Thickness(0, 10, 0, 5), - Background = new SolidColorBrush(Color.FromRgb(85, 85, 85)) - }; - MonitorButtonsPanel.Children.Add(separator); + Text = $"Monitor {i + 1}{isPrimary}", + Margin = new Thickness(9, 0, 0, 0), + VerticalAlignment = System.Windows.VerticalAlignment.Center + }); - var label = new TextBlock + var button = new System.Windows.Controls.Button { - Text = "Individual Monitors:", - FontSize = 14, - Foreground = new SolidColorBrush(Colors.White), - Margin = new Thickness(0, 5, 0, 5), - HorizontalAlignment = System.Windows.HorizontalAlignment.Center + Content = content, + Style = subtleStyle, + Padding = new Thickness(14, 9, 14, 9), + Margin = new Thickness(0, 3, 0, 3), + HorizontalContentAlignment = System.Windows.HorizontalAlignment.Left, + ToolTip = hotkeyHint == null ? null : $"Capture this monitor (hotkey: {hotkeyHint})" }; - MonitorButtonsPanel.Children.Add(label); - - for (int i = 0; i < screens.Count; i++) - { - var screenIndex = i; - var screen = screens[i]; - var isPrimary = screen.Primary ? " (Primary)" : ""; - var button = new System.Windows.Controls.Button - { - Content = $"🖥️ Monitor {i + 1}{isPrimary}", - Padding = new Thickness(20, 10, 20, 10), - Margin = new Thickness(0, 3, 0, 3), - FontSize = 14, - Background = new SolidColorBrush(Color.FromRgb(62, 62, 66)), - Foreground = new SolidColorBrush(Colors.White), - BorderBrush = new SolidColorBrush(Color.FromRgb(85, 85, 85)), - Cursor = System.Windows.Input.Cursors.Hand - }; - button.Click += (s, ev) => CaptureMonitor(screenIndex); - MonitorButtonsPanel.Children.Add(button); - } + button.Click += (s, ev) => CaptureMonitor(screenIndex); + MonitorButtonsPanel.Children.Add(button); } } @@ -389,36 +392,11 @@ private void OpenEditor(System.Windows.Media.Imaging.BitmapSource screenshot, st finally { // The editor's BitmapSource backings, RenderTargetBitmaps and pixelate brushes live in - // both managed and native heaps. Without forcing a collection plus a working-set trim, - // the process sits at several hundred MB until the next major GC — undesirable for a - // tray app that should hover near 80MB while idle. - ReleaseEditorMemory(); - } - } - - private static void ReleaseEditorMemory() - { - try - { - System.Runtime.GCSettings.LargeObjectHeapCompactionMode = - System.Runtime.GCLargeObjectHeapCompactionMode.CompactOnce; - GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); - GC.WaitForPendingFinalizers(); - GC.Collect(); - - // Ask Windows to trim the working set. -1, -1 is the documented "trim now" sentinel. - using var process = System.Diagnostics.Process.GetCurrentProcess(); - SetProcessWorkingSetSize(process.Handle, new IntPtr(-1), new IntPtr(-1)); - } - catch (Exception ex) - { - MoneyShot.Services.Logger.Warn("Could not release editor memory", ex); + // both managed and native heaps — see MemoryTrimmer for why this is forced here. + MemoryTrimmer.TrimAfterEditorClose(); } } - [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] - private static extern bool SetProcessWorkingSetSize(IntPtr proc, IntPtr min, IntPtr max); - private void ShowSettings() { var settings = new SettingsWindow(); @@ -461,32 +439,30 @@ private void Settings_Click(object sender, RoutedEventArgs e) ShowSettings(); } + private void History_Click(object sender, RoutedEventArgs e) + { + ShowHistory(); + } + private void About_Click(object sender, RoutedEventArgs e) { var settings = _settingsService.LoadSettings(); var screens = _screenshotService.GetAllScreens(); - var monitorHotkeys = screens.Count > 1 ? $"\n• Ctrl+Shift+1-{Math.Min(screens.Count, MaxMonitorHotkeys)} - Capture individual monitors" : ""; - + var monitorHotkeys = screens.Count > 1 ? $"\n• Ctrl+Shift+1-{Math.Min(screens.Count, MaxMonitorHotkeys)} — Capture individual monitors" : ""; + // SemVer portion only — the 4th part is the CI build number and isn't meaningful to users. + var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + var displayVersion = version == null ? "unknown" : $"{version.Major}.{version.Minor}.{version.Build}"; + System.Windows.MessageBox.Show( - "Money Shot - Incredible AI Slop\n\n" + - "Version 2.0.0\n\n" + + "Money Shot — screenshot capture and annotation for Windows\n\n" + + $"Version {displayVersion}\n" + "Developed by Daolyap & iSaluki\n\n" + - "Features:\n" + - "• Full screen, region, and individual monitor capture\n" + - "• Multi-monitor support\n" + - "• Rich annotation tools (shapes, text, arrows, numbers, blur)\n" + - "• Customizable hotkeys\n" + - "• Save to file or clipboard\n" + - "• System tray integration\n" + - "• Start in tray option\n" + - "• Disable default print screen behaviour\n\n" + - "Current Hotkeys:\n" + - $"• {settings.HotKeyCapture} - Capture full screen\n" + - $"• {settings.HotKeyRegionCapture} - Capture region" + + "Current hotkeys:\n" + + $"• {settings.HotKeyCapture} — Capture full screen\n" + + $"• {settings.HotKeyRegionCapture} — Capture region" + monitorHotkeys + - "\n\nContact:\n" + - "Features - https://github.com/daolyap/moneyshot/issues\n" + - "Security - moneyshot@daolyap.dev", + "\n\nFeedback & issues: https://github.com/Daolyap/Money-Shot/issues\n" + + "Security contact: moneyshot@daolyap.dev", "About Money Shot", MessageBoxButton.OK, MessageBoxImage.Information); @@ -552,21 +528,15 @@ private void MaximizeRestore_Click(object sender, RoutedEventArgs e) if (WindowState == WindowState.Maximized) { WindowState = WindowState.Normal; - if (MaximizeRestoreButton != null) - { - MaximizeRestoreButton.Content = "🗖"; - } + MaximizeRestoreIcon.Data = (Geometry)FindResource("Icon.WindowMaximize"); } else { WindowState = WindowState.Maximized; - if (MaximizeRestoreButton != null) - { - MaximizeRestoreButton.Content = "🗗"; - } + MaximizeRestoreIcon.Data = (Geometry)FindResource("Icon.WindowRestore"); } } - + private void Close_Click(object sender, RoutedEventArgs e) { Close(); diff --git a/MoneyShot/MoneyShot.csproj b/MoneyShot/MoneyShot.csproj index c6a3c8d..952a93c 100644 --- a/MoneyShot/MoneyShot.csproj +++ b/MoneyShot/MoneyShot.csproj @@ -9,9 +9,9 @@ true app.manifest favicon.ico - 2.0.0 - 2.0.0.0 - 2.0.0.0 + 2.1.0 + 2.1.0.0 + 2.1.0.0 Daolyap & iSaluki Daolyap & iSaluki Money Shot Screenshot Tool diff --git a/MoneyShot/Services/AutoUpdateService.cs b/MoneyShot/Services/AutoUpdateService.cs index 740943e..cc7f42b 100644 --- a/MoneyShot/Services/AutoUpdateService.cs +++ b/MoneyShot/Services/AutoUpdateService.cs @@ -16,8 +16,12 @@ namespace MoneyShot.Services; public sealed class AutoUpdateService { + // SECURITY: must track the repository's CURRENT name. The repo was renamed from + // "MoneyShot" to "Money-Shot"; GitHub redirects the old name only until someone + // re-registers it, at which point they would control this app's update channel + // (repojacking). If the repo is ever renamed again, update this immediately. private const string Owner = "Daolyap"; - private const string Repository = "MoneyShot"; + private const string Repository = "Money-Shot"; private const string LatestReleaseUrl = $"https://api.github.com/repos/{Owner}/{Repository}/releases/latest"; private const string AllReleasesUrl = $"https://api.github.com/repos/{Owner}/{Repository}/releases?per_page=1"; private static readonly Regex VersionNumberRegex = new(@"\d+", RegexOptions.Compiled); @@ -48,7 +52,10 @@ public AutoUpdateService(HttpClient? httpClient = null) _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); } - var token = Environment.GetEnvironmentVariable(""); // I can put a GitHub token in here if I want authenticated API requests + // Optional: authenticated API requests (higher rate limit). The 401 guidance message + // below already documents this variable name. Treat the value as a secret — it is only + // ever sent to api.github.com over HTTPS and never logged. + var token = Environment.GetEnvironmentVariable("MONEYSHOT_GITHUB_TOKEN"); if (!string.IsNullOrWhiteSpace(token) && _httpClient.DefaultRequestHeaders.Authorization == null) { _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Trim()); diff --git a/MoneyShot/Services/HotKeyService.cs b/MoneyShot/Services/HotKeyService.cs index 7e8076e..da0b381 100644 --- a/MoneyShot/Services/HotKeyService.cs +++ b/MoneyShot/Services/HotKeyService.cs @@ -33,6 +33,10 @@ public int RegisterHotKey(uint modifiers, uint key, Action action) _hotKeyActions[_currentId] = action; return _currentId; } + + // Most often the combination is already claimed by another application (or by a + // still-running instance). Without this log the hotkey just silently does nothing. + Logger.Warn($"RegisterHotKey failed for modifiers=0x{modifiers:X} key=0x{key:X} — combination may be in use by another application."); return -1; } @@ -59,7 +63,16 @@ private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref var id = wParam.ToInt32(); if (_hotKeyActions.TryGetValue(id, out var action)) { - action?.Invoke(); + try + { + action?.Invoke(); + } + catch (Exception ex) + { + // An exception escaping a window-procedure hook can take down the process; + // a failed capture should be logged, not fatal. + Logger.Error("Hotkey action threw", ex); + } handled = true; } } diff --git a/MoneyShot/Services/Logger.cs b/MoneyShot/Services/Logger.cs index 411eda5..78a88ff 100644 --- a/MoneyShot/Services/Logger.cs +++ b/MoneyShot/Services/Logger.cs @@ -31,9 +31,13 @@ internal static class Logger private static void Write(string level, string message, Exception? exception) { + // Errors get the full exception (incl. stack trace) — "ERR ... :: IOException: access + // denied" alone is rarely enough to diagnose a user report. Warnings stay single-line. var line = exception == null ? $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}" - : $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message} :: {exception.GetType().Name}: {exception.Message}"; + : level == "ERR" + ? $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message} :: {exception}" + : $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message} :: {exception.GetType().Name}: {exception.Message}"; System.Diagnostics.Debug.WriteLine(line); diff --git a/MoneyShot/Services/MemoryTrimmer.cs b/MoneyShot/Services/MemoryTrimmer.cs new file mode 100644 index 0000000..85eda59 --- /dev/null +++ b/MoneyShot/Services/MemoryTrimmer.cs @@ -0,0 +1,35 @@ +using System.Runtime; +using System.Runtime.InteropServices; + +namespace MoneyShot.Services; + +/// +/// Releases the large native/managed bitmap backings the editor leaves behind and asks Windows +/// to trim the working set. Without this a tray app that should idle near ~80 MB sits at several +/// hundred MB after the editor closes, until the next major GC happens on its own schedule. +/// Shared by every code path that closes an EditorWindow (capture flow and history). +/// +internal static class MemoryTrimmer +{ + public static void TrimAfterEditorClose() + { + try + { + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + // Ask Windows to trim the working set. -1, -1 is the documented "trim now" sentinel. + using var process = System.Diagnostics.Process.GetCurrentProcess(); + SetProcessWorkingSetSize(process.Handle, new IntPtr(-1), new IntPtr(-1)); + } + catch (Exception ex) + { + Logger.Warn("Could not release editor memory", ex); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetProcessWorkingSetSize(IntPtr proc, IntPtr min, IntPtr max); +} diff --git a/MoneyShot/Services/SaveService.cs b/MoneyShot/Services/SaveService.cs index 3fe9121..e559889 100644 --- a/MoneyShot/Services/SaveService.cs +++ b/MoneyShot/Services/SaveService.cs @@ -27,10 +27,12 @@ public void SaveToFile(BitmapSource image, string filePath, string format = "PNG try { - BitmapEncoder? encoder = format.ToUpper() switch + BitmapEncoder encoder = format.ToUpper() switch { "PNG" => new PngBitmapEncoder(), - "JPG" or "JPEG" => new JpegBitmapEncoder(), + // Default JPEG quality (75) visibly smears text in screenshots; 90 keeps + // UI text legible at a still-reasonable file size. + "JPG" or "JPEG" => new JpegBitmapEncoder { QualityLevel = 90 }, "BMP" => new BmpBitmapEncoder(), "GIF" => new GifBitmapEncoder(), _ => new PngBitmapEncoder() @@ -120,9 +122,16 @@ private void ValidateFilePath(string filePath) foreach (var sysDir in systemDirs) { - if (!string.IsNullOrEmpty(sysDir) && - !string.IsNullOrEmpty(directory) && - directory.StartsWith(sysDir, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(sysDir) || string.IsNullOrEmpty(directory)) + { + continue; + } + + // Compare with a trailing separator so "C:\Windows" blocks "C:\Windows\..." + // and "C:\Windows" itself, but not sibling folders like "C:\WindowsBackup". + var sysDirWithSeparator = Path.TrimEndingDirectorySeparator(sysDir) + Path.DirectorySeparatorChar; + var directoryWithSeparator = Path.TrimEndingDirectorySeparator(directory) + Path.DirectorySeparatorChar; + if (directoryWithSeparator.StartsWith(sysDirWithSeparator, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException("Cannot save to system directories.", nameof(filePath)); } diff --git a/MoneyShot/Services/SettingsService.cs b/MoneyShot/Services/SettingsService.cs index 017fca8..f984ba9 100644 --- a/MoneyShot/Services/SettingsService.cs +++ b/MoneyShot/Services/SettingsService.cs @@ -152,7 +152,11 @@ internal static AppSettings ValidateAndSanitizeSettings(AppSettings settings) { settings.DefaultLineThickness = 3; } - + + // Clamp history retention to the range the UI offers (0 disables retention + // enforcement) so a hand-edited settings.json can't request absurd values. + settings.HistoryRetentionCount = Math.Clamp(settings.HistoryRetentionCount, 0, 500); + return settings; } diff --git a/MoneyShot/Themes/CocoaTheme.xaml b/MoneyShot/Themes/CocoaTheme.xaml new file mode 100644 index 0000000..8b07d84 --- /dev/null +++ b/MoneyShot/Themes/CocoaTheme.xaml @@ -0,0 +1,690 @@ + + + + + + #221A13 + #2A2018 + #2E241A + #3A2D20 + #46382A + #534333 + #4C3C2B + #3A2E22 + #C28E5C + #D2A06E + #AD7C4D + #403122 + #271C11 + #F1E9DE + #C8B69E + #97846C + #C75048 + #191209 + + + + + + + + + + + + + + + + + + + + + + + M5,2 L5,15 L8.2,12.2 L10.4,17 L12.6,16 L10.4,11.4 L14.6,11.4 Z + M5.5,1.5 V14.5 H18.5 M1.5,5.5 H14.5 V18.5 + M2.5,4.5 H17.5 V15.5 H2.5 Z + M10,4.5 A7.5,5.5 0 1 0 10,15.5 A7.5,5.5 0 1 0 10,4.5 Z + M4,16 L15,5 M15,5 H9 M15,5 V11 + M3.5,16.5 L16.5,3.5 + M3,15 C5,7 7,17 10,10 C12,5.5 14,8 17,4.5 + M7.5,3.5 L6,16.5 M13.5,3.5 L12,16.5 M4,7.5 H16.5 M3.5,12.5 H16 + M4,5.5 V3.5 H16 V5.5 M10,3.5 V16.5 M8,16.5 H12 + M3,3 H8 V8 H3 Z M8,8 H13 V13 H8 Z M13,3 H18 V8 H13 Z M3,13 H8 V18 H3 Z M13,13 H18 V18 H13 Z + M4,8 H12.5 A4.5,4.5 0 0 1 12.5,17 H7 M7.5,4.5 L4,8 L7.5,11.5 + M16.5,10 A6.5,6.5 0 1 1 13.2,4.35 M13.2,1.2 V4.7 H16.7 + M8.5,3 A5.5,5.5 0 1 0 8.5,14 A5.5,5.5 0 1 0 8.5,3 M12.7,12.7 L17,17 M8.5,6 V11 M6,8.5 H11 + M8.5,3 A5.5,5.5 0 1 0 8.5,14 A5.5,5.5 0 1 0 8.5,3 M12.7,12.7 L17,17 M6,8.5 H11 + M8.5,3 A5.5,5.5 0 1 0 8.5,14 A5.5,5.5 0 1 0 8.5,3 M12.7,12.7 L17,17 + M3.5,3.5 H13.5 L16.5,6.5 V16.5 H3.5 Z M6.5,3.5 V7.5 H12.5 V3.5 M5.5,16.5 V11.5 H14.5 V16.5 + M7.5,5.5 V2.5 H17.5 V12.5 H14.5 M2.5,5.5 H12.5 V17.5 H2.5 Z + M3.5,6.5 H6.5 L8,4.5 H12 L13.5,6.5 H16.5 V15.5 H3.5 Z M10,8.5 A2.6,2.6 0 1 0 10,13.7 A2.6,2.6 0 1 0 10,8.5 + M3.5,6.5 V3.5 H6.5 M13.5,3.5 H16.5 V6.5 M16.5,13.5 V16.5 H13.5 M6.5,16.5 H3.5 V13.5 + M2.5,3.5 H17.5 V13.5 H2.5 Z M7,16.5 H13 M10,13.5 V16.5 + M10,3 A7,7 0 1 0 10,17 A7,7 0 1 0 10,3 M10,6 V10 L13,12 + M2.5,4.5 H8 L9.5,6.5 H17.5 V15.5 H2.5 Z + M3,5.5 H17 M3,10 H17 M3,14.5 H17 M7,3.5 V7.5 M13,8 V12 M5.5,12.5 V16.5 + M10,3 A7,7 0 1 0 10,17 A7,7 0 1 0 10,3 M10,9 V13.5 M10,6.4 V6.9 + M6.5,7 A3.5,3.5 0 1 1 10,10.5 V12.5 M10,15.4 V15.9 + + + M0,5 H10 + M0.5,0.5 H9.5 V9.5 H0.5 Z + M2.5,2.5 V0.5 H9.5 V7.5 H7.5 M0.5,2.5 H7.5 V9.5 H0.5 Z + M0.5,0.5 L9.5,9.5 M9.5,0.5 L0.5,9.5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MoneyShot/Views/EditorWindow.xaml b/MoneyShot/Views/EditorWindow.xaml index 2d4d4e6..a3985ba 100644 --- a/MoneyShot/Views/EditorWindow.xaml +++ b/MoneyShot/Views/EditorWindow.xaml @@ -1,477 +1,332 @@ - - - + ResizeMode="CanResize" + UseLayoutRounding="True" + TextOptions.TextFormattingMode="Display"> + + + + - - + + + + + + - - + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - + - - - - - - + + + + + + - - - - + - - - - + - - - - - - + + + + - - - - - - - - + - + + - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/MoneyShot/Views/EditorWindow.xaml.cs b/MoneyShot/Views/EditorWindow.xaml.cs index 09990e3..b61fd45 100644 --- a/MoneyShot/Views/EditorWindow.xaml.cs +++ b/MoneyShot/Views/EditorWindow.xaml.cs @@ -78,6 +78,17 @@ public partial class EditorWindow : Window // Cached pen for hit testing to avoid repeated allocations private static readonly Pen HitTestPen = new(Brushes.Black, 10); + // Selection chrome (border + handles). Bright caramel matches the cocoa theme and stays + // visible against most screenshot content. Frozen so it can be shared by every handle. + private static readonly SolidColorBrush SelectionBrush = CreateFrozen(Color.FromRgb(0xE8, 0xA8, 0x5C)); + + private static SolidColorBrush CreateFrozen(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + // Middle-mouse pan state. Held while the user is dragging with MMB to translate the view. private bool _isPanning; private Point _panStartPoint; @@ -91,7 +102,6 @@ public EditorWindow(BitmapSource image) _originalImage = image; _saveService = new SaveService(); DisplayImage(); - SetupToolbar(); // Add keyboard event handler for Delete key KeyDown += EditorWindow_KeyDown; @@ -169,36 +179,36 @@ private void EditorWindow_KeyDown(object sender, KeyEventArgs e) switch (e.Key) { case Key.R: - _currentTool = AnnotationTool.Rectangle; + SelectTool(AnnotationTool.Rectangle); e.Handled = true; break; case Key.C when !e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control): - _currentTool = AnnotationTool.Circle; + SelectTool(AnnotationTool.Circle); e.Handled = true; break; case Key.A when !e.KeyboardDevice.Modifiers.HasFlag(ModifierKeys.Control): - _currentTool = AnnotationTool.Arrow; + SelectTool(AnnotationTool.Arrow); e.Handled = true; break; case Key.L: - _currentTool = AnnotationTool.Line; + SelectTool(AnnotationTool.Line); e.Handled = true; break; case Key.F: - _currentTool = AnnotationTool.Freehand; + SelectTool(AnnotationTool.Freehand); e.Handled = true; break; case Key.T: - _currentTool = AnnotationTool.Text; + SelectTool(AnnotationTool.Text); e.Handled = true; break; case Key.P: - _currentTool = AnnotationTool.Blur; + SelectTool(AnnotationTool.Blur); e.Handled = true; break; case Key.D1: case Key.NumPad1: - _currentTool = AnnotationTool.Number; + SelectTool(AnnotationTool.Number); e.Handled = true; break; case Key.Escape: @@ -409,7 +419,7 @@ internal void UndoCrop(BitmapSource previousImage, IReadOnlyList prev _cropRectangle = null; _isCropping = false; _numberCounter = previousNumberCounter; - _currentTool = AnnotationTool.Cursor; + SelectTool(AnnotationTool.Cursor); ClearSelection(); } @@ -429,11 +439,6 @@ private void DisplayImage() DrawingCanvas.Height = _originalImage.PixelHeight; } - private void SetupToolbar() - { - // Tool buttons will be set up in XAML - } - private Point ClampToCanvasBounds(Point point) { var clampedX = Math.Max(0, Math.Min(point.X, DrawingCanvas.Width)); @@ -862,65 +867,64 @@ private TextBlock CreateNumberLabel() private TextBlock? CreateTextLabel() { - // Show a simple input dialog + // Small modal prompt for the label text, styled from the cocoa theme. var inputDialog = new Window { - Title = "Enter Text", - Width = 300, - Height = 150, + Title = "Add text", + Width = 340, + SizeToContent = SizeToContent.Height, + ResizeMode = ResizeMode.NoResize, WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this, - Background = new SolidColorBrush(Color.FromRgb(45, 45, 48)) + Background = (Brush)FindResource("Cocoa.WindowBrush"), + Foreground = (Brush)FindResource("Cocoa.TextBrush") }; - var grid = new Grid { Margin = new Thickness(10) }; + var grid = new Grid { Margin = new Thickness(16) }; + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); var label = new TextBlock { - Text = "Enter text:", - Foreground = Brushes.White, - Margin = new Thickness(0, 0, 0, 5) + Text = "Label text", + Foreground = (Brush)FindResource("Cocoa.TextSecondaryBrush"), + Margin = new Thickness(0, 0, 0, 6) }; Grid.SetRow(label, 0); grid.Children.Add(label); var textBox = new TextBox { - Margin = new Thickness(0, 5, 0, 10), - Padding = new Thickness(5), - Background = new SolidColorBrush(Color.FromRgb(62, 62, 66)), - Foreground = Brushes.White, - BorderBrush = new SolidColorBrush(Color.FromRgb(85, 85, 85)) + Style = (Style)FindResource("CocoaTextBox"), + Margin = new Thickness(0, 0, 0, 14) }; Grid.SetRow(textBox, 1); grid.Children.Add(textBox); var okButton = new Button { - Content = "OK", - Padding = new Thickness(20, 5, 20, 5), - Background = new SolidColorBrush(Color.FromRgb(14, 99, 156)), - Foreground = Brushes.White, - BorderBrush = new SolidColorBrush(Color.FromRgb(17, 119, 187)), - HorizontalAlignment = HorizontalAlignment.Right + Content = "Add", + Style = (Style)FindResource("AccentButton"), + MinWidth = 76, + IsDefault = true }; okButton.Click += (s, e) => inputDialog.DialogResult = true; + var cancelButton = new Button + { + Content = "Cancel", + Style = (Style)FindResource("SubtleButton"), + MinWidth = 76, + Margin = new Thickness(0, 0, 8, 0), + IsCancel = true + }; + var buttonPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; - var cancelButton = new Button - { - Content = "Cancel", - Padding = new Thickness(20, 5, 20, 5), - Margin = new Thickness(0, 0, 8, 0) - }; - cancelButton.Click += (s, e) => inputDialog.DialogResult = false; buttonPanel.Children.Add(cancelButton); buttonPanel.Children.Add(okButton); Grid.SetRow(buttonPanel, 2); @@ -1083,12 +1087,8 @@ private void UpdateArrow(Point currentPoint) private bool IsPointInElement(UIElement element, Point point) { - var left = Canvas.GetLeft(element); - var top = Canvas.GetTop(element); - - // Handle NaN values (elements without explicit positioning) - if (double.IsNaN(left)) left = 0; - if (double.IsNaN(top)) top = 0; + var left = CanvasPosition.GetLeft(element); + var top = CanvasPosition.GetTop(element); if (element is Path path) { @@ -1155,7 +1155,7 @@ private void SelectElement(UIElement element) // Add visual indicator for selection _selectionBorder = new Border { - BorderBrush = new SolidColorBrush(Colors.Blue), + BorderBrush = SelectionBrush, BorderThickness = new Thickness(2), IsHitTestVisible = false }; @@ -1224,7 +1224,7 @@ private void CreateResizeHandles(double left, double top, double width, double h { ClearResizeHandlesOnly(); - var handleColor = new SolidColorBrush(Colors.Blue); + var handleColor = SelectionBrush; // 8-handle bounding box. Corner handles drive proportional resize, edges drive single-axis. _resizeHandles.Add(CreateResizeHandle(left - 2, top - 2, handleColor, ElementResizeMode.TopLeft)); @@ -1240,7 +1240,7 @@ private void CreateResizeHandles(double left, double top, double width, double h private void CreateEndpointHandles(Point start, Point end) { ClearResizeHandlesOnly(); - var handleColor = new SolidColorBrush(Colors.Blue); + var handleColor = SelectionBrush; var startHandle = CreateEndpointHandle(start, handleColor, isStart: true); var endHandle = CreateEndpointHandle(end, handleColor, isStart: false); _resizeHandles.Add(startHandle); @@ -1704,28 +1704,26 @@ private void ToolButton_Click(object sender, RoutedEventArgs e) { if (Enum.TryParse(toolName, out var tool)) { - _currentTool = tool; - UpdateActiveToolButton(button); + SelectTool(tool); } } } /// - /// Highlights the toolbar button for the active tool by swapping its style. Walks up the - /// visual tree to find the WrapPanel that holds all tool buttons so we don't need named - /// references for each one. + /// Switches the active tool and highlights its toolbar button. Used by both toolbar clicks + /// and keyboard shortcuts so the highlight never goes out of sync with the actual tool. /// - private void UpdateActiveToolButton(Button activeButton) + private void SelectTool(AnnotationTool tool) { - var glassStyle = (Style)Resources["GlassButton"]; - var activeStyle = (Style)Resources["ActiveToolButton"]; - var parent = System.Windows.Media.VisualTreeHelper.GetParent(activeButton); - if (parent is not Panel toolPanel) return; - foreach (var child in toolPanel.Children) + _currentTool = tool; + var normalStyle = (Style)FindResource("ToolButton"); + var activeStyle = (Style)FindResource("ToolButtonActive"); + var toolName = tool.ToString(); + foreach (var child in ToolButtonsPanel.Children) { - if (child is Button b && b.Tag is string) + if (child is Button b && b.Tag is string tag) { - b.Style = ReferenceEquals(b, activeButton) ? activeStyle : glassStyle; + b.Style = tag == toolName ? activeStyle : normalStyle; } } } @@ -1842,10 +1840,12 @@ private void ChangeElementColor(UIElement element, Color newColor) private bool IsColorChangeableElement(UIElement element) { - // Pixelate rectangles have DrawingBrush and should not be color-changed - if (element is Rectangle rect && rect.Fill is DrawingBrush) + // Pixelate rectangles render an ImageBrush of the underlying pixels; recolouring them + // makes no sense and would just paint a visible stroke. (Detect via tag — the fill is + // an ImageBrush, not a DrawingBrush, so a brush-type check doesn't identify them.) + if (element is Rectangle { Tag: PixelateTag }) return false; - + return element is Shape || element is TextBlock; } @@ -1921,7 +1921,7 @@ private void ApplyCrop() _numberCounter = 1; // Reset to cursor tool - _currentTool = AnnotationTool.Cursor; + SelectTool(AnnotationTool.Cursor); _undo.Push(new UndoController.CropUndoAction(previousImage, previousElements, previousNumberCounter)); } catch (Exception ex) @@ -1937,12 +1937,18 @@ private void Save_Click(object sender, RoutedEventArgs e) try { var finalImage = CaptureCanvasAsImage(); - + + // Honour the user's configured save folder and default format — previously the + // dialog always opened wherever Windows last left it, defaulting to PNG. + var settings = new SettingsService().LoadSettings(); + var defaultFormat = settings.DefaultFileFormat.ToUpperInvariant(); var saveDialog = new Microsoft.Win32.SaveFileDialog { Filter = "PNG Image|*.png|JPEG Image|*.jpg|Bitmap Image|*.bmp", - DefaultExt = ".png", - FileName = _saveService.GenerateFileName("PNG") + FilterIndex = defaultFormat switch { "JPG" or "JPEG" => 2, "BMP" => 3, _ => 1 }, + DefaultExt = defaultFormat switch { "JPG" or "JPEG" => ".jpg", "BMP" => ".bmp", _ => ".png" }, + InitialDirectory = settings.DefaultSavePath, + FileName = _saveService.GenerateFileName(settings.DefaultFileFormat) }; if (saveDialog.ShowDialog() == true) @@ -2015,6 +2021,10 @@ private void ApplyZoom() { ZoomTransform.ScaleX = _zoomLevel; ZoomTransform.ScaleY = _zoomLevel; + if (ZoomLevelLabel != null) + { + ZoomLevelLabel.Text = $"{Math.Round(_zoomLevel * 100)}%"; + } } private BitmapSource CaptureCanvasAsImage() => @@ -2051,21 +2061,15 @@ private void MaximizeRestore_Click(object sender, RoutedEventArgs e) if (WindowState == WindowState.Maximized) { WindowState = WindowState.Normal; - if (MaximizeRestoreButton != null) - { - MaximizeRestoreButton.Content = "🗖"; - } + MaximizeRestoreIcon.Data = (Geometry)FindResource("Icon.WindowMaximize"); } else { WindowState = WindowState.Maximized; - if (MaximizeRestoreButton != null) - { - MaximizeRestoreButton.Content = "🗗"; - } + MaximizeRestoreIcon.Data = (Geometry)FindResource("Icon.WindowRestore"); } } - + private void Close_Click(object sender, RoutedEventArgs e) { Close(); diff --git a/MoneyShot/Views/HistoryWindow.xaml b/MoneyShot/Views/HistoryWindow.xaml index 59e7c9a..de93d63 100644 --- a/MoneyShot/Views/HistoryWindow.xaml +++ b/MoneyShot/Views/HistoryWindow.xaml @@ -1,41 +1,86 @@ + Background="{StaticResource Cocoa.WindowBrush}" + Foreground="{StaticResource Cocoa.TextBrush}" + FontFamily="Segoe UI" + WindowStyle="None" + ResizeMode="CanResize" + UseLayoutRounding="True" + TextOptions.TextFormattingMode="Display"> + + + + + + - + + + + + + + + + + + + + + + + - - + + - + + @@ -45,13 +90,19 @@ - + + - + + + + + + + + + - + - - - - - -