Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions Sources/StableBar/BarEditDrag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ struct BarEditDropTarget<Content: View>: NSViewRepresentable {
let displayID: Int
let position: WidgetPosition
let frames: [String: CGRect]
/// The tray's +, which shapes its rows.
let addSlot: CGRect
@ViewBuilder let content: () -> Content

func makeNSView(context: Context) -> BarEditDropTargetView {
let view = BarEditDropTargetView(rootView: AnyView(content()))
let view = BarEditDropTargetView(rootView: AnyView(content()), sizingOptions: [])
view.registerForDraggedTypes([BarEditDragSourceView.pasteboardType])
return view
}
Expand All @@ -110,19 +112,25 @@ struct BarEditDropTarget<Content: View>: NSViewRepresentable {
view.displayID = displayID
view.position = position
view.frames = frames
view.addSlot = addSlot.isNull ? nil : addSlot
}

func sizeThatFits(_ proposal: ProposedViewSize, nsView: BarEditDropTargetView, context: Context) -> CGSize? {
CGSize(width: proposal.width ?? nsView.fittingSize.width, height: proposal.height ?? nsView.fittingSize.height)
}
}

/// A tray takes the size the edit bar's layout gives it. Its widgets on one row are often wider, so
/// neither the tray nor its content reports their size to Auto Layout, which would otherwise stretch
/// the tray past the screen's edge.
final class BarEditDropTargetView: WidgetHostingView {
override var isFlipped: Bool { true }
override var intrinsicContentSize: NSSize { NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) }
weak var editing: BarEditing?
var displayID = 0
var position = WidgetPosition.left
var frames: [String: CGRect] = [:]
var addSlot: CGRect?

override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) }

Expand Down Expand Up @@ -160,8 +168,9 @@ final class BarEditDropTargetView: WidgetHostingView {
}

private func target(_ sender: any NSDraggingInfo, payload: WidgetDragSession.Payload) -> BarEditInsertion? {
editing?.dropTarget(payload.widgetID, on: displayID, position: position,
x: convert(sender.draggingLocation, from: nil).x, frames: frames)
let location = convert(sender.draggingLocation, from: nil)
return editing?.dropTarget(payload.widgetID, on: displayID, position: position, x: location.x, y: location.y,
frames: frames, addSlot: addSlot)
}

private func acceptedPayload(_ sender: any NSDraggingInfo) -> WidgetDragSession.Payload? {
Expand Down
52 changes: 52 additions & 0 deletions Sources/StableBar/BarEditFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import SwiftUI

/// Lays out a tray's widgets left to right, continuing on a new row when the next one doesn't fit the
/// proposed width, so a crowded position keeps every widget in reach while editing. Without a width, as
/// in the measuring copy, everything stays on one row. Each row centers its widgets vertically, like the bar.
struct BarEditFlow: Layout {
var rowSpacing: CGFloat

/// The items on each row. An item wider than `width` gets a row of its own; an item without width,
/// such as a widget collapsed while it's dragged elsewhere, stays on the row before it.
static func rows(_ widths: [CGFloat], fitting width: CGFloat?) -> [Range<Int>] {
var rows: [Range<Int>] = []
var start = 0
var used: CGFloat = 0
for (index, itemWidth) in widths.enumerated() {
if let width, index > start, itemWidth > 0, used + itemWidth > width + 0.5 {
rows.append(start..<index)
start = index
used = 0
}
used += itemWidth
}
if start < widths.count { rows.append(start..<widths.count) }
return rows
}

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
let rows = Self.rows(sizes.map(\.width), fitting: Self.width(of: proposal))
let width = rows.map { sizes[$0].reduce(0) { $0 + $1.width } }.max() ?? 0
let height = rows.reduce(0) { $0 + (sizes[$1].map(\.height).max() ?? 0) } + CGFloat(max(rows.count - 1, 0)) * rowSpacing
return CGSize(width: width, height: height)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
var y = bounds.minY
for row in Self.rows(sizes.map(\.width), fitting: Self.width(of: proposal)) {
let height = sizes[row].map(\.height).max() ?? 0
var x = bounds.minX
for index in row {
subviews[index].place(at: CGPoint(x: x, y: y + height / 2), anchor: .leading, proposal: ProposedViewSize(sizes[index]))
x += sizes[index].width
}
y += height + rowSpacing
}
}

private static func width(of proposal: ProposedViewSize) -> CGFloat? {
proposal.width.flatMap { $0.isFinite ? $0 : nil }
}
}
38 changes: 0 additions & 38 deletions Sources/StableBar/BarEditScrollWheel.swift

This file was deleted.

121 changes: 56 additions & 65 deletions Sources/StableBar/BarEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,38 @@ import SwiftUI
struct BarEditView: View {
let store: BarStore
let display: BarDisplay
/// Each position's row as one line, from the measuring copy: what the layout gives room to.
@State private var measuredSizes: [WidgetPosition: CGSize] = [:]
/// Each tray's height at its width, once it has laid its widgets out on as many rows as they need.
@State private var trayHeights: [WidgetPosition: CGFloat] = [:]
@Environment(\.accessibilityReduceMotion) private var reduceMotion

var body: some View {
if let config = store.configuration?.onScreen(ScreenKind(display)), let geometry = store.barEditing.geometry(for: display) {
let entries = store.barEditing.entries(on: display)
let scale = CGFloat(config.bar.scale(for: display))
// Trays sit centered on the bar's line, as they do on one row; a tray that wraps grows down from there,
// and the bar grows to the bottom of the lowest tray.
let line: CGFloat = max(config.bar.height * scale - 2 * config.bar.margin,
(measuredSizes.values.map(\.height).max() ?? 0) * scale) / scale
let tops = Dictionary(uniqueKeysWithValues: WidgetPosition.allCases.map { position in
(position, max(0, (line - (measuredSizes[position]?.height ?? line)) / 2))
})
let heights = Dictionary(uniqueKeysWithValues: WidgetPosition.allCases.map { position -> (WidgetPosition, CGFloat) in
(position, max(trayHeights[position] ?? 0, measuredSizes[position]?.height ?? line))
})
let bottom = WidgetPosition.allCases.map { tops[$0]! + heights[$0]! }.max() ?? 0
GeometryReader { proxy in
let widths = measuredSizes.mapValues { $0.width * scale }
let layout = BarEditLayout(width: proxy.size.width, display: display, bar: config.bar, preferredWidths: widths)
ZStack(alignment: .topLeading) {
ForEach(WidgetPosition.allCases, id: \.self) { position in
if let section = layout.sections[position] {
let height = min(proxy.size.height / scale, measuredSizes[position]?.height ?? proxy.size.height / scale)
BarEditSection(store: store, display: display, position: position, entries: entries, config: config,
scrolling: layout.scrolling.contains(position),
contentWidth: measuredSizes[position]?.width ?? 0, viewportWidth: section.width / scale,
adding: store.barEditing.addPopover.displayID == display.id
&& store.barEditing.addPopover.position == position)
.frame(width: section.width / scale, height: height)
.offset(x: section.x / scale, y: (proxy.size.height / scale - height) / 2)
&& store.barEditing.addPopover.position == position) { trayHeights[position] = $0 }
.frame(width: section.width / scale, height: heights[position]!)
.offset(x: section.x / scale, y: tops[position]!)
}
}
}
Expand Down Expand Up @@ -61,7 +72,7 @@ struct BarEditView: View {
.onChange(of: Set(entries.map(\.id)), initial: true) { _, ids in
store.setVisibleWidgets(ids: ids, displayID: display.id)
}
.onChange(of: (measuredSizes.values.map(\.height).max() ?? 0) * scale, initial: true) { _, height in
.onChange(of: bottom * scale, initial: true) { _, height in
store.barEditing.measure(height: height, on: display.id)
}
}
Expand All @@ -78,9 +89,14 @@ private struct BarEditFrames: PreferenceKey {
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue()) { $1 } }
}

private struct BarEditRowBounds: PreferenceKey {
private struct BarEditAddSlotFrame: PreferenceKey {
static let defaultValue = CGRect.null
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = nextValue() }
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = value.union(nextValue()) }
}

private struct BarEditRowHeight: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) }
}

private struct BarEditSection: View {
Expand All @@ -89,65 +105,40 @@ private struct BarEditSection: View {
let position: WidgetPosition
let entries: [BarEditWidget]
let config: Configuration
let scrolling: Bool
let contentWidth: CGFloat
let viewportWidth: CGFloat
let adding: Bool
/// Reports the tray's height at the width it was given.
let measured: (CGFloat) -> Void
@State private var frames: [String: CGRect] = [:]
@State private var rowBounds = CGRect.null
@State private var addSlot = CGRect.null
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var editing: BarEditing { store.barEditing }

var body: some View {
let state = editing.trayState(on: display.id, position: position)
let overflow = BarEditOverflow(bounds: rowBounds, viewportWidth: viewportWidth)
let surface = BarEditTraySurface(barStyle: store.barStyle(on: ScreenKind(display)), config: config, colorScheme: colorScheme)
let shape = RoundedRectangle(cornerRadius: 9)
BarEditDropTarget(editing: editing, displayID: display.id, position: position, frames: frames) {
ScrollView(.horizontal) {
BarEditSectionRow(store: store, display: display, position: position, entries: entries, config: config,
adding: adding)
.background {
GeometryReader { proxy in
Color.clear.preference(key: BarEditRowBounds.self, value: proxy.frame(in: .named("editSection")))
}
}
.overlay { BarEditScrollWheel(contentWidth: contentWidth, viewportWidth: viewportWidth).allowsHitTesting(scrolling) }
}
.scrollIndicators(.hidden)
.scrollDisabled(!scrolling)
.mask {
HStack(spacing: 0) {
if overflow.leading {
LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing).frame(width: 18)
}
Rectangle().fill(.black)
if overflow.trailing {
LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing).frame(width: 18)
BarEditDropTarget(editing: editing, displayID: display.id, position: position, frames: frames, addSlot: addSlot) {
BarEditSectionRow(store: store, display: display, position: position, entries: entries, config: config,
adding: adding)
.background {
GeometryReader { proxy in
Color.clear.preference(key: BarEditRowHeight.self, value: proxy.size.height)
}
}
}
.overlay {
HStack {
if overflow.leading { overflowEdge(.leading) }
Spacer(minLength: 0)
if overflow.trailing { overflowEdge(.trailing) }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.clipped()
.background(surface.tint, in: shape)
.background(state.isTarget ? Color.accentColor.opacity(0.16) : .clear, in: shape)
.background(surface.backing, in: shape)
.overlay {
shape.strokeBorder(state.isTarget ? Color.accentColor : surface.hairline, lineWidth: state.isTarget ? 2 : 0.5)
}
.environment(\.colorScheme, surface.scheme)
.allowsHitTesting(false)
}
.help(scrolling ? "Scroll to see more widgets. Use the mouse wheel or swipe sideways." : "")
.background(surface.tint, in: shape)
.background(state.isTarget ? Color.accentColor.opacity(0.16) : .clear, in: shape)
.background(surface.backing, in: shape)
.overlay {
shape.strokeBorder(state.isTarget ? Color.accentColor : surface.hairline, lineWidth: state.isTarget ? 2 : 0.5)
}
.animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: state.isTarget)
.coordinateSpace(name: "editSection")
.onPreferenceChange(BarEditFrames.self) { frames = $0 }
.onPreferenceChange(BarEditRowBounds.self) { rowBounds = $0 }
.animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: state.isTarget)
.coordinateSpace(name: "editSection")
.onPreferenceChange(BarEditFrames.self) { frames = $0 }
.onPreferenceChange(BarEditAddSlotFrame.self) { addSlot = $0 }
.onPreferenceChange(BarEditRowHeight.self) { if $0 > 0 { measured($0) } }
}
.overlay(alignment: .topLeading) {
Text(position.title).font(.system(size: 9, weight: .bold)).lineLimit(1)
Expand All @@ -166,13 +157,6 @@ private struct BarEditSection: View {
.accessibilityElement(children: .contain)
.accessibilityLabel(position.title)
}

private func overflowEdge(_ edge: HorizontalEdge) -> some View {
Image(systemName: edge == .leading ? "chevron.left" : "chevron.right")
.font(.system(size: 9, weight: .bold)).foregroundStyle(.secondary)
.frame(width: 18)
.accessibilityHidden(true)
}
}

/// Trays and their chrome follow the surface they sit on. A tray tints the theme's strip when the
Expand Down Expand Up @@ -236,7 +220,7 @@ struct BarEditSectionRow: View {
var body: some View {
let editing = store.barEditing
let leadingSlot = position == .right || position == .notchLeft
HStack(spacing: 0) {
BarEditFlow(rowSpacing: BarEditTrayMetrics.rowSpacing) {
if leadingSlot { addSlot }
let units = BarUnit.make(widgets: editing.arranged(entries, on: display.id, position: position).map(\.widget),
groups: config.groups)
Expand All @@ -263,7 +247,7 @@ struct BarEditSectionRow: View {
}
.animation(reduceMotion ? nil : .reorder, value: editing.insertion)
.frame(minHeight: BarEditTrayMetrics.slot)
.fixedSize()
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, BarEditTrayMetrics.horizontalPadding)
.padding(.vertical, BarEditTrayMetrics.verticalPadding)
}
Expand All @@ -286,7 +270,14 @@ struct BarEditSectionRow: View {
.opacity(store.barEditing.trayState(on: display.id, position: position).showsAddSlot ? 1 : 0)
.allowsHitTesting(store.barEditing.trayState(on: display.id, position: position).showsAddSlot)
.accessibilityHidden(!store.barEditing.trayState(on: display.id, position: position).showsAddSlot)
.background { if !measuring { BarEditAddAnchor(view: anchor) } }
.background {
if !measuring {
BarEditAddAnchor(view: anchor)
GeometryReader { proxy in
Color.clear.preference(key: BarEditAddSlotFrame.self, value: proxy.frame(in: .named("editSection")))
}
}
}
.help("Add a widget to \(position.title)")
.accessibilityLabel("Add a widget to \(position.title)")
}
Expand Down
Loading
Loading