Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +22,17 @@ The base version lives in `MoneyShot/MoneyShot.csproj` (`<Version>`, `<AssemblyV

## Architecture

### Theme & rendering rules (CocoaTheme)

All visual styling lives in `MoneyShot/Themes/CocoaTheme.xaml` (merged in `App.xaml`): the cocoa-brown palette, vector icon geometries, and every control style (`ToolButton`, `AccentButton`, `SubtleButton`, `TitleBarButton`, `ColorSwatch`, `CocoaCheckBox`, `CocoaComboBox`, `CocoaSlider`, etc.). Rules:

- **Never reintroduce `AllowsTransparency="True"`** on a window. Layered windows are composed in software for the whole surface — this was the editor's main source of lag. Custom title bars are done with `WindowChrome` (`CaptionHeight=0`, `GlassFrameThickness="0,0,0,1"` to keep the DWM shadow/rounded corners) plus `DragMove()` in the title-bar handler.
- **No `DropShadowEffect`/`BlurEffect`** — WPF bitmap effects force per-element software rendering. Depth comes from the palette (surface vs. window colors), not shadows.
- New surfaces pick brushes from the theme (`Cocoa.*Brush`); don't inline hex colors in views. (`RegionSelector.xaml` is the deliberate exception — it's a self-contained full-screen overlay.)
- Icons are `Geometry` resources (`Icon.*`) rendered through the `ToolIcon`/`ToolIconFilled`/`CaptionIcon` Path styles — not emoji, which render inconsistently via font fallback.
- Code-behind looks styles up with `FindResource` (App-scoped), not `Resources[...]` (window-scoped).
- The pixelate tool (`CanvasRenderer.CreatePixelatedBrush`) copies only the covered region's pixels once and block-averages in that buffer. Don't go back to rendering the full image into a `RenderTargetBitmap` — at 4K that allocated ~33 MB per pixelation plus a `CroppedBitmap` per block.

### Two windows + service layer (no DI, no MVVM framework)

Services are instantiated directly in `MainWindow` (`MainWindow.xaml.cs:31-35`). There is no DI container and no MVVM library — XAML is wired with code-behind throughout. New services should be added the same way; don't introduce a container for one or two extra dependencies.
Expand Down Expand Up @@ -62,7 +73,9 @@ Version comparison is **SemVer-only** (Major.Minor.Patch via `CompareSemVer`)

If a release lacks `SHA256SUMS.txt` (older releases), the update is allowed through with a logged warning rather than refused. New releases generated by `release.yml` always include it.

The optional GitHub token read at line 50 is intentionally `Environment.GetEnvironmentVariable("")` — i.e. disabled. If you wire one up, do it through a real env-var name and treat it as a secret.
An optional GitHub token is read from the `MONEYSHOT_GITHUB_TOKEN` environment variable (higher API rate limit). Treat it as a secret — never log it.

**The repo/owner constants must track the repository's current name** (`Daolyap/Money-Shot` since the rename). GitHub redirects an old repo name only until someone re-registers it — at which point they own the update channel (repojacking). Update the constants immediately on any rename.

### Editor undo model

Expand Down
64 changes: 64 additions & 0 deletions MoneyShot.Tests/SaveServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System.IO;
using MoneyShot.Services;
using Xunit;

namespace MoneyShot.Tests;

public class SaveServiceTests
{
private readonly SaveService _service = new();

// Path validation runs before the image is touched, so a null image never reaches the
// encoder for these rejection cases — no WPF/STA setup needed.

[Fact]
public void SaveToFile_InsideWindowsDirectory_IsRejected()
{
var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
var path = Path.Combine(windows, "moneyshot-test.png");
Assert.Throws<ArgumentException>(() => _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<ArgumentException>(() => _service.SaveToFile(null!, path));
}

[Theory]
[InlineData("")]
[InlineData(" ")]
public void SaveToFile_EmptyPath_IsRejected(string path)
{
Assert.Throws<ArgumentException>(() => _service.SaveToFile(null!, path));
}

[Fact]
public void SaveToFile_BareDirectoryPath_IsRejected()
{
Assert.Throws<ArgumentException>(() => _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<InvalidOperationException>(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);
}
}
13 changes: 13 additions & 0 deletions MoneyShot.Tests/SettingsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
15 changes: 13 additions & 2 deletions MoneyShot/App.xaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
<Application x:Class="MoneyShot.App"
<Application x:Class="MoneyShot.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:MoneyShot"
StartupUri="MainWindow.xaml">
<Application.Resources>

<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/CocoaTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>

<!-- Implicit styles so tooltips, scrollbars and menus are themed everywhere without
per-window opt-in. -->
<Style TargetType="ToolTip" BasedOn="{StaticResource CocoaToolTip}"/>
<Style TargetType="ScrollBar" BasedOn="{StaticResource CocoaScrollBar}"/>
<Style TargetType="ContextMenu" BasedOn="{StaticResource CocoaContextMenu}"/>
<Style TargetType="MenuItem" BasedOn="{StaticResource CocoaMenuItem}"/>
</ResourceDictionary>
</Application.Resources>
</Application>
23 changes: 14 additions & 9 deletions MoneyShot/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Configuration;
using System.Data;
using System.Windows;
using System.Threading;

Expand All @@ -11,14 +9,16 @@ namespace MoneyShot;
public partial class App : Application
{
private static Mutex? _mutex;

private static bool _ownsMutex;

protected override void OnStartup(StartupEventArgs e)
{
// Create a unique mutex name for the application
const string mutexName = "MoneyShot_SingleInstance_Mutex_3E6F8A2D";

_mutex = new Mutex(true, mutexName, out bool createdNew);

_ownsMutex = createdNew;

if (!createdNew)
{
// Another instance is already running
Expand All @@ -30,15 +30,20 @@ protected override void OnStartup(StartupEventArgs e)
Shutdown();
return;
}

base.OnStartup(e);
}

protected override void OnExit(ExitEventArgs e)
{
_mutex?.ReleaseMutex();
// Only the instance that actually acquired the mutex may release it — calling
// ReleaseMutex without ownership throws and would crash the "already running"
// second instance on its way out.
if (_ownsMutex)
{
_mutex?.ReleaseMutex();
}
_mutex?.Dispose();
base.OnExit(e);
}
}

92 changes: 61 additions & 31 deletions MoneyShot/Editor/CanvasRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,56 +52,86 @@ public static BitmapSource CaptureCanvasAsImage(FrameworkElement imageCanvas, Bi
}

/// <summary>
/// Builds an ImageBrush whose contents are a downsampled version of the area beneath the
/// Builds an ImageBrush whose contents are a block-averaged version of the area beneath the
/// supplied rectangle, producing the classic "censor bar" pixelation effect.
///
/// Performance note: the previous implementation rendered the ENTIRE screenshot into a
/// RenderTargetBitmap (~33 MB at 4K) and allocated a CroppedBitmap per block. This version
/// copies only the covered region once and averages blocks in that single buffer, which is
/// both faster on mouse-up and a large RAM saving.
/// </summary>
public static Brush CreatePixelatedBrush(Rectangle pixelateRect, BitmapSource originalImage)
{
var left = CanvasPosition.GetLeft(pixelateRect);
var top = CanvasPosition.GetTop(pixelateRect);

var left = (int)Math.Round(CanvasPosition.GetLeft(pixelateRect));
var top = (int)Math.Round(CanvasPosition.GetTop(pixelateRect));
var width = (int)pixelateRect.Width;
var height = (int)pixelateRect.Height;
if (width <= 0 || height <= 0) return pixelateRect.Fill;

try
{
var renderBitmap = new RenderTargetBitmap(originalImage.PixelWidth, originalImage.PixelHeight, RenderDpi, RenderDpi, PixelFormats.Pbgra32);
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
dc.DrawImage(originalImage, new Rect(0, 0, originalImage.PixelWidth, originalImage.PixelHeight));
}
renderBitmap.Render(visual);

var pixelatedBitmap = new RenderTargetBitmap(width, height, RenderDpi, RenderDpi, PixelFormats.Pbgra32);
var drawingVisual = new DrawingVisual();
using (var drawingContext = drawingVisual.RenderOpen())
// Clamp the sampled region to the image; the brush stretches to the rect, so a
// rectangle that hangs off the image edge still gets full coverage.
var srcX = Math.Max(0, Math.Min(left, originalImage.PixelWidth - 1));
var srcY = Math.Max(0, Math.Min(top, originalImage.PixelHeight - 1));
var srcW = Math.Min(width, originalImage.PixelWidth - srcX);
var srcH = Math.Min(height, originalImage.PixelHeight - srcY);
if (srcW <= 0 || srcH <= 0) return pixelateRect.Fill;

BitmapSource source = originalImage.Format == PixelFormats.Bgra32 || originalImage.Format == PixelFormats.Pbgra32
? originalImage
: new FormatConvertedBitmap(originalImage, PixelFormats.Bgra32, null, 0);

var stride = srcW * 4;
var pixels = new byte[stride * srcH];
source.CopyPixels(new Int32Rect(srcX, srcY, srcW, srcH), pixels, stride, 0);

// Average each block, then write the averaged colour back over the block's pixels.
for (var blockTop = 0; blockTop < srcH; blockTop += PixelateBlockSize)
{
for (int y = 0; y < height; y += PixelateBlockSize)
var blockH = Math.Min(PixelateBlockSize, srcH - blockTop);
for (var blockLeft = 0; blockLeft < srcW; blockLeft += PixelateBlockSize)
{
for (int x = 0; x < width; x += PixelateBlockSize)
{
var blockWidth = Math.Min(PixelateBlockSize, width - x);
var blockHeight = Math.Min(PixelateBlockSize, height - y);
var blockW = Math.Min(PixelateBlockSize, srcW - blockLeft);

var sampleX = (int)(left + x + blockWidth / 2);
var sampleY = (int)(top + y + blockHeight / 2);
sampleX = Math.Max(0, Math.Min(sampleX, originalImage.PixelWidth - 1));
sampleY = Math.Max(0, Math.Min(sampleY, originalImage.PixelHeight - 1));
long sumB = 0, sumG = 0, sumR = 0;
for (var y = 0; y < blockH; y++)
{
var offset = (blockTop + y) * stride + blockLeft * 4;
for (var x = 0; x < blockW; x++)
{
sumB += pixels[offset];
sumG += pixels[offset + 1];
sumR += pixels[offset + 2];
offset += 4;
}
}

var croppedBitmap = new CroppedBitmap(renderBitmap, new Int32Rect(sampleX, sampleY, 1, 1));
var pixels = new byte[4];
croppedBitmap.CopyPixels(pixels, 4, 0);
var count = blockW * blockH;
var b = (byte)(sumB / count);
var g = (byte)(sumG / count);
var r = (byte)(sumR / count);

var color = Color.FromArgb(pixels[3], pixels[2], pixels[1], pixels[0]);
drawingContext.DrawRectangle(new SolidColorBrush(color), null, new Rect(x, y, blockWidth, blockHeight));
for (var y = 0; y < blockH; y++)
{
var offset = (blockTop + y) * stride + blockLeft * 4;
for (var x = 0; x < blockW; x++)
{
pixels[offset] = b;
pixels[offset + 1] = g;
pixels[offset + 2] = r;
pixels[offset + 3] = 0xFF;
offset += 4;
}
}
}
}
pixelatedBitmap.Render(drawingVisual);

return new ImageBrush(pixelatedBitmap) { Stretch = Stretch.Fill };
var pixelated = BitmapSource.Create(srcW, srcH, RenderDpi, RenderDpi, PixelFormats.Bgra32, null, pixels, stride);
pixelated.Freeze();
var brush = new ImageBrush(pixelated) { Stretch = Stretch.Fill };
brush.Freeze();
return brush;
}
catch (ArgumentException)
{
Expand Down
Loading
Loading