diff options
Diffstat (limited to 'menubar/panel.swift')
| -rw-r--r-- | menubar/panel.swift | 128 |
1 files changed, 123 insertions, 5 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift index 003a061..6ab85f2 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -36,13 +36,15 @@ struct PanelVisibility: Equatable { var enabled = true var policy: PanelPolicy = .whenWaiting var anyWaiting = false + /// tmux stopped and its sessions can be restored. + var restoreOffered = false /// Turning the panel on with nothing to show shows it briefly, so the /// shortcut visibly did something. var peekUntil: Date? func visible(at now: Date) -> Bool { guard enabled else { return false } - if policy == .always || anyWaiting { return true } + if policy == .always || anyWaiting || restoreOffered { return true } return peekUntil.map { now < $0 } ?? false } @@ -84,7 +86,15 @@ struct PanelContent { /// Whether the panel offers the compact/full toggle (shown always). let canToggle: Bool - init(rows all: [Row], full: Bool = false, canToggle: Bool = false) { + /// Sessions to restore, shown first. + let offer: RestoreOffer? + /// A restore is running. + let restoring: Bool + + init(rows all: [Row], full: Bool = false, canToggle: Bool = false, + offer: RestoreOffer? = nil, restoring: Bool = false) { + self.offer = offer + self.restoring = restoring let waiting = all.filter(\.waiting) if full { rows = all @@ -127,14 +137,28 @@ struct PanelContent { var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" } + /// The offer row's title: what restore will do, or that it runs. + var offerTitle: String? { + guard let offer else { return nil } + return restoring ? "Restoring \(offer.sessions.count) \(offer.sessions.count == 1 ? "session" : "sessions")…" : offer.title + } + + /// The summary line is noise when tmux is gone and only the offer shows. + var showsSummary: Bool { offer == nil || working + idle + rows.count > 0 } + var plainText: String { var lines: [String] = [] + if let offer, let offerTitle { + lines.append("↺ \(offerTitle)\t\(offer.detail)" + (restoring ? "" : "\t[Not now]")) + } for (index, row) in rows.enumerated() { if index == separatorBefore { lines.append("---") } lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") } if overflow > 0 { lines.append("+\(overflow) more waiting") } - lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) + if showsSummary { + lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) + } return lines.joined(separator: "\n") } } @@ -272,6 +296,43 @@ final class PanelRowView: ClickView { } } +/// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was. +private final class OfferView: ClickView { + init(title: String, detail: String, onClick: @escaping () -> Void) { + super.init(frame: .zero) + self.onClick = onClick + let font = NSFont.menuFont(ofSize: 0) + let text = NSMutableAttributedString(string: "↺ ", attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .bold), .foregroundColor: mochaBlue, + ]) + text.append(NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: mochaText])) + let titleLabel = singleLine(NSTextField(labelWithAttributedString: text)) + let detailLabel = NSTextField(labelWithString: detail) + detailLabel.font = .systemFont(ofSize: 11) + detailLabel.textColor = mochaSubtext + let stack = NSStackView(views: [titleLabel, singleLine(detailLabel)]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 1 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 5), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), + ]) + } + + required init?(coder: NSCoder) { fatalError("not used") } + + override func draw(_ dirtyRect: NSRect) { + guard hovered else { return } + NSColor.white.withAlphaComponent(0.08).setFill() + NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() + } +} + /// "Show all ▾" / "Show less ▴". private final class ToggleView: ClickView { private let label: NSTextField @@ -302,11 +363,16 @@ final class PanelController { private let stack = NSStackView() private let onClick: (String) -> Void private let onToggleFull: () -> Void + private let onRestore: () -> Void + private let onDismissRestore: () -> Void private var rendered: String? - init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void) { + init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void, + onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) { self.onClick = onClick self.onToggleFull = onToggleFull + self.onRestore = onRestore + self.onDismissRestore = onDismissRestore panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) @@ -356,6 +422,10 @@ final class PanelController { stack.removeArrangedSubview(view) view.removeFromSuperview() } + if let offer = content.offer, let title = content.offerTitle { + add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true) + if content.showsSummary || !content.rows.isEmpty { add(separatorLine()) } + } for (index, row) in content.rows.enumerated() { if index == content.separatorBefore { add(separatorLine()) } add(PanelRowView(row: row, onClick: onClick), fullWidth: true) @@ -365,7 +435,7 @@ final class PanelController { string: "+\(content.overflow) more waiting, see the menu bar", attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } - add(summaryLine(content), fullWidth: true) + if content.showsSummary { add(summaryLine(content), fullWidth: true) } // Grow or shrink downward from the top edge. stack.layoutSubtreeIfNeeded() @@ -413,6 +483,34 @@ final class PanelController { return box } + /// The offer row, with "Not now" beside it (a row takes every click + /// inside it, so the link cannot be inside). + private func offerLine(title: String, detail: String, restoring: Bool) -> NSView { + let box = NSView() + let row = OfferView(title: title, detail: detail, onClick: restoring ? {} : onRestore) + row.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(row) + var constraints = [ + row.leadingAnchor.constraint(equalTo: box.leadingAnchor), + row.topAnchor.constraint(equalTo: box.topAnchor), + row.bottomAnchor.constraint(equalTo: box.bottomAnchor), + ] + if restoring { + constraints.append(row.trailingAnchor.constraint(equalTo: box.trailingAnchor)) + } else { + let dismiss = ToggleView(text: "Not now", onClick: onDismissRestore) + dismiss.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(dismiss) + constraints += [ + dismiss.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), + dismiss.centerYAnchor.constraint(equalTo: box.centerYAnchor), + row.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -8), + ] + } + NSLayoutConstraint.activate(constraints) + return box + } + /// Summary on the left, the compact/full toggle on the right. private func summaryLine(_ content: PanelContent) -> NSView { let box = caption(content.summary) @@ -511,6 +609,12 @@ func panelSelftest() -> [String] { check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting), "migration: when waiting") + var offered = PanelVisibility(enabled: true, policy: .whenWaiting) + offered.restoreOffered = true + check(offered.visible(at: t0), "a restore offer shows the panel") + offered.toggle(at: t0) + check(!offered.visible(at: at(10)), "off: a restore offer does not show it") + check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting") check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen") check(!panelShowsFull(policy: .always, prefersFull: false), "layout: compact in always by default") @@ -529,5 +633,19 @@ func panelSelftest() -> [String] { "full: no separator without waiting agents") check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + + let offer = RestoreOffer(saved_at: 0, sessions: [ + .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0), + ], agents: 2, interrupted: 1) + let afterReboot = PanelContent(rows: [], offer: offer) + check(afterReboot.plainText.hasPrefix("↺ Restore 2 sessions · 2 agents\t") + && afterReboot.plainText.contains("1 was mid-turn") && afterReboot.plainText.hasSuffix("[Not now]"), + "offer: first, with what it brings back and a way to dismiss it") + check(!afterReboot.plainText.contains("No agents"), "offer: no empty summary when tmux is gone") + let running = PanelContent(rows: [], offer: offer, restoring: true) + check(running.plainText.hasPrefix("↺ Restoring 2 sessions…") && !running.plainText.contains("Not now"), + "offer: while restoring, no dismiss") + check(PanelContent(rows: many, offer: offer).plainText.contains("○ 1 idle"), + "offer: the summary stays when there are agents") return failures } |
