From 61528de14fb1adb5f47e944e6435f0ba381566ca Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Wed, 4 Nov 2026 10:48:23 +0100 Subject: [PATCH 1/4] fix: wrap a crowded position onto more rows while editing the bar in place A position with more widgets than fit ran past the screen edge, as on the right of a notched display. Its drop target reported the widgets' one-row width to Auto Layout, which outranked the width SwiftUI gave it, so the tray grew past the screen and its scroll view had nothing to scroll. Trays now take the width the layout gives them and lay their widgets out in rows, and the bar grows to the lowest tray while editing. Drops follow the row under the pointer. The sideways scroll, its fades and arrows are gone, and a new scene draws a crowded edit bar. Fixes #428 --- Sources/StableBar/BarEditDrag.swift | 10 +- Sources/StableBar/BarEditFlow.swift | 52 ++++++++++ Sources/StableBar/BarEditScrollWheel.swift | 38 ------- Sources/StableBar/BarEditView.swift | 105 ++++++++------------ Sources/StableBar/BarEditing.swift | 31 ++---- Sources/StableBar/SceneRenderer.swift | 10 +- Sources/StableBar/WidgetDragView.swift | 3 +- Sources/StableBar/WidgetPlacement.swift | 29 +++++- Tests/StableBarTests/BarEditViewTests.swift | 72 ++++++++------ Tests/StableBarTests/BarEditingTests.swift | 74 +++++++++----- docs/settings.md | 2 +- scenes/crowded-edit-bar.json | 25 +++++ 12 files changed, 257 insertions(+), 194 deletions(-) create mode 100644 Sources/StableBar/BarEditFlow.swift delete mode 100644 Sources/StableBar/BarEditScrollWheel.swift create mode 100644 scenes/crowded-edit-bar.json diff --git a/Sources/StableBar/BarEditDrag.swift b/Sources/StableBar/BarEditDrag.swift index fdedd3ad..0c884eb7 100644 --- a/Sources/StableBar/BarEditDrag.swift +++ b/Sources/StableBar/BarEditDrag.swift @@ -99,7 +99,7 @@ struct BarEditDropTarget: NSViewRepresentable { @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 } @@ -117,8 +117,12 @@ struct BarEditDropTarget: NSViewRepresentable { } } +/// 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 @@ -160,8 +164,8 @@ 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) } private func acceptedPayload(_ sender: any NSDraggingInfo) -> WidgetDragSession.Payload? { diff --git a/Sources/StableBar/BarEditFlow.swift b/Sources/StableBar/BarEditFlow.swift new file mode 100644 index 00000000..4f064757 --- /dev/null +++ b/Sources/StableBar/BarEditFlow.swift @@ -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] { + var rows: [Range] = [] + 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.. 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 } + } +} diff --git a/Sources/StableBar/BarEditScrollWheel.swift b/Sources/StableBar/BarEditScrollWheel.swift deleted file mode 100644 index 79d82922..00000000 --- a/Sources/StableBar/BarEditScrollWheel.swift +++ /dev/null @@ -1,38 +0,0 @@ -import AppKit -import SwiftUI - -/// Only wheel events are captured; clicks, drags and accessibility stay with the tiles. -/// Use measured sizes because SwiftUI's native scroll document can have empty bounds. -struct BarEditScrollWheel: NSViewRepresentable { - let contentWidth: CGFloat - let viewportWidth: CGFloat - - func makeNSView(context: Context) -> BarEditScrollWheelView { BarEditScrollWheelView() } - func updateNSView(_ view: BarEditScrollWheelView, context: Context) { - view.contentWidth = contentWidth - view.viewportWidth = viewportWidth - } -} - -final class BarEditScrollWheelView: NSView { - var contentWidth: CGFloat? - var viewportWidth: CGFloat? - - override func hitTest(_ point: NSPoint) -> NSView? { - NSApp?.currentEvent?.type == .scrollWheel ? super.hitTest(point) : nil - } - - override func scrollWheel(with event: NSEvent) { - guard let scroll = enclosingScrollView else { - super.scrollWheel(with: event) - return - } - let clip = scroll.contentView - let x = BarEditOverflow.offset(from: clip.bounds.minX, deltaX: event.scrollingDeltaX, - deltaY: event.scrollingDeltaY, precise: event.hasPreciseScrollingDeltas, - contentWidth: contentWidth ?? scroll.documentView?.bounds.width ?? 0, - viewportWidth: viewportWidth ?? clip.bounds.width) - clip.scroll(to: CGPoint(x: x, y: clip.bounds.minY)) - scroll.reflectScrolledClipView(clip) - } -} diff --git a/Sources/StableBar/BarEditView.swift b/Sources/StableBar/BarEditView.swift index e70de4a0..40d962fd 100644 --- a/Sources/StableBar/BarEditView.swift +++ b/Sources/StableBar/BarEditView.swift @@ -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]!) } } } @@ -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) } } @@ -78,9 +89,9 @@ private struct BarEditFrames: PreferenceKey { static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue()) { $1 } } } -private struct BarEditRowBounds: PreferenceKey { - static let defaultValue = CGRect.null - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { value = 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 { @@ -89,65 +100,38 @@ 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 @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) + 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(BarEditRowHeight.self) { if $0 > 0 { measured($0) } } } .overlay(alignment: .topLeading) { Text(position.title).font(.system(size: 9, weight: .bold)).lineLimit(1) @@ -166,13 +150,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 @@ -236,7 +213,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) @@ -263,7 +240,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) } diff --git a/Sources/StableBar/BarEditing.swift b/Sources/StableBar/BarEditing.swift index e8d99814..98effe90 100644 --- a/Sources/StableBar/BarEditing.swift +++ b/Sources/StableBar/BarEditing.swift @@ -213,11 +213,11 @@ final class BarEditing { /// Frames and pointer share the section viewport's coordinates, including its scroll offset. /// Groups are one insertion target, even when a member's frame is missing. - func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, + func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, y: CGFloat? = nil, frames: [String: CGRect]) -> BarEditInsertion? { guard isActive, let shared = store.configuration else { return nil } let configuration = screenKind(displayID).map(shared.onScreen) ?? shared - return WidgetPlacement.dropTarget(id, on: displayID, position: position, x: x, frames: frames, + return WidgetPlacement.dropTarget(id, on: displayID, position: position, x: x, y: y, frames: frames, widgets: configuration.widgets, groups: configuration.groups) } @@ -344,6 +344,8 @@ enum BarEditTrayMetrics { static let slot: CGFloat = 22 static let horizontalPadding: CGFloat = 7 static let verticalPadding: CGFloat = 6 + /// Between the rows of a tray that wraps, enough for the − badges that overlap a widget's top-left corner. + static let rowSpacing: CGFloat = 6 static let emptyWidth = slot + 2 * horizontalPadding static func chipWidth(for position: WidgetPosition) -> CGFloat { @@ -359,10 +361,9 @@ struct BarEditTrayState: Equatable { } /// The normal planner supplies screen order and centering. Only trays wider than -/// their share of the available space scroll. +/// their share of the available space wrap onto more rows. struct BarEditLayout { let sections: [WidgetPosition: BarPlacement] - let scrolling: Set init(width: CGFloat, display: BarDisplay, bar: BarSettings, preferredWidths: [WidgetPosition: CGFloat] = [:]) { let scale = CGFloat(bar.scale(for: display)) @@ -396,16 +397,14 @@ struct BarEditLayout { } let plan = AdaptiveBarPlanner(inset: inset, spacing: spacing, notchSpacing: notchSpacing, notch: notch) .plan(width: width, items: items) - let sections = Dictionary(uniqueKeysWithValues: WidgetPosition.allCases.compactMap { position in + sections = Dictionary(uniqueKeysWithValues: WidgetPosition.allCases.compactMap { position in plan.placements[position.rawValue].map { (position, $0) } }) - self.sections = sections - scrolling = Set(WidgetPosition.allCases.filter { sections[$0]!.width < content[$0]! - 0.5 }) } private static func fit(_ positions: [WidgetPosition], capacity: CGFloat, content: [WidgetPosition: CGFloat], widths: inout [WidgetPosition: CGFloat]) { - // Shorten a drag chip before scrolling content that would otherwise fit. + // Shorten a drag chip before wrapping content that would otherwise fit. let excess = max(0, positions.reduce(CGFloat.zero) { $0 + widths[$1]! } - max(0, capacity)) let slack = positions.reduce(CGFloat.zero) { $0 + max(0, widths[$1]! - content[$1]!) } if slack > 0 { @@ -419,19 +418,3 @@ struct BarEditLayout { } } } - -struct BarEditOverflow: Equatable { - let leading: Bool - let trailing: Bool - - init(bounds: CGRect, viewportWidth: CGFloat) { - leading = !bounds.isNull && bounds.minX < -0.5 - trailing = !bounds.isNull && bounds.maxX > viewportWidth + 0.5 - } - - static func offset(from current: CGFloat, deltaX: CGFloat, deltaY: CGFloat, precise: Bool, - contentWidth: CGFloat, viewportWidth: CGFloat) -> CGFloat { - let delta = abs(deltaX) > abs(deltaY) ? deltaX : deltaY - return min(max(0, current - delta * (precise ? 1 : 10)), max(0, contentWidth - viewportWidth)) - } -} diff --git a/Sources/StableBar/SceneRenderer.swift b/Sources/StableBar/SceneRenderer.swift index 094b3cc1..92a98b0b 100644 --- a/Sources/StableBar/SceneRenderer.swift +++ b/Sources/StableBar/SceneRenderer.swift @@ -241,8 +241,9 @@ enum SceneRenderer { } /// The edit bar's height once its layout settles: edit mode measures its trays after the first - /// layout, so the strip grows to fit them. Lays the bar out offscreen until the height stops - /// changing and every widget has a frame, as BarEditViewTests does. + /// layout, and a crowded tray reports its extra rows after the next, so the strip grows to fit them. + /// Lays the bar out offscreen until every widget has a frame and the height holds for a few passes, + /// as BarEditViewTests does. private static func editBarHeight(_ store: BarStore, width: CGFloat) async throws -> CGFloat { guard var height = store.barEditing.geometry(for: display)?.layoutHeight else { throw CommandFailure(message: "draw: Edit mode needs a configuration.") @@ -256,14 +257,17 @@ enum SceneRenderer { host.sizingOptions = [] window.contentView = host let expected = Set(store.barEditing.entries(on: display).map(\.id)) + var settled = 0 for _ in 0..<100 { host.layoutSubtreeIfNeeded() let measured = store.barEditing.geometry(for: display)?.layoutHeight ?? height if measured != height { height = measured + settled = 0 window.setContentSize(CGSize(width: width, height: height)) } else if expected.isSubset(of: dropTargets(in: host).flatMap(\.frames.keys)) { - break + settled += 1 + if settled == 3 { break } } try await Task.sleep(for: .milliseconds(10)) } diff --git a/Sources/StableBar/WidgetDragView.swift b/Sources/StableBar/WidgetDragView.swift index 83ef85e0..2b77234f 100644 --- a/Sources/StableBar/WidgetDragView.swift +++ b/Sources/StableBar/WidgetDragView.swift @@ -9,8 +9,9 @@ class WidgetHostingView: NSView { set { hostingView.rootView = newValue; invalidateIntrinsicContentSize() } } - init(rootView: AnyView) { + init(rootView: AnyView, sizingOptions: NSHostingSizingOptions = .standardBounds) { hostingView = NSHostingView(rootView: rootView) + hostingView.sizingOptions = sizingOptions super.init(frame: .zero) hostingView.translatesAutoresizingMaskIntoConstraints = false addSubview(hostingView) diff --git a/Sources/StableBar/WidgetPlacement.swift b/Sources/StableBar/WidgetPlacement.swift index 2571f2a0..dd0cc822 100644 --- a/Sources/StableBar/WidgetPlacement.swift +++ b/Sources/StableBar/WidgetPlacement.swift @@ -12,7 +12,9 @@ enum WidgetPlacement { } } - static func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, + /// Where a widget dropped at `x` lands among `frames`. With `y`, the tray may wrap onto several rows: + /// the row nearest the pointer decides, and past its last widget comes the next row's first. + static func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, y: CGFloat? = nil, frames: [String: CGRect], widgets: [WidgetConfiguration], groups: [String: WidgetGroupConfiguration]) -> BarEditInsertion? { guard let moving = widgets.first(where: { $0.id == id }) else { return nil } @@ -20,12 +22,14 @@ enum WidgetPlacement { $0.position == position && $0.id != id && (moving.group == nil || $0.group != moving.group) } let units = BarUnit.make(widgets: remaining, groups: groups) + let bounds = units.map { unit in unit.widgets.compactMap { frames[$0.id] }.reduce(CGRect.null) { $0.union($1) } } + let wrapped = y.map { rows(of: bounds, nearest: $0) } var index = 0 var end: CGFloat = 8 - for unit in units { - let bounds = unit.widgets.compactMap { frames[$0.id] }.reduce(CGRect.null) { $0.union($1) } + for (unit, (offset, bounds)) in zip(units, bounds.enumerated()) { guard !bounds.isNull else { index += unit.widgets.count; continue } - if x < bounds.midX { + let row = wrapped.map { $0.rows[offset] - $0.pointer } ?? 0 + if row > 0 || row == 0 && x < bounds.midX { return BarEditInsertion(displayID: displayID, position: position, before: unit.widgets[0].id, index: index, x: bounds.minX - 3) } @@ -35,6 +39,23 @@ enum WidgetPlacement { return BarEditInsertion(displayID: displayID, position: position, before: nil, index: remaining.count, x: end) } + /// The row of each frame, top to bottom, where frames that overlap vertically share a row, and the + /// row nearest `y`. Frames come in the tray's order, so each row follows the one before it. + private static func rows(of frames: [CGRect], nearest y: CGFloat) -> (rows: [Int], pointer: Int) { + var bands: [ClosedRange] = [] + let rows = frames.map { frame in + guard !frame.isNull else { return max(bands.count - 1, 0) } + if let last = bands.last, frame.minY < last.upperBound { + bands[bands.count - 1] = min(last.lowerBound, frame.minY)...max(last.upperBound, frame.maxY) + } else { + bands.append(frame.minY...frame.maxY) + } + return bands.count - 1 + } + let distance = { (band: ClosedRange) in max(band.lowerBound - y, y - band.upperBound, 0) } + return (rows, bands.indices.min { distance(bands[$0]) < distance(bands[$1]) } ?? 0) + } + static func destination(selectedID: String?, widgets: [WidgetConfiguration]) -> WidgetPosition { widgets.first { $0.id == selectedID }?.position ?? .right } diff --git a/Tests/StableBarTests/BarEditViewTests.swift b/Tests/StableBarTests/BarEditViewTests.swift index 573b57f6..c7efbc0b 100644 --- a/Tests/StableBarTests/BarEditViewTests.swift +++ b/Tests/StableBarTests/BarEditViewTests.swift @@ -52,7 +52,7 @@ final class BarEditViewTests: XCTestCase { } } - @MainActor func testFifteenRealSizeRightWidgetsFitTheNotchedBarWithoutScrolling() async throws { + @MainActor func testFifteenRealSizeRightWidgetsFitTheNotchedBarWithoutWrapping() async throws { let widgets = (0..<15).map { #"{"id":"widget-\#($0)","type":"text","text":"•","position":"right","style":{"width":18,"height":20}}"# }.joined(separator: ",") @@ -79,25 +79,40 @@ final class BarEditViewTests: XCTestCase { XCTAssertTrue(right.frames.values.allSatisfy { abs($0.width - 18) < 0.01 }) XCTAssertTrue(right.frames.values.allSatisfy { $0.minX >= 0 && $0.maxX <= right.bounds.width }, "Section: \(right.frame), bounds: \(right.bounds), tiles: \(right.frames)") - for section in sections { - let scroll = try XCTUnwrap(descendants(section, of: NSScrollView.self).first) - XCTAssertFalse(scroll.hasHorizontalScroller) - } + XCTAssertEqual(Set(right.frames.values.map { Int($0.minY.rounded()) }).count, 1, "Widgets that fit stay on one row") XCTAssertNil(host.window) XCTAssertLessThan(try XCTUnwrap(store.barEditing.geometry(for: display)).layoutHeight, 84) } - @MainActor func testVerticalMouseWheelScrollsTheNativeViewportWithoutAKeyWindow() throws { - let scroll = NSScrollView(frame: CGRect(x: 0, y: 0, width: 200, height: 40)) - let document = NSView(frame: CGRect(x: 0, y: 0, width: 500, height: 40)) - let wheel = BarEditScrollWheelView(frame: document.bounds) - document.addSubview(wheel) - scroll.documentView = document - let cgEvent = try XCTUnwrap(CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: -3, wheel2: 0, wheel3: 0)) - wheel.scrollWheel(with: try XCTUnwrap(NSEvent(cgEvent: cgEvent))) - XCTAssertGreaterThan(scroll.contentView.bounds.minX, 0) - XCTAssertNil(scroll.window) - XCTAssertNil(wheel.hitTest(.zero), "The wheel overlay must leave clicks and drags alone") + @MainActor func testACrowdedSectionWrapsOntoMoreRowsSoEveryWidgetStaysOnTheNotchedBar() async throws { + let widgets = (0..<4).map { #"{"id":"left-\#($0)","type":"text","text":"Left widget \#($0)","position":"left"}"# } + + (0..<18).map { #"{"id":"right-\#($0)","type":"text","text":"Right widget \#($0)","position":"right"}"# } + let fixture = try ThemeGalleryFixture("{\"widgets\":[\(widgets.joined(separator: ","))]}") + defer { fixture.close() } + let store = try fixture.store() + defer { store.stop() } + store.barEditing.begin() + let display = BarDisplay(id: 1, isBuiltIn: true, isMain: true, notch: 661...851) + let render = try await editBarImage(store, on: display, width: 1512) + XCTAssertEqual(render.frames.count, 22, "Every widget is laid out") + for (position, tray) in render.trays { + XCTAssertGreaterThanOrEqual(tray.minX, 0, "\(position)") + XCTAssertLessThanOrEqual(tray.maxX, 1512 * render.scale, "\(position) stays on the screen") + } + for (id, frame) in render.frames { + let tray = try XCTUnwrap(render.trays[id.hasPrefix("left") ? .left : .right]) + XCTAssertTrue(frame.minX >= tray.minX - 0.5 && frame.maxX <= tray.maxX + 0.5, "\(id): \(frame) leaves its tray \(tray)") + XCTAssertTrue(frame.minY >= tray.minY - 0.5 && frame.maxY <= tray.maxY + 0.5, "\(id): \(frame) leaves its tray \(tray)") + } + let rows = { (prefix: String) in Set(render.frames.filter { $0.key.hasPrefix(prefix) }.values.map { Int($0.minY.rounded()) }) } + XCTAssertEqual(rows("left").count, 1, "A section that fits keeps one row") + XCTAssertGreaterThan(rows("right").count, 1, "The crowded section continues on more rows") + let right = render.frames.filter { $0.key.hasPrefix("right") } + let order = (0..<18).compactMap { right["right-\($0)"] } + XCTAssertEqual(order, order.sorted { $0.minY < $1.minY - 0.5 || (abs($0.minY - $1.minY) <= 0.5 && $0.minX < $1.minX) }, + "Rows keep the bar's order, left to right and top to bottom") + let height = try XCTUnwrap(store.barEditing.geometry(for: display)).layoutHeight + XCTAssertGreaterThanOrEqual(height * render.scale, try XCTUnwrap(render.trays[.right]).height, "The bar grows to the tallest tray") } @MainActor func testUnorderedWindowsLayOutAllSectionsAndReportWidgetFramesWithoutRevealingTheNormalBar() async throws { @@ -137,18 +152,12 @@ final class BarEditViewTests: XCTestCase { let left = try XCTUnwrap(sections.first { $0.position == .left }) XCTAssertEqual(Set(left.frames.keys), ["a", "b", "d", "e", "f"]) XCTAssertLessThan(try XCTUnwrap(left.frames["a"]).midX, try XCTUnwrap(left.frames["b"]).midX) - let scroll = try XCTUnwrap(descendants(left, of: NSScrollView.self).first) - let original = try XCTUnwrap(left.frames["a"]).minX - let wheel = try XCTUnwrap(descendants(left, of: BarEditScrollWheelView.self).first) - XCTAssertTrue(wheel.enclosingScrollView === scroll) - let event = try XCTUnwrap(CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: -12, wheel2: 0, wheel3: 0)) - wheel.scrollWheel(with: try XCTUnwrap(NSEvent(cgEvent: event))) - for _ in 0..<20 { - host.layoutSubtreeIfNeeded() - if left.frames["a"]?.minX != original { break } - try await Task.sleep(for: .milliseconds(10)) + XCTAssertLessThanOrEqual(left.frame.maxX, 1512, "The tray stays on the screen") + for id in ["d", "e", "f"] { + let frame = try XCTUnwrap(left.frames[id]) + XCTAssertGreaterThan(frame.minY, try XCTUnwrap(left.frames["a"]).maxY, "\(id) wraps below the group wider than its tray") + XCTAssertLessThanOrEqual(frame.maxX, left.bounds.width, "\(id) stays in its tray") } - XCTAssertEqual(try XCTUnwrap(left.frames["a"]).minX, original - 120, accuracy: 1) XCTAssertFalse(window.isVisible) store.barEditing.end() for _ in 0..<500 { @@ -323,9 +332,8 @@ final class BarEditViewTests: XCTestCase { /// Draws the edit bar into memory, in a window that is never ordered onto the screen. /// Trays and widget frames are in pixels; a frame's x is exact, its y depends on the host's flip. - @MainActor private func editBarImage(_ store: BarStore, on display: BarDisplay) async throws + @MainActor private func editBarImage(_ store: BarStore, on display: BarDisplay, width: CGFloat = 900) async throws -> (image: NSBitmapImageRep, scale: CGFloat, trays: [WidgetPosition: CGRect], frames: [String: CGRect]) { - let width: CGFloat = 900 var height = try XCTUnwrap(store.barEditing.geometry(for: display)).layoutHeight let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: [.borderless], backing: .buffered, defer: false) @@ -335,14 +343,18 @@ final class BarEditViewTests: XCTestCase { host.sizingOptions = [] window.contentView = host let expected = Set(store.barEditing.entries(on: display).map(\.id)) + // Trays report their heights after a layout, so wait until the height holds for a few passes. + var settled = 0 for _ in 0..<100 { host.layoutSubtreeIfNeeded() let measured = try XCTUnwrap(store.barEditing.geometry(for: display)).layoutHeight if measured != height { height = measured + settled = 0 window.setContentSize(CGSize(width: width, height: height)) } else if expected.isSubset(of: descendants(host, of: BarEditDropTargetView.self).flatMap(\.frames.keys)) { - break + settled += 1 + if settled == 3 { break } } try await Task.sleep(for: .milliseconds(10)) } diff --git a/Tests/StableBarTests/BarEditingTests.swift b/Tests/StableBarTests/BarEditingTests.swift index 6018073c..e4d1d622 100644 --- a/Tests/StableBarTests/BarEditingTests.swift +++ b/Tests/StableBarTests/BarEditingTests.swift @@ -122,11 +122,10 @@ final class BarEditingTests: XCTestCase { } } - func testMeasuredSectionsThatFitABusyNotchedBarKeepTheirWidthWithoutScrolling() throws { + func testMeasuredSectionsThatFitABusyNotchedBarKeepTheirWidthWithoutWrapping() throws { for rightWidth: CGFloat in [350, 450] { let widths: [WidgetPosition: CGFloat] = [.left: 50, .notchLeft: 86, .center: 50, .notchRight: 86, .right: rightWidth] let layout = BarEditLayout(width: 1512, display: laptop, bar: BarSettings(), preferredWidths: widths) - XCTAssertTrue(layout.scrolling.isEmpty) for position in WidgetPosition.allCases { let frame = try XCTUnwrap(layout.sections[position]) XCTAssertEqual(frame.width, try XCTUnwrap(widths[position]), accuracy: 0.01, @@ -139,12 +138,12 @@ final class BarEditingTests: XCTestCase { } } - func testOnlyTheSectionThatCannotFitScrollsAndSectionsReachTheRightInset() throws { + func testOnlyTheSectionThatCannotFitWrapsAndSectionsReachTheRightInset() throws { let widths: [WidgetPosition: CGFloat] = [.left: 50, .notchLeft: 86, .center: 50, .notchRight: 86, .right: 2000] for (width, display) in [(CGFloat(800), external), (1512, laptop)] { let layout = BarEditLayout(width: width, display: display, bar: BarSettings(), preferredWidths: widths) - XCTAssertEqual(layout.scrolling, [.right]) let right = try XCTUnwrap(layout.sections[.right]) + XCTAssertLessThan(right.width, 2000, "Only the crowded section gets less room than its widgets need") XCTAssertEqual(right.x + right.width, width - 12, accuracy: 0.01) let ordered = layout.sections.values.sorted { $0.x < $1.x } for (index, frame) in ordered.enumerated() { @@ -161,7 +160,6 @@ final class BarEditingTests: XCTestCase { func testEmptyTraysHoldOnlyTheirDragChipWithoutOverlappingOtherTraysOrTheNotch() throws { for display in [laptop, external] { let layout = BarEditLayout(width: 1512, display: display, bar: BarSettings()) - XCTAssertTrue(layout.scrolling.isEmpty) let ordered = layout.sections.sorted { $0.value.x < $1.value.x } for (index, entry) in ordered.enumerated() { XCTAssertEqual(entry.value.width, max(36, BarEditTrayMetrics.chipWidth(for: entry.key))) @@ -175,10 +173,9 @@ final class BarEditingTests: XCTestCase { } } - func testDragLabelsYieldSpaceBeforeFittingWidgetsHaveToScroll() throws { + func testDragLabelsYieldSpaceBeforeFittingWidgetsHaveToWrap() throws { let widths: [WidgetPosition: CGFloat] = [.left: 50, .notchLeft: 36, .notchRight: 36, .center: 36, .right: 530] let layout = BarEditLayout(width: 1512, display: laptop, bar: BarSettings(), preferredWidths: widths) - XCTAssertTrue(layout.scrolling.isEmpty) XCTAssertEqual(try XCTUnwrap(layout.sections[.right]).width, 530, accuracy: 0.01) } @@ -233,25 +230,6 @@ final class BarEditingTests: XCTestCase { XCTAssertEqual(editing.opacity(for: "b"), 1) } - func testOverflowEdgesAndMouseWheelOffsetsFollowTheVisibleContent() { - XCTAssertEqual(BarEditOverflow(bounds: CGRect(x: 6, y: 0, width: 188, height: 22), viewportWidth: 200), - BarEditOverflow(bounds: .null, viewportWidth: 200)) - let start = BarEditOverflow(bounds: CGRect(x: 6, y: 0, width: 500, height: 22), viewportWidth: 200) - XCTAssertFalse(start.leading) - XCTAssertTrue(start.trailing) - let middle = BarEditOverflow(bounds: CGRect(x: -114, y: 0, width: 500, height: 22), viewportWidth: 200) - XCTAssertTrue(middle.leading) - XCTAssertTrue(middle.trailing) - let end = BarEditOverflow(bounds: CGRect(x: -306, y: 0, width: 500, height: 22), viewportWidth: 200) - XCTAssertTrue(end.leading) - XCTAssertFalse(end.trailing) - XCTAssertEqual(BarEditOverflow.offset(from: 0, deltaX: 0, deltaY: -3, precise: false, contentWidth: 500, viewportWidth: 200), 30) - XCTAssertEqual(BarEditOverflow.offset(from: 30, deltaX: -12, deltaY: 0, precise: true, contentWidth: 500, viewportWidth: 200), 42) - XCTAssertEqual(BarEditOverflow.offset(from: 30, deltaX: 0, deltaY: 100, precise: true, contentWidth: 500, viewportWidth: 200), 0) - XCTAssertEqual(BarEditOverflow.offset(from: 290, deltaX: 0, deltaY: -100, precise: true, contentWidth: 500, viewportWidth: 200), 300) - XCTAssertEqual(BarEditOverflow.offset(from: 0, deltaX: 0, deltaY: -10, precise: false, contentWidth: 100, viewportWidth: 200), 0) - } - @MainActor func testEditHeightFollowsMeasuredContentPerDisplayAndClearsOnExit() throws { let fixture = try ThemeGalleryFixture() defer { fixture.close() } @@ -300,6 +278,50 @@ final class BarEditingTests: XCTestCase { XCTAssertNil(editing.dropTarget("missing", on: 2, position: .left, x: 0, frames: frames)) } + @MainActor func testDropTargetsInAWrappedTrayFollowTheRowUnderThePointer() throws { + let fixture = try ThemeGalleryFixture(#""" + {"widgets":[{"id":"a","type":"clock","position":"right"},{"id":"b","type":"clock","position":"right"}, + {"id":"c","type":"clock","position":"right"},{"id":"d","type":"clock","position":"right"}, + {"id":"moving","type":"clock","position":"left"}]} + """#) + defer { fixture.close() } + let store = try fixture.store() + defer { store.stop() } + let editing = store.barEditing + editing.begin() + let frames = ["a": CGRect(x: 10, y: 6, width: 40, height: 22), "b": CGRect(x: 60, y: 6, width: 40, height: 22), + "c": CGRect(x: 10, y: 34, width: 40, height: 22), "d": CGRect(x: 60, y: 34, width: 40, height: 22)] + let target = { (x: CGFloat, y: CGFloat) in editing.dropTarget("moving", on: 2, position: .right, x: x, y: y, frames: frames) } + XCTAssertEqual(target(5, 15)?.before, "a") + XCTAssertEqual(target(90, 15)?.before, "c", "Past the end of the first row inserts before the second row") + XCTAssertEqual(target(90, 15)?.index, 2) + XCTAssertEqual(target(5, 45)?.before, "c") + XCTAssertEqual(target(70, 45)?.before, "d") + XCTAssertNil(try XCTUnwrap(target(150, 45)).before, "Past the last widget appends") + XCTAssertEqual(target(5, 32)?.before, "c", "Between rows, the nearer row counts") + XCTAssertEqual(target(5, 30)?.before, "a") + XCTAssertEqual(target(70, 0)?.before, "b", "Above the first row counts as the first row") + XCTAssertEqual(target(5, 80)?.before, "c", "Below the last row counts as the last row") + } + + func testFlowRowsContinueOnTheNextRowOnlyWhenTheNextItemDoesNotFit() { + XCTAssertEqual(BarEditFlow.rows([100, 100, 100], fitting: nil), [0..<3]) + XCTAssertEqual(BarEditFlow.rows([100, 100, 100], fitting: 300), [0..<3]) + XCTAssertEqual(BarEditFlow.rows([100, 100, 100], fitting: 250), [0..<2, 2..<3]) + XCTAssertEqual(BarEditFlow.rows([300, 50, 50], fitting: 200), [0..<1, 1..<3], "An item wider than the tray gets a row of its own") + XCTAssertEqual(BarEditFlow.rows([100, 0, 100], fitting: 100), [0..<2, 2..<3], "A collapsed item never starts a row") + XCTAssertEqual(BarEditFlow.rows([], fitting: 100), []) + } + + @MainActor func testFlowLayoutWrapsAtTheProposedWidthAndStaysOneRowWhenUnconstrained() { + let items = BarEditFlow(rowSpacing: 4) { + ForEach(0..<3, id: \.self) { _ in Color.clear.frame(width: 100, height: 20) } + } + XCTAssertEqual(NSHostingView(rootView: items).fittingSize, CGSize(width: 300, height: 20)) + let wrapped = NSHostingView(rootView: items.frame(width: 250).fixedSize(horizontal: false, vertical: true)) + XCTAssertEqual(wrapped.fittingSize, CGSize(width: 250, height: 44)) + } + @MainActor func testDropMovesTheWholeGroupOnceAcrossDisplaysAndPositionsAndUndoKeepsEditing() async throws { let fixture = try ThemeGalleryFixture(#"{"groups":{"pair":{}},"widgets":[{"id":"a","type":"clock","position":"left","group":"pair"},{"id":"b","type":"clock","position":"left","group":"pair"},{"id":"c","type":"clock","position":"right"}]}"#) defer { fixture.close() } diff --git a/docs/settings.md b/docs/settings.md index 8dc706f5..78a1d5a9 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -23,7 +23,7 @@ Drag widgets in the bar preview or the list, within or between position groups, **Remove** below the list, **Remove from bar** in the context menu and the widget's **More** menu delete a widget defined in your configuration. For a widget from a package, they take the package off this bar, like **Remove from bar…** in **Packages**, so all of its widgets go and **Add widget** offers it again. A widget from an included file can't be removed in Settings: remove it from that file, or choose **Hidden everywhere**. **Undo** brings a removed widget back. -Choose **Edit Bar in Place** in **Settings → Bar**, or in the bar's right-click or menu-bar icon menu, to arrange widgets on every display. Widgets keep their look and size; the one under the pointer is shaded, and the pointer becomes a hand that grabs it while you drag. Each position has a softly outlined tray, including empty positions; position names appear only while dragging, in room each tray keeps for them. Trays tint the theme’s bar surface, and their outlines, **+** and names are drawn to read on it. On a theme that leaves the bar see-through, such as Built-in, each tray gets a light or dark backing that the widgets’ text reads on, so the trays stay readable over any wallpaper. The bar grows just enough for the trays and badges. Widgets hidden by their output or screen choice appear as named placeholders with a crossed-out eye; empty widgets show their names too. Turned-off widgets aren't shown; turn one on again in **Settings → Bar**, or add a Store package again with **+**. Other names appear in tooltips. Drag a widget within or between positions: it lifts off the bar, the other widgets slide aside to make room, and it settles into the gap when you drop it. Grouped widgets move together. With Reduce Motion on, widgets move without sliding. If a position is too crowded to fit, faded edges and arrows show where more widgets are available: use the mouse wheel or swipe sideways to reach them. **+** opens an **Add here** popover below it, with search and a two-column choice of widgets. Adding puts the widget exactly where that **+** sits. With more than one kind of screen connected, the popover has an **Add to** checkbox for each; the widget shows on the checked ones, all by default. Packages with commands, permissions, hosts, required tools or secret settings are reviewed in the popover first. The **−** badge on a widget’s top-left corner removes it from the bar; a widget from a package takes its package off the bar, and a widget from an include is turned off. Click a widget to open its settings here. Its usual clicks, popups and mouse actions pause while editing. The update tile isn't shown while editing the bar. The widget's context menu also offers **Move Left**, **Move Right**, **Move To** and **Shows On**, with the same screen choices as in Settings. With VoiceOver, use the widget's **Move left** and **Move right** accessibility actions. Changes save automatically. **Undo** and **Done** sit in a small panel below the bar. Use **Undo**, or ⌘Z and ⇧⌘Z while the bar has focus, to undo and redo through the same history as Settings. **Esc** closes an open Add here popover first. **Done**, **Esc** while the bar has focus, **Finish Editing Bar** or an explicit or external configuration reload restores the normal bar. Automatic profile changes keep editing open. Temporary command-line changes stay separate and unsaved. +Choose **Edit Bar in Place** in **Settings → Bar**, or in the bar's right-click or menu-bar icon menu, to arrange widgets on every display. Widgets keep their look and size; the one under the pointer is shaded, and the pointer becomes a hand that grabs it while you drag. Each position has a softly outlined tray, including empty positions; position names appear only while dragging, in room each tray keeps for them. Trays tint the theme’s bar surface, and their outlines, **+** and names are drawn to read on it. On a theme that leaves the bar see-through, such as Built-in, each tray gets a light or dark backing that the widgets’ text reads on, so the trays stay readable over any wallpaper. The bar grows just enough for the trays and badges. Widgets hidden by their output or screen choice appear as named placeholders with a crossed-out eye; empty widgets show their names too. Turned-off widgets aren't shown; turn one on again in **Settings → Bar**, or add a Store package again with **+**. Other names appear in tooltips. Drag a widget within or between positions: it lifts off the bar, the other widgets slide aside to make room, and it settles into the gap when you drop it. Grouped widgets move together. With Reduce Motion on, widgets move without sliding. If a position is too crowded to fit, its widgets continue on more rows inside its tray and the bar grows taller while you edit, so every widget stays on the screen. Drag a widget onto any row; the bar returns to its usual height when you finish. **+** opens an **Add here** popover below it, with search and a two-column choice of widgets. Adding puts the widget exactly where that **+** sits. With more than one kind of screen connected, the popover has an **Add to** checkbox for each; the widget shows on the checked ones, all by default. Packages with commands, permissions, hosts, required tools or secret settings are reviewed in the popover first. The **−** badge on a widget’s top-left corner removes it from the bar; a widget from a package takes its package off the bar, and a widget from an include is turned off. Click a widget to open its settings here. Its usual clicks, popups and mouse actions pause while editing. The update tile isn't shown while editing the bar. The widget's context menu also offers **Move Left**, **Move Right**, **Move To** and **Shows On**, with the same screen choices as in Settings. With VoiceOver, use the widget's **Move left** and **Move right** accessibility actions. Changes save automatically. **Undo** and **Done** sit in a small panel below the bar. Use **Undo**, or ⌘Z and ⇧⌘Z while the bar has focus, to undo and redo through the same history as Settings. **Esc** closes an open Add here popover first. **Done**, **Esc** while the bar has focus, **Finish Editing Bar** or an explicit or external configuration reload restores the normal bar. Automatic profile changes keep editing open. Temporary command-line changes stay separate and unsaved. Widgets use your chosen IDs as names. Widgets added in Settings use their package name, short text or type, with numbers to tell matching names apart. A selected widget has **Content**, **Style** and **Behavior** inspector tabs. Content contains text, symbols, clock format and ordinary package settings; **Open package** opens shared settings and credentials in Packages. Style edits colors, font, size, padding, corners, border and workspace presentation. Behavior contains click actions, tooltip, popup-on-hover and position. **Shows on**, beside the widget's name, chooses its screens: **All screens**, **Only on** the selected screen, each kind of screen, **Other rules** for the active or main screen, external screens and screens with or without a notch, or **Hidden everywhere**, which turns the widget off and keeps its settings. Hidden widgets offer **Show here**; widgets with a problem offer **Troubleshoot…**. diff --git a/scenes/crowded-edit-bar.json b/scenes/crowded-edit-bar.json new file mode 100644 index 00000000..9226c00a --- /dev/null +++ b/scenes/crowded-edit-bar.json @@ -0,0 +1,25 @@ +{ + "$schema": "../scene.schema.json", + "configuration": { + "bar": { "height": 40, "offset": 0, "inset": 12, "spacing": 8 }, + "widgets": [ + { "id": "apple", "type": "appleMenu", "position": "left" }, + { "id": "app-menu", "type": "appMenu", "position": "left" }, + { "id": "cpu", "type": "text", "icon": "cpu", "text": "12%", "position": "right" }, + { "id": "memory", "type": "text", "icon": "memorychip", "text": "8.4 GB", "position": "right" }, + { "id": "disk", "type": "text", "icon": "internaldrive", "text": "214 GB", "position": "right" }, + { "id": "network", "type": "text", "icon": "arrow.up.arrow.down", "text": "1.2 MB/s", "position": "right" }, + { "id": "weather", "type": "text", "icon": "cloud.sun", "text": "14°", "position": "right" }, + { "id": "meeting", "type": "text", "icon": "video", "text": "Stand-up in 5 min", "position": "right" }, + { "id": "focus", "type": "text", "icon": "moon", "text": "Focus", "position": "right" }, + { "id": "vpn", "type": "text", "icon": "lock.shield", "text": "VPN", "position": "right" }, + { "id": "bluetooth", "type": "text", "icon": "headphones", "text": "AirPods", "position": "right" }, + { "id": "battery", "type": "text", "icon": "battery.75percent", "text": "76%", "position": "right" }, + { "id": "wifi", "type": "text", "icon": "wifi", "text": "Home", "position": "right" }, + { "id": "timer", "type": "text", "icon": "timer", "text": "24:59", "position": "right" }, + { "id": "music", "type": "text", "icon": "music.note", "text": "Clair de Lune", "position": "right" }, + { "id": "clock", "type": "clock", "position": "right", "format": "EEE d MMM HH:mm" } + ] + }, + "draw": "editBar" +} From f3cce03a031d0ea972007673c15cef1fa7f92c13 Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Wed, 4 Nov 2026 11:14:18 +0100 Subject: [PATCH 2/4] fix: keep a dragged widget at the end when its preview wraps onto a new row Drop targets built a wrapped tray's rows from the other widgets only. When the dragged widget's preview filled a new last row by itself, a pointer on that row picked the row above and moved the preview there. Rows now come from every tile in the tray, the preview included, and the dragged widget is still never an anchor. --- Sources/StableBar/WidgetPlacement.swift | 37 ++++++++++++---------- Tests/StableBarTests/BarEditingTests.swift | 5 +++ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Sources/StableBar/WidgetPlacement.swift b/Sources/StableBar/WidgetPlacement.swift index dd0cc822..0d60b1d4 100644 --- a/Sources/StableBar/WidgetPlacement.swift +++ b/Sources/StableBar/WidgetPlacement.swift @@ -13,7 +13,9 @@ enum WidgetPlacement { } /// Where a widget dropped at `x` lands among `frames`. With `y`, the tray may wrap onto several rows: - /// the row nearest the pointer decides, and past its last widget comes the next row's first. + /// the row nearest the pointer decides, and past its last widget comes the next row's first. Rows come + /// from every tile in `frames`, the dragged widget's preview too, so a row that only the preview fills + /// keeps it at the end; the dragged widget is never an anchor. static func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, y: CGFloat? = nil, frames: [String: CGRect], widgets: [WidgetConfiguration], groups: [String: WidgetGroupConfiguration]) -> BarEditInsertion? { @@ -22,13 +24,16 @@ enum WidgetPlacement { $0.position == position && $0.id != id && (moving.group == nil || $0.group != moving.group) } let units = BarUnit.make(widgets: remaining, groups: groups) - let bounds = units.map { unit in unit.widgets.compactMap { frames[$0.id] }.reduce(CGRect.null) { $0.union($1) } } - let wrapped = y.map { rows(of: bounds, nearest: $0) } + let bands = y == nil ? [] : rows(of: Array(frames.values)) + let pointer = y.flatMap { y in + bands.indices.min { distance(from: y, to: bands[$0]) < distance(from: y, to: bands[$1]) } + } var index = 0 var end: CGFloat = 8 - for (unit, (offset, bounds)) in zip(units, bounds.enumerated()) { + for unit in units { + let bounds = unit.widgets.compactMap { frames[$0.id] }.reduce(CGRect.null) { $0.union($1) } guard !bounds.isNull else { index += unit.widgets.count; continue } - let row = wrapped.map { $0.rows[offset] - $0.pointer } ?? 0 + let row = pointer.map { pointer in (bands.firstIndex { $0.contains(bounds.midY) } ?? pointer) - pointer } ?? 0 if row > 0 || row == 0 && x < bounds.midX { return BarEditInsertion(displayID: displayID, position: position, before: unit.widgets[0].id, index: index, x: bounds.minX - 3) @@ -39,21 +44,19 @@ enum WidgetPlacement { return BarEditInsertion(displayID: displayID, position: position, before: nil, index: remaining.count, x: end) } - /// The row of each frame, top to bottom, where frames that overlap vertically share a row, and the - /// row nearest `y`. Frames come in the tray's order, so each row follows the one before it. - private static func rows(of frames: [CGRect], nearest y: CGFloat) -> (rows: [Int], pointer: Int) { - var bands: [ClosedRange] = [] - let rows = frames.map { frame in - guard !frame.isNull else { return max(bands.count - 1, 0) } - if let last = bands.last, frame.minY < last.upperBound { - bands[bands.count - 1] = min(last.lowerBound, frame.minY)...max(last.upperBound, frame.maxY) + /// The rows that `frames` cover, top to bottom: frames that overlap vertically share a row. + private static func rows(of frames: [CGRect]) -> [ClosedRange] { + frames.filter { !$0.isNull }.sorted { $0.minY < $1.minY }.reduce(into: []) { rows, frame in + if let last = rows.last, frame.minY < last.upperBound { + rows[rows.count - 1] = last.lowerBound...max(last.upperBound, frame.maxY) } else { - bands.append(frame.minY...frame.maxY) + rows.append(frame.minY...frame.maxY) } - return bands.count - 1 } - let distance = { (band: ClosedRange) in max(band.lowerBound - y, y - band.upperBound, 0) } - return (rows, bands.indices.min { distance(bands[$0]) < distance(bands[$1]) } ?? 0) + } + + private static func distance(from y: CGFloat, to row: ClosedRange) -> CGFloat { + max(row.lowerBound - y, y - row.upperBound, 0) } static func destination(selectedID: String?, widgets: [WidgetConfiguration]) -> WidgetPosition { diff --git a/Tests/StableBarTests/BarEditingTests.swift b/Tests/StableBarTests/BarEditingTests.swift index e4d1d622..cc6e34c2 100644 --- a/Tests/StableBarTests/BarEditingTests.swift +++ b/Tests/StableBarTests/BarEditingTests.swift @@ -302,6 +302,11 @@ final class BarEditingTests: XCTestCase { XCTAssertEqual(target(5, 30)?.before, "a") XCTAssertEqual(target(70, 0)?.before, "b", "Above the first row counts as the first row") XCTAssertEqual(target(5, 80)?.before, "c", "Below the last row counts as the last row") + var preview = frames + preview["moving"] = CGRect(x: 10, y: 62, width: 40, height: 22) + XCTAssertNil(try XCTUnwrap(editing.dropTarget("moving", on: 2, position: .right, x: 12, y: 70, frames: preview)).before, + "A row that only the dragged widget's preview fills keeps it at the end") + XCTAssertEqual(editing.dropTarget("moving", on: 2, position: .right, x: 12, y: 45, frames: preview)?.before, "c") } func testFlowRowsContinueOnTheNextRowOnlyWhenTheNextItemDoesNotFit() { From b40077f7f394991c56e9aaf2d68639f04457952d Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Wed, 4 Nov 2026 11:32:33 +0100 Subject: [PATCH 3/4] fix: count a wrapped tray's + as a row when dropping a widget A tray's + can take a row of its own: the last row of a left tray, or the first of a narrow right one. Only widget tiles shaped the rows, so a drop on the + row picked the next row and could land beside its widgets instead of at the end or the start. The tray now hands the frame of its + to drop targeting, which uses it for rows but never as an anchor. --- Sources/StableBar/BarEditDrag.swift | 7 ++++++- Sources/StableBar/BarEditView.swift | 18 ++++++++++++++++-- Sources/StableBar/BarEditing.swift | 4 ++-- Sources/StableBar/WidgetPlacement.swift | 7 ++++--- Tests/StableBarTests/BarEditViewTests.swift | 2 ++ Tests/StableBarTests/BarEditingTests.swift | 8 ++++++++ 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Sources/StableBar/BarEditDrag.swift b/Sources/StableBar/BarEditDrag.swift index 0c884eb7..0179a75e 100644 --- a/Sources/StableBar/BarEditDrag.swift +++ b/Sources/StableBar/BarEditDrag.swift @@ -96,6 +96,8 @@ struct BarEditDropTarget: 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 { @@ -110,6 +112,7 @@ struct BarEditDropTarget: 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? { @@ -127,6 +130,7 @@ final class BarEditDropTargetView: WidgetHostingView { var displayID = 0 var position = WidgetPosition.left var frames: [String: CGRect] = [:] + var addSlot: CGRect? override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } @@ -165,7 +169,8 @@ final class BarEditDropTargetView: WidgetHostingView { private func target(_ sender: any NSDraggingInfo, payload: WidgetDragSession.Payload) -> BarEditInsertion? { let location = convert(sender.draggingLocation, from: nil) - return editing?.dropTarget(payload.widgetID, on: displayID, position: position, x: location.x, y: location.y, frames: frames) + 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? { diff --git a/Sources/StableBar/BarEditView.swift b/Sources/StableBar/BarEditView.swift index 40d962fd..c4e2007e 100644 --- a/Sources/StableBar/BarEditView.swift +++ b/Sources/StableBar/BarEditView.swift @@ -89,6 +89,11 @@ private struct BarEditFrames: PreferenceKey { static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue()) { $1 } } } +private struct BarEditAddSlotFrame: PreferenceKey { + static let defaultValue = CGRect.null + 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()) } @@ -104,6 +109,7 @@ private struct BarEditSection: View { /// Reports the tray's height at the width it was given. let measured: (CGFloat) -> Void @State private var frames: [String: CGRect] = [:] + @State private var addSlot = CGRect.null @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var reduceMotion private var editing: BarEditing { store.barEditing } @@ -112,7 +118,7 @@ private struct BarEditSection: View { let state = editing.trayState(on: display.id, position: position) 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) { + 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 { @@ -131,6 +137,7 @@ private struct BarEditSection: View { .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) { @@ -263,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)") } diff --git a/Sources/StableBar/BarEditing.swift b/Sources/StableBar/BarEditing.swift index 98effe90..c6571df3 100644 --- a/Sources/StableBar/BarEditing.swift +++ b/Sources/StableBar/BarEditing.swift @@ -214,10 +214,10 @@ final class BarEditing { /// Frames and pointer share the section viewport's coordinates, including its scroll offset. /// Groups are one insertion target, even when a member's frame is missing. func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, y: CGFloat? = nil, - frames: [String: CGRect]) -> BarEditInsertion? { + frames: [String: CGRect], addSlot: CGRect? = nil) -> BarEditInsertion? { guard isActive, let shared = store.configuration else { return nil } let configuration = screenKind(displayID).map(shared.onScreen) ?? shared - return WidgetPlacement.dropTarget(id, on: displayID, position: position, x: x, y: y, frames: frames, + return WidgetPlacement.dropTarget(id, on: displayID, position: position, x: x, y: y, frames: frames, addSlot: addSlot, widgets: configuration.widgets, groups: configuration.groups) } diff --git a/Sources/StableBar/WidgetPlacement.swift b/Sources/StableBar/WidgetPlacement.swift index 0d60b1d4..a895b0b7 100644 --- a/Sources/StableBar/WidgetPlacement.swift +++ b/Sources/StableBar/WidgetPlacement.swift @@ -15,16 +15,17 @@ enum WidgetPlacement { /// Where a widget dropped at `x` lands among `frames`. With `y`, the tray may wrap onto several rows: /// the row nearest the pointer decides, and past its last widget comes the next row's first. Rows come /// from every tile in `frames`, the dragged widget's preview too, so a row that only the preview fills - /// keeps it at the end; the dragged widget is never an anchor. + /// keeps it at the end; the dragged widget is never an anchor. The tray's + in `addSlot` can take a row + /// of its own, so it shapes rows too without being an anchor. static func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, y: CGFloat? = nil, - frames: [String: CGRect], widgets: [WidgetConfiguration], + frames: [String: CGRect], addSlot: CGRect? = nil, widgets: [WidgetConfiguration], groups: [String: WidgetGroupConfiguration]) -> BarEditInsertion? { guard let moving = widgets.first(where: { $0.id == id }) else { return nil } let remaining = widgets.filter { $0.position == position && $0.id != id && (moving.group == nil || $0.group != moving.group) } let units = BarUnit.make(widgets: remaining, groups: groups) - let bands = y == nil ? [] : rows(of: Array(frames.values)) + let bands = y == nil ? [] : rows(of: Array(frames.values) + [addSlot].compactMap { $0 }) let pointer = y.flatMap { y in bands.indices.min { distance(from: y, to: bands[$0]) < distance(from: y, to: bands[$1]) } } diff --git a/Tests/StableBarTests/BarEditViewTests.swift b/Tests/StableBarTests/BarEditViewTests.swift index c7efbc0b..6c7cb47b 100644 --- a/Tests/StableBarTests/BarEditViewTests.swift +++ b/Tests/StableBarTests/BarEditViewTests.swift @@ -80,6 +80,8 @@ final class BarEditViewTests: XCTestCase { XCTAssertTrue(right.frames.values.allSatisfy { $0.minX >= 0 && $0.maxX <= right.bounds.width }, "Section: \(right.frame), bounds: \(right.bounds), tiles: \(right.frames)") XCTAssertEqual(Set(right.frames.values.map { Int($0.minY.rounded()) }).count, 1, "Widgets that fit stay on one row") + let slot = try XCTUnwrap(right.addSlot, "The tray hands its + to drop targeting, which shapes rows") + XCTAssertLessThanOrEqual(slot.maxX, try XCTUnwrap(right.frames.values.map(\.minX).min()), "+ leads the right tray") XCTAssertNil(host.window) XCTAssertLessThan(try XCTUnwrap(store.barEditing.geometry(for: display)).layoutHeight, 84) } diff --git a/Tests/StableBarTests/BarEditingTests.swift b/Tests/StableBarTests/BarEditingTests.swift index cc6e34c2..7dd35a6e 100644 --- a/Tests/StableBarTests/BarEditingTests.swift +++ b/Tests/StableBarTests/BarEditingTests.swift @@ -307,6 +307,14 @@ final class BarEditingTests: XCTestCase { XCTAssertNil(try XCTUnwrap(editing.dropTarget("moving", on: 2, position: .right, x: 12, y: 70, frames: preview)).before, "A row that only the dragged widget's preview fills keeps it at the end") XCTAssertEqual(editing.dropTarget("moving", on: 2, position: .right, x: 12, y: 45, frames: preview)?.before, "c") + let trailingSlot = CGRect(x: 10, y: 62, width: 22, height: 22) + XCTAssertNil(try XCTUnwrap(editing.dropTarget("moving", on: 2, position: .right, x: 12, y: 70, frames: frames, + addSlot: trailingSlot)).before, + "The row that only + fills comes after the last widget") + let leading = ["a": CGRect(x: 10, y: 34, width: 40, height: 22), "b": CGRect(x: 60, y: 34, width: 40, height: 22)] + XCTAssertEqual(editing.dropTarget("moving", on: 2, position: .right, x: 90, y: 15, frames: leading, + addSlot: CGRect(x: 10, y: 6, width: 22, height: 22))?.before, "a", + "The row that only + fills comes before the first widget") } func testFlowRowsContinueOnTheNextRowOnlyWhenTheNextItemDoesNotFit() { From e9590c4a0fd5cddff071d12b69f108c92f9f817a Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Wed, 4 Nov 2026 12:46:49 +0100 Subject: [PATCH 4/4] chore: sync the pull request with its branch after a GitHub outage