From 7d2da0f703e080371d626f2739151dec1743b125 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 16 Jun 2026 22:59:42 -0700 Subject: [PATCH] =?UTF-8?q?shotwizard:=20full-width=20drag-and-drop=20thum?= =?UTF-8?q?bnail=20filmstrip=20(all=20shots=20visible,=20drag=20a=20tile?= =?UTF-8?q?=20to=20reorder)=20replacing=20the=20=E2=97=80/=E2=96=B6=20reor?= =?UTF-8?q?der=20buttons;=20add=20=E2=80=B9=20prev=20/=20next=20=E2=80=BA?= =?UTF-8?q?=20browse=20buttons=20in=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ShotWizard/ShotWizardController.swift | 97 +++++-------- .../Sources/ShotWizard/ThumbStripView.swift | 129 ++++++++++++++++++ 2 files changed, 162 insertions(+), 64 deletions(-) create mode 100644 shot-wizard/Sources/ShotWizard/ThumbStripView.swift diff --git a/shot-wizard/Sources/ShotWizard/ShotWizardController.swift b/shot-wizard/Sources/ShotWizard/ShotWizardController.swift index 4f96dce7fe..000544b154 100644 --- a/shot-wizard/Sources/ShotWizard/ShotWizardController.swift +++ b/shot-wizard/Sources/ShotWizard/ShotWizardController.swift @@ -18,15 +18,15 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { var titleLabel: NSTextField! var totalLabel: NSTextField! var playAllButton: NSButton! + var navPrevButton: NSButton! // browse ‹ (select previous shot) + var navNextButton: NSButton! // browse › (select next shot) var playerView: ScrubPlayerView! var stillView: NSImageView! var player: AVPlayer? var loopObserver: NSObjectProtocol? var stripScroll: NSScrollView! - var stripStack: NSStackView! - var moveLeftButton: NSButton! - var moveRightButton: NSButton! + var strip: ThumbStripView! // sidebar var idLabel: NSTextField! @@ -94,6 +94,10 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { let last = board.shots.last?.t1 ?? 0 totalLabel.stringValue = String(format: "%d shots · %.0fs", board.shots.count, last) playAllButton = button("▶ play all", #selector(playAll)) + // Browse buttons — step the SELECTION through the shots (same as the + // ←/→ arrow keys; non-destructive, never reorder). + navPrevButton = button("‹ prev", #selector(navPrev)) + navNextButton = button("next ›", #selector(navNext)) playerView = ScrubPlayerView(frame: .zero) stillView = NSImageView(frame: .zero) @@ -103,13 +107,12 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { stillView.layer?.cornerRadius = 8 stillView.isHidden = true - stripStack = NSStackView(frame: .zero) - stripStack.orientation = .horizontal - stripStack.alignment = .centerY - stripStack.spacing = 8 - stripStack.edgeInsets = NSEdgeInsets(top: 6, left: 6, bottom: 6, right: 6) + // The filmstrip: a thumbnail per shot, click to select, DRAG to + // reorder. Lives inside a horizontal scroll view. + strip = ThumbStripView(frame: .zero) + strip.controller = self stripScroll = NSScrollView(frame: .zero) - stripScroll.documentView = stripStack + stripScroll.documentView = strip stripScroll.hasHorizontalScroller = true stripScroll.hasVerticalScroller = false stripScroll.drawsBackground = false @@ -117,13 +120,6 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { stripScroll.layer?.cornerRadius = 8 stripScroll.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor - // These REORDER the sequence (they shift the selected shot in the - // running order). Plain ←/→ arrow keys just NAVIGATE between shots - // (non-destructive) — so the buttons say "reorder" to avoid being - // mistaken for next/prev. - moveLeftButton = button("◀ reorder", #selector(moveShotLeft)) - moveRightButton = button("reorder ▶", #selector(moveShotRight)) - idLabel = label(15, .semibold) laneLabel = label(11, .semibold) laneLabel.textColor = .systemTeal @@ -169,8 +165,8 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { jobLog.lineBreakMode = .byTruncatingHead let views: [NSView] = [ - titleLabel, totalLabel, playAllButton, playerView, stillView, - stripScroll, moveLeftButton, moveRightButton, idLabel, laneLabel, + titleLabel, totalLabel, playAllButton, navPrevButton, navNextButton, playerView, stillView, + stripScroll, idLabel, laneLabel, timeLabel, voScroll, sourceThumb, promptScroll, statusLabel, genButton, assembleButton, jobProgress, jobLog, ] @@ -210,19 +206,19 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { let W = cv.bounds.width, H = cv.bounds.height let pad: CGFloat = 16 - // header + // header: title (left) · [‹ prev][next ›] · total · ▶ play all (right) let headerY = H - 42 - titleLabel.frame = NSRect(x: pad, y: headerY, width: W - 2 * pad - 220, height: 28) + let navW: CGFloat = 64 playAllButton.frame = NSRect(x: W - pad - 110, y: headerY, width: 110, height: 28) - totalLabel.frame = NSRect(x: W - pad - 110 - 150 - 8, y: headerY + 4, width: 150, height: 20) + navNextButton.frame = NSRect(x: W - pad - 110 - 8 - navW, y: headerY, width: navW, height: 28) + navPrevButton.frame = NSRect(x: navNextButton.frame.minX - 4 - navW, y: headerY, width: navW, height: 28) + totalLabel.frame = NSRect(x: navPrevButton.frame.minX - 8 - 130, y: headerY + 4, width: 130, height: 20) + titleLabel.frame = NSRect(x: pad, y: headerY, width: max(80, totalLabel.frame.minX - 2 * pad), height: 28) - // bottom strip + move buttons + // bottom filmstrip — full width (drag a tile to reorder) let stripY = pad - let moveW: CGFloat = 78 - moveLeftButton.frame = NSRect(x: pad, y: stripY + (stripH - 26) / 2, width: moveW, height: 26) - moveRightButton.frame = NSRect(x: W - pad - moveW, y: stripY + (stripH - 26) / 2, width: moveW, height: 26) - stripScroll.frame = NSRect(x: pad + moveW + 8, y: stripY, - width: W - 2 * (pad + moveW + 8), height: stripH) + stripScroll.frame = NSRect(x: pad, y: stripY, width: W - 2 * pad, height: stripH) + strip?.reload() // right sidebar let sx = W - pad - sidebarW @@ -260,29 +256,7 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { } // ── strip ───────────────────────────────────────────────────────── - func buildStrip() { - stripStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - for (i, shot) in board.shots.enumerated() { - let b = NSButton(frame: NSRect(x: 0, y: 0, width: 150, height: 92)) - b.imagePosition = .imageOnly - b.bezelStyle = .regularSquare - b.isBordered = false - b.image = thumbnail(for: shot) - b.imageScaling = .scaleProportionallyUpOrDown - b.tag = i - b.target = self - b.action = #selector(stripClicked(_:)) - b.toolTip = "\(i + 1). \(shot.id) · \(shot.lane.rawValue) · \(shot.effectiveStatus)" - b.wantsLayer = true - b.layer?.cornerRadius = 6 - b.layer?.masksToBounds = true - b.layer?.borderWidth = i == sel ? 3 : 1.5 - b.layer?.borderColor = borderColor(for: shot, selected: i == sel).cgColor - b.widthAnchor.constraint(equalToConstant: 150).isActive = true - b.heightAnchor.constraint(equalToConstant: 92).isActive = true - stripStack.addArrangedSubview(b) - } - } + func buildStrip() { strip?.reload() } func borderColor(for shot: Shot, selected: Bool) -> NSColor { if selected { return .systemYellow } @@ -411,21 +385,16 @@ final class ShotWizardController: NSWindowController, NSWindowDelegate { vp.play() } - // ── reorder (the core "line up the sequence" gesture) ───────────── - @objc func moveShotLeft() { - guard sel > 0 else { return } - board.move(from: sel, to: sel - 1) - sel -= 1 - buildStrip(); select(sel) - status("moved ◀ — sequence saved") - } + // ── browse (non-destructive: just move the selection) ───────────── + @objc func navPrev() { select(max(0, sel - 1)) } + @objc func navNext() { select(min(board.shots.count - 1, sel + 1)) } - @objc func moveShotRight() { - guard sel < board.shots.count - 1 else { return } - board.move(from: sel, to: sel + 1) - sel += 1 - buildStrip(); select(sel) - status("moved ▶ — sequence saved") + // ── reorder (drag-and-drop in the filmstrip) ────────────────────── + func reorder(from: Int, to: Int) { + guard from != to, board.shots.indices.contains(from), board.shots.indices.contains(to) else { return } + board.move(from: from, to: to) + select(to) // select() rebuilds the strip + preview + status("reordered — sequence saved") } // ── generate / assemble via the board driver ────────────────────── diff --git a/shot-wizard/Sources/ShotWizard/ThumbStripView.swift b/shot-wizard/Sources/ShotWizard/ThumbStripView.swift new file mode 100644 index 0000000000..446f2e148f --- /dev/null +++ b/shot-wizard/Sources/ShotWizard/ThumbStripView.swift @@ -0,0 +1,129 @@ +// ThumbStripView — the bottom filmstrip: every shot as a thumbnail tile in +// running order, the selected one ringed. Click a tile to select it; DRAG a +// tile to reorder the sequence (drag-and-drop replaces the old ◀/▶ reorder +// buttons). Portrait tiles (the reel is 9:16). Lives as the documentView of +// the strip scroll view so it scrolls when there are more tiles than fit. +import AppKit + +final class ThumbStripView: NSView { + weak var controller: ShotWizardController? + private let inset: CGFloat = 10 + private let gap: CGFloat = 10 + + private var dragFrom: Int? = nil + private var dragging = false + private var downX: CGFloat = 0 + private var curX: CGFloat = 0 + + override var isFlipped: Bool { true } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + private var count: Int { controller?.board.shots.count ?? 0 } + private var tileH: CGFloat { max(40, bounds.height - inset * 2) } + private var tileW: CGFloat { tileH * 9.0 / 16.0 } // portrait reel + private var stride: CGFloat { tileW + gap } + + /// Total content width so the enclosing scroll view can scroll. + var contentWidth: CGFloat { inset * 2 + CGFloat(max(1, count)) * stride - gap } + + private func tileRect(_ i: Int) -> NSRect { + NSRect(x: inset + CGFloat(i) * stride, y: inset, width: tileW, height: tileH) + } + private func indexAt(_ p: NSPoint) -> Int? { + for i in 0.. Int { + let s = Int(((x - inset) / stride).rounded()) + return max(0, min(count - 1, s)) + } + + func reload() { + frame = NSRect(x: 0, y: 0, width: max(contentWidth, superview?.bounds.width ?? contentWidth), + height: superview?.bounds.height ?? bounds.height) + needsDisplay = true + } + + override func draw(_ dirty: NSRect) { + guard let c = controller else { return } + for (i, shot) in c.board.shots.enumerated() { + if dragging && i == dragFrom { continue } // hide the lifted tile in place + drawTile(i, shot: shot, rect: tileRect(i), c: c) + } + // the lifted tile floats under the cursor, drawn last (on top) + if dragging, let di = dragFrom, c.board.shots.indices.contains(di) { + var r = tileRect(di); r.origin.x = curX - tileW / 2 + // insertion marker + let slot = slotForX(curX) + let markX = inset + CGFloat(slot) * stride - gap / 2 + NSColor.systemYellow.setFill() + NSBezierPath(rect: NSRect(x: markX - 1.5, y: inset, width: 3, height: tileH)).fill() + drawTile(di, shot: c.board.shots[di], rect: r, c: c, floating: true) + } + } + + private func drawTile(_ i: Int, shot: Shot, rect r: NSRect, c: ShotWizardController, floating: Bool = false) { + let img = c.thumbnail(for: shot) + NSGraphicsContext.saveGraphicsState() + if floating { + let sh = NSShadow(); sh.shadowColor = NSColor.black.withAlphaComponent(0.6) + sh.shadowBlurRadius = 12; sh.shadowOffset = NSSize(width: 0, height: -3); sh.set() + } + let clip = NSBezierPath(roundedRect: r, xRadius: 6, yRadius: 6) + clip.addClip() + // aspect-FILL the portrait tile + let iw = img.size.width, ih = img.size.height + var dr = r + if iw > 0 && ih > 0 { + let scale = max(r.width / iw, r.height / ih) + let w = iw * scale, h = ih * scale + dr = NSRect(x: r.midX - w / 2, y: r.midY - h / 2, width: w, height: h) + } + img.draw(in: dr, from: .zero, operation: .copy, fraction: 1.0) + NSGraphicsContext.restoreGraphicsState() + // ring + let ring = NSBezierPath(roundedRect: r.insetBy(dx: 1.5, dy: 1.5), xRadius: 6, yRadius: 6) + (i == c.sel ? NSColor.systemYellow : c.borderColor(for: shot, selected: false)).setStroke() + ring.lineWidth = i == c.sel ? 3 : 1.5 + ring.stroke() + // index badge + let badge = "\(i + 1)" + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 11, weight: .bold), + .foregroundColor: NSColor.white, + ] + let bg = NSRect(x: r.minX + 4, y: r.minY + 4, width: 18, height: 16) + NSColor.black.withAlphaComponent(0.55).setFill() + NSBezierPath(roundedRect: bg, xRadius: 3, yRadius: 3).fill() + badge.draw(at: NSPoint(x: r.minX + 8, y: r.minY + 4), withAttributes: attrs) + } + + override func mouseDown(with e: NSEvent) { + let p = convert(e.locationInWindow, from: nil) + downX = p.x; curX = p.x + dragFrom = indexAt(p) + dragging = false + } + override func mouseDragged(with e: NSEvent) { + guard dragFrom != nil else { return } + let p = convert(e.locationInWindow, from: nil) + if !dragging && abs(p.x - downX) < 6 { return } // small threshold → it's a click + dragging = true; curX = p.x + // auto-scroll the enclosing clip view when dragging near an edge + if let clip = superview as? NSClipView { + let vis = clip.documentVisibleRect + if p.x > vis.maxX - 30 { clip.scroll(to: NSPoint(x: min(frame.width - vis.width, vis.minX + 24), y: 0)) } + else if p.x < vis.minX + 30 { clip.scroll(to: NSPoint(x: max(0, vis.minX - 24), y: 0)) } + } + needsDisplay = true + } + override func mouseUp(with e: NSEvent) { + let p = convert(e.locationInWindow, from: nil) + defer { dragging = false; dragFrom = nil; needsDisplay = true } + guard let di = dragFrom else { return } + if !dragging { controller?.select(di); return } // a click → select + let to = slotForX(p.x) + if to != di { controller?.reorder(from: di, to: to) } + } +} -- 2.54.0