[object Object]

← back to Ticket System

Make desktop ticket bar tiles interactive

bffd86b79314a314639d8ca954beb58f0e9a44b0 · 2026-09-02 14:36:19 -0700 · Steve Abrams

Files touched

Diff

commit bffd86b79314a314639d8ca954beb58f0e9a44b0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 14:36:19 2026 -0700

    Make desktop ticket bar tiles interactive
---
 desktop-bar/TicketBar.swift | 134 +++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 126 insertions(+), 8 deletions(-)

diff --git a/desktop-bar/TicketBar.swift b/desktop-bar/TicketBar.swift
index 94cbc446..76c11dc8 100644
--- a/desktop-bar/TicketBar.swift
+++ b/desktop-bar/TicketBar.swift
@@ -11,11 +11,54 @@ private struct TicketState {
   var updated = ""
 }
 
+private final class BarTile: NSView {
+  let key: String
+  let label: NSTextField
+  var menuProvider: (() -> NSMenu)?
+  var dragHandler: ((String, CGFloat) -> Void)?
+  private var dragged = false
+  var widthConstraint: NSLayoutConstraint!
+
+  init(key: String, label: NSTextField, width: CGFloat) {
+    self.key = key
+    self.label = label
+    super.init(frame: .zero)
+    wantsLayer = true
+    layer?.cornerRadius = 6
+    layer?.backgroundColor = NSColor(calibratedWhite: 0.11, alpha: 0.9).cgColor
+    label.translatesAutoresizingMaskIntoConstraints = false
+    addSubview(label)
+    NSLayoutConstraint.activate([
+      label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 9),
+      label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -9),
+      label.centerYAnchor.constraint(equalTo: centerYAnchor)
+    ])
+    widthConstraint = widthAnchor.constraint(equalToConstant: width)
+    widthConstraint.isActive = true
+    toolTip = "Drag to reorder · click for options"
+  }
+
+  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+  override func mouseDown(with event: NSEvent) { dragged = false }
+  override func mouseDragged(with event: NSEvent) {
+    dragged = true
+    dragHandler?(key, event.locationInWindow.x)
+  }
+  override func mouseUp(with event: NSEvent) {
+    guard !dragged, let menu = menuProvider?() else { return }
+    menu.popUp(positioning: nil, at: NSPoint(x: 4, y: bounds.height - 2), in: self)
+  }
+}
+
 private final class BarController: NSObject, NSApplicationDelegate {
   private var panel: NSPanel!
   private let counts = NSTextField(labelWithString: "Tickets · loading…")
   private let working = NSTextField(labelWithString: "Working · —")
   private let latest = NSTextField(labelWithString: "Latest · —")
+  private var stack: NSStackView!
+  private var tiles: [BarTile] = []
+  private var workingDetails: [String] = []
+  private var latestDetail = "No activity yet"
   private var timer: Timer?
 
   func applicationDidFinishLaunching(_ notification: Notification) {
@@ -35,9 +78,6 @@ private final class BarController: NSObject, NSApplicationDelegate {
     panel.hasShadow = true
     panel.hidesOnDeactivate = false
 
-    let open = NSButton(title: "Open Fleet ↗", target: self, action: #selector(openViewer))
-    open.bezelStyle = .inline
-    open.contentTintColor = .white
     for label in [counts, working, latest] {
       label.textColor = .white
       label.font = .systemFont(ofSize: 12, weight: label === counts ? .bold : .medium)
@@ -47,20 +87,96 @@ private final class BarController: NSObject, NSApplicationDelegate {
     working.textColor = NSColor(calibratedRed: 0.45, green: 0.78, blue: 1, alpha: 1)
     latest.textColor = NSColor(calibratedWhite: 0.72, alpha: 1)
 
-    let stack = NSStackView(views: [counts, divider(), working, divider(), latest, open])
+    let savedWidths = UserDefaults.standard.dictionary(forKey: "ticketBarWidths") as? [String: CGFloat] ?? [:]
+    let definitions: [(String, NSTextField, CGFloat)] = [
+      ("counts", counts, 285), ("working", working, 620), ("latest", latest, 560)
+    ]
+    let savedOrder = UserDefaults.standard.stringArray(forKey: "ticketBarOrder") ?? definitions.map(\.0)
+    let byKey = Dictionary(uniqueKeysWithValues: definitions.map { ($0.0, $0) })
+    let ordered = savedOrder.compactMap { byKey[$0] } + definitions.filter { !savedOrder.contains($0.0) }
+    tiles = ordered.map { key, label, defaultWidth in
+      let tile = BarTile(key: key, label: label, width: savedWidths[key] ?? defaultWidth)
+      tile.dragHandler = { [weak self] key, x in self?.moveTile(key, toward: x) }
+      tile.menuProvider = { [weak self] in self?.menu(for: key) ?? NSMenu() }
+      return tile
+    }
+    let open = NSButton(title: "Open Fleet ↗", target: self, action: #selector(openViewer))
+    open.bezelStyle = .inline
+    open.contentTintColor = .white
+    stack = NSStackView(views: tiles + [open])
     stack.orientation = .horizontal
-    stack.spacing = 12
+    stack.spacing = 6
     stack.alignment = .centerY
     stack.edgeInsets = NSEdgeInsets(top: 0, left: 14, bottom: 0, right: 10)
-    counts.setContentHuggingPriority(.required, for: .horizontal)
-    working.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
-    latest.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+    for tile in tiles { tile.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) }
     open.setContentHuggingPriority(.required, for: .horizontal)
     panel.contentView = stack
     reposition()
     panel.orderFrontRegardless()
   }
 
+  private func moveTile(_ key: String, toward windowX: CGFloat) {
+    guard let source = tiles.firstIndex(where: { $0.key == key }) else { return }
+    var target = source
+    for (index, tile) in tiles.enumerated() where tile.key != key {
+      let mid = tile.convert(tile.bounds, to: nil).midX
+      if windowX > mid { target = index }
+    }
+    if windowX < tiles.first?.convert(tiles.first!.bounds, to: nil).midX ?? 0 { target = 0 }
+    guard target != source else { return }
+    let tile = tiles.remove(at: source)
+    target = min(target, tiles.count)
+    tiles.insert(tile, at: target)
+    stack.removeArrangedSubview(tile)
+    tile.removeFromSuperview()
+    stack.insertArrangedSubview(tile, at: target)
+    UserDefaults.standard.set(tiles.map(\.key), forKey: "ticketBarOrder")
+  }
+
+  private func menu(for key: String) -> NSMenu {
+    let menu = NSMenu(title: key)
+    let details: [String]
+    if key == "counts" {
+      details = [counts.stringValue.replacingOccurrences(of: "   ", with: "  •  ")]
+    } else if key == "working" {
+      details = workingDetails.isEmpty ? ["No tickets currently marked doing"] : workingDetails
+    } else {
+      details = [latestDetail]
+    }
+    for detail in details.prefix(12) {
+      let item = NSMenuItem(title: detail, action: nil, keyEquivalent: "")
+      item.isEnabled = false
+      menu.addItem(item)
+    }
+    menu.addItem(.separator())
+    for (title, action) in [("Move Left", #selector(moveLeft(_:))), ("Move Right", #selector(moveRight(_:))), ("Wider", #selector(wider(_:))), ("Narrower", #selector(narrower(_:)))] {
+      let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
+      item.target = self
+      item.representedObject = key
+      menu.addItem(item)
+    }
+    return menu
+  }
+
+  @objc private func moveLeft(_ sender: NSMenuItem) { shift(sender.representedObject as? String, by: -1) }
+  @objc private func moveRight(_ sender: NSMenuItem) { shift(sender.representedObject as? String, by: 1) }
+  private func shift(_ key: String?, by delta: Int) {
+    guard let key, let index = tiles.firstIndex(where: { $0.key == key }) else { return }
+    let target = max(0, min(tiles.count - 1, index + delta))
+    guard target != index else { return }
+    let tile = tiles.remove(at: index)
+    tiles.insert(tile, at: target)
+    stack.removeArrangedSubview(tile); tile.removeFromSuperview(); stack.insertArrangedSubview(tile, at: target)
+    UserDefaults.standard.set(tiles.map(\.key), forKey: "ticketBarOrder")
+  }
+  @objc private func wider(_ sender: NSMenuItem) { resize(sender.representedObject as? String, by: 80) }
+  @objc private func narrower(_ sender: NSMenuItem) { resize(sender.representedObject as? String, by: -80) }
+  private func resize(_ key: String?, by delta: CGFloat) {
+    guard let tile = tiles.first(where: { $0.key == key }) else { return }
+    tile.widthConstraint.constant = max(180, min(900, tile.widthConstraint.constant + delta))
+    UserDefaults.standard.set(Dictionary(uniqueKeysWithValues: tiles.map { ($0.key, $0.widthConstraint.constant) }), forKey: "ticketBarWidths")
+  }
+
   private func divider() -> NSView {
     let view = NSView(frame: NSRect(x: 0, y: 0, width: 1, height: 18))
     view.wantsLayer = true
@@ -109,6 +225,8 @@ private final class BarController: NSObject, NSApplicationDelegate {
           self.counts.stringValue = "Tickets  OPEN \(open)   BLOCKED \(blocked)   DOING \(doing.count)"
           self.working.stringValue = "Working · \(workText.isEmpty ? "none" : workText)"
           self.latest.stringValue = "Latest · \(latestText)"
+          self.workingDetails = doing.map { "\(self.shortID($0.id)) · \($0.title)" }
+          self.latestDetail = latestText
           self.panel.orderFrontRegardless()
         }
       } catch {

← c73300b3 Record desktop ticket bar proof  ·  back to Ticket System  ·  Route ticket tile clicks to menus 84f666e5 →