diff options
Diffstat (limited to 'menubar/panel.swift')
| -rw-r--r-- | menubar/panel.swift | 123 |
1 files changed, 106 insertions, 17 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift index 5347ceb..35494e0 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -313,9 +313,17 @@ final class PanelRowView: ClickView { self.onClick = { onClick(row.paneId) } let title = singleLine(NSTextField( labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext))) - let placeLabel = NSTextField(labelWithString: row.place) - placeLabel.font = .systemFont(ofSize: 11) - placeLabel.textColor = mochaSubtext + // A failed run's "failed 3m · why" in red. + let small = NSFont.systemFont(ofSize: 11) + let placeText = NSMutableAttributedString( + string: row.placeBase, attributes: [.font: small, .foregroundColor: mochaSubtext]) + if let failure = row.failure { + placeText.append(NSAttributedString(string: " · ", attributes: [.font: small, .foregroundColor: mochaSubtext])) + placeText.append(NSAttributedString(string: failure, attributes: [.font: small, .foregroundColor: mochaRed])) + } + let placeLabel = NSTextField(labelWithAttributedString: placeText) + // Its own font too, or it lays out as 13 pt (the colors stay). + placeLabel.font = small let place = singleLine(placeLabel) let cost = NSTextField(labelWithString: row.cost >= 0.005 ? dollars(row.cost) : "") cost.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) @@ -464,9 +472,23 @@ private final class ToggleView: ClickView { override func hoverChanged() { label.textColor = hovered ? mochaText : mochaSubtext } } +private extension Optional where Wrapped == CGFloat { + var orZero: CGFloat { self ?? 0 } +} + +/// A document view whose content starts at the top. +private final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + final class PanelController { private let panel: NSPanel + /// The list (offer, rows, overflow), which scrolls once the panel would + /// reach below the screen; the summary line stays under it. private let stack = NSStackView() + private let scroll = NSScrollView() + private let summary = NSStackView() + private var scrollHeight: NSLayoutConstraint! private let onClick: (String) -> Void private let onMarkUnread: (String) -> Void private let onToggleFull: () -> Void @@ -498,15 +520,39 @@ final class PanelController { let effect = darkHUD() panel.contentView = effect - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 0 - stack.translatesAutoresizingMaskIntoConstraints = false - effect.addSubview(stack) + for column in [stack, summary] { + column.orientation = .vertical + column.alignment = .leading + column.spacing = 0 + column.translatesAutoresizingMaskIntoConstraints = false + } + let document = FlippedView() + document.translatesAutoresizingMaskIntoConstraints = false + document.addSubview(stack) + scroll.documentView = document + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + scroll.translatesAutoresizingMaskIntoConstraints = false + effect.addSubview(scroll) + effect.addSubview(summary) + scrollHeight = scroll.heightAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset), - stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset), - stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset), + scroll.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset), + scroll.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset), + scroll.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset), + scrollHeight, + document.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor), + document.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor), + document.topAnchor.constraint(equalTo: scroll.contentView.topAnchor), + stack.leadingAnchor.constraint(equalTo: document.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: document.trailingAnchor), + stack.topAnchor.constraint(equalTo: document.topAnchor), + stack.bottomAnchor.constraint(equalTo: document.bottomAnchor), + summary.leadingAnchor.constraint(equalTo: scroll.leadingAnchor), + summary.trailingAnchor.constraint(equalTo: scroll.trailingAnchor), + summary.topAnchor.constraint(equalTo: scroll.bottomAnchor), ]) // Remember where the user put it; first time, top right of the screen. @@ -527,9 +573,11 @@ final class PanelController { guard text != rendered else { return false } rendered = text - stack.arrangedSubviews.forEach { view in - stack.removeArrangedSubview(view) - view.removeFromSuperview() + for column in [stack, summary] { + column.arrangedSubviews.forEach { view in + column.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) @@ -544,17 +592,34 @@ final class PanelController { string: "+\(content.overflow) more waiting, see the menu bar", attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } - if content.showsSummary { add(summaryLine(content), fullWidth: true) } + if content.showsSummary { + let line = summaryLine(content) + summary.addArrangedSubview(line) + line.widthAnchor.constraint(equalTo: summary.widthAnchor).isActive = true + } - // Grow or shrink downward from the top edge. + // Grow or shrink downward from the top edge, down to the bottom of + // the screen; past that the list scrolls. stack.layoutSubtreeIfNeeded() - let height = stack.fittingSize.height + inset * 2 + summary.layoutSubtreeIfNeeded() + let list = stack.arrangedSubviews.isEmpty ? 0 : stack.fittingSize.height + let below = summary.arrangedSubviews.isEmpty ? 0 : summary.fittingSize.height let top = panel.frame.maxY + let room = top - (panel.screen ?? NSScreen.main).map { $0.visibleFrame.minY + 16 }.orZero + // At least a few rows, wherever the panel was dragged. + let shown = max(min(list, 150), min(list, room - below - inset * 2)) + scrollHeight.constant = shown + let height = shown + below + inset * 2 panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height), display: true) + listScrolls = shown < list return true } + /// Whether the list is taller than the panel shows (tests). + private(set) var listScrolls = false + var frame: NSRect { panel.frame } + private func add(_ view: NSView, fullWidth: Bool = false) { stack.addArrangedSubview(view) if fullWidth { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } @@ -777,6 +842,30 @@ func panelSelftest() -> [String] { check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + // A long list scrolls instead of running off the screen. + _ = NSApplication.shared + if let area = NSScreen.main?.visibleFrame { + let panel = PanelController(persistFrame: false, onClick: { _ in }, onToggleFull: {}) + panel.render(PanelContent(rows: (0..<80).map { row("long\($0)", .idle) }, full: true, canToggle: true)) + check(panel.frame.minY >= area.minY && panel.frame.maxY <= area.maxY && panel.listScrolls, + "panel: 80 rows stay on screen and scroll (\(panel.frame) in \(area))") + panel.render(PanelContent(rows: (0..<3).map { row("short\($0)", .idle) }, full: true, canToggle: true)) + check(!panel.listScrolls && panel.frame.height < 300, "panel: 3 rows shown whole (\(panel.frame))") + } else { + check(false, "panel: no screen to measure against") + } + + // A failed run. + var broken = row("b", .done) + broken.error = "401: invalid x-api-key" + broken.cost = 1.5 + check(broken.failed && broken.place.hasPrefix("s · b · pi · failed ") && broken.place.hasSuffix(" · 401: invalid x-api-key"), + "failed: in place of the wait, with why") + check(broken.subtitle.hasSuffix("401: invalid x-api-key · $1.50"), "failed: the menu's text, then the cost") + var seen = row("b", .idle) + seen.error = broken.error + check(!seen.failed && seen.place == "s · b · pi", "failed: once seen, plain idle") + // Context use. var filling = row("f", .idle) filling.cost = 3.12 |
