From 41cb06dcb5d273a9a08f465e2e54f6874d4b769b Mon Sep 17 00:00:00 2001 From: TZGyn Date: Sun, 23 Aug 2026 13:39:06 +0800 Subject: [PATCH] Add draw toolbar with thickness, undo/redo, and color picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When drawing is enabled a dedicated top bar shows a thickness slider, color picker, and undo/redo buttons (⌘Z / ⌘⇧Z). Strokes render and export with the chosen width and color. 💘 Generated with Crush Assisted-by: Crush:deepseek-v4-flash-0731 --- Sources/Kapture/ResultWindow.swift | 128 +++++++++++++++++++++++++---- build-app.sh | 4 +- 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift index bf2ab01..9524bda 100644 --- a/Sources/Kapture/ResultWindow.swift +++ b/Sources/Kapture/ResultWindow.swift @@ -92,10 +92,14 @@ final class CaptureModel: ObservableObject { @Published var selection: CGRect? /// Freehand drawing strokes in IMAGE point space @Published var strokes: [[CGPoint]] = [] + /// Redo stack of removed strokes + @Published var redoStack: [[CGPoint]] = [] /// Whether the pen is active (drag draws instead of selecting) @Published var isDrawing = false /// Current drawing color @Published var drawColor: NSColor = .systemRed + /// Stroke thickness in points (image space) + @Published var strokeWidth: CGFloat = 3.0 enum CaptureMode { case screenshot @@ -107,6 +111,23 @@ final class CaptureModel: ObservableObject { } var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 } + var canUndo: Bool { !strokes.isEmpty } + var canRedo: Bool { !redoStack.isEmpty } + + func undoStroke() { + guard let last = strokes.popLast() else { return } + redoStack.append(last) + } + + func redoStroke() { + guard let last = redoStack.popLast() else { return } + strokes.append(last) + } + + func clearAnnotations() { + strokes = [] + redoStack = [] + } /// Image with drawn strokes flattened in, for copy/save/export. var annotatedImage: CGImage { @@ -123,7 +144,7 @@ final class CaptureModel: ObservableObject { // CGImage draws with a flipped Y in NSGraphicsContext ctx.draw(image, in: NSRect(origin: .zero, size: size)) drawColor.setStroke() - ctx.setLineWidth(3) + ctx.setLineWidth(strokeWidth) ctx.setLineCap(.round) ctx.setLineJoin(.round) for stroke in strokes { @@ -142,10 +163,7 @@ final class CaptureModel: ObservableObject { func addStroke(_ points: [CGPoint]) { guard points.count > 1 else { return } strokes.append(points) - } - - func clearAnnotations() { - strokes = [] + redoStack = [] } var displayText: String { @@ -297,15 +315,37 @@ struct ResultView: View { .help("Drawing color") } - if !model.strokes.isEmpty { - Button { - model.clearAnnotations() - } label: { - Label("Clear", systemImage: "eraser") + if model.isDrawing || !model.strokes.isEmpty { + HStack(spacing: 6) { + Button { + model.undoStroke() + } label: { + Image(systemName: "arrow.uturn.backward") + } + .buttonStyle(.bordered) + .disabled(!model.canUndo) + .keyboardShortcut("z", modifiers: .command) + .help("Undo last stroke (⌘Z)") + + Button { + model.redoStroke() + } label: { + Image(systemName: "arrow.uturn.forward") + } + .buttonStyle(.bordered) + .disabled(!model.canRedo) + .keyboardShortcut("z", modifiers: [.command, .shift]) + .help("Redo stroke (⌘⇧Z)") + + Button { + model.clearAnnotations() + } label: { + Image(systemName: "eraser") + } + .buttonStyle(.bordered) + .disabled(model.strokes.isEmpty) + .help("Remove all drawn annotations") } - .buttonStyle(.bordered) - .foregroundStyle(.secondary) - .help("Remove all drawn annotations") } Button { @@ -338,6 +378,64 @@ struct ResultView: View { Divider() + // Draw toolbar (only when drawing) + if model.isDrawing { + HStack(spacing: 14) { + Label("Draw", systemImage: "pencil.tip") + .font(.caption) + .foregroundStyle(.secondary) + + Divider().frame(height: 18) + + HStack(spacing: 6) { + Image(systemName: "circle.fill").font(.system(size: 4)) + Image(systemName: "circle.fill").font(.system(size: 7)) + Image(systemName: "circle.fill").font(.system(size: 10)) + } + .foregroundStyle(.secondary) + Slider(value: $model.strokeWidth, in: 1...12, step: 0.5) + .frame(width: 120) + .help("Stroke thickness") + + Divider().frame(height: 18) + + ColorPicker("", selection: Binding( + get: { Color(nsColor: model.drawColor) }, + set: { model.drawColor = NSColor($0) } + ), supportsOpacity: false) + .labelsHidden() + .frame(width: 28) + .help("Drawing color") + + Spacer() + + Button { + model.undoStroke() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!model.canUndo) + .help("Undo last stroke (⌘Z)") + + Button { + model.redoStroke() + } label: { + Label("Redo", systemImage: "arrow.uturn.forward") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!model.canRedo) + .help("Redo stroke (⌘⇧Z)") + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.bar) + + Divider() + } + // Content if model.mode == .screenshot { ScreenshotPane(model: model) @@ -441,7 +539,7 @@ struct ScreenshotPane: View { path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale)) } } - context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: 2.5) + context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth) } .allowsHitTesting(false) @@ -455,7 +553,7 @@ struct ScreenshotPane: View { path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale)) } } - context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: 2.5) + context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth) } .allowsHitTesting(false) } diff --git a/build-app.sh b/build-app.sh index dc7aa22..d19934a 100755 --- a/build-app.sh +++ b/build-app.sh @@ -108,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST' CFBundleIconFile Icon CFBundleShortVersionString - 1.3.3 + 1.4.0 CFBundleVersion - 33 + 40 LSUIElement NSHighResolutionCapable