From d56745c91826f417f4d99fc10085e5ba81ae06fe Mon Sep 17 00:00:00 2001 From: Steve Mendes Reis Date: Sun, 1 Nov 2026 13:46:04 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20let=20a=20slider=20end=20in=20a=20separ?= =?UTF-8?q?ate=20stop=20past=20its=20range,=20such=20as=20=E2=88=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slider block's optional endStop adds one more stop past max, set apart by a notch: a gap with a hairline, then a short stretch of track. Dragging past max holds the knob there until the pointer is well into the notch, then snaps it onto the end stop with the alignment haptic edit mode plays. Releasing there sends the end stop's value, and a source equal to it puts the knob there, so the value must lie outside min…max. The arrow keys and VoiceOver step onto it too. SwiftUI's slider can't draw the notch, so StableBar draws these sliders to the native slider's measures on macOS 26 and later, per control size: the track, the knob, a dot under each step and the end stop's label in place of a dot. The fill follows the tint, gray in an inactive window, and the title shows beside it unless labels are hidden, as native sliders do. The new scene draws a popup with the knob on a step, on max and on the end stop. Closes #399 --- Sources/StableBar/EndStopSlider.swift | 258 ++++++++++++++++++ Sources/StableBar/PopupConfiguration.swift | 11 +- Sources/StableBar/PopupView.swift | 14 +- Tests/StableBarTests/EndStopSliderTests.swift | 81 ++++++ .../ModuleConfigurationTests.swift | 3 +- docs/create-a-widget.md | 2 +- docs/popups.md | 2 +- scenes/slider-end-stop-popup.json | 58 ++++ stablebar.schema.json | 19 ++ 9 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 Sources/StableBar/EndStopSlider.swift create mode 100644 Tests/StableBarTests/EndStopSliderTests.swift create mode 100644 scenes/slider-end-stop-popup.json diff --git a/Sources/StableBar/EndStopSlider.swift b/Sources/StableBar/EndStopSlider.swift new file mode 100644 index 0000000..9753dc5 --- /dev/null +++ b/Sources/StableBar/EndStopSlider.swift @@ -0,0 +1,258 @@ +import AppKit +import SwiftUI + +/// A slider block's last stop past `max`, set apart by a notch, such as ∞ after a range of minutes. +struct SliderEndStop: Decodable, Sendable, Equatable { + /// What the slider sends as `VALUE` on the end stop, and the value of `source` that puts the knob there. + let value: Double + /// Drawn under the end stop, where the steps have dots, while the knob is elsewhere, and read by VoiceOver. + let label: String + + enum CodingKeys: String, CodingKey, CaseIterable { case value, label } + + init(value: Double, label: String) { + self.value = value + self.label = label + } + + init(from decoder: Decoder) throws { + try decoder.rejectUnknownKeys(CodingKeys.self) + let values = try decoder.container(keyedBy: CodingKeys.self) + value = try values.decode(Double.self, forKey: .value) + label = try values.decode(String.self, forKey: .label) + guard value.isFinite else { + throw DecodingError.dataCorruptedError(forKey: .value, in: values, debugDescription: "An end stop needs a finite value.") + } + guard !label.trimmingCharacters(in: .whitespaces).isEmpty else { + throw DecodingError.dataCorruptedError(forKey: .label, in: values, debugDescription: "An end stop needs a label, such as ∞.") + } + } +} + +/// Where a slider with an end stop draws its steps, its notch and its end stop, and which stop a pointer picks. +/// Positions are knob centers in points from the slider's leading edge. +struct EndStopSliderTrack: Equatable { + enum Stop: Equatable { + case value(Double) + case end + } + + /// The gap between the last step and the end stop, with a hairline in its middle. + static let notch = 8.0 + /// Dots closer than this would run together, so the slider leaves them out. + static let minimumTickSpacing = 3.0 + + let width: Double + let knobWidth: Double + let minimum: Double + let maximum: Double + let step: Double + + /// The end stop's stretch of track, a little wider than the knob that rests on it. + var stopWidth: Double { knobWidth + 6 } + /// Where the stepped track ends and the notch begins. + var rangeEnd: Double { max(width - Self.notch - stopWidth, knobWidth) } + var notchCenter: Double { rangeEnd + Self.notch / 2 } + var stopStart: Double { rangeEnd + Self.notch } + + /// The step nearest `value`, within `minimum`…`maximum`. + func stepped(_ value: Double) -> Double { + let clamped = min(max(value, minimum), maximum) + return min(minimum + ((clamped - minimum) / step).rounded() * step, maximum) + } + + func center(of stop: Stop) -> Double { + switch stop { + case .end: width - stopWidth / 2 + case let .value(value): knobWidth / 2 + (stepped(value) - minimum) / (maximum - minimum) * (rangeEnd - knobWidth) + } + } + + /// The stop under a pointer at `x`. Reaching the end stop takes most of the notch, and so does leaving it, so the + /// knob holds at `maximum` before it snaps over and doesn't flicker between the two at the notch. + func stop(at x: Double, from current: Stop) -> Stop { + if x >= rangeEnd + Self.notch * (current == .end ? 0.25 : 0.75) { return .end } + let fraction = (x - knobWidth / 2) / max(rangeEnd - knobWidth, 1) + return .value(stepped(minimum + fraction * (maximum - minimum))) + } + + /// The stop one step after or before `stop`: the end stop follows `maximum`. + func stop(after stop: Stop, forward: Bool) -> Stop { + switch stop { + case .end: forward ? .end : .value(stepped(maximum)) + case let .value(value): + if forward, stepped(value) + step > maximum + step / 1000 { .end } else { .value(stepped(value + (forward ? step : -step))) } + } + } + + /// The knob centers of the steps, where the slider draws a dot under each, as the native slider does. Empty when + /// the dots would run together. + var ticks: [Double] { + let count = Int(((maximum - minimum) / step + 1e-9).rounded(.down)) + 1 + guard count > 1, (rangeEnd - knobWidth) / Double(count - 1) >= Self.minimumTickSpacing else { return [] } + return (0.. Void + @State private var dragged: EndStopSliderTrack.Stop? + @State private var editing = false + @Environment(\.controlSize) private var controlSize + @Environment(\.controlActiveState) private var activeState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The native slider's measures on macOS 26 and later, per control size. + private struct Metrics { + let height: Double, track: Double, knob: CGSize, tickOffset: Double, labelSize: Double + } + + private var metrics: Metrics { + switch controlSize { + case .mini: Metrics(height: 18, track: 4, knob: CGSize(width: 8, height: 16), tickOffset: 5, labelSize: 8) + case .small: Metrics(height: 20, track: 4, knob: CGSize(width: 10, height: 18), tickOffset: 6, labelSize: 9) + default: Metrics(height: 18, track: 6, knob: CGSize(width: 20, height: 16), tickOffset: 7, labelSize: 10) + } + } + + private var stored: EndStopSliderTrack.Stop { + abs(value - endStop.value) < 1e-9 ? .end : .value(value) + } + + private var shown: EndStopSliderTrack.Stop { dragged ?? stored } + + /// The stops in order, for the arrow keys and VoiceOver, which don't need the track's size. + private var steps: EndStopSliderTrack { + EndStopSliderTrack(width: 0, knobWidth: 0, minimum: minimum, maximum: maximum, step: step) + } + + /// Its title beside it, as the native slider shows its label, unless labels are hidden, as in hover controls. + var body: some View { + LabeledContent { + slider + } label: { + Text(title).accessibilityHidden(true) + } + } + + private var slider: some View { + let metrics = metrics + return GeometryReader { geometry in + let track = EndStopSliderTrack(width: geometry.size.width, knobWidth: metrics.knob.width, + minimum: minimum, maximum: maximum, step: step) + let middle = geometry.size.height / 2 + let knob = track.center(of: shown) + ZStack(alignment: .topLeading) { + bar(from: 0, to: track.rangeEnd, middle: middle, fill: .primary.opacity(0.1)) + bar(from: 0, to: shown == .end ? track.rangeEnd : knob, middle: middle, fill: fill) + Rectangle().fill(.primary.opacity(0.4)) + .frame(width: 1, height: metrics.knob.height * 0.6) + .offset(x: track.notchCenter - 0.5, y: middle - metrics.knob.height * 0.3) + bar(from: track.stopStart, to: track.width, middle: middle, fill: shown == .end ? fill : AnyShapeStyle(.primary.opacity(0.1))) + ForEach(Array(track.ticks.enumerated()), id: \.offset) { _, x in + Circle().fill(.primary.opacity(0.25)).frame(width: 2, height: 2) + .offset(x: x - 1, y: middle + metrics.tickOffset - 1) + } + Text(endStop.label) + .font(.system(size: metrics.labelSize, weight: .semibold)) + .foregroundStyle(.primary.opacity(0.5)) + .fixedSize() + .frame(width: 0) + .offset(x: track.center(of: .end), y: middle + metrics.tickOffset - metrics.labelSize * 0.62) + // Under the knob, like the dots of the other steps, rather than peeking out below a wide one. + .opacity(shown == .end ? 0 : 1) + .accessibilityHidden(true) + RoundedRectangle(cornerRadius: min(metrics.knob.width, metrics.knob.height) / 2) + .fill(.white) + .overlay { + RoundedRectangle(cornerRadius: min(metrics.knob.width, metrics.knob.height) / 2) + .strokeBorder(.black.opacity(colorScheme == .dark ? 0 : 0.08), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.22), radius: 1, y: 0.5) + .frame(width: metrics.knob.width, height: metrics.knob.height) + .offset(x: knob - metrics.knob.width / 2, y: middle - metrics.knob.height / 2) + } + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { drag in move(to: track.stop(at: drag.location.x, from: shown)) } + .onEnded { _ in finish() }) + } + .frame(minWidth: metrics.knob.width * 3 + EndStopSliderTrack.notch, maxWidth: .infinity) + .frame(height: metrics.height) + .focusable(interactions: .activate) + .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow]) { press in + commit(steps.stop(after: shown, forward: press.key == .rightArrow || press.key == .upArrow)) + return .handled + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) + .accessibilityValue(shown == .end ? endStop.label : number(shown)) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: commit(steps.stop(after: shown, forward: true)) + case .decrement: commit(steps.stop(after: shown, forward: false)) + @unknown default: break + } + } + .onChange(of: value) { if !editing { dragged = nil } } + .preference(key: ControlEditingPreference.self, value: editing) + } + + /// The fill of the track up to the knob: the tint in an active window, gray in an inactive one, as macOS draws it. + private var fill: AnyShapeStyle { + activeState == .inactive ? AnyShapeStyle(.primary.opacity(0.22)) : AnyShapeStyle(.tint) + } + + private func bar(from start: Double, to end: Double, middle: Double, fill: some ShapeStyle) -> some View { + Capsule().fill(fill) + .frame(width: max(end - start, 0), height: metrics.track) + .offset(x: start, y: middle - metrics.track / 2) + } + + private func number(_ stop: EndStopSliderTrack.Stop) -> String { + if case let .value(value) = stop { return min(max(value, minimum), maximum).formatted() } + return endStop.label + } + + private func move(to stop: EndStopSliderTrack.Stop) { + editing = true + let previous = shown + guard stop != previous else { return } + if (stop == .end) != (previous == .end) { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + withAnimation(reduceMotion ? nil : .snappy(duration: 0.2)) { dragged = stop } + } else { + dragged = stop + } + } + + private func finish() { + editing = false + if let dragged { onChange(sent(dragged)) } + } + + /// A keyboard or VoiceOver step, which applies at once. + private func commit(_ stop: EndStopSliderTrack.Stop) { + guard stop != shown else { return } + withAnimation(reduceMotion ? nil : .snappy(duration: 0.2)) { dragged = stop } + onChange(sent(stop)) + } + + private func sent(_ stop: EndStopSliderTrack.Stop) -> Double { + switch stop { + case .end: endStop.value + case let .value(value): value + } + } +} diff --git a/Sources/StableBar/PopupConfiguration.swift b/Sources/StableBar/PopupConfiguration.swift index 11381f6..48e9edf 100644 --- a/Sources/StableBar/PopupConfiguration.swift +++ b/Sources/StableBar/PopupConfiguration.swift @@ -50,7 +50,8 @@ indirect enum PopupContent: Sendable { case graph(height: Double, source: String?, lineWidth: Double) case list(source: String, row: PopupBlock) case button(title: String, command: String) - case slider(title: String, source: String, minimum: Double, maximum: Double, step: Double, command: String) + /// A slider over `minimum`…`maximum`, and past a notch the `endStop` when it has one. + case slider(title: String, source: String, minimum: Double, maximum: Double, step: Double, endStop: SliderEndStop?, command: String) case toggle(title: String, source: String, command: String) case segmented(title: String, source: String, options: [PopupControlOption], command: String) /// A pill that opens a panel: the groups in `lists[groupsSource]` beside the chosen group's options and levels, rows of @@ -117,7 +118,7 @@ struct PopupBlock: Decodable, Sendable { case type, role, style, alignment, children, text, field, height, source, row, title, onClick case path, fit, name, animation, styleSource, styleField, onClickField, lineWidth case hideWhenEmpty, scrollSpeed, min, max, step, onChange, options, searchFields, open, openField, quitField, transition - case placeholder, onSubmit, autofocus, wrap, maxHeight, markdown, shimmer, groupsSource, optionsSource, levelsSource + case placeholder, onSubmit, autofocus, wrap, maxHeight, markdown, shimmer, groupsSource, optionsSource, levelsSource, endStop } init(content: PopupContent, role: PresentationRole? = nil, style: WidgetStyle = WidgetStyle(), hideWhenEmpty: String? = nil, monospacedDigits: Bool = true, @@ -207,9 +208,13 @@ struct PopupBlock: Decodable, Sendable { guard minimum < maximum, step > 0, minimum.isFinite, maximum.isFinite, step.isFinite else { throw DecodingError.dataCorruptedError(forKey: .min, in: values, debugDescription: "A slider needs finite min < max and a positive finite step.") } + let endStop = try values.decodeIfPresent(SliderEndStop.self, forKey: .endStop) + if let endStop, (minimum...maximum).contains(endStop.value) { + throw DecodingError.dataCorruptedError(forKey: .endStop, in: values, debugDescription: "An end stop's value must be outside min…max, so the slider can tell it from a step.") + } content = .slider(title: try values.decode(String.self, forKey: .title), source: try values.decode(String.self, forKey: .source), minimum: minimum, maximum: maximum, - step: step, command: try values.decode(String.self, forKey: .onChange)) + step: step, endStop: endStop, command: try values.decode(String.self, forKey: .onChange)) case "toggle": content = .toggle(title: try values.decode(String.self, forKey: .title), source: try values.decode(String.self, forKey: .source), diff --git a/Sources/StableBar/PopupView.swift b/Sources/StableBar/PopupView.swift index ee21fad..850b88e 100644 --- a/Sources/StableBar/PopupView.swift +++ b/Sources/StableBar/PopupView.swift @@ -405,9 +405,17 @@ struct PopupBlockView: View { Text(title).frame(maxWidth: .infinity).contentShape(Rectangle()) } .buttonStyle(.plain) - case let .slider(title, source, minimum, maximum, step, command): - PopupSlider(title: title, value: context.values[source] ?? Double(fields[source] ?? "") ?? minimum, - minimum: minimum, maximum: maximum, step: step) { context.runAction(command, ["VALUE": String($0)]) } + case let .slider(title, source, minimum, maximum, step, endStop, command): + let value = context.values[source] ?? Double(fields[source] ?? "") ?? minimum + if let endStop { + EndStopSlider(title: title, value: value, minimum: minimum, maximum: maximum, step: step, endStop: endStop) { + context.runAction(command, ["VALUE": String($0)]) + } + } else { + PopupSlider(title: title, value: value, minimum: minimum, maximum: maximum, step: step) { + context.runAction(command, ["VALUE": String($0)]) + } + } case let .toggle(title, source, command): PopupToggle(title: title, value: fields[source] == "true" || fields[source] == "1" || context.values[source] == 1) { context.runAction(command, ["VALUE": $0 ? "true" : "false"]) diff --git a/Tests/StableBarTests/EndStopSliderTests.swift b/Tests/StableBarTests/EndStopSliderTests.swift new file mode 100644 index 0000000..2e15e00 --- /dev/null +++ b/Tests/StableBarTests/EndStopSliderTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import StableBar + +private func timer(_ slider: String) -> String { + #"{"widgets":[{"id":"timer","type":"text","text":"T","position":"right","hoverLayout":{"type":"slider","title":"Minutes","source":"duration","min":15,"max":240,"step":15,"onChange":"set","# + + slider + "}}]}" +} + +@Test func aSliderEndStopDecodesWithItsValueAndLabel() throws { + let url = FileManager.default.temporaryDirectory.appending(path: "end-stop.json") + var loader = ConfigurationLoader() + let configuration = try loader.load(url, data: Data(timer(#""endStop":{"value":0,"label":"∞"}"#).utf8)) + guard case let .slider(_, _, minimum, maximum, step, endStop, _) = configuration.widgets.first?.hoverLayout?.content else { + Issue.record("Expected a slider") + return + } + #expect([minimum, maximum, step] == [15, 240, 15]) + #expect(endStop == SliderEndStop(value: 0, label: "∞")) + let plain = try loader.load(url, data: Data(timer(#""style":{"width":104}"#).utf8)) + guard case let .slider(_, _, _, _, _, none, _) = plain.widgets.first?.hoverLayout?.content else { + Issue.record("Expected a slider") + return + } + #expect(none == nil) +} + +@Test func sliderEndStopMistakesNameTheirProblem() throws { + let url = FileManager.default.temporaryDirectory.appending(path: "end-stop.json") + var loader = ConfigurationLoader() + let mistakes = [ + #""endStop":{"value":60,"label":"∞"}"#: "widgets.timer: hoverLayout.endStop: An end stop's value must be outside min…max, so the slider can tell it from a step.", + #""endStop":{"value":0,"label":" "}"#: "widgets.timer: hoverLayout.endStop.label: An end stop needs a label, such as ∞.", + #""endStop":{"value":0,"label":"∞","symbol":"infinity"}"#: "widgets.timer: hoverLayout.endStop.symbol: Unknown property.", + ] + for (slider, message) in mistakes { + let error = #expect(throws: (any Error).self) { try loader.load(url, data: Data(timer(slider).utf8)) } + #expect(error.map(errorMessage) == message, "\(slider)") + } + #expect(throws: (any Error).self) { try loader.load(url, data: Data(timer(#""endStop":{"value":0}"#).utf8)) } +} + +/// A 104-point slider with the small control size's 10-point knob, over Amphetamine's 15–240 minutes. +private let minutes = EndStopSliderTrack(width: 104, knobWidth: 10, minimum: 15, maximum: 240, step: 15) + +@Test func anEndStopSitsPastANotchAfterTheSteps() { + #expect(minutes.rangeEnd == 80) + #expect(minutes.notchCenter == 84) + #expect(minutes.stopStart == 88) + #expect(minutes.center(of: .value(15)) == 5) + #expect(minutes.center(of: .value(240)) == 75) + #expect(minutes.center(of: .end) == 96) + #expect(minutes.center(of: .value(1000)) == 75, "Values past max sit on max, not on the end stop") + #expect(minutes.ticks.count == 16) + #expect(minutes.ticks.first == 5) + #expect(minutes.ticks.last == 75) +} + +@Test func draggingPastTheMaximumHoldsTheKnobUntilWellIntoTheNotch() { + #expect(minutes.stop(at: 75, from: .value(225)) == .value(240)) + #expect(minutes.stop(at: 85.9, from: .value(240)) == .value(240)) + #expect(minutes.stop(at: 86, from: .value(240)) == .end) + #expect(minutes.stop(at: 82, from: .end) == .end, "Leaving takes most of the notch too") + #expect(minutes.stop(at: 81.9, from: .end) == .value(240)) + #expect(minutes.stop(at: -20, from: .end) == .value(15)) + #expect(minutes.stop(at: 40, from: .value(15)) == .value(135), "Halfway along 70 points is 127.5, the nearest step 135") +} + +@Test func arrowKeysStepFromTheMaximumOntoTheEndStopAndBack() { + #expect(minutes.stop(after: .value(225), forward: true) == .value(240)) + #expect(minutes.stop(after: .value(240), forward: true) == .end) + #expect(minutes.stop(after: .end, forward: true) == .end) + #expect(minutes.stop(after: .end, forward: false) == .value(240)) + #expect(minutes.stop(after: .value(15), forward: false) == .value(15)) + let uneven = EndStopSliderTrack(width: 104, knobWidth: 10, minimum: 0, maximum: 1, step: 0.3) + #expect(uneven.stop(after: .value(0.9), forward: true) == .end, "The last step a step can't pass leads to the end stop") +} + +@Test func stepsTooCloseForDotsDrawNone() { + #expect(EndStopSliderTrack(width: 104, knobWidth: 10, minimum: 0, maximum: 1, step: 0.01).ticks.isEmpty) +} diff --git a/Tests/StableBarTests/ModuleConfigurationTests.swift b/Tests/StableBarTests/ModuleConfigurationTests.swift index 0e46d83..75e3cfc 100644 --- a/Tests/StableBarTests/ModuleConfigurationTests.swift +++ b/Tests/StableBarTests/ModuleConfigurationTests.swift @@ -447,7 +447,8 @@ private func schemaProperties(_ definition: ConfigurationValue?) throws -> Set