diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml new file mode 100644 index 0000000..d94fd92 --- /dev/null +++ b/.github/workflows/build-and-release.yml @@ -0,0 +1,80 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.rid }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + artifact_name: Ink-Canvas-Next-win-x64 + output_name: Ink-Canvas-Next-win-x64 + # - rid: linux-x64 + # artifact_name: Ink-Canvas-Next-linux-x64 + # output_name: Ink-Canvas-Next-linux-x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore "Ink Canvas Next.slnx" + + - name: Test + run: dotnet test "Ink Canvas Next.slnx" --configuration Release --no-restore + + - name: Publish + run: | + dotnet publish Ink-Canvas-Next/Ink-Canvas-Next.csproj \ + --configuration Release \ + --runtime ${{ matrix.rid }} \ + --self-contained false \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -o publish/${{ matrix.output_name }} + + - name: Archive package + run: | + cd publish + tar -czf ${{ matrix.output_name }}.tar.gz ${{ matrix.output_name }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: publish/${{ matrix.output_name }}.tar.gz + + release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + release-assets/**/*.tar.gz diff --git a/.github/workflows/qoder-assistant.yml.disabled b/.github/workflows/qoder-assistant.yml similarity index 100% rename from .github/workflows/qoder-assistant.yml.disabled rename to .github/workflows/qoder-assistant.yml diff --git a/.github/workflows/qoder-auto-review.yml.disabled b/.github/workflows/qoder-auto-review.yml similarity index 100% rename from .github/workflows/qoder-auto-review.yml.disabled rename to .github/workflows/qoder-auto-review.yml diff --git a/.gitignore b/.gitignore index 39d4e29..2f71908 100644 --- a/.gitignore +++ b/.gitignore @@ -423,3 +423,4 @@ FodyWeavers.xsd .kiro/ .vscode/ .idea/ +.workbuddy/ \ No newline at end of file diff --git a/Ink-Canvas-Next.Tests/AvaloniaTestCollection.cs b/Ink-Canvas-Next.Tests/AvaloniaTestCollection.cs deleted file mode 100644 index 9b4e9c0..0000000 --- a/Ink-Canvas-Next.Tests/AvaloniaTestCollection.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Avalonia; -using Avalonia.Headless; -using Avalonia.Threading; -using Xunit; - -namespace Ink_Canvas_Next.Tests; - -/// -/// Collection definition for Avalonia tests -/// Ensures all tests in this collection share the same Avalonia instance -/// -[CollectionDefinition("Avalonia")] -public class AvaloniaTestCollection : ICollectionFixture -{ -} - -/// -/// Shared fixture for Avalonia initialization -/// Initializes Avalonia once for all tests in the collection -/// -public class AvaloniaFixture -{ - public AvaloniaFixture() - { - // Initialize Avalonia once - AppBuilder.Configure() - .UseHeadless(new AvaloniaHeadlessPlatformOptions()) - .SetupWithoutStarting(); - } -} - -/// -/// Test application for Avalonia headless mode -/// -internal class TestApplication : Application -{ -} diff --git a/Ink-Canvas-Next.Tests/Ink-Canvas-Next.Tests.csproj b/Ink-Canvas-Next.Tests/Ink-Canvas-Next.Tests.csproj deleted file mode 100644 index c8a398b..0000000 --- a/Ink-Canvas-Next.Tests/Ink-Canvas-Next.Tests.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - enable - enable - false - true - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - diff --git a/Ink-Canvas-Next.Tests/Services/CanvasServicePropertyTests.cs b/Ink-Canvas-Next.Tests/Services/CanvasServicePropertyTests.cs deleted file mode 100644 index 22dd334..0000000 --- a/Ink-Canvas-Next.Tests/Services/CanvasServicePropertyTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.IO; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Headless; -using Avalonia.Media.Imaging; -using Avalonia.Platform; -using FsCheck; -using FsCheck.Xunit; -using Ink_Canvas_Next.Services; -using Xunit; - -namespace Ink_Canvas_Next.Tests.Services; - -/// -/// Property-based tests for CanvasService -/// Uses FsCheck to verify universal properties across many inputs -/// -[Collection("Avalonia")] -public class CanvasServicePropertyTests : IDisposable -{ - private readonly CanvasService _service; - private readonly string _testDirectory; - - public CanvasServicePropertyTests() - { - _service = new CanvasService(); - - // Create a temporary test directory - var picturesPath = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); - _testDirectory = Path.Combine(picturesPath, "InkCanvasScreenshots"); - } - - public void Dispose() - { - // Cleanup: Remove test files created during tests - if (Directory.Exists(_testDirectory)) - { - try - { - var files = Directory.GetFiles(_testDirectory, "InkCanvas_*.png"); - foreach (var file in files) - { - try - { - File.Delete(file); - } - catch - { - // Ignore cleanup errors - } - } - } - catch - { - // Ignore cleanup errors - } - } - } - - /// - /// Property 1: Screenshot file format and location - /// For any canvas state, when saving a screenshot, the resulting file should be in PNG format, - /// located in the specified image folder, and the file should exist and be readable. - /// **Validates: Requirements 1.2** - /// - [Property(MaxTest = 50)] - public Property SavedFile_ShouldBePngInCorrectLocation() - { - return Prop.ForAll( - ArbBitmap(), - (bitmap) => - { - // Act: Save the canvas (synchronously) - var filePath = _service.SaveCanvasToFileAsync(bitmap).GetAwaiter().GetResult(); - - // Assert: File should exist - Assert.True(File.Exists(filePath), "File should exist after saving"); - - // Assert: File should be in the correct directory - var directory = Path.GetDirectoryName(filePath); - Assert.Equal(_testDirectory, directory); - - // Assert: File should have .png extension - var extension = Path.GetExtension(filePath); - Assert.Equal(".png", extension); - - // Assert: File should be readable (non-zero size) - var fileInfo = new FileInfo(filePath); - Assert.True(fileInfo.Length > 0, "File should have content"); - - // Cleanup - File.Delete(filePath); - - return true; - } - ); - } - - /// - /// Property 2: Filename uniqueness and format - /// For any sequence of consecutive save operations, the generated filenames should follow - /// the "InkCanvas_YYYYMMDD_HHMMSS.png" format, and each filename should be unique (via timestamp). - /// **Validates: Requirements 1.3** - /// - [Property(MaxTest = 20)] - public Property ConsecutiveSaves_ShouldHaveUniqueFilenamesWithCorrectFormat() - { - return Prop.ForAll( - Gen.Choose(2, 3).ToArbitrary(), // Test with 2-3 consecutive saves - (saveCount) => - { - var filePaths = new List(); - var fileNames = new HashSet(); - - try - { - for (int i = 0; i < saveCount; i++) - { - // Create a small test bitmap - var bitmap = CreateTestBitmap(10, 10); - - // Save the canvas (synchronously) - var filePath = _service.SaveCanvasToFileAsync(bitmap).GetAwaiter().GetResult(); - filePaths.Add(filePath); - - var fileName = Path.GetFileName(filePath); - - // Assert: Filename should follow the correct format - var pattern = @"^InkCanvas_\d{8}_\d{6}\.png$"; - Assert.Matches(pattern, fileName); - - // Assert: Filename should be unique - Assert.DoesNotContain(fileName, fileNames); - fileNames.Add(fileName); - - // Small delay to ensure timestamp changes - if (i < saveCount - 1) - { - Task.Delay(1100).GetAwaiter().GetResult(); // Wait just over 1 second - } - } - - // Assert: All filenames should be unique - Assert.Equal(saveCount, fileNames.Count); - - return true; - } - finally - { - // Cleanup - foreach (var filePath in filePaths) - { - try - { - if (File.Exists(filePath)) - { - File.Delete(filePath); - } - } - catch - { - // Ignore cleanup errors - } - } - } - } - ); - } - - /// - /// Helper: Creates an arbitrary bitmap generator for property tests - /// - private static Arbitrary ArbBitmap() - { - return Gen.Choose(10, 100) - .Two() - .Select(t => CreateTestBitmap(t.Item1, t.Item2)) - .ToArbitrary(); - } - - /// - /// Helper: Creates a test bitmap with the specified dimensions - /// - private static RenderTargetBitmap CreateTestBitmap(int width, int height) - { - var pixelSize = new PixelSize(width, height); - var dpi = new Vector(96, 96); - return new RenderTargetBitmap(pixelSize, dpi); - } -} diff --git a/Ink-Canvas-Next.Tests/Services/CanvasServiceTests.cs b/Ink-Canvas-Next.Tests/Services/CanvasServiceTests.cs deleted file mode 100644 index 6bfddfb..0000000 --- a/Ink-Canvas-Next.Tests/Services/CanvasServiceTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using System; -using System.IO; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Ink_Canvas_Next.Services; -using Xunit; - -namespace Ink_Canvas_Next.Tests.Services; - -/// -/// Unit tests for CanvasService -/// Validates: Requirements 1.3, 1.4, 1.6 -/// -public class CanvasServiceTests : IDisposable -{ - private readonly CanvasService _service; - private readonly string _testDirectory; - - public CanvasServiceTests() - { - _service = new CanvasService(); - - // Create a temporary test directory - var picturesPath = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); - _testDirectory = Path.Combine(picturesPath, "InkCanvasScreenshots"); - } - - public void Dispose() - { - // Cleanup: Remove test files created during tests - if (Directory.Exists(_testDirectory)) - { - try - { - var files = Directory.GetFiles(_testDirectory, "InkCanvas_*.png"); - foreach (var file in files) - { - try - { - File.Delete(file); - } - catch - { - // Ignore cleanup errors - } - } - } - catch - { - // Ignore cleanup errors - } - } - } - - /// - /// Tests that GenerateFileName produces the correct format - /// Validates: Requirement 1.3 - filename format "InkCanvas_YYYYMMDD_HHMMSS.png" - /// - [Fact] - public void GenerateFileName_ShouldFollowCorrectFormat() - { - // Use reflection to access private method - var method = typeof(CanvasService).GetMethod("GenerateFileName", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var fileName = method?.Invoke(_service, null) as string; - - Assert.NotNull(fileName); - - // Verify format: InkCanvas_YYYYMMDD_HHMMSS.png - var pattern = @"^InkCanvas_\d{8}_\d{6}\.png$"; - Assert.Matches(pattern, fileName); - } - - /// - /// Tests that consecutive filename generations produce unique names - /// Validates: Requirement 1.3 - unique filenames through timestamps - /// - [Fact] - public async Task GenerateFileName_ShouldProduceUniqueNames() - { - var method = typeof(CanvasService).GetMethod("GenerateFileName", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var fileName1 = method?.Invoke(_service, null) as string; - - // Wait a small amount to ensure timestamp changes - await Task.Delay(1100); // Wait just over 1 second - - var fileName2 = method?.Invoke(_service, null) as string; - - Assert.NotNull(fileName1); - Assert.NotNull(fileName2); - Assert.NotEqual(fileName1, fileName2); - } - - /// - /// Tests that GetSaveDirectory creates the directory if it doesn't exist - /// Validates: Requirement 1.4 - create folder if it doesn't exist - /// - [Fact] - public void GetSaveDirectory_ShouldCreateDirectoryIfNotExists() - { - // Delete the directory if it exists - if (Directory.Exists(_testDirectory)) - { - Directory.Delete(_testDirectory, true); - } - - // Use reflection to access private method - var method = typeof(CanvasService).GetMethod("GetSaveDirectory", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var directory = method?.Invoke(_service, null) as string; - - Assert.NotNull(directory); - Assert.True(Directory.Exists(directory)); - Assert.Equal(_testDirectory, directory); - } - - /// - /// Tests that GetSaveDirectory returns correct path when directory exists - /// Validates: Requirement 1.4 - use existing folder - /// - [Fact] - public void GetSaveDirectory_ShouldReturnExistingDirectory() - { - // Ensure directory exists - if (!Directory.Exists(_testDirectory)) - { - Directory.CreateDirectory(_testDirectory); - } - - var method = typeof(CanvasService).GetMethod("GetSaveDirectory", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var directory = method?.Invoke(_service, null) as string; - - Assert.NotNull(directory); - Assert.True(Directory.Exists(directory)); - Assert.Equal(_testDirectory, directory); - } - - /// - /// Tests that SaveCanvasToFileAsync throws exception when bitmap is null - /// Validates: Requirement 1.6 - error handling - /// - [Fact] - public async Task SaveCanvasToFileAsync_ShouldThrowException_WhenBitmapIsNull() - { - await Assert.ThrowsAsync(async () => - { - await _service.SaveCanvasToFileAsync(null!); - }); - } - - /// - /// Tests filename timestamp parsing to ensure correct format - /// Validates: Requirement 1.3 - timestamp format YYYYMMDD_HHMMSS - /// - [Fact] - public void GenerateFileName_ShouldContainValidTimestamp() - { - var method = typeof(CanvasService).GetMethod("GenerateFileName", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - var fileName = method?.Invoke(_service, null) as string; - - Assert.NotNull(fileName); - - // Extract timestamp from filename - var match = Regex.Match(fileName, @"InkCanvas_(\d{8})_(\d{6})\.png"); - Assert.True(match.Success); - - var dateStr = match.Groups[1].Value; - var timeStr = match.Groups[2].Value; - - // Verify date format (YYYYMMDD) - var year = int.Parse(dateStr.Substring(0, 4)); - var month = int.Parse(dateStr.Substring(4, 2)); - var day = int.Parse(dateStr.Substring(6, 2)); - - Assert.InRange(year, 2024, 2100); - Assert.InRange(month, 1, 12); - Assert.InRange(day, 1, 31); - - // Verify time format (HHMMSS) - var hour = int.Parse(timeStr.Substring(0, 2)); - var minute = int.Parse(timeStr.Substring(2, 2)); - var second = int.Parse(timeStr.Substring(4, 2)); - - Assert.InRange(hour, 0, 23); - Assert.InRange(minute, 0, 59); - Assert.InRange(second, 0, 59); - } -} diff --git a/Ink-Canvas-Next.Tests/ViewModels/MainWindowViewModelPropertyTests.cs b/Ink-Canvas-Next.Tests/ViewModels/MainWindowViewModelPropertyTests.cs deleted file mode 100644 index 08dafc5..0000000 --- a/Ink-Canvas-Next.Tests/ViewModels/MainWindowViewModelPropertyTests.cs +++ /dev/null @@ -1,329 +0,0 @@ -using System; -using Avalonia; -using Avalonia.Headless; -using Avalonia.Media; -using Avalonia.Threading; -using FsCheck; -using FsCheck.Xunit; -using Ink_Canvas_Next.ViewModels; -using Ink_Canvas_Next.Models; -using Xunit; - -namespace Ink_Canvas_Next.Tests.ViewModels; - -/// -/// Property-based tests for MainWindowViewModel background-adaptive color functionality -/// Uses FsCheck to verify universal properties across many inputs -/// -[Collection("Avalonia")] -public class MainWindowViewModelPropertyTests -{ - public MainWindowViewModelPropertyTests() - { - } - - /// - /// Property 3: Highlighter tool activation - /// For any initial tool state and slider value, when the user selects highlighter mode, - /// the CurrentTool property should update to Highlighter, PenColor should be set to yellow - /// (with 50% alpha), the actual rendering thickness should be 10x the slider value, - /// but the slider display value remains unchanged. - /// **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5** - /// - [Property(MaxTest = 100)] - public Property HighlighterActivation_ShouldSetCorrectProperties() - { - return Prop.ForAll( - ArbDrawingTool(), - ArbPenSize(), - (initialTool, penSize) => - { - return Dispatcher.UIThread.InvokeAsync(() => - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(initialTool); - viewModel.PenSize = penSize; - - // Act: Switch to highlighter mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - - // Assert: CurrentTool should be Highlighter - Assert.Equal(DrawingTool.Highlighter, viewModel.CurrentTool); - - // Assert: PenColor should be yellow with 50% opacity (ARGB: 128, 255, 255, 0) - var expectedColor = Color.FromArgb(HighlighterSettings.Opacity, 255, 255, 0); - Assert.Equal(expectedColor, viewModel.PenColor); - - // Assert: PenSize slider value should remain unchanged - Assert.Equal(penSize, viewModel.PenSize); - - // Assert: Effective thickness should be 10x the slider value - var expectedThickness = penSize * HighlighterSettings.ThicknessMultiplier; - Assert.Equal(expectedThickness, viewModel.GetEffectiveThickness()); - - return true; - }).GetAwaiter().GetResult(); - } - ); - } - - /// - /// Property 4: Pen state round-trip - /// For any initial pen color and slider value, switching to highlighter mode and then - /// switching back to pen mode should restore the original color and 100% opacity, - /// and the actual rendering thickness should restore to the slider value (no longer multiplied by 10). - /// **Validates: Requirements 2.6, 2.7, 2.8** - /// - [Property(MaxTest = 100)] - public Property PenStateRoundTrip_ShouldRestoreOriginalState() - { - return Prop.ForAll( - ArbPenSize(), - ArbBackgroundMode(), - (penSize, backgroundMode) => - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Set background first - viewModel.ChangeBackgroundCommand.Execute(backgroundMode); - - // The pen color will be set to the adaptive color for this background - var initialAdaptiveColor = viewModel.PenColor; - - // Set pen size - viewModel.PenSize = penSize; - - // Act: Switch to highlighter and back to pen - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - - // Assert: PenColor should be the adaptive color for the current background - // (not the highlighter yellow, and with 100% opacity) - var expectedColor = GetExpectedAdaptiveColor(backgroundMode); - Assert.Equal(expectedColor, viewModel.PenColor); - - // Assert: Alpha should be 255 (100% opacity) - Assert.Equal(255, viewModel.PenColor.A); - - // Assert: PenSize should remain unchanged - Assert.Equal(penSize, viewModel.PenSize); - - // Assert: Effective thickness should be back to normal (not multiplied by 10) - Assert.Equal(penSize, viewModel.GetEffectiveThickness()); - - return true; - } - ); - } - - /// - /// Property 5: Background adaptive color mapping - /// For any background mode change (when not in highlighter mode), the pen color should - /// automatically update according to the following mapping: - /// Black background → White ink, White background → Black ink, Transparent background → Red ink - /// **Validates: Requirements 3.1, 3.2, 3.3** - /// - [Property(MaxTest = 100)] - public Property BackgroundChange_ShouldApplyAdaptiveColorMapping() - { - return Prop.ForAll( - ArbBackgroundMode(), - (backgroundMode) => - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Ensure we're in pen mode (not highlighter) - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - - // Act - viewModel.ChangeBackgroundCommand.Execute(backgroundMode); - - // Assert: Verify adaptive color mapping - var expectedColor = GetExpectedAdaptiveColor(backgroundMode); - Assert.Equal(expectedColor, viewModel.PenColor); - - return true; - } - ); - } - - /// - /// Property 6: Highlighter mode ignores background adaptation - /// For any background mode change, when highlighter mode is active, the pen color should - /// remain yellow and not be affected by background changes. - /// **Validates: Requirements 3.4** - /// - [Property(MaxTest = 100)] - public Property HighlighterMode_ShouldIgnoreBackgroundAdaptiveColor() - { - return Prop.ForAll( - ArbBackgroundMode(), - (backgroundMode) => - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Switch to highlighter mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - var highlighterColor = viewModel.PenColor; - - // Act: Change background while in highlighter mode - viewModel.ChangeBackgroundCommand.Execute(backgroundMode); - - // Assert: Pen color should remain unchanged (yellow highlighter color) - Assert.Equal(highlighterColor, viewModel.PenColor); - - return true; - } - ); - } - - /// - /// Property 7: Manual color change persistence - /// For any manual color change, the color should persist and not be affected by - /// operations other than background changes, until the next background change occurs. - /// **Validates: Requirements 3.5** - /// - [Property(MaxTest = 100)] - public Property ManualColorChange_ShouldPersistUntilBackgroundChange() - { - return Prop.ForAll( - ArbColor(), - ArbBackgroundMode(), - (manualColor, initialBackground) => - { - return Dispatcher.UIThread.InvokeAsync(() => - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeBackgroundCommand.Execute(initialBackground); - - // Act: Manually change pen color - viewModel.PenColor = manualColor; - - // Assert: Color should remain after tool switches (except highlighter) - viewModel.ChangeToolCommand.Execute(DrawingTool.Eraser); - Assert.Equal(manualColor, viewModel.PenColor); - - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - Assert.Equal(manualColor, viewModel.PenColor); - - // Assert: Manual color should persist through first background change - var newBackground = GetDifferentBackground(initialBackground); - viewModel.ChangeBackgroundCommand.Execute(newBackground); - Assert.Equal(manualColor, viewModel.PenColor); - - // Assert: After first background change, next background change should apply adaptive color - var thirdBackground = GetDifferentBackground(newBackground); - viewModel.ChangeBackgroundCommand.Execute(thirdBackground); - var expectedColor = GetExpectedAdaptiveColor(thirdBackground); - Assert.Equal(expectedColor, viewModel.PenColor); - - return true; - }).GetAwaiter().GetResult(); - } - ); - } - - /// - /// Property 8: Highlighter to pen background adaptation - /// For any current background mode, when switching from highlighter mode back to pen mode, - /// the pen color should apply background adaptive color rules based on the current background. - /// **Validates: Requirements 3.6** - /// - [Property(MaxTest = 100)] - public Property HighlighterToPen_ShouldApplyBackgroundAdaptiveColor() - { - return Prop.ForAll( - ArbBackgroundMode(), - (backgroundMode) => - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Set background mode first - viewModel.ChangeBackgroundCommand.Execute(backgroundMode); - - // Switch to highlighter mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - - // Act: Switch back to pen mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - - // Assert: Pen color should match the background adaptive color - var expectedColor = GetExpectedAdaptiveColor(backgroundMode); - Assert.Equal(expectedColor, viewModel.PenColor); - - return true; - } - ); - } - - // Helper methods - - /// - /// Generates arbitrary background modes for property tests - /// - private static Arbitrary ArbBackgroundMode() - { - return Gen.Elements("transparent", "white", "black").ToArbitrary(); - } - - /// - /// Generates arbitrary colors for property tests - /// - private static Arbitrary ArbColor() - { - return Gen.Choose(0, 255) - .Four() - .Select(t => Color.FromArgb((byte)t.Item1, (byte)t.Item2, (byte)t.Item3, (byte)t.Item4)) - .ToArbitrary(); - } - - /// - /// Generates arbitrary drawing tools for property tests - /// - private static Arbitrary ArbDrawingTool() - { - return Gen.Elements(DrawingTool.Pen, DrawingTool.Highlighter, DrawingTool.Eraser).ToArbitrary(); - } - - /// - /// Generates arbitrary pen sizes for property tests (1.0 to 20.0) - /// - private static Arbitrary ArbPenSize() - { - return Gen.Choose(1, 20).Select(i => (double)i).ToArbitrary(); - } - - /// - /// Gets the expected adaptive color for a given background mode - /// - private static Color GetExpectedAdaptiveColor(string backgroundMode) - { - return backgroundMode.ToLower() switch - { - "black" => Colors.White, - "white" => Colors.Black, - "transparent" => Colors.Red, - _ => Colors.Red - }; - } - - /// - /// Gets a different background mode from the given one - /// - private static string GetDifferentBackground(string currentBackground) - { - return currentBackground.ToLower() switch - { - "black" => "white", - "white" => "transparent", - "transparent" => "black", - _ => "white" - }; - } -} diff --git a/Ink-Canvas-Next.Tests/ViewModels/MainWindowViewModelTests.cs b/Ink-Canvas-Next.Tests/ViewModels/MainWindowViewModelTests.cs deleted file mode 100644 index 4bac6ee..0000000 --- a/Ink-Canvas-Next.Tests/ViewModels/MainWindowViewModelTests.cs +++ /dev/null @@ -1,663 +0,0 @@ -using Avalonia; -using Avalonia.Headless; -using Avalonia.Media; -using Ink_Canvas_Next.ViewModels; -using Xunit; - -namespace Ink_Canvas_Next.Tests.ViewModels; - -/// -/// Unit tests for MainWindowViewModel -/// Validates: Requirements 3.1, 3.2, 3.3 -/// -[Collection("Avalonia")] -public class MainWindowViewModelTests -{ - public MainWindowViewModelTests() - { - } - /// - /// Tests that BackgroundMode is initialized to "Transparent" - /// Validates: Requirement 3.3 - default background mode - /// - [Fact] - public void BackgroundMode_ShouldInitializeToTransparent() - { - // Arrange & Act - var viewModel = new MainWindowViewModel(); - - // Assert - Assert.Equal("Transparent", viewModel.BackgroundMode); - } - - /// - /// Tests that ChangeBackground updates BackgroundMode to "White" - /// Validates: Requirement 3.2 - white background mode tracking - /// - [Fact] - public void ChangeBackground_ShouldSetBackgroundModeToWhite() - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Act - viewModel.ChangeBackgroundCommand.Execute("white"); - - // Assert - Assert.Equal("White", viewModel.BackgroundMode); - } - - /// - /// Tests that ChangeBackground updates BackgroundMode to "Black" - /// Validates: Requirement 3.1 - black background mode tracking - /// - [Fact] - public void ChangeBackground_ShouldSetBackgroundModeToBlack() - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Act - viewModel.ChangeBackgroundCommand.Execute("black"); - - // Assert - Assert.Equal("Black", viewModel.BackgroundMode); - } - - /// - /// Tests that ChangeBackground updates BackgroundMode to "Transparent" - /// Validates: Requirement 3.3 - transparent background mode tracking - /// - [Fact] - public void ChangeBackground_ShouldSetBackgroundModeToTransparent() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeBackgroundCommand.Execute("black"); // Start with non-transparent - - // Act - viewModel.ChangeBackgroundCommand.Execute("transparent"); - - // Assert - Assert.Equal("Transparent", viewModel.BackgroundMode); - } - - /// - /// Tests that ChangeBackground is case-insensitive - /// Validates: Requirement 3.1, 3.2, 3.3 - robust mode handling - /// - [Theory] - [InlineData("WHITE", "White")] - [InlineData("White", "White")] - [InlineData("white", "White")] - [InlineData("BLACK", "Black")] - [InlineData("Black", "Black")] - [InlineData("black", "Black")] - [InlineData("TRANSPARENT", "Transparent")] - [InlineData("Transparent", "Transparent")] - [InlineData("transparent", "Transparent")] - public void ChangeBackground_ShouldBeCaseInsensitive(string input, string expected) - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Act - viewModel.ChangeBackgroundCommand.Execute(input); - - // Assert - Assert.Equal(expected, viewModel.BackgroundMode); - } - - /// - /// Tests that BackgroundMode changes are observable - /// Validates: Requirements 3.1, 3.2, 3.3 - property change notifications - /// - [Fact] - public void BackgroundMode_ShouldRaisePropertyChanged() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var propertyChangedRaised = false; - viewModel.PropertyChanged += (sender, args) => - { - if (args.PropertyName == nameof(MainWindowViewModel.BackgroundMode)) - { - propertyChangedRaised = true; - } - }; - - // Act - viewModel.ChangeBackgroundCommand.Execute("white"); - - // Assert - Assert.True(propertyChangedRaised); - } - - /// - /// Tests that BackgroundMode persists across multiple changes - /// Validates: Requirements 3.1, 3.2, 3.3 - state persistence - /// - [Fact] - public void BackgroundMode_ShouldPersistAcrossMultipleChanges() - { - // Arrange - var viewModel = new MainWindowViewModel(); - - // Act & Assert - viewModel.ChangeBackgroundCommand.Execute("white"); - Assert.Equal("White", viewModel.BackgroundMode); - - viewModel.ChangeBackgroundCommand.Execute("black"); - Assert.Equal("Black", viewModel.BackgroundMode); - - viewModel.ChangeBackgroundCommand.Execute("transparent"); - Assert.Equal("Transparent", viewModel.BackgroundMode); - - viewModel.ChangeBackgroundCommand.Execute("white"); - Assert.Equal("White", viewModel.BackgroundMode); - } - - /// - /// Tests that PenColor initializes to Red (default for transparent background) - /// Validates: Requirement 3.3 - default pen color - /// - [Fact] - public void PenColor_ShouldInitializeToRed() - { - // Arrange & Act - var viewModel = new MainWindowViewModel(); - - // Assert - Assert.Equal(Colors.Red, viewModel.PenColor); - } - - /// - /// Tests that manually changing PenColor prevents background adaptive color changes - /// Validates: Requirement 3.5 - manual color change persistence - /// - [Fact] - public void ManualPenColorChange_ShouldPreventBackgroundAdaptiveColor() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeBackgroundCommand.Execute("transparent"); // Start with transparent (red pen) - - // Act - Manually change pen color - viewModel.PenColor = Colors.Blue; - - // Change background - should NOT change pen color because it was manually set - viewModel.ChangeBackgroundCommand.Execute("white"); - - // Assert - Pen color should remain blue, not change to black - Assert.Equal(Colors.Blue, viewModel.PenColor); - } - - /// - /// Tests that background change resets manual color flag and applies adaptive color - /// Validates: Requirement 3.5 - manual color preference until next background change - /// - [Fact] - public void BackgroundChange_ShouldResetManualColorFlagAndApplyAdaptiveColor() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.PenColor = Colors.Blue; // Manual color change - - // Act - First background change resets flag but doesn't change color (already manual) - viewModel.ChangeBackgroundCommand.Execute("white"); - Assert.Equal(Colors.Blue, viewModel.PenColor); // Still blue from manual change - - // Second background change should now apply adaptive color - viewModel.ChangeBackgroundCommand.Execute("black"); - - // Assert - Should apply adaptive color (white for black background) - Assert.Equal(Colors.White, viewModel.PenColor); - } - - /// - /// Tests that manual color change in highlighter mode does NOT set the flag - /// Validates: Requirement 3.5 - highlighter mode exception - /// - [Fact] - public void ManualPenColorChange_InHighlighterMode_ShouldNotSetFlag() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - - // Act - Change color in highlighter mode - viewModel.PenColor = Colors.Green; - - // Switch back to pen mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - - // Change background - should apply adaptive color because flag wasn't set in highlighter mode - viewModel.ChangeBackgroundCommand.Execute("black"); - - // Assert - Should apply adaptive color (white for black background) - Assert.Equal(Colors.White, viewModel.PenColor); - } - - /// - /// Tests that pen color changes apply adaptive color when not manually changed - /// Validates: Requirement 3.1, 3.2, 3.3 - background adaptive color - /// - [Theory] - [InlineData("black", 255, 255, 255, 255)] // White for black background - [InlineData("white", 255, 0, 0, 0)] // Black for white background - [InlineData("transparent", 255, 255, 0, 0)] // Red for transparent background - public void BackgroundChange_ShouldApplyAdaptiveColor(string background, byte a, byte r, byte g, byte b) - { - // Arrange - var viewModel = new MainWindowViewModel(); - var expectedColor = Color.FromArgb(a, r, g, b); - - // Act - viewModel.ChangeBackgroundCommand.Execute(background); - - // Assert - Assert.Equal(expectedColor, viewModel.PenColor); - } - - /// - /// Tests that SavePenState preserves the current pen color - /// Validates: Requirement 2.6 - pen state preservation - /// - [Fact] - public void SavePenState_ShouldPreserveCurrentPenColor() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var originalColor = Colors.Blue; - viewModel.PenColor = originalColor; - - // Act - Use reflection to call private SavePenState method - var method = typeof(MainWindowViewModel).GetMethod("SavePenState", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - method?.Invoke(viewModel, null); - - // Change the pen color after saving - viewModel.PenColor = Colors.Green; - - // Act - Restore the saved state - var restoreMethod = typeof(MainWindowViewModel).GetMethod("RestorePenState", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - restoreMethod?.Invoke(viewModel, null); - - // Assert - Should restore to the saved color - Assert.Equal(originalColor, viewModel.PenColor); - } - - /// - /// Tests that RestorePenState restores the previously saved pen color - /// Validates: Requirement 2.6 - pen state restoration - /// - [Fact] - public void RestorePenState_ShouldRestoreSavedPenColor() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var savedColor = Colors.Purple; - viewModel.PenColor = savedColor; - - // Act - Save the state - var saveMethod = typeof(MainWindowViewModel).GetMethod("SavePenState", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - saveMethod?.Invoke(viewModel, null); - - // Change color multiple times - viewModel.PenColor = Colors.Orange; - viewModel.PenColor = Colors.Cyan; - - // Restore the state - var restoreMethod = typeof(MainWindowViewModel).GetMethod("RestorePenState", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - restoreMethod?.Invoke(viewModel, null); - - // Assert - Should restore to the originally saved color - Assert.Equal(savedColor, viewModel.PenColor); - } - - /// - /// Tests that SavePenState can be called multiple times and only the last save is restored - /// Validates: Requirement 2.6 - pen state save/restore behavior - /// - [Fact] - public void SavePenState_MultipleSaves_ShouldOnlyRestoreLastSave() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var firstColor = Colors.Red; - var secondColor = Colors.Blue; - - // Act - First save - viewModel.PenColor = firstColor; - var saveMethod = typeof(MainWindowViewModel).GetMethod("SavePenState", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - saveMethod?.Invoke(viewModel, null); - - // Second save (should overwrite first) - viewModel.PenColor = secondColor; - saveMethod?.Invoke(viewModel, null); - - // Change color - viewModel.PenColor = Colors.Green; - - // Restore - var restoreMethod = typeof(MainWindowViewModel).GetMethod("RestorePenState", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - restoreMethod?.Invoke(viewModel, null); - - // Assert - Should restore to the second saved color, not the first - Assert.Equal(secondColor, viewModel.PenColor); - } - - /// - /// Tests that GetEffectiveThickness returns PenSize for Pen mode - /// Validates: Requirement 2.5 - pen mode thickness - /// - [Fact] - public void GetEffectiveThickness_InPenMode_ShouldReturnPenSize() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - viewModel.PenSize = 5.0; - - // Act - var thickness = viewModel.GetEffectiveThickness(); - - // Assert - Assert.Equal(5.0, thickness); - } - - /// - /// Tests that GetEffectiveThickness returns PenSize × 10 for Highlighter mode - /// Validates: Requirement 2.4 - highlighter mode thickness multiplier - /// - [Fact] - public void GetEffectiveThickness_InHighlighterMode_ShouldReturnPenSizeTimesMultiplier() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - viewModel.PenSize = 3.0; - - // Act - var thickness = viewModel.GetEffectiveThickness(); - - // Assert - Assert.Equal(30.0, thickness); // 3.0 × 10 - } - - /// - /// Tests that GetEffectiveThickness returns PenSize for Eraser mode - /// Validates: Requirement 2.5 - eraser mode thickness - /// - [Fact] - public void GetEffectiveThickness_InEraserMode_ShouldReturnPenSize() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(DrawingTool.Eraser); - viewModel.PenSize = 8.0; - - // Act - var thickness = viewModel.GetEffectiveThickness(); - - // Assert - Assert.Equal(8.0, thickness); - } - - /// - /// Tests that GetEffectiveThickness updates when PenSize changes in Pen mode - /// Validates: Requirement 2.5 - dynamic thickness updates - /// - [Theory] - [InlineData(1.0)] - [InlineData(5.0)] - [InlineData(10.0)] - [InlineData(15.0)] - [InlineData(20.0)] - public void GetEffectiveThickness_InPenMode_ShouldUpdateWithPenSize(double penSize) - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - - // Act - viewModel.PenSize = penSize; - var thickness = viewModel.GetEffectiveThickness(); - - // Assert - Assert.Equal(penSize, thickness); - } - - /// - /// Tests that GetEffectiveThickness updates when PenSize changes in Highlighter mode - /// Validates: Requirement 2.4 - dynamic thickness updates with multiplier - /// - [Theory] - [InlineData(1.0, 10.0)] - [InlineData(2.0, 20.0)] - [InlineData(3.0, 30.0)] - [InlineData(5.0, 50.0)] - [InlineData(10.0, 100.0)] - public void GetEffectiveThickness_InHighlighterMode_ShouldUpdateWithPenSize(double penSize, double expectedThickness) - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - - // Act - viewModel.PenSize = penSize; - var thickness = viewModel.GetEffectiveThickness(); - - // Assert - Assert.Equal(expectedThickness, thickness); - } - - /// - /// Tests that GetEffectiveThickness changes when switching between tools - /// Validates: Requirements 2.4, 2.5 - tool-specific thickness behavior - /// - [Fact] - public void GetEffectiveThickness_ShouldChangeWhenSwitchingTools() - { - // Arrange - var viewModel = new MainWindowViewModel(); - viewModel.PenSize = 4.0; - - // Act & Assert - Pen mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - Assert.Equal(4.0, viewModel.GetEffectiveThickness()); - - // Act & Assert - Highlighter mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - Assert.Equal(40.0, viewModel.GetEffectiveThickness()); - - // Act & Assert - Back to Pen mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - Assert.Equal(4.0, viewModel.GetEffectiveThickness()); - - // Act & Assert - Eraser mode - viewModel.ChangeToolCommand.Execute(DrawingTool.Eraser); - Assert.Equal(4.0, viewModel.GetEffectiveThickness()); - } - - /// - /// Tests that clear operation preserves BackgroundMode - /// Validates: Requirement 4.2 - clear preserves background mode - /// - [Theory] - [InlineData("transparent")] - [InlineData("white")] - [InlineData("black")] - public void ClearCanvas_ShouldPreserveBackgroundMode(string backgroundMode) - { - // Arrange - var viewModel = new MainWindowViewModel(); - var clearWasCalled = false; - viewModel.ClearCanvasAction = () => clearWasCalled = true; - - // Set background mode - viewModel.ChangeBackgroundCommand.Execute(backgroundMode); - var expectedBackgroundMode = viewModel.BackgroundMode; - - // Act - viewModel.ClearCommand.Execute(null); - - // Assert - Assert.True(clearWasCalled, "ClearCanvasAction should have been invoked"); - Assert.Equal(expectedBackgroundMode, viewModel.BackgroundMode); - } - - /// - /// Tests that clear operation preserves CurrentTool - /// Validates: Requirement 4.3 - clear preserves current tool - /// - [Theory] - [InlineData(DrawingTool.Pen)] - [InlineData(DrawingTool.Highlighter)] - [InlineData(DrawingTool.Eraser)] - public void ClearCanvas_ShouldPreserveCurrentTool(DrawingTool tool) - { - // Arrange - var viewModel = new MainWindowViewModel(); - var clearWasCalled = false; - viewModel.ClearCanvasAction = () => clearWasCalled = true; - - // Set tool - viewModel.ChangeToolCommand.Execute(tool); - - // Act - viewModel.ClearCommand.Execute(null); - - // Assert - Assert.True(clearWasCalled, "ClearCanvasAction should have been invoked"); - Assert.Equal(tool, viewModel.CurrentTool); - } - - /// - /// Tests that clear operation preserves PenColor - /// Validates: Requirement 4.4 - clear preserves pen color - /// - [Fact] - public void ClearCanvas_ShouldPreservePenColor() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var clearWasCalled = false; - viewModel.ClearCanvasAction = () => clearWasCalled = true; - - // Set a specific pen color - var expectedColor = Colors.Purple; - viewModel.PenColor = expectedColor; - - // Act - viewModel.ClearCommand.Execute(null); - - // Assert - Assert.True(clearWasCalled, "ClearCanvasAction should have been invoked"); - Assert.Equal(expectedColor, viewModel.PenColor); - } - - /// - /// Tests that clear operation preserves PenSize - /// Validates: Requirement 4.4 - clear preserves pen size - /// - [Theory] - [InlineData(1.0)] - [InlineData(5.0)] - [InlineData(10.0)] - [InlineData(15.0)] - [InlineData(20.0)] - public void ClearCanvas_ShouldPreservePenSize(double penSize) - { - // Arrange - var viewModel = new MainWindowViewModel(); - var clearWasCalled = false; - viewModel.ClearCanvasAction = () => clearWasCalled = true; - - // Set pen size - viewModel.PenSize = penSize; - - // Act - viewModel.ClearCommand.Execute(null); - - // Assert - Assert.True(clearWasCalled, "ClearCanvasAction should have been invoked"); - Assert.Equal(penSize, viewModel.PenSize); - } - - /// - /// Tests that clear operation preserves all settings together - /// Validates: Requirements 4.2, 4.3, 4.4 - clear preserves all settings - /// - [Fact] - public void ClearCanvas_ShouldPreserveAllSettings() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var clearWasCalled = false; - viewModel.ClearCanvasAction = () => clearWasCalled = true; - - // Set up a specific state - viewModel.ChangeBackgroundCommand.Execute("black"); - viewModel.ChangeToolCommand.Execute(DrawingTool.Highlighter); - viewModel.PenSize = 7.5; - - // Capture expected values - var expectedBackgroundMode = viewModel.BackgroundMode; - var expectedTool = viewModel.CurrentTool; - var expectedColor = viewModel.PenColor; - var expectedSize = viewModel.PenSize; - - // Act - viewModel.ClearCommand.Execute(null); - - // Assert - Assert.True(clearWasCalled, "ClearCanvasAction should have been invoked"); - Assert.Equal(expectedBackgroundMode, viewModel.BackgroundMode); - Assert.Equal(expectedTool, viewModel.CurrentTool); - Assert.Equal(expectedColor, viewModel.PenColor); - Assert.Equal(expectedSize, viewModel.PenSize); - } - - /// - /// Tests that clear operation can be called multiple times without affecting settings - /// Validates: Requirements 4.2, 4.3, 4.4 - clear is idempotent regarding settings - /// - [Fact] - public void ClearCanvas_MultipleCalls_ShouldPreserveSettings() - { - // Arrange - var viewModel = new MainWindowViewModel(); - var clearCallCount = 0; - viewModel.ClearCanvasAction = () => clearCallCount++; - - // Set up a specific state - viewModel.ChangeBackgroundCommand.Execute("white"); - viewModel.ChangeToolCommand.Execute(DrawingTool.Pen); - viewModel.PenColor = Colors.Blue; - viewModel.PenSize = 4.0; - - // Capture expected values - var expectedBackgroundMode = viewModel.BackgroundMode; - var expectedTool = viewModel.CurrentTool; - var expectedColor = viewModel.PenColor; - var expectedSize = viewModel.PenSize; - - // Act - Call clear multiple times - viewModel.ClearCommand.Execute(null); - viewModel.ClearCommand.Execute(null); - viewModel.ClearCommand.Execute(null); - - // Assert - Assert.Equal(3, clearCallCount); - Assert.Equal(expectedBackgroundMode, viewModel.BackgroundMode); - Assert.Equal(expectedTool, viewModel.CurrentTool); - Assert.Equal(expectedColor, viewModel.PenColor); - Assert.Equal(expectedSize, viewModel.PenSize); - } -} diff --git a/Ink-Canvas-Next/Converters/UiLanguageDisplayConverter.cs b/Ink-Canvas-Next/Converters/UiLanguageDisplayConverter.cs new file mode 100644 index 0000000..57d4e7b --- /dev/null +++ b/Ink-Canvas-Next/Converters/UiLanguageDisplayConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Ink_Canvas_Next.ViewModels; + +namespace Ink_Canvas_Next.Converters; + +/// +/// Converts UiLanguage enum values to their display strings +/// +public class UiLanguageDisplayConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is UiLanguage lang) + { + return lang switch + { + UiLanguage.ZhCn => "中文", + UiLanguage.EnUs => "English", + _ => lang.ToString() + }; + } + return value?.ToString(); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/Ink-Canvas-Next/Ink-Canvas-Next.csproj b/Ink-Canvas-Next/Ink-Canvas-Next.csproj index 659ede1..e9d7091 100644 --- a/Ink-Canvas-Next/Ink-Canvas-Next.csproj +++ b/Ink-Canvas-Next/Ink-Canvas-Next.csproj @@ -24,6 +24,6 @@ - + diff --git a/Ink-Canvas-Next/Ink-Canvas-Next.csproj.lscache b/Ink-Canvas-Next/Ink-Canvas-Next.csproj.lscache new file mode 100644 index 0000000..8422c6a --- /dev/null +++ b/Ink-Canvas-Next/Ink-Canvas-Next.csproj.lscache @@ -0,0 +1,309 @@ +version=1 + +# This file caches language service data to improve the performance of C# Dev Kit. +# It is not intended for manual editing. It can safely be deleted and will be +# regenerated automatically. For more information, see https://aka.ms/lscache +# +# To control where cache files are stored, use the following VS Code setting: +# "dotnet.projectsystem.cacheInProjectFolder": true + +[project] +language=C# +primary +lastDtbSucceeded + +[properties] +AssemblyName=Ink-Canvas-Next +CommandLineArgsForDesignTimeEvaluation=-langversion:14.0 -define:TRACE +CompilerGeneratedFilesOutputPath= +MaxSupportedLangVersion=14.0 +ProjectAssetsFile=obj/project.assets.json +RootNamespace=Ink-Canvas-Next +RunAnalyzers= +RunAnalyzersDuringLiveAnalysis= +SolutionPath=../Ink Canvas Next.slnx +TargetFrameworkIdentifier=.NETCoreApp +TargetPath=bin/Debug/net11.0/Ink-Canvas-Next.dll +TargetRefPath=obj/Debug/net11.0/ref/Ink-Canvas-Next.dll +TemporaryDependencyNodeTargetIdentifier=net11.0 + +[commandLineArguments] +/noconfig +/unsafe- +/checked- +/nowarn:1701,1702,1701,1702,8002 +/fullpaths +/nostdlib+ +/errorreport:prompt +/warn:11 +/define:TRACE;DEBUG;NET;NET11_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NET11_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER +/highentropyva+ +/nullable:enable +/features:"InterceptorsNamespaces=;Microsoft.Extensions.Validation.Generated" +/debug+ +/debug:portable +/filealign:512 +/optimize- +/out:obj\Debug\net11.0\Ink-Canvas-Next.dll +/refout:obj\Debug\net11.0\refint\Ink-Canvas-Next.dll +/target:winexe +/warnaserror- +/utf8output +/win32manifest:app.manifest +/deterministic+ +/langversion:14.0 +/warnaserror+:NU1605,SYSLIB0011 + +[sourceFiles] +App.axaml.cs +Converters/UiLanguageDisplayConverter.cs +Models/ + AdaptiveColors.cs + HighlighterSettings.cs +obj/Debug/net11.0/ + .NETCoreApp,Version=v11.0.AssemblyAttributes.cs + Ink-Canvas-Next.AssemblyInfo.cs +Program.cs +Services/CanvasService.cs +ViewLocator.cs +ViewModels/ + MainWindowViewModel.cs + ViewModelBase.cs +Views/MainWindow.axaml.cs + +[metadataReferences] +/packs/Microsoft.NETCore.App.Ref/11.0.0-preview.3.26207.106/ref/net11.0/ + Microsoft.CSharp.dll + Microsoft.VisualBasic.Core.dll + Microsoft.VisualBasic.dll + Microsoft.Win32.Primitives.dll + Microsoft.Win32.Registry.dll + mscorlib.dll + netstandard.dll + System.AppContext.dll + System.Buffers.dll + System.Collections.Concurrent.dll + System.Collections.dll + System.Collections.Immutable.dll + System.Collections.NonGeneric.dll + System.Collections.Specialized.dll + System.ComponentModel.Annotations.dll + System.ComponentModel.DataAnnotations.dll + System.ComponentModel.dll + System.ComponentModel.EventBasedAsync.dll + System.ComponentModel.Primitives.dll + System.ComponentModel.TypeConverter.dll + System.Configuration.dll + System.Console.dll + System.Core.dll + System.Data.Common.dll + System.Data.DataSetExtensions.dll + System.Data.dll + System.Diagnostics.Contracts.dll + System.Diagnostics.Debug.dll + System.Diagnostics.DiagnosticSource.dll + System.Diagnostics.FileVersionInfo.dll + System.Diagnostics.Process.dll + System.Diagnostics.StackTrace.dll + System.Diagnostics.TextWriterTraceListener.dll + System.Diagnostics.Tools.dll + System.Diagnostics.TraceSource.dll + System.Diagnostics.Tracing.dll + System.dll + System.Drawing.dll + System.Drawing.Primitives.dll + System.Dynamic.Runtime.dll + System.Formats.Asn1.dll + System.Formats.Tar.dll + System.Globalization.Calendars.dll + System.Globalization.dll + System.Globalization.Extensions.dll + System.IO.Compression.Brotli.dll + System.IO.Compression.dll + System.IO.Compression.FileSystem.dll + System.IO.Compression.ZipFile.dll + System.IO.dll + System.IO.FileSystem.AccessControl.dll + System.IO.FileSystem.dll + System.IO.FileSystem.DriveInfo.dll + System.IO.FileSystem.Primitives.dll + System.IO.FileSystem.Watcher.dll + System.IO.IsolatedStorage.dll + System.IO.MemoryMappedFiles.dll + System.IO.Pipelines.dll + System.IO.Pipes.AccessControl.dll + System.IO.Pipes.dll + System.IO.UnmanagedMemoryStream.dll + System.Linq.AsyncEnumerable.dll + System.Linq.dll + System.Linq.Expressions.dll + System.Linq.Parallel.dll + System.Linq.Queryable.dll + System.Memory.dll + System.Net.dll + System.Net.Http.dll + System.Net.Http.Json.dll + System.Net.HttpListener.dll + System.Net.Mail.dll + System.Net.NameResolution.dll + System.Net.NetworkInformation.dll + System.Net.Ping.dll + System.Net.Primitives.dll + System.Net.Quic.dll + System.Net.Requests.dll + System.Net.Security.dll + System.Net.ServerSentEvents.dll + System.Net.ServicePoint.dll + System.Net.Sockets.dll + System.Net.WebClient.dll + System.Net.WebHeaderCollection.dll + System.Net.WebProxy.dll + System.Net.WebSockets.Client.dll + System.Net.WebSockets.dll + System.Numerics.dll + System.Numerics.Vectors.dll + System.ObjectModel.dll + System.Reflection.DispatchProxy.dll + System.Reflection.dll + System.Reflection.Emit.dll + System.Reflection.Emit.ILGeneration.dll + System.Reflection.Emit.Lightweight.dll + System.Reflection.Extensions.dll + System.Reflection.Metadata.dll + System.Reflection.Primitives.dll + System.Reflection.TypeExtensions.dll + System.Resources.Reader.dll + System.Resources.ResourceManager.dll + System.Resources.Writer.dll + System.Runtime.CompilerServices.Unsafe.dll + System.Runtime.CompilerServices.VisualC.dll + System.Runtime.dll + System.Runtime.Extensions.dll + System.Runtime.Handles.dll + System.Runtime.InteropServices.dll + System.Runtime.InteropServices.JavaScript.dll + System.Runtime.InteropServices.RuntimeInformation.dll + System.Runtime.Intrinsics.dll + System.Runtime.Loader.dll + System.Runtime.Numerics.dll + System.Runtime.Serialization.dll + System.Runtime.Serialization.Formatters.dll + System.Runtime.Serialization.Json.dll + System.Runtime.Serialization.Primitives.dll + System.Runtime.Serialization.Xml.dll + System.Security.AccessControl.dll + System.Security.Claims.dll + System.Security.Cryptography.Algorithms.dll + System.Security.Cryptography.Cng.dll + System.Security.Cryptography.Csp.dll + System.Security.Cryptography.dll + System.Security.Cryptography.Encoding.dll + System.Security.Cryptography.OpenSsl.dll + System.Security.Cryptography.Primitives.dll + System.Security.Cryptography.X509Certificates.dll + System.Security.dll + System.Security.Principal.dll + System.Security.Principal.Windows.dll + System.Security.SecureString.dll + System.ServiceModel.Web.dll + System.ServiceProcess.dll + System.Text.Encoding.CodePages.dll + System.Text.Encoding.dll + System.Text.Encoding.Extensions.dll + System.Text.Encodings.Web.dll + System.Text.Json.dll + System.Text.RegularExpressions.dll + System.Threading.AccessControl.dll + System.Threading.Channels.dll + System.Threading.dll + System.Threading.Overlapped.dll + System.Threading.Tasks.Dataflow.dll + System.Threading.Tasks.dll + System.Threading.Tasks.Extensions.dll + System.Threading.Tasks.Parallel.dll + System.Threading.Thread.dll + System.Threading.ThreadPool.dll + System.Threading.Timer.dll + System.Transactions.dll + System.Transactions.Local.dll + System.ValueTuple.dll + System.Web.dll + System.Web.HttpUtility.dll + System.Windows.dll + System.Xml.dll + System.Xml.Linq.dll + System.Xml.ReaderWriter.dll + System.Xml.Serialization.dll + System.Xml.XDocument.dll + System.Xml.XmlDocument.dll + System.Xml.XmlSerializer.dll + System.Xml.XPath.dll + System.Xml.XPath.XDocument.dll + WindowsBase.dll +/ + avalonia.controls.colorpicker/11.3.11/lib/net8.0/Avalonia.Controls.ColorPicker.dll + avalonia.controls.datagrid/11.3.10/lib/net8.0/Avalonia.Controls.DataGrid.dll + avalonia.desktop/11.3.11/lib/net8.0/Avalonia.Desktop.dll + avalonia.diagnostics/11.3.11/lib/net8.0/Avalonia.Diagnostics.dll + avalonia.fonts.inter/11.3.11/lib/net8.0/Avalonia.Fonts.Inter.dll + avalonia.freedesktop/11.3.11/lib/net8.0/Avalonia.FreeDesktop.dll + avalonia.native/11.3.11/lib/net8.0/Avalonia.Native.dll + avalonia.remote.protocol/11.3.11/lib/net8.0/Avalonia.Remote.Protocol.dll + avalonia.skia/11.3.11/lib/net8.0/Avalonia.Skia.dll + avalonia.themes.fluent/11.3.11/lib/net8.0/Avalonia.Themes.Fluent.dll + avalonia.themes.simple/11.3.11/lib/net8.0/Avalonia.Themes.Simple.dll + avalonia.win32/11.3.11/lib/net8.0/ + Avalonia.Win32.Automation.dll + Avalonia.Win32.dll + avalonia.x11/11.3.11/lib/net8.0/Avalonia.X11.dll + avalonia/11.3.11/ref/net8.0/ + Avalonia.Base.dll + Avalonia.Controls.dll + Avalonia.DesignerSupport.dll + Avalonia.Dialogs.dll + Avalonia.dll + Avalonia.Markup.dll + Avalonia.Markup.Xaml.dll + Avalonia.Metal.dll + Avalonia.MicroCom.dll + Avalonia.OpenGL.dll + Avalonia.Vulkan.dll + communitytoolkit.mvvm/8.2.1/lib/net6.0/CommunityToolkit.Mvvm.dll + dotnetcampus.avaloniainkcanvas/1.0.1/lib/net8.0/DotNetCampus.AvaloniaInkCanvas.dll + dotnetcampus.logger/1.3.0-alpha01/lib/net8.0/DotNetCampus.Logger.dll + dotnetcampus.numerics.geometry/1.0.1-alpha22/lib/net8.0/DotNetCampus.Numerics.Geometry.dll + dotnetcampus.numerics/1.0.1-alpha22/lib/net8.0/DotNetCampus.Numerics.dll + fluentavaloniaui/2.5.0/lib/net10.0/FluentAvalonia.dll + harfbuzzsharp/8.3.1.1/lib/net8.0/HarfBuzzSharp.dll + microcom.runtime/0.11.0/lib/net5.0/MicroCom.Runtime.dll + skiasharp/2.88.9/lib/net6.0/SkiaSharp.dll + tmds.dbus.protocol/0.21.2/lib/net8.0/Tmds.DBus.Protocol.dll + +[analyzerReferences] +/packs/Microsoft.NETCore.App.Ref/11.0.0-preview.3.26207.106/analyzers/dotnet/cs/ + Microsoft.Interop.ComInterfaceGenerator.dll + Microsoft.Interop.JavaScript.JSImportGenerator.dll + Microsoft.Interop.LibraryImportGenerator.dll + Microsoft.Interop.SourceGeneration.dll + System.Text.Json.SourceGeneration.dll + System.Text.RegularExpressions.Generator.dll +/sdk/11.0.100-preview.3.26207.106/Sdks/Microsoft.NET.Sdk/analyzers/ + Microsoft.CodeAnalysis.CSharp.NetAnalyzers.dll + Microsoft.CodeAnalysis.NetAnalyzers.dll +/avalonia/11.3.11/analyzers/dotnet/cs/ + Avalonia.Analyzers.dll + Avalonia.Generators.dll +/communitytoolkit.mvvm/8.2.1/analyzers/dotnet/roslyn4.3/cs/ + CommunityToolkit.Mvvm.CodeFixers.dll + CommunityToolkit.Mvvm.SourceGenerators.dll +/dotnetcampus.logger/1.3.0-alpha01/analyzers/dotnet/cs/DotNetCampus.Logger.Analyzer.dll + +[analyzerConfigFiles] +/sdk/11.0.100-preview.3.26207.106/Sdks/Microsoft.NET.Sdk/ + analyzers/build/config/analysislevel_11_default.globalconfig + codestyle/cs/build/config/analysislevelstyle_default.globalconfig +obj/Debug/net11.0/Ink-Canvas-Next.GeneratedMSBuildEditorConfig.editorconfig + +[additionalFiles] +App.axaml +Views/MainWindow.axaml diff --git a/Ink-Canvas-Next/Services/CanvasService.cs b/Ink-Canvas-Next/Services/CanvasService.cs index a314909..34aa536 100644 --- a/Ink-Canvas-Next/Services/CanvasService.cs +++ b/Ink-Canvas-Next/Services/CanvasService.cs @@ -11,16 +11,20 @@ namespace Ink_Canvas_Next.Services; public class CanvasService { private const string DefaultFolderName = "InkCanvasScreenshots"; + private static int _fileCounter = 0; /// /// Saves a canvas bitmap to a PNG file /// /// The rendered canvas bitmap to save /// The full path to the saved file + /// Thrown when bitmap is null /// Thrown when file save operation fails /// Thrown when lacking permissions to write public async Task SaveCanvasToFileAsync(RenderTargetBitmap bitmap) { + ArgumentNullException.ThrowIfNull(bitmap); + // Get save directory var directory = GetSaveDirectory(); @@ -28,11 +32,16 @@ public async Task SaveCanvasToFileAsync(RenderTargetBitmap bitmap) var fileName = GenerateFileName(); var filePath = Path.Combine(directory, fileName); - // Save bitmap as PNG + // Save bitmap to memory on UI thread (bitmap is an Avalonia UI resource), + // then write to disk in background to avoid blocking UI + using var memoryStream = new MemoryStream(); + bitmap.Save(memoryStream); + memoryStream.Position = 0; + await Task.Run(() => { using var fileStream = File.Create(filePath); - bitmap.Save(fileStream); + memoryStream.CopyTo(fileStream); }); return filePath; @@ -60,12 +69,14 @@ private string GetSaveDirectory() } /// - /// Generates a unique filename with timestamp + /// Generates a unique filename with timestamp and counter /// - /// Filename in format "InkCanvas_YYYYMMDD_HHMMSS.png" + /// Filename in format "InkCanvas_YYYYMMDD_HHmmss_fff_NNN.png" private string GenerateFileName() { - var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); - return $"InkCanvas_{timestamp}.png"; + var now = DateTime.Now; + var timestamp = now.ToString("yyyyMMdd_HHmmss_fff"); + var counter = System.Threading.Interlocked.Increment(ref _fileCounter); + return $"InkCanvas_{timestamp}_{counter:D3}.png"; } } diff --git a/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs b/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs index 44574a8..c269d37 100644 --- a/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs +++ b/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs @@ -1,8 +1,9 @@ -using Avalonia.Media; +using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using System.Collections.ObjectModel; using System; +using System.Threading.Tasks; using Ink_Canvas_Next.Models; namespace Ink_Canvas_Next.ViewModels @@ -14,8 +15,27 @@ public enum DrawingTool Eraser } + public enum UiLanguage + { + ZhCn, + EnUs + } + public partial class MainWindowViewModel : ViewModelBase { + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PenToolTip))] + [NotifyPropertyChangedFor(nameof(HighlighterToolTip))] + [NotifyPropertyChangedFor(nameof(EraserToolTip))] + [NotifyPropertyChangedFor(nameof(SizeToolTip))] + [NotifyPropertyChangedFor(nameof(TransparentBackgroundToolTip))] + [NotifyPropertyChangedFor(nameof(WhiteboardToolTip))] + [NotifyPropertyChangedFor(nameof(BlackboardToolTip))] + [NotifyPropertyChangedFor(nameof(ClearCanvasToolTip))] + [NotifyPropertyChangedFor(nameof(SaveToolTip))] + [NotifyPropertyChangedFor(nameof(ExitToolTip))] + private UiLanguage _currentLanguage = UiLanguage.ZhCn; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsPenSelected))] [NotifyPropertyChangedFor(nameof(IsHighlighterSelected))] @@ -33,7 +53,7 @@ public partial class MainWindowViewModel : ViewModelBase private double _penSize = 3.0; [ObservableProperty] - private IBrush _backgroundBrush = Brushes.Transparent; + private IBrush _backgroundBrush = new SolidColorBrush(Color.Parse("#01000000")); [ObservableProperty] private string _backgroundMode = "Transparent"; @@ -42,16 +62,17 @@ public partial class MainWindowViewModel : ViewModelBase private Color _savedPenColor = Colors.Red; private bool _isManualColorChange = false; private bool _isRestoringPenState = false; // Flag to prevent manual color change tracking during restoration + private bool _isApplyingAdaptiveColor = false; // Flag to prevent manual color change tracking during adaptive color // Action to trigger view operations public Action? ClearCanvasAction { get; set; } - public Action? SaveCanvasAction { get; set; } + public Func? SaveCanvasAction { get; set; } partial void OnPenColorChanged(Color value) { - // Skip tracking if we're restoring pen state - if (_isRestoringPenState) return; - + // Skip tracking if we're restoring pen state or applying adaptive color + if (_isRestoringPenState || _isApplyingAdaptiveColor) return; + // Mark as manual color change only if not in highlighter mode // This allows background adaptive color to work when switching from highlighter if (CurrentTool != DrawingTool.Highlighter) @@ -68,6 +89,8 @@ private void ApplyBackgroundAdaptiveColor() // Skip if user manually changed color if (_isManualColorChange) return; + _isApplyingAdaptiveColor = true; + // Apply adaptive color based on background mode switch (BackgroundMode) { @@ -81,6 +104,8 @@ private void ApplyBackgroundAdaptiveColor() PenColor = Colors.Red; break; } + + _isApplyingAdaptiveColor = false; } private void SavePenState() @@ -108,11 +133,33 @@ public double GetEffectiveThickness() Colors.Orange, Colors.Purple, Colors.Yellow, Colors.White }; + public ObservableCollection Languages { get; } = new() + { + UiLanguage.ZhCn, + UiLanguage.EnUs + }; + + public string PenToolTip => CurrentLanguage == UiLanguage.EnUs ? "Pen" : "画笔"; + public string HighlighterToolTip => CurrentLanguage == UiLanguage.EnUs ? "Highlighter" : "荧光笔"; + public string EraserToolTip => CurrentLanguage == UiLanguage.EnUs ? "Eraser" : "橡皮擦"; + public string SizeToolTip => CurrentLanguage == UiLanguage.EnUs ? "Size" : "笔宽"; + public string TransparentBackgroundToolTip => CurrentLanguage == UiLanguage.EnUs ? "Transparent" : "透明"; + public string WhiteboardToolTip => CurrentLanguage == UiLanguage.EnUs ? "Whiteboard" : "白板"; + public string BlackboardToolTip => CurrentLanguage == UiLanguage.EnUs ? "Blackboard" : "黑板"; + public string ClearCanvasToolTip => CurrentLanguage == UiLanguage.EnUs ? "Clear Canvas" : "清空画布"; + public string SaveToolTip => CurrentLanguage == UiLanguage.EnUs ? "Save" : "保存"; + public string ExitToolTip => CurrentLanguage == UiLanguage.EnUs ? "Exit" : "退出"; + + public string CurrentLanguageDisplay => CurrentLanguage == UiLanguage.EnUs ? "English" : "中文"; + [RelayCommand] private void ChangeTool(DrawingTool tool) { + var oldTool = CurrentTool; + CurrentTool = tool; + // Handle switching TO highlighter - if (tool == DrawingTool.Highlighter && CurrentTool != DrawingTool.Highlighter) + if (tool == DrawingTool.Highlighter && oldTool != DrawingTool.Highlighter) { // Save current pen color SavePenState(); @@ -121,7 +168,7 @@ private void ChangeTool(DrawingTool tool) PenColor = Color.FromArgb(HighlighterSettings.Opacity, 255, 255, 0); } // Handle switching FROM highlighter to another tool - else if (CurrentTool == DrawingTool.Highlighter && tool != DrawingTool.Highlighter) + else if (oldTool == DrawingTool.Highlighter && tool != DrawingTool.Highlighter) { // Restore saved pen color RestorePenState(); @@ -132,8 +179,6 @@ private void ChangeTool(DrawingTool tool) // Apply background adaptive color rules ApplyBackgroundAdaptiveColor(); } - - CurrentTool = tool; } [RelayCommand] @@ -157,13 +202,11 @@ private void ChangeBackground(string mode) break; } - // Apply background adaptive color BEFORE resetting the flag - // This ensures manual color changes persist until the NEXT background change - ApplyBackgroundAdaptiveColor(); - - // Reset manual color change flag AFTER applying adaptive color - // This allows the next background change to apply adaptive color + // Reset manual color change flag to allow adaptive color for this background change _isManualColorChange = false; + + // Apply background adaptive color + ApplyBackgroundAdaptiveColor(); } [RelayCommand] @@ -173,9 +216,18 @@ private void Clear() } [RelayCommand] - private void Save() + private async Task Save() + { + if (SaveCanvasAction != null) + { + await SaveCanvasAction(); + } + } + + [RelayCommand] + private void ChangeLanguage(UiLanguage language) { - SaveCanvasAction?.Invoke(); + CurrentLanguage = language; } } } diff --git a/Ink-Canvas-Next/Views/MainWindow.axaml b/Ink-Canvas-Next/Views/MainWindow.axaml index 9aa7d9a..f617eb1 100644 --- a/Ink-Canvas-Next/Views/MainWindow.axaml +++ b/Ink-Canvas-Next/Views/MainWindow.axaml @@ -5,6 +5,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ic="using:DotNetCampus.Inking" xmlns:ui="using:FluentAvalonia.UI.Controls" + xmlns:conv="using:Ink_Canvas_Next.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Ink_Canvas_Next.Views.MainWindow" x:DataType="vm:MainWindowViewModel" @@ -16,6 +17,10 @@ WindowState="Maximized" Topmost="False"> + + + + @@ -25,13 +30,13 @@ - + - + - + @@ -51,31 +56,39 @@ - + + + + + + + + + - - - - - - diff --git a/Ink-Canvas-Next/Views/MainWindow.axaml.cs b/Ink-Canvas-Next/Views/MainWindow.axaml.cs index 91f9901..030c557 100644 --- a/Ink-Canvas-Next/Views/MainWindow.axaml.cs +++ b/Ink-Canvas-Next/Views/MainWindow.axaml.cs @@ -18,18 +18,36 @@ public MainWindow() InitializeComponent(); } + private readonly Services.CanvasService _canvasService = new(); + + private MainWindowViewModel? _previousViewModel; + protected override void OnDataContextChanged(EventArgs e) { + // Unsubscribe from previous ViewModel to prevent memory leak + if (_previousViewModel != null) + { + _previousViewModel.PropertyChanged -= ViewModel_PropertyChanged; + _previousViewModel.ClearCanvasAction = null; + _previousViewModel.SaveCanvasAction = null; + } + base.OnDataContextChanged(e); + if (DataContext is MainWindowViewModel vm) { + _previousViewModel = vm; vm.PropertyChanged += ViewModel_PropertyChanged; vm.ClearCanvasAction = ClearCanvas; - vm.SaveCanvasAction = SaveCanvas; - + vm.SaveCanvasAction = SaveCanvasAsync; + // Initialize UpdatePen(vm); } + else + { + _previousViewModel = null; + } } private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -48,7 +66,14 @@ private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs private void UpdatePen(MainWindowViewModel vm) { // Access settings via AvaloniaSkiaInkCanvas property - var settings = MyInkCanvas.AvaloniaSkiaInkCanvas.Settings; + var skiaCanvas = MyInkCanvas?.AvaloniaSkiaInkCanvas; + if (skiaCanvas == null) + { + System.Diagnostics.Debug.WriteLine("Warning: AvaloniaSkiaInkCanvas is null, cannot update pen settings"); + return; + } + + var settings = skiaCanvas.Settings; settings.InkColor = new SKColor(vm.PenColor.R, vm.PenColor.G, vm.PenColor.B, vm.PenColor.A); // Apply effective thickness (10x for highlighter mode) @@ -69,150 +94,99 @@ private void ClearCanvas() { try { - // Primary method: Access the AvaloniaSkiaInkCanvas and try to manipulate strokes - var skiaCanvas = MyInkCanvas.AvaloniaSkiaInkCanvas; + var skiaCanvas = MyInkCanvas?.AvaloniaSkiaInkCanvas; if (skiaCanvas != null) { - // Attempt 1: Try to use reflection to access and clear the internal strokes collection - // The Strokes property is IReadOnlyList, but the underlying collection might be mutable - var strokesProperty = skiaCanvas.GetType().GetProperty("Strokes"); - if (strokesProperty != null) + // Use ResetStaticStrokeListByEraserResult with empty collection to clear all strokes + // This internal method replaces the entire stroke list, which is the correct way to clear + var resetMethod = skiaCanvas.GetType().GetMethod("ResetStaticStrokeListByEraserResult", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (resetMethod != null) { - var strokes = strokesProperty.GetValue(skiaCanvas); - if (strokes != null) - { - // Try to find a Clear method on the collection - var clearMethod = strokes.GetType().GetMethod("Clear"); - if (clearMethod != null) - { - clearMethod.Invoke(strokes, null); - System.Diagnostics.Debug.WriteLine("Canvas cleared using Strokes.Clear()"); - - // Force a visual update - MyInkCanvas.InvalidateVisual(); - return; - } - - // Try to find RemoveAll or similar method - var removeAllMethod = strokes.GetType().GetMethod("RemoveAll"); - if (removeAllMethod != null) - { - removeAllMethod.Invoke(strokes, new object[] { (Func)(x => true) }); - System.Diagnostics.Debug.WriteLine("Canvas cleared using Strokes.RemoveAll()"); - MyInkCanvas.InvalidateVisual(); - return; - } - } - } - - // Attempt 2: Look for a Clear method directly on AvaloniaSkiaInkCanvas - var clearMethod2 = skiaCanvas.GetType().GetMethod("Clear"); - if (clearMethod2 != null) - { - clearMethod2.Invoke(skiaCanvas, null); - System.Diagnostics.Debug.WriteLine("Canvas cleared using AvaloniaSkiaInkCanvas.Clear()"); + resetMethod.Invoke(skiaCanvas, new object[] { Array.Empty() }); MyInkCanvas.InvalidateVisual(); + System.Diagnostics.Debug.WriteLine("Canvas cleared using ResetStaticStrokeListByEraserResult"); return; } - - // Attempt 3: Try to set Strokes property to a new empty collection - if (strokesProperty != null && strokesProperty.CanWrite) + + // Fallback: remove strokes one by one + var strokes = skiaCanvas.StaticStrokeList; + if (strokes != null && strokes.Count > 0) { - try + var removeMethod = skiaCanvas.GetType().GetMethod("RemoveStaticStroke", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + if (removeMethod != null) { - var emptyCollection = Activator.CreateInstance(strokesProperty.PropertyType); - strokesProperty.SetValue(skiaCanvas, emptyCollection); - System.Diagnostics.Debug.WriteLine("Canvas cleared by setting empty Strokes collection"); + // Iterate in reverse to avoid index shifting issues + for (int i = strokes.Count - 1; i >= 0; i--) + { + removeMethod.Invoke(skiaCanvas, new object[] { strokes[i] }); + } MyInkCanvas.InvalidateVisual(); + System.Diagnostics.Debug.WriteLine("Canvas cleared by removing strokes individually"); return; } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to set empty collection: {ex.Message}"); - } } + + // Final fallback: try to clear strokes via public API if available + System.Diagnostics.Debug.WriteLine("Warning: Internal clear methods not found, attempting alternative approaches"); + } + else + { + System.Diagnostics.Debug.WriteLine("Warning: AvaloniaSkiaInkCanvas is null, cannot clear canvas"); } - - // If all attempts failed, log a warning - System.Diagnostics.Debug.WriteLine("Warning: Could not find a method to clear canvas strokes"); - System.Diagnostics.Debug.WriteLine("Fallback: Attempting to recreate InkCanvas control"); - - // Fallback: Try to recreate the InkCanvas control - RecreateInkCanvas(); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error clearing canvas: {ex.Message}"); System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); - // In production, this should show an error dialog to the user - } - } - - private void RecreateInkCanvas() - { - try - { - // Fallback method: Recreate the InkCanvas control - // This is a last resort if we can't clear strokes directly - System.Diagnostics.Debug.WriteLine("Attempting to recreate InkCanvas control"); - - // Note: This method has limitations because MyInkCanvas is defined in XAML - // and we can't easily replace it. This is here as documentation of the fallback approach. - // In a real scenario, we would need to modify the XAML to make the canvas replaceable. - - System.Diagnostics.Debug.WriteLine("Canvas recreation not fully implemented - requires XAML changes"); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error recreating canvas: {ex.Message}"); } } - private async void SaveCanvas() + private async Task SaveCanvasAsync() { try { // Get canvas bounds var canvas = MyInkCanvas; var bounds = canvas.Bounds; - + // Validate canvas size if (bounds.Width <= 0 || bounds.Height <= 0) { await ShowErrorDialog("画布大小无效,无法保存截图。"); return; } - + // Create render target bitmap var pixelSize = new Avalonia.PixelSize((int)bounds.Width, (int)bounds.Height); var dpiVector = new Avalonia.Vector(96, 96); - var renderTarget = new Avalonia.Media.Imaging.RenderTargetBitmap(pixelSize, dpiVector); - + using var renderTarget = new Avalonia.Media.Imaging.RenderTargetBitmap(pixelSize, dpiVector); + // Render canvas to bitmap renderTarget.Render(canvas); - + // Save to file using CanvasService - var service = new Services.CanvasService(); - var filePath = await service.SaveCanvasToFileAsync(renderTarget); - + var filePath = await _canvasService.SaveCanvasToFileAsync(renderTarget); + // Show success notification await ShowSaveSuccessNotification(filePath); } catch (UnauthorizedAccessException ex) { System.Diagnostics.Debug.WriteLine($"Save failed due to permissions: {ex.Message}"); - await ShowErrorDialog("保存失败:没有权限写入文件。请检查文件夹权限。"); + try { await ShowErrorDialog("保存失败:没有权限写入文件。请检查文件夹权限。"); } catch { } } catch (IOException ex) { System.Diagnostics.Debug.WriteLine($"Save failed due to IO error: {ex.Message}"); - await ShowErrorDialog($"保存失败:{ex.Message}"); + try { await ShowErrorDialog($"保存失败:{ex.Message}"); } catch { } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Unexpected error during save: {ex.Message}"); System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); - await ShowErrorDialog($"保存时发生意外错误:{ex.Message}"); + try { await ShowErrorDialog($"保存时发生意外错误:{ex.Message}"); } catch { } } }