diff --git a/Sources/StableBar/BarEditDrag.swift b/Sources/StableBar/BarEditDrag.swift index fdedd3a..0179a75 100644 --- a/Sources/StableBar/BarEditDrag.swift +++ b/Sources/StableBar/BarEditDrag.swift @@ -96,10 +96,12 @@ 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 { - let view = BarEditDropTargetView(rootView: AnyView(content())) + let view = BarEditDropTargetView(rootView: AnyView(content()), sizingOptions: []) view.registerForDraggedTypes([BarEditDragSourceView.pasteboardType]) return view } @@ -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? { @@ -117,12 +120,17 @@ 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 var frames: [String: CGRect] = [:] + var addSlot: CGRect? override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { draggingUpdated(sender) } @@ -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? { diff --git a/Sources/StableBar/BarEditFlow.swift b/Sources/StableBar/BarEditFlow.swift new file mode 100644 index 0000000..4f06475 --- /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 79d8292..0000000 --- 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 e70de4a..c4e2007 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,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 { @@ -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) @@ -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 @@ -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) @@ -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) } @@ -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)") } diff --git a/Sources/StableBar/BarEditing.swift b/Sources/StableBar/BarEditing.swift index e8d9981..c6571df 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, - frames: [String: CGRect]) -> BarEditInsertion? { + func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, y: CGFloat? = nil, + 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, frames: frames, + return WidgetPlacement.dropTarget(id, on: displayID, position: position, x: x, y: y, frames: frames, addSlot: addSlot, 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 094b3cc..92a98b0 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 83ef85e..2b77234 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 2571f2a..a895b0b 100644 --- a/Sources/StableBar/WidgetPlacement.swift +++ b/Sources/StableBar/WidgetPlacement.swift @@ -12,20 +12,30 @@ enum WidgetPlacement { } } - static func dropTarget(_ id: String, on displayID: Int, position: WidgetPosition, x: CGFloat, - frames: [String: CGRect], widgets: [WidgetConfiguration], + /// 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. 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], 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) + [addSlot].compactMap { $0 }) + 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 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 } - if x < bounds.midX { + 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) } @@ -35,6 +45,21 @@ enum WidgetPlacement { return BarEditInsertion(displayID: displayID, position: position, before: nil, index: remaining.count, x: end) } + /// 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 { + rows.append(frame.minY...frame.maxY) + } + } + } + + 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 { widgets.first { $0.id == selectedID }?.position ?? .right } diff --git a/Tests/StableBarTests/BarEditViewTests.swift b/Tests/StableBarTests/BarEditViewTests.swift index 573b57f..6c7cb47 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,42 @@ 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") + 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) } - @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 +154,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 +334,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 +345,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 6018073..7dd35a6 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,63 @@ 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") + 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") + 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() { + 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 8dc706f..78a1d5a 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 0000000..9226c00 --- /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" +}