Browse Source

Add basic sfml ui

Equbuxu 2 năm trước cách đây
mục cha
commit
8c3264b54b

+ 35 - 3
src/PixiEditor.sln

@@ -32,11 +32,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PixiEditor.ChangeableDocume
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PixiEditor.Zoombox", "PixiEditor.Zoombox\PixiEditor.Zoombox.csproj", "{69DD5830-C682-49FB-B1A5-D2A506EEA06B}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.DrawingApi.Core", "PixiEditor.DrawingApi.Core\PixiEditor.DrawingApi.Core.csproj", "{5FC5E9C5-F439-43AA-92AF-9B7554D6FA13}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PixiEditor.DrawingApi.Core", "PixiEditor.DrawingApi.Core\PixiEditor.DrawingApi.Core.csproj", "{5FC5E9C5-F439-43AA-92AF-9B7554D6FA13}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditor.DrawingApi.Skia", "PixiEditor.DrawingApi.Skia\PixiEditor.DrawingApi.Skia.csproj", "{98040E8A-F08E-45F8-956F-6480C8272049}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PixiEditor.DrawingApi.Skia", "PixiEditor.DrawingApi.Skia\PixiEditor.DrawingApi.Skia.csproj", "{98040E8A-F08E-45F8-956F-6480C8272049}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PixiEditorGen", "PixiEditorGen\PixiEditorGen.csproj", "{1DC5B4C4-6902-4659-AE7E-17FDA0403DEB}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PixiEditorGen", "PixiEditorGen\PixiEditorGen.csproj", "{1DC5B4C4-6902-4659-AE7E-17FDA0403DEB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfmlUi", "SfmlUi\SfmlUi.csproj", "{18FEDB87-AB42-48C9-9FC3-D521855A078B}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -488,6 +490,36 @@ Global
 		{1DC5B4C4-6902-4659-AE7E-17FDA0403DEB}.Release|x64.Build.0 = Release|Any CPU
 		{1DC5B4C4-6902-4659-AE7E-17FDA0403DEB}.Release|x86.ActiveCfg = Release|Any CPU
 		{1DC5B4C4-6902-4659-AE7E-17FDA0403DEB}.Release|x86.Build.0 = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Debug|x64.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Debug|x86.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Dev Release|Any CPU.ActiveCfg = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Dev Release|Any CPU.Build.0 = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Dev Release|x64.ActiveCfg = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Dev Release|x64.Build.0 = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Dev Release|x86.ActiveCfg = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Dev Release|x86.Build.0 = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX Debug|Any CPU.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX Debug|x64.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX Debug|x64.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX Debug|x86.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX Debug|x86.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX|Any CPU.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX|x64.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX|x64.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX|x86.ActiveCfg = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.MSIX|x86.Build.0 = Debug|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Release|Any CPU.Build.0 = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Release|x64.ActiveCfg = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Release|x64.Build.0 = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Release|x86.ActiveCfg = Release|Any CPU
+		{18FEDB87-AB42-48C9-9FC3-D521855A078B}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 99 - 0
src/SfmlUi/ActionAccumulator.cs

@@ -0,0 +1,99 @@
+using System.Diagnostics;
+using System.Windows;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument.Actions;
+using PixiEditor.ChangeableDocument.Actions.Undo;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using SfmlUi.Rendering;
+
+namespace SfmlUi;
+
+internal class ActionAccumulator
+{
+    private bool executing = false;
+
+    private List<IAction> queuedActions = new();
+
+    private WriteableBitmapUpdater renderer;
+    private readonly DocumentViewModel document;
+
+    public ActionAccumulator(DocumentViewModel document)
+    {
+        Dictionary<ChunkResolution, DrawingSurface> docSurfaces = new()
+        {
+            [ChunkResolution.Full] = document.Textures[ChunkResolution.Full].Surface,
+            [ChunkResolution.Half] = document.Textures[ChunkResolution.Half].Surface,
+            [ChunkResolution.Quarter] = document.Textures[ChunkResolution.Quarter].Surface,
+            [ChunkResolution.Eighth] = document.Textures[ChunkResolution.Eighth].Surface,
+        };
+
+        renderer = new(docSurfaces, document);
+        this.document = document;
+    }
+
+    public void AddFinishedActions(params IAction[] actions)
+    {
+        queuedActions.AddRange(actions);
+        queuedActions.Add(new ChangeBoundary_Action());
+        TryExecuteAccumulatedActions();
+    }
+
+    public void AddActions(params IAction[] actions)
+    {
+        queuedActions.AddRange(actions);
+        TryExecuteAccumulatedActions();
+    }
+
+    private void TryExecuteAccumulatedActions()
+    {
+        if (executing || queuedActions.Count == 0)
+            return;
+        executing = true;
+
+        while (queuedActions.Count > 0)
+        {
+            // select actions to be processed
+            var toExecute = queuedActions;
+            queuedActions = new List<IAction>();
+
+            // pass them to changeabledocument for processing
+            List<IChangeInfo?> changes = document.Tracker.ProcessActionsSync(toExecute);
+
+            // update viewmodels based on changes
+            bool undoBoundaryPassed = toExecute.Any(static action => action is ChangeBoundary_Action or Redo_Action or Undo_Action);
+            //foreach (IChangeInfo? info in changes)
+            {
+                //internals.Updater.ApplyChangeFromChangeInfo(info);
+            }
+
+            // render changes
+            // update the contents of the bitmaps
+            var affectedChunks = new AffectedChunkGatherer(document.Tracker, changes);
+            var renderResult = renderer.Render(affectedChunks, document.Viewport?.VisibleArea ?? new RectD(), ChunkResolution.Full);
+            
+            // add dirty rectangles
+            AddDirtyRects(renderResult);
+
+            // update bitmaps
+            foreach (var (_, texture) in document.Textures)
+            {
+                texture.UpdateTextureFromBuffer();
+            }
+        }
+
+        executing = false;
+    }
+
+    private void AddDirtyRects(List<DirtyRect_RenderInfo> changes)
+    {
+        foreach (DirtyRect_RenderInfo renderInfo in changes)
+        {
+            var texture = document.Textures[renderInfo.Resolution];
+            RectI finalRect = new RectI(VecI.Zero, texture.Size);
+            RectI dirtyRect = new RectI(renderInfo.Pos, renderInfo.Size).Intersect(finalRect);
+            texture.AddDirtyRect(dirtyRect);
+        }
+    }
+}

+ 75 - 0
src/SfmlUi/BufferBackedTexture.cs

@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.ImageData;
+using SFML.Graphics;
+
+namespace SfmlUi;
+internal class BufferBackedTexture : IDisposable
+{
+    public Texture Texture { get; private set; }
+    public DrawingSurface Surface { get; private set; }
+    private nint buffer;
+    public VecI Size { get; }
+
+    private List<RectI> dirtyRects = new();
+
+    public BufferBackedTexture(VecI size)
+    {
+        using SFML.Graphics.Image tempImage = new((uint)size.X, (uint)size.Y);
+        Texture = new Texture(tempImage);
+        buffer = Marshal.AllocHGlobal(size.X * size.Y * 4);
+        Surface = DrawingSurface.Create(new ImageInfo(size.X, size.Y, ColorType.Rgba8888), buffer);
+        this.Size = size;
+    }
+
+    public void AddDirtyRect(RectI dirtyRect)
+    {
+        dirtyRects.Add(dirtyRect);
+    }
+
+    public unsafe void UpdateTextureFromBuffer()
+    {
+        RectI textureRect = new RectI(VecI.Zero, Size);
+        foreach (var rect in dirtyRects)
+        {
+            var fixedRect = rect.Intersect(textureRect);
+            if (fixedRect.IsZeroOrNegativeArea)
+                continue;
+
+            byte[] pixels = new byte[fixedRect.Width * fixedRect.Height * 4];
+
+            int w = fixedRect.Width * 4;
+            int h = fixedRect.Height;
+
+            for (int j = 0; j < h; j++)
+            {
+                for (int i = 0; i < w; i++)
+                {
+                    int globalX = i + fixedRect.X * 4;
+                    int globalY = j + fixedRect.Y;
+
+                    int posInBuffer = globalY * Size.X * 4 + globalX;
+                    int posInPixels = j * w + i;
+
+                    pixels[posInPixels] = *((byte*)buffer + posInBuffer);
+                }
+            }
+
+            Texture.Update(pixels, (uint)fixedRect.Width, (uint)fixedRect.Height, (uint)fixedRect.X, (uint)fixedRect.Y);
+        }
+        dirtyRects.Clear();
+    }
+
+    public void Dispose()
+    {
+        Texture.Dispose();
+        Surface.Dispose();
+        Marshal.FreeHGlobal(buffer);
+    }
+}

+ 54 - 0
src/SfmlUi/DocumentViewModel.cs

@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument;
+using PixiEditor.DrawingApi.Core.ColorsImpl;
+using PixiEditor.DrawingApi.Core.Numerics;
+using SFML.Graphics;
+
+namespace SfmlUi;
+internal class DocumentViewModel
+{
+    public Dictionary<ChunkResolution, BufferBackedTexture> Textures { get; private set; } = new();
+
+    public DocumentChangeTracker Tracker { get; private set; }
+    public ActionAccumulator ActionAccumulator { get; private set; }
+    public Viewport? Viewport { get; set; }
+    public VecI Size { get; }
+
+    public DocumentViewModel(VecI size)
+    {
+        Textures.Add(ChunkResolution.Full, CreateTexture(size));
+        Textures.Add(ChunkResolution.Half, CreateTexture((VecI)(size * ChunkResolution.Half.Multiplier())));
+        Textures.Add(ChunkResolution.Quarter, CreateTexture((VecI)(size * ChunkResolution.Quarter.Multiplier())));
+        Textures.Add(ChunkResolution.Eighth, CreateTexture((VecI)(size * ChunkResolution.Eighth.Multiplier())));
+
+        Tracker = new DocumentChangeTracker();
+        ActionAccumulator = new(this);
+
+        ActionAccumulator.AddFinishedActions(
+            new ResizeCanvas_Action(size, PixiEditor.ChangeableDocument.Enums.ResizeAnchor.TopLeft),
+            new CreateStructureMember_Action(Tracker.Document.StructureRoot.GuidValue, Guid.NewGuid(), 0, PixiEditor.ChangeableDocument.Enums.StructureMemberType.Layer)
+            );
+        Size = size;
+    }
+
+    public void Draw(VecI pos)
+    {
+        ActionAccumulator.AddActions(new LineBasedPen_Action(Tracker.Document.StructureRoot.Children[0].GuidValue, Colors.Red, pos, 1, false, false));
+    }
+
+    public void StopDrawing()
+    {
+        ActionAccumulator.AddFinishedActions(new EndLineBasedPen_Action());
+    }
+
+    private BufferBackedTexture CreateTexture(VecI size)
+    {
+        return new BufferBackedTexture(new(Math.Max(size.X, 1), Math.Max(size.Y, 1)));
+    }
+}

+ 1 - 0
src/SfmlUi/GlobalUsings.cs

@@ -0,0 +1 @@
+global using PixiEditor.ChangeableDocument.Actions.Generated;

+ 65 - 0
src/SfmlUi/Program.cs

@@ -0,0 +1,65 @@
+using PixiEditor.DrawingApi.Core.Bridge;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Skia;
+using SFML.Graphics;
+using SFML.Window;
+
+namespace SfmlUi;
+
+internal class Program
+{
+    const int WIDTH = 640;
+    const int HEIGHT = 480;
+    const string TITLE = "SfmlPixiEditor";
+
+    private static DocumentViewModel? doc;
+
+    static void Main(string[] args)
+    {
+        SkiaDrawingBackend skiaDrawingBackend = new SkiaDrawingBackend();
+        DrawingBackendApi.SetupBackend(skiaDrawingBackend);
+
+        VideoMode mode = new VideoMode(WIDTH, HEIGHT);
+        RenderWindow window = new RenderWindow(mode, TITLE);
+
+        window.SetVerticalSyncEnabled(true);
+        window.Closed += (sender, args) => window.Close();
+        
+        doc = new((512, 512));
+        Viewport viewport = new(window, doc);
+        doc.Viewport = viewport;
+
+        viewport.CanvasMouseDown += Viewport_CanvasMouseDown;
+        viewport.CanvasMouseMove += Viewport_CanvasMouseMove;
+        viewport.CanvasMouseUp += Viewport_CanvasMouseUp;
+
+        while (window.IsOpen)
+        {
+            window.DispatchEvents();
+            window.Clear(Color.Black);
+
+            viewport.Draw();
+
+            window.Display();
+        }
+    }
+
+    private static void Viewport_CanvasMouseUp(object? sender, PixiEditor.DrawingApi.Core.Numerics.VecD e)
+    {
+        drawing = false;
+        doc!.StopDrawing();
+    }
+
+    private static void Viewport_CanvasMouseMove(object? sender, PixiEditor.DrawingApi.Core.Numerics.VecD e)
+    {
+        if (drawing)
+            doc!.Draw((VecI)e);
+    }
+
+    private static bool drawing = false;
+    private static void Viewport_CanvasMouseDown(object? sender, PixiEditor.DrawingApi.Core.Numerics.VecD e)
+    {
+        drawing = true;
+        doc!.Draw((VecI)e);
+    }
+}

+ 117 - 0
src/SfmlUi/Rendering/AffectedChunkGatherer.cs

@@ -0,0 +1,117 @@
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.ChangeableDocument;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.ChangeInfos;
+using PixiEditor.ChangeableDocument.ChangeInfos.Drawing;
+using PixiEditor.ChangeableDocument.ChangeInfos.Properties;
+using PixiEditor.ChangeableDocument.ChangeInfos.Root;
+using PixiEditor.ChangeableDocument.ChangeInfos.Structure;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace SfmlUi.Rendering;
+#nullable enable
+internal class AffectedChunkGatherer
+{
+    private readonly DocumentChangeTracker tracker;
+
+    public HashSet<VecI> MainImageChunks { get; private set; } = new();
+    public Dictionary<Guid, HashSet<VecI>> ImagePreviewChunks { get; private set; } = new();
+    public Dictionary<Guid, HashSet<VecI>> MaskPreviewChunks { get; private set; } = new();
+
+    public AffectedChunkGatherer(DocumentChangeTracker tracker, IReadOnlyList<IChangeInfo?> changes)
+    {
+        this.tracker = tracker;
+        ProcessChanges(changes);
+    }
+
+    private void ProcessChanges(IReadOnlyList<IChangeInfo?> changes)
+    {
+        foreach (var change in changes)
+        {
+            switch (change)
+            {
+                case MaskChunks_ChangeInfo info:
+                    if (info.Chunks is null)
+                        throw new InvalidOperationException("Chunks must not be null");
+                    AddToMainImage(info.Chunks);
+                    break;
+                case LayerImageChunks_ChangeInfo info:
+                    if (info.Chunks is null)
+                        throw new InvalidOperationException("Chunks must not be null");
+                    AddToMainImage(info.Chunks);
+                    break;
+                case CreateStructureMember_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    break;
+                case DeleteStructureMember_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    break;
+                case MoveStructureMember_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    break;
+                case Size_ChangeInfo:
+                    AddWholeCanvasToMainImage();
+                    break;
+                case StructureMemberMask_ChangeInfo info:
+                    AddWholeCanvasToMainImage();
+                    break;
+                case StructureMemberBlendMode_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    break;
+                case StructureMemberClipToMemberBelow_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    break;
+                case StructureMemberOpacity_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    break;
+                case StructureMemberIsVisible_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue);
+                    break;
+                case StructureMemberMaskIsVisible_ChangeInfo info:
+                    AddAllToMainImage(info.GuidValue, false);
+                    break;
+            }
+        }
+    }
+
+    private void AddAllToMainImage(Guid memberGuid, bool useMask = true)
+    {
+        var member = tracker.Document.FindMember(memberGuid);
+        if (member is IReadOnlyLayer layer)
+        {
+            var chunks = layer.LayerImage.FindAllChunks();
+            if (layer.Mask is not null && layer.MaskIsVisible && useMask)
+                chunks.IntersectWith(layer.Mask.FindAllChunks());
+            AddToMainImage(chunks);
+        }
+        else
+        {
+            AddWholeCanvasToMainImage();
+        }
+    }
+
+    private void AddToMainImage(HashSet<VecI> chunks)
+    {
+        MainImageChunks.UnionWith(chunks);
+    }
+
+    private void AddWholeCanvasToMainImage()
+    {
+        AddAllChunks(MainImageChunks);
+    }
+
+    private void AddAllChunks(HashSet<VecI> chunks)
+    {
+        VecI size = new(
+            (int)Math.Ceiling(tracker.Document.Size.X / (float)ChunkyImage.FullChunkSize),
+            (int)Math.Ceiling(tracker.Document.Size.Y / (float)ChunkyImage.FullChunkSize));
+        for (int i = 0; i < size.X; i++)
+        {
+            for (int j = 0; j < size.Y; j++)
+            {
+                chunks.Add(new(i, j));
+            }
+        }
+    }
+}

+ 6 - 0
src/SfmlUi/Rendering/DirtyRect_RenderInfo.cs

@@ -0,0 +1,6 @@
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+
+namespace SfmlUi.Rendering;
+#nullable enable
+public record class DirtyRect_RenderInfo(VecI Pos, VecI Size, ChunkResolution Resolution);

+ 112 - 0
src/SfmlUi/Rendering/WriteableBitmapUpdater.cs

@@ -0,0 +1,112 @@
+using ChunkyImageLib;
+using ChunkyImageLib.DataHolders;
+using ChunkyImageLib.Operations;
+using PixiEditor.ChangeableDocument.Changeables.Interfaces;
+using PixiEditor.ChangeableDocument.Rendering;
+using PixiEditor.DrawingApi.Core.Numerics;
+using PixiEditor.DrawingApi.Core.Surface;
+using PixiEditor.DrawingApi.Core.Surface.PaintImpl;
+
+namespace SfmlUi.Rendering;
+#nullable enable
+internal class WriteableBitmapUpdater
+{
+    private static readonly Paint ReplacingPaint = new() { BlendMode = BlendMode.Src };
+    private static readonly Paint SmoothReplacingPaint = new() { BlendMode = BlendMode.Src, FilterQuality = FilterQuality.Medium, IsAntiAliased = true };
+    private static readonly Paint ClearPaint = new() { BlendMode = BlendMode.Src, Color = PixiEditor.DrawingApi.Core.ColorsImpl.Colors.Transparent };
+
+    /// <summary>
+    /// Chunks that have been updated but don't need to be re-rendered because they are out of view
+    /// </summary>
+    private readonly Dictionary<ChunkResolution, HashSet<VecI>> globalPostponedChunks = new()
+    {
+        [ChunkResolution.Full] = new(),
+        [ChunkResolution.Half] = new(),
+        [ChunkResolution.Quarter] = new(),
+        [ChunkResolution.Eighth] = new()
+    };
+    private readonly Dictionary<ChunkResolution, DrawingSurface> surfaces;
+    private readonly DocumentViewModel document;
+
+    public WriteableBitmapUpdater(Dictionary<ChunkResolution, DrawingSurface> images, DocumentViewModel document)
+    {
+        this.surfaces = images;
+        this.document = document;
+    }
+
+    private Dictionary<ChunkResolution, HashSet<VecI>> FindGlobalChunksToRerender(AffectedChunkGatherer chunkGatherer, RectD viewportRect, ChunkResolution viewportResolution)
+    {
+        foreach (var (_, postponed) in globalPostponedChunks)
+        {
+            postponed.UnionWith(chunkGatherer.MainImageChunks);
+        }
+
+        var chunksToUpdate = new Dictionary<ChunkResolution, HashSet<VecI>>() 
+        { 
+            [ChunkResolution.Full] = new(), 
+            [ChunkResolution.Half] = new(), 
+            [ChunkResolution.Quarter] = new(), 
+            [ChunkResolution.Eighth] = new() 
+        };
+
+        var viewportChunks = OperationHelper.FindChunksTouchingRectangle(
+            viewportRect.Center,
+            viewportRect.Size,
+            0,
+            ChunkResolution.Full.PixelSize());
+
+        chunksToUpdate[viewportResolution].UnionWith(viewportChunks);
+
+        // exclude the chunks that don't need to be updated, remove chunks that will be updated from postponed
+        foreach (var (res, postponed) in globalPostponedChunks)
+        {
+            chunksToUpdate[res].IntersectWith(postponed);
+            postponed.ExceptWith(chunksToUpdate[res]);
+        }
+
+        return chunksToUpdate;
+    }
+
+    public List<DirtyRect_RenderInfo> Render(AffectedChunkGatherer chunkGatherer, RectD viewportRect, ChunkResolution viewportResolution)
+    {
+        Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender = FindGlobalChunksToRerender(chunkGatherer, viewportRect, viewportResolution);
+
+        List<DirtyRect_RenderInfo> infos = new();
+        UpdateMainImage(chunksToRerender, infos);
+
+        return infos;
+    }
+
+    private void UpdateMainImage(Dictionary<ChunkResolution, HashSet<VecI>> chunksToRerender, List<DirtyRect_RenderInfo> infos)
+    {
+        foreach (var (resolution, chunks) in chunksToRerender)
+        {
+            int chunkSize = resolution.PixelSize();
+            DrawingSurface screenSurface = surfaces[resolution];
+            foreach (var chunkPos in chunks)
+            {
+                RenderChunk(chunkPos, screenSurface, resolution);
+                infos.Add(new DirtyRect_RenderInfo(
+                    chunkPos * chunkSize,
+                    new(chunkSize, chunkSize),
+                    resolution
+                ));
+            }
+        }
+    }
+
+    private void RenderChunk(VecI chunkPos, DrawingSurface screenSurface, ChunkResolution resolution)
+    {
+        ChunkRenderer.MergeWholeStructure(chunkPos, resolution, document.Tracker.Document.StructureRoot).Switch(
+            (Chunk chunk) =>
+            {
+                screenSurface.Canvas.DrawSurface(chunk.Surface.DrawingSurface, chunkPos.Multiply(chunk.PixelSize), ReplacingPaint);
+                chunk.Dispose();
+            },
+            (EmptyChunk _) =>
+            {
+                var pos = chunkPos * resolution.PixelSize();
+                screenSurface.Canvas.DrawRect(pos.X, pos.Y, resolution.PixelSize(), resolution.PixelSize(), ClearPaint);
+            });
+    }
+}

+ 20 - 0
src/SfmlUi/SfmlUi.csproj

@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net7.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="SFML.Net" Version="2.5.0" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\PixiEditor.ChangeableDocument\PixiEditor.ChangeableDocument.csproj" />
+    <ProjectReference Include="..\PixiEditor.DrawingApi.Skia\PixiEditor.DrawingApi.Skia.csproj" />
+  </ItemGroup>
+
+</Project>

+ 104 - 0
src/SfmlUi/Viewport.cs

@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using ChunkyImageLib.DataHolders;
+using PixiEditor.DrawingApi.Core.Numerics;
+using SFML.Graphics;
+using SFML.System;
+using SFML.Window;
+
+namespace SfmlUi;
+internal class Viewport
+{
+    public event EventHandler<VecD>? CanvasMouseDown;
+    public event EventHandler<VecD>? CanvasMouseMove;
+    public event EventHandler<VecD>? CanvasMouseUp;
+
+    private readonly RenderWindow window;
+    private readonly DocumentViewModel document;
+
+    private Dictionary<ChunkResolution, Sprite> sprites = new();
+
+    private Vector2f centerOnDragStart;
+    private Vector2i dragStartPos;
+    private bool isDragging = false;
+
+    private View view;
+
+    public RectD VisibleArea => RectD.FromCenterAndSize(new(view.Center.X, view.Center.Y), new(view.Size.X, view.Size.Y));
+    
+    public Viewport(RenderWindow window, DocumentViewModel document)
+    {
+        this.window = window;
+        this.document = document;
+
+        sprites.Add(ChunkResolution.Full, new Sprite(document.Textures[ChunkResolution.Full].Texture));
+        sprites.Add(ChunkResolution.Half, new Sprite(document.Textures[ChunkResolution.Half].Texture));
+        sprites.Add(ChunkResolution.Quarter, new Sprite(document.Textures[ChunkResolution.Quarter].Texture));
+        sprites.Add(ChunkResolution.Eighth, new Sprite(document.Textures[ChunkResolution.Eighth].Texture));
+
+        view = new View(window.DefaultView);
+
+        window.MouseMoved += MouseMove;
+        window.MouseButtonPressed += MouseDown;
+        window.MouseButtonReleased += MouseUp;
+        window.MouseWheelScrolled += MouseWheelScrolled;
+    }
+
+    private void MouseWheelScrolled(object? sender, MouseWheelScrollEventArgs e)
+    {
+        if (e.Delta < 0)
+            view.Zoom(1.1f);
+        else
+            view.Zoom(0.9f);
+    }
+
+    private void MouseUp(object? sender, MouseButtonEventArgs e)
+    {
+        if (e.Button == Mouse.Button.Left)
+        {
+            var pos = window.MapPixelToCoords(new(e.X, e.Y), view);
+            CanvasMouseUp?.Invoke(this, new(pos.X, pos.Y));
+            return;
+        }
+        isDragging = false;
+    }
+
+    private void MouseDown(object? sender, MouseButtonEventArgs e)
+    {
+        if (e.Button == Mouse.Button.Left)
+        {
+            var pos = window.MapPixelToCoords(new(e.X, e.Y), view);
+            CanvasMouseDown?.Invoke(this, new(pos.X, pos.Y));
+            return;
+        }
+        isDragging = true;
+        dragStartPos = new(e.X, e.Y);
+        centerOnDragStart = view.Center;
+    }
+
+    private void MouseMove(object? sender, MouseMoveEventArgs e)
+    {
+        Vector2i windowMousePos = new(e.X, e.Y);
+        Vector2f curPosOnCanvas = window.MapPixelToCoords(windowMousePos, view);
+
+        CanvasMouseMove?.Invoke(this, new(curPosOnCanvas.X, curPosOnCanvas.Y));
+        if (!isDragging)
+            return;
+
+        Vector2f startPosOnCavnas = window.MapPixelToCoords(dragStartPos, view);
+        Vector2f delta = curPosOnCanvas - startPosOnCavnas;
+
+        view.Center = centerOnDragStart - delta;
+    }
+
+    public void Draw()
+    {
+        window.SetView(view);
+
+        window.Draw(new RectangleShape(new Vector2f(document.Size.X, document.Size.Y)) { FillColor = Color.Yellow });
+        window.Draw(sprites[ChunkResolution.Full]);
+    }
+}