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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Sources/Stag/Views/Editor/BoundedHistory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

/// Bounded undo/redo history for editor state. Owns two stacks of snapshots and
/// the push/cap/clear-redo and pop/swap rules that previously lived as loose
/// `@State` arrays inside `EditorView`. Recording a new snapshot clears the redo
/// stack; `undo`/`redo` swap the supplied "current" snapshot across the stacks
/// and return the snapshot the caller should apply (or `nil`).
struct BoundedHistory<Snapshot> {
private(set) var undoStack: [Snapshot] = []
private(set) var redoStack: [Snapshot] = []
let limit: Int

init(limit: Int = 100) { self.limit = limit }

var canUndo: Bool { !undoStack.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }

/// Pushes a snapshot onto the undo stack (capped at `limit`) and clears redo.
mutating func record(_ snapshot: Snapshot) {
undoStack.append(snapshot)
if undoStack.count > limit {
undoStack.removeFirst(undoStack.count - limit)
}
redoStack.removeAll()
}

/// Pops the previous snapshot, pushing `current` onto the redo stack. Returns
/// the snapshot to apply, or `nil` when there is nothing to undo.
mutating func undo(current: Snapshot) -> Snapshot? {
guard let previous = undoStack.popLast() else { return nil }
redoStack.append(current)
return previous
}

/// Pops the next redo snapshot, pushing `current` onto the undo stack. Returns
/// the snapshot to apply, or `nil` when there is nothing to redo.
mutating func redo(current: Snapshot) -> Snapshot? {
guard let next = redoStack.popLast() else { return nil }
undoStack.append(current)
return next
}
}

/// The editor's annotation-canvas undo/redo history.
typealias CanvasHistory = BoundedHistory<CanvasState>
42 changes: 0 additions & 42 deletions Sources/Stag/Views/Editor/CanvasHistory.swift

This file was deleted.

19 changes: 7 additions & 12 deletions Sources/Stag/Views/Editor/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ struct EditorView: View {
@State private var rotation: CGFloat = 0

// Image undo stack (for destructive edits)
@State private var imageUndoStack: [NSImage] = []
@State private var imageRedoStack: [NSImage] = []
@State private var imageHistory = BoundedHistory<NSImage>(limit: 20)
@State private var cloneStampSource: CGPoint?
@State private var removeBgRunning = false
@State private var eraserSwipePoints: [CGPoint] = []
Expand Down Expand Up @@ -1230,16 +1229,13 @@ struct EditorView: View {
}

private func pushUndoImage() {
imageUndoStack.append(workingImage)
if imageUndoStack.count > 20 { imageUndoStack.removeFirst() }
imageRedoStack.removeAll()
imageHistory.record(workingImage)
pushUndo()
}

private func undo() {
if !imageUndoStack.isEmpty {
imageRedoStack.append(workingImage)
workingImage = imageUndoStack.removeLast()
if let image = imageHistory.undo(current: workingImage) {
workingImage = image
}
let current = CanvasState(annotations: annotations, currentTool: currentTool, selectedAnnotationId: selectedAnnotationId, rotation: rotation)
guard let state = canvasHistory.undo(current: current) else { return }
Expand All @@ -1250,9 +1246,8 @@ struct EditorView: View {
}

private func redo() {
if !imageRedoStack.isEmpty {
imageUndoStack.append(workingImage)
workingImage = imageRedoStack.removeLast()
if let image = imageHistory.redo(current: workingImage) {
workingImage = image
}
let current = CanvasState(annotations: annotations, currentTool: currentTool, selectedAnnotationId: selectedAnnotationId, rotation: rotation)
guard let state = canvasHistory.redo(current: current) else { return }
Expand Down Expand Up @@ -1853,7 +1848,7 @@ struct EditorView: View {

/// True when the user has made any change worth persisting.
private var hasEdits: Bool {
!annotations.isEmpty || rotation != 0 || backdrop.isActive || !imageUndoStack.isEmpty
!annotations.isEmpty || rotation != 0 || backdrop.isActive || imageHistory.canUndo
}

/// Writes the edited image back to its source file synchronously (for ⌘S and safety-net onDisappear).
Expand Down
46 changes: 46 additions & 0 deletions Tests/StagTests/BoundedHistoryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import XCTest
@testable import Stag

/// CanvasHistoryTests already exercises the history rules via the
/// `CanvasHistory = BoundedHistory<CanvasState>` typealias. These tests pin the
/// generic behavior with a value type other than CanvasState (mirroring the
/// image-undo use, BoundedHistory<NSImage>) so the generalization can't regress.
final class BoundedHistoryTests: XCTestCase {

func testGenericRecordUndoRedoRoundTrip() {
var h = BoundedHistory<Int>()
h.record(1)
XCTAssertTrue(h.canUndo)
XCTAssertEqual(h.undo(current: 2), 1) // returns previous, 2 -> redo
XCTAssertFalse(h.canUndo)
XCTAssertTrue(h.canRedo)
XCTAssertEqual(h.redo(current: 1), 2) // returns swapped, 1 -> undo
XCTAssertTrue(h.canUndo)
}

func testRecordingClearsRedo() {
var h = BoundedHistory<Int>()
h.record(1)
_ = h.undo(current: 2)
XCTAssertTrue(h.canRedo)
h.record(3)
XCTAssertFalse(h.canRedo)
}

func testEmptyReturnsNil() {
var h = BoundedHistory<Int>()
XCTAssertNil(h.undo(current: 0))
XCTAssertNil(h.redo(current: 0))
}

func testCapDropsOldest() {
var h = BoundedHistory<Int>(limit: 20)
for i in 1...25 { h.record(i) } // retains 6...25
var popped: [Int] = []
var current = 999
while let v = h.undo(current: current) { popped.append(v); current = v }
XCTAssertEqual(popped.count, 20)
XCTAssertEqual(popped.first, 25) // most recent first
XCTAssertEqual(popped.last, 6) // 1...5 were dropped
}
}
Loading