/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import SwiftUI struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } } struct MeasureSizeModifier: ViewModifier { func body(content: Content) -> some View { content.background(GeometryReader { geometry in Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size) }) } } struct MarqueeText: View { @Binding var text: String let font: Font let nsFont: NSFont.TextStyle let textColor: Color let backgroundColor: Color let minDuration: Double let frameWidth: CGFloat @State private var animate = false @State private var textSize: CGSize = .zero @State private var offset: CGFloat = 0 init(_ text: Binding, font: Font = .body, nsFont: NSFont.TextStyle = .body, textColor: Color = .primary, backgroundColor: Color = .clear, minDuration: Double = 3.0, frameWidth: CGFloat = 200) { _text = text self.font = font self.nsFont = nsFont self.textColor = textColor self.backgroundColor = backgroundColor self.minDuration = minDuration self.frameWidth = frameWidth } private var needsScrolling: Bool { textSize.width > frameWidth } var body: some View { GeometryReader { geometry in ZStack(alignment: .leading) { HStack(spacing: 20) { Text(text) Text(text) .opacity(needsScrolling ? 1 : 0) } .id(text) .font(font) .foregroundColor(textColor) .fixedSize(horizontal: true, vertical: false) .offset(x: self.animate ? offset : 0) .animation( self.animate ? .linear(duration: Double(textSize.width / 30)) .delay(minDuration) .repeatForever(autoreverses: false) : .none, value: self.animate ) .background(backgroundColor) .modifier(MeasureSizeModifier()) .onPreferenceChange(SizePreferenceKey.self) { size in self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize) self.animate = false self.offset = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.02){ if needsScrolling { self.animate = true self.offset = -(textSize.width + 20) } } } .onChange(of: text) { _, _ in // Reset animation when text changes self.animate = false self.offset = 0 DispatchQueue.main.asyncAfter(deadline: .now() + 0.02){ if needsScrolling { self.animate = true self.offset = -(textSize.width + 20) } } } } .frame(width: frameWidth, alignment: .leading) .clipped() } .frame(height: textSize.height * 1.3) } }