Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5c825d5
Add a Swift Testing target
Jeehut Oct 13, 2026
5728bbe
Write the inverted value through a negated Bool binding
Jeehut Oct 13, 2026
90da0d6
Encode NSImage.jpegData as JPEG instead of JPEG 2000
Jeehut Oct 13, 2026
65c6e48
Strip symbol metadata in Image and Label convertible initializers
Jeehut Oct 13, 2026
461ad39
Respect muteContent in HorizontalLabeledContentStyle
Jeehut Oct 13, 2026
05fc7fc
End the loading state of CachedAsyncImage on failed downloads
Jeehut Oct 13, 2026
a6e6391
Load the URL of WebView only when it changed
Jeehut Oct 13, 2026
c45f215
Avoid force-casting allCases in HPicker and VPicker
Jeehut Oct 13, 2026
ad1cf73
Localize the remaining texts of SearchableGridPicker and MultiSelector
Jeehut Oct 13, 2026
148c149
Build with swiftbuild in CI so the String Catalog is compiled
Jeehut Oct 13, 2026
e3f3f67
Return a hex string for dynamic and grayscale colors in toHex
Jeehut Oct 13, 2026
837d937
Change opacity without a round trip through HLC
Jeehut Oct 13, 2026
a05a35c
Highlight the right characters when folding changes the length
Jeehut Oct 13, 2026
4a78a19
Describe precisely what isPersisted checks
Jeehut Oct 13, 2026
9dc9437
Move Text(format:) to Swift Regex and text interpolation
Jeehut Oct 13, 2026
7b1be71
Turn the #warning TODOs into regular TODO comments
Jeehut Oct 13, 2026
e225917
Avoid the deprecated onChange(of:perform:) on newer OS versions
Jeehut Oct 13, 2026
c54d988
Show a failed CachedAsyncImage download as a failure
Jeehut Oct 13, 2026
28df8ef
Test the WebView update path instead of a helper function
Jeehut Oct 13, 2026
97fe31b
Derive the expected languages from the String Catalog
Jeehut Oct 13, 2026
ebce0a1
Document the format key and the trait resolution of toHex
Jeehut Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ jobs:
- uses: actions/checkout@v4

- name: Build package
run: swift build
run: swift build --build-system swiftbuild

- name: Run tests
if: hashFiles('Tests/**') != ''
run: swift test
run: swift test --build-system swiftbuild
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ let package = Package(
name: "HandySwiftUI",
dependencies: [.product(name: "HandySwift", package: "HandySwift")],
resources: [.process("Localizable.xcstrings")]
)
),
.testTarget(name: "HandySwiftUITests", dependencies: ["HandySwiftUI"]),
]
)
2 changes: 1 addition & 1 deletion Sources/HandySwiftUI/Extensions/BindingExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import SwiftUI
public prefix func ! (value: Binding<Bool>) -> Binding<Bool> {
Binding<Bool>(
get: { !value.wrappedValue },
set: { value.wrappedValue = $0 }
set: { value.wrappedValue = !$0 }
)
}

Expand Down
48 changes: 28 additions & 20 deletions Sources/HandySwiftUI/Extensions/ColorExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ extension Color {
return self.change(attribute, to: self.hlco.chroma + addition)

case .opacity:
return self.change(attribute, to: self.hlco.opacity + addition)
return self.change(attribute, to: self.rgbo.opacity + addition)
}
}

Expand All @@ -445,13 +445,14 @@ extension Color {
/// - Returns: The resulting new `Color` with the specified change applied.
public func change(_ attribute: ChangeableAttribute, to newValue: Double) -> Self {
switch attribute {
case .red, .green, .blue:
case .red, .green, .blue, .opacity:
// Opacity is the same in every color space, so the RGB path changes it without a lossy round trip through HLC
return self.newRgboColor(attribute, newValue)

case .hueHSB, .saturation, .brightness:
return self.newHsboColor(attribute, newValue)

case .hueHLC, .luminance, .chroma, .opacity:
case .hueHLC, .luminance, .chroma:
return self.newHlcoColor(attribute, newValue)
}
}
Expand All @@ -469,9 +470,6 @@ extension Color {
case .chroma:
newHlco.chroma = newValue

case .opacity:
newHlco.opacity = newValue

default:
break
}
Expand All @@ -492,9 +490,6 @@ extension Color {
case .brightness:
newHsbo.brightness = newValue

case .opacity:
newHsbo.opacity = newValue

default:
break
}
Expand Down Expand Up @@ -543,26 +538,39 @@ extension Color {

/// Converts the `Color` instance to a hexadecimal string representation.
///
/// Dynamic colors like `.primary` resolve through `UIColor`/`NSColor` against the traits current at the time of the call:
/// `UITraitCollection.current` on iOS-based platforms, which outside of trait-aware callbacks usually holds default traits, and
/// `NSAppearance.currentDrawing()` on macOS. For the color a view actually displays, resolve it in the view's environment first
/// (e.g. with `rgbo(in:)`). Wide-gamut colors are clamped to sRGB.
///
/// - Parameter alpha: A Boolean value indicating whether the output string should include the alpha component.
/// If `true`, the alpha channel is included (e.g., "#RRGGBBAA"). If `false`, only RGB values are included (e.g., "#RRGGBB").
/// If `true`, the alpha channel is included (e.g., "RRGGBBAA"). If `false`, only RGB values are included (e.g., "RRGGBB").
/// Defaults to `false`.
/// - Returns: A hexadecimal string representing the color, or `nil` if the color components could not be retrieved.
/// - Returns: A hexadecimal string without a leading `#` representing the color, or `nil` if the color components could not be retrieved.
public func toHex(alpha: Bool = false) -> String? {
guard let components = cgColor?.components, components.count >= 3 else { return nil }
// Read the components through the platform color in sRGB: unlike `cgColor`, which is `nil` for dynamic colors (e.g. `.primary`,
// `.systemBlue`) and has only two components for grayscale colors, this works for every color
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var opacity: CGFloat = 0

let r = Float(components[0])
let g = Float(components[1])
let b = Float(components[2])
var a = Float(1.0)
#if os(macOS)
guard let color = NativeColor(self).usingColorSpace(.sRGB) else { return nil }
color.getRed(&red, green: &green, blue: &blue, alpha: &opacity)
#else
guard NativeColor(self).getRed(&red, green: &green, blue: &blue, alpha: &opacity) else { return nil }
#endif

if components.count >= 4 {
a = Float(components[3])
// Extended sRGB components of wide-gamut colors can lie outside of 0...1, hex can only represent the sRGB gamut
func hexByte(_ component: CGFloat) -> Int {
lroundf(Float(min(max(component, 0), 1)) * 255)
}

if alpha {
return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255))
return String(format: "%02lX%02lX%02lX%02lX", hexByte(red), hexByte(green), hexByte(blue), hexByte(opacity))
} else {
return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255))
return String(format: "%02lX%02lX%02lX", hexByte(red), hexByte(green), hexByte(blue))
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/HandySwiftUI/Extensions/ImageExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ extension Image {
/// let imageView = Image(convertible: symbol)
/// ```
public init(convertible: CustomSymbolConvertible, variableValue: Double? = nil) {
self.init(systemName: convertible.symbolName, variableValue: variableValue)
self.init(systemName: convertible.symbolSystemName, variableValue: variableValue)
}
}

Expand Down Expand Up @@ -51,7 +51,7 @@ extension Image {
else { return nil }

return bitmapImage.representation(
using: .jpeg2000,
using: .jpeg,
properties: [NSBitmapImageRep.PropertyKey.compressionFactor: NSNumber(value: compressionQuality)]
)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/HandySwiftUI/Extensions/LabelExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ extension Label<Text, Image> {
/// // Creates a Label with "Settings" text and a gear icon
/// ```
public init(convertible: CustomLabelConvertible) {
self.init(convertible.description, systemImage: convertible.symbolName)
self.init(convertible.description, systemImage: convertible.symbolSystemName)
}
}
8 changes: 4 additions & 4 deletions Sources/HandySwiftUI/Extensions/PersistentModelExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension PersistentModel {
/// Returns whether this model instance is currently managed by a ModelContext.
/// Returns whether this model instance is currently managed by a `ModelContext`.
///
/// Use this property to determine if a model instance has ever been inserted into
/// a ModelContext (i.e., exists in the database) or if it's just an in-memory instance
/// that hasn't been saved yet.
/// Use this property to determine if a model instance has been inserted into a `ModelContext` or if it's just an in-memory
/// instance that was never inserted. Inserting is enough for this to be `true`: the model might not be saved to the store yet.
/// To check whether it was saved, use `persistentModelID.isTemporary` (iOS 27, macOS 27, tvOS 27, watchOS 27, visionOS 27).
///
/// Example:
/// ```swift
Expand Down
17 changes: 10 additions & 7 deletions Sources/HandySwiftUI/Extensions/StringExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ extension String {
/// - Parameters:
/// - searchText: The text to search for within the string.
/// - locale: The locale to use for string comparisons. Defaults to `nil`.
/// - applytingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used.
/// - applyingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used.
///
/// - Returns: An `AttributedString` with matching prefixes highlighted.
///
Expand All @@ -60,7 +60,6 @@ extension String {
/// let highlighted = text.highlightMatchingTokenizedPrefixes(in: "He Wo")
/// // Returns an AttributedString with "He" and "Wo" in bold
/// ```
@available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *)
public func highlightMatchingTokenizedPrefixes(
in searchText: String,
locale: Locale? = nil,
Expand All @@ -71,12 +70,16 @@ extension String {
}
) -> AttributedString {
var attributedSelf = AttributedString(self)
let normalizedSelf = self.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale)

for token in searchText.tokenized() {
if let range = AttributedString(normalizedSelf).range(of: token) {
applyingAttributes(&attributedSelf[range])
}
for token in searchText.tokenized(locale: locale) where !token.isEmpty {
// Search the original string with the folding as comparison options instead of searching a folded copy: folding can change
// the length (e.g. "é" or full-width letters), so ranges found in a folded copy point to the wrong characters in the original
guard
let stringRange = self.range(of: token, options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale),
let range = Range(stringRange, in: attributedSelf)
else { continue }

applyingAttributes(&attributedSelf[range])
}

return attributedSelf
Expand Down
47 changes: 23 additions & 24 deletions Sources/HandySwiftUI/Extensions/TextExt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,45 +34,44 @@ extension Text {
var subtexts: [Text] = []
var previousRange: Range<String.Index>?

let regex = try! HandyRegex(#"<([^<>]+)>([^<>]+)</([^<>]+)>|<([^<>]+)/>"#)
for match in regex.matches(in: formatString) {
// Either a paired tag like `<b>bold</b>` (captures: opening tag, content, closing tag) or a symbol tag like `<lock.filled/>`
let regex = #/<([^<>]+)>([^<>]+)</([^<>]+)>|<([^<>]+)/>/#
for match in formatString.matches(of: regex) {
let prefix = formatString[(previousRange?.upperBound ?? formatString.startIndex)..<match.range.lowerBound]
if !prefix.isEmpty {
subtexts.append(Text(prefix))
subtexts.append(Text(verbatim: String(prefix)))
}

let captures = match.captures.compactMap { $0 }
switch captures.count {
case 3: // the first part matched, e.g. `<b>bold</b>` => ["b", "bold", "b"]
if let openingTag = match.output.1, let content = match.output.2, let closingTag = match.output.3 {
guard
captures[0] == captures[2],
let style = partialStyling[captures[0]]
openingTag == closingTag,
let style = partialStyling[String(openingTag)]
else {
subtexts.append(Text(match.string))
subtexts.append(Text(verbatim: String(match.output.0)))
previousRange = match.range
continue
}

subtexts.append(style(Text(captures[1])))
previousRange = match.range

case 1: // the second part matched, e.g. `<lock.filled/>` => ["lock.filled"]
if let style = partialStyling[captures[0]] {
subtexts.append(style(Text(Image(systemName: captures[0]))))
} else {
subtexts.append(Text(Image(systemName: captures[0])))
}
previousRange = match.range

default:
fatalError("A match should have exactly 1 or 3 captures, found: \(captures).")
subtexts.append(style(Text(verbatim: String(content))))
} else if let symbolName = match.output.4 {
let symbolText = Text(Image(systemName: String(symbolName)))
subtexts.append(partialStyling[String(symbolName)]?(symbolText) ?? symbolText)
}

previousRange = match.range
}

let suffix = String(formatString[(previousRange?.upperBound ?? formatString.startIndex)..<formatString.endIndex])
subtexts.append(Text(suffix))
subtexts.append(Text(verbatim: suffix))

self = subtexts.reduce(Text(verbatim: "")) { $0 + $1 }
// Joins the parts by interpolating them into one text, as concatenating texts with `+` is deprecated starting with iOS 26
var interpolation = LocalizedStringKey.StringInterpolation(literalCapacity: 0, interpolationCount: subtexts.count)
for subtext in subtexts {
interpolation.appendInterpolation(subtext)
}
// The resulting key is "%@%@…" and is looked up in the module bundle on purpose: it must never get a catalog entry, so the
// lookup always falls back to the key itself and only the interpolated parts are shown
self.init(LocalizedStringKey(stringInterpolation: interpolation), bundle: .module)
}
}

Expand Down
Loading
Loading