Skip to content

Fix bugs in existing helpers - #7

Open
Jeehut wants to merge 21 commits into
mainfrom
wip/fix-helper-bugs
Open

Jeehut wants to merge 21 commits into
mainfrom
wip/fix-helper-bugs

Conversation

@Jeehut

@Jeehut Jeehut commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Overview

This PR fixes bugs in existing helpers: eight from a first pass, plus a second set listed under "Further fixes". It also adds the package's first test target (Swift Testing), and every fix comes with a test that fails on the code from main and passes on this branch. One commit per fix.

# Helper Bug on main Fix
1 prefix func ! on Binding<Bool> The setter wrote $0 unchanged, so Toggle(isOn: !$isOff) stored the opposite of the user's choice Writes !$0
2 NSImage.jpegData(compressionQuality:) Encoded with .jpeg2000 Encodes with .jpeg
3 Image(convertible:), Label(convertible:) Passed the raw symbolName, including the :angle*count metadata the protocol documents as ignored, which is not a valid symbol name. Also affected the CaseIterable Picker initializer and SideTabView Use symbolSystemName like CustomLabelConvertible.label already did
4 HorizontalLabeledContentStyle muteContent was ignored; content was always .secondary Applies .secondary only when muteContent is true
5 CachedAsyncImage A failed download (no data, or not an image) returned early, so the progress view spun forever Loading lives in an internal Loader with explicit idle / loading / loaded / failed phases. A failure shows a neutral photo placeholder and is not retried automatically, only when the view appears again or the URL changes (.task(id:), which also cancels the download on disappear)
6 WebView Loaded its URL in every updateUIView/updateNSView, i.e. on any state change of the parent view, which reset navigation and scroll position The coordinator remembers the loaded URL and loads again only when it changed. It also updates parent, so a new onOpenExternalURL closure takes effect
7 HPicker, VPicker T.allCases as! [T] crashed for CaseIterable types whose AllCases is not an Array Array(T.allCases)
8 SearchableGridPicker, MultiSelector "None" and the macOS "Done" were looked up in the app's main bundle, so they stayed English. The empty search state said "No emojis match …" even when listing SF Symbols, and was not localized either "None"/"Done" use bundle: .module (both keys already had all 37 translations). The empty state is now ContentUnavailableView.search(text:) (iOS 17, macOS 14, tvOS 17, watchOS 10, the same availability as SearchableGridPicker), which is neutral about the option type and uses Apple's wording in every language. The two catalog keys it replaces ("No matches", "No emojis match '%@'") are removed as unused

For 4 and 8, rendered pixels are compared via ImageRenderer. 5 and 6 are tested through their real code paths (see "Review follow-up").

How this was verified

To produce the red runs, a script reverted exactly the fixed lines to their main behavior: for 5 and 6, the extracted functions returned what the old code did ("stay loading" on failure, "always load"). Tests were then run with swift test.

Red (fixes reverted, swift test --skip PickerTests):

✘ Test negatedBindingWritesTheInvertedValue() failed with 2 issues.
     Expectation failed: stored == true
✘ Test jpegDataEncodesAsJPEG() failed with 1 issue.
     Expectation failed: Array(data.prefix(3)) == [0xFF, 0xD8, 0xFF]
✘ Test imageUsesTheSymbolNameWithoutMetadata() failed with 1 issue.
✘ Test labelUsesTheSymbolNameWithoutMetadata() failed with 1 issue.
✘ Test unmutedContentIsPrimary() failed with 1 issue.
✘ Test failedDownloadEndsLoading() failed with 1 issue.
✘ Test doesNotReloadTheSameURL() failed with 1 issue.
✘ Test noSelectionTextIsLocalized(language:) with 37 test cases failed with 36 issues.
✘ Test doneButtonTextIsLocalized(language:) with 37 test cases failed with 36 issues.
✔ Test mutedContentIsSecondary() passed
✔ Test successfulDownloadEndsLoadingWithImage() passed
✔ Test loadsWhenNothingWasLoadedYet() passed
✔ Test loadsWhenTheURLChanged() passed
✔ Test catalogCoversAllLanguages() passed
✔ Test keyIsTranslatedIntoEveryLanguage(key:) with 2 test cases passed
✘ Test run with 15 tests in 7 suites failed with 80 issues.

The tests for 5 and 6 and the language count in this run were later replaced by tests of the real code paths, see "Review follow-up".

The passing tests in the red run cover behavior that was already right on main (muted content, successful downloads, first load and URL change). They guard the fixes against regressions. The two catalog tests are green on both sides because the keys were never missing; only the bundle: argument was. The 36 issues per localization test are every catalog language except en-GB, whose value equals the English source.

Red for the picker fix (separate run, since the old code crashes the test process):

$ swift test --filter PickerTests
Test hPickerAcceptsNonArrayAllCases() started.
Test vPickerAcceptsNonArrayAllCases() started.
Could not cast value of type 'Swift.ContiguousArray<HandySwiftUITests.PickerTests.Fruit>' (0x7a1b024d88) to 'Swift.Array<HandySwiftUITests.PickerTests.Fruit>' (0x7a1b024fc0).
error: … exited with unexpected signal code 6

Green (this branch):

✔ Test negatedBindingWritesTheInvertedValue() passed
✔ Test jpegDataEncodesAsJPEG() passed
✔ Test imageUsesTheSymbolNameWithoutMetadata() passed
✔ Test labelUsesTheSymbolNameWithoutMetadata() passed
✔ Test mutedContentIsSecondary() passed
✔ Test unmutedContentIsPrimary() passed
✔ Test failedDownloadEndsLoading() passed
✔ Test successfulDownloadEndsLoadingWithImage() passed
✔ Test loadsWhenNothingWasLoadedYet() passed
✔ Test doesNotReloadTheSameURL() passed
✔ Test loadsWhenTheURLChanged() passed
✔ Test hPickerAcceptsNonArrayAllCases() passed
✔ Test vPickerAcceptsNonArrayAllCases() passed
✔ Test catalogCoversAllLanguages() passed
✔ Test keyIsTranslatedIntoEveryLanguage(key:) with 2 test cases passed
✔ Test noSelectionTextIsLocalized(language:) with 37 test cases passed
✔ Test doneButtonTextIsLocalized(language:) with 37 test cases passed
✔ Test run with 17 tests in 8 suites passed after 0.595 seconds.

The localization tests check that the text no longer renders as the English source under each language's locale. They do not compare pixels with the expected translation, because localized texts pick language-specific glyphs (Chinese, Bulgarian) that a verbatim reference text does not get. That made an exact comparison fail for those languages even with the right string.

Builds: swift build (macOS) and xcodebuild for generic iOS, tvOS and visionOS. Their deduplicated diagnostics (file + message) were identical to main's: 8, 12, 11 and 14 entries, no new warnings. The test target compiles without warnings. watchOS fails to build on main already ('UIApplication' is unavailable in watchOS, GroupBox, the semantic Color statics, …), and this branch neither fixes nor adds to that. The string catalog check of the internal localization tooling exited 0 with 20 keys × 37 languages and nothing open.

Further fixes

Helper Bug on main Fix
Color.toHex(alpha:) Read cgColor, which is nil for dynamic colors (.primary, .systemBlue, …) and has two components in a gray color space, so it returned nil for both Reads sRGB components through UIColor/NSColor, which resolve dynamic colors against the current appearance, and clamps wide-gamut values to sRGB. The doc comment now says the result has no leading #
Color.change(.opacity, …) Routed opacity through HLC and back, shifting RGB by up to ~1.7e-6 (green 0 → 1.7e-6). The .opacity cases of the HSB and HLC helpers were never reached Opacity goes through the RGB path; the dead cases are removed
highlightMatchingTokenizedPrefixes Applied ranges found in a folded copy to the original string. Folding changes the length of e.g. "é" or full-width letters, so "Café Olé" / "ole" highlighted " Ol" Searches the original with the folding as comparison options, passes locale on to the tokenization, and drops the @available that only repeated the package minimum
PersistentModel.isPersisted The docs said "exists in the database", but the property is true right after insert(_:) Docs describe that and point to persistentModelID.isTemporary (OS 27) for the saved state
Text(format:partialStyling:) Used the deprecated HandyRegex and Text's +, which is deprecated starting with iOS 26 Swift Regex literal, and the parts are joined by interpolating them into one Text. Rendering is unchanged
CachedAsyncImage, LimitedTextField onChange(of:perform:) warned on every visionOS build (deprecated from visionOS 1.0) An internal onValueChange(of:perform:) uses the two-parameter onChange where available
Four #warnings (ViewExt, CachedAsyncImage, Emoji, SFSymbol) Every app depending on the package saw them as build warnings Regular // TODO: comments

Not changed: the @State in PulsatingButtonStyle is not a bug. A probe style with a @State flag that is set in onAppear was hosted in an NSHostingView, and makeBody was called again with the updated value (rendered flags: [false, true]), so SwiftUI installs the state of button styles.

How the further fixes were verified

Red (the sources of this second set reverted to the previous commit of this branch, tests kept):

✘ Test hexOfDynamicColors() failed with 1 issue.        Expectation failed: color.toHex()
✘ Test hexOfGrayscaleColor() failed with 1 issue.       Expectation failed: gray.toHex()
✘ Test changingOpacityKeepsTheRGBComponents(red:green:blue:) with 3 test cases failed with 4 issues.
✘ Test highlightsTheMatchingCharactersWhenFoldingChangesTheLength(text:searchText:expected:) with 4 test cases failed with 4 issues.
     "Café Olé"/"ole", "Hello World"/"wor", "Ärger über alles"/"alles" and /"uber"
✔ Test highlightsPlainMatches() passed
✔ Test hexRoundTrips() passed
✔ Test hexOfWideGamutColorIsClampedToSRGB() passed
✔ Test textWithoutTags() / pairedTags() / symbolTags() / unknownAndMismatchedTagsStayLiteral() / textWithPercentSigns() passed
✘ Test run with 12 tests in 3 suites failed with 10 issues.

The five TextFormatTests pass on the old and the new implementation on purpose: they compare Text(format:) pixel by pixel with texts concatenated by hand, and the old implementation passing them shows the rendering did not change. That they can fail at all was checked by temporarily dropping the styling of paired tags, which made pairedTags, unknownAndMismatchedTagsStayLiteral and textWithPercentSigns fail.

Green, the whole suite on this branch:

$ swift test --build-system swiftbuild
✔ Test run with 29 tests in 11 suites passed after 0.473 seconds.

Builds: compared with main, the deduplicated diagnostics now only lose entries. On macOS, iOS and tvOS the two HandyRegex deprecations and the four #warnings are gone; visionOS also loses the two onChange(of:perform:) deprecations. The two remaining warnings on every platform are the unused throwing Tasks in AsyncButton and WebView, which this PR does not touch:

macOS:    main=8  branch=2
iOS:      main=8  branch=2
tvOS:     main=7  branch=1
visionOS: main=10 branch=2

Combined check: main with this PR, #6 and #8 merged locally builds for watchOS, iOS and visionOS and passes all 34 tests of the combined suites.

Review follow-up

  • 5, CachedAsyncImage: the first fix ended the loading state on failure, but that state then looked exactly like "never started". Failure is now its own phase (see the table). CachedAsyncImageTests run the real Loader with stubbed downloads: a thrown error and non-image data both end in .failed, and a successful load ends in .loaded and is served from the cache on the next load (one download for two loads).
  • 6, WebView: WebViewTests call the representable's real update(_:coordinator:) with a WKWebView subclass that records load requests. Three updates with the same URL load once, and a URL change loads the new URL once and updates the coordinator's parent. The suite runs on the main actor, and the test target compiles without warnings. The tests have to write HandySwiftUI.WebView because they import WebKit, the same ambiguity Deprecate WebView and add an environment-aware Color.rgbo(in:) #6 documents.
  • Localization: the expected languages are read from Localizable.xcstrings and compared with the compiled bundle, instead of a hard-coded 37.
  • Docs: Text(format:) has a comment that its %@%@… key is looked up in the module bundle on purpose and must never get a catalog entry. toHex now says that dynamic colors resolve against the traits current at the time of the call (UITraitCollection.current, usually default traits outside of trait-aware callbacks; NSAppearance.currentDrawing() on macOS), and points to resolving in the view's environment first.

Red runs with the OLD code restored. update(_:coordinator:) got back the exact body of main's updateNSView (load, then set the delegate). The loader got main's failure behavior (return without changing state, i.e. stay loading) and, in a second run, the previous commit's behavior (back to idle):

main's code:
✘ Test repeatedUpdatesLoadTheURLOnlyOnce() failed with 1 issue.      webView.loadedURLs == [self.pageURL]
✘ Test updateWithChangedURLLoadsTheNewURL() failed with 2 issues.    loadedURLs, coordinator.parent.url
✘ Test failedDownloadEndsInFailedPhase() failed with 1 issue.        loader.phase == .failed
✘ Test dataThatIsNotAnImageEndsInFailedPhase() failed with 1 issue.  loader.phase == .failed
✔ Test successfulDownloadEndsInLoadedPhaseAndIsCached() passed
✘ Test run with 5 tests in 2 suites failed with 5 issues.

previous commit (failure back to idle):
✘ Test failedDownloadEndsInFailedPhase() failed with 1 issue.
✘ Test dataThatIsNotAnImageEndsInFailedPhase() failed with 1 issue.
✔ Test successfulDownloadEndsInLoadedPhaseAndIsCached() passed
✘ Test run with 3 tests in 1 suite failed with 2 issues.

Green, whole suite, with both build systems:
$ swift test --build-system swiftbuild
✔ Test run with 29 tests in 11 suites passed after 0.381 seconds.
$ swift test
✔ Test run with 29 tests in 11 suites passed after 0.263 seconds.

CI

The workflow now runs swift build and swift test with --build-system swiftbuild. On the macos-26 runner (Xcode 26), the native build system copies Localizable.xcstrings without compiling it into .lproj folders, so the localization tests found no languages there; swiftbuild compiles the catalog the way Xcode does.

Jeehut added 17 commits October 13, 2026 13:11
The package had no tests, so fixes could not be proven by a test that
fails on the old code and passes on the new one. The shared helper
renders a view into pixel data, which lets view-level behavior be
compared without a host app.
The setter of `!binding` wrote the new value unchanged into the
original binding, so toggling a control bound to `!$isOff` stored the
opposite of what the user chose.
The method used the .jpeg2000 file type, so callers expecting a JPEG
received a JPEG 2000 file that many consumers (browsers, upload APIs)
cannot read.
CustomSymbolConvertible allows metadata after `:` or `*` (rotation
and repeat count used by HPicker and VPicker) and documents that it is
ignored when creating images. Image(convertible:) and
Label(convertible:) passed the raw name though, so such symbols showed
no icon at all, also in the CaseIterable Picker initializer and
SideTabView that build on them.
The style always rendered the content in the secondary style, so
`.horizontal(muteContent: false)` had no effect.
When a download returned no data or data that is not an image, the
completion handler returned early and isLoading stayed true, so the
progress view spun forever. The state after a download is now decided
by a small pure function that ends loading in both cases.
SwiftUI calls updateUIView/updateNSView on every state change of the
surrounding view. WebView loaded its URL in there each time, which
threw away pages the user had navigated to and reset the scroll
position. The coordinator now remembers the loaded URL and also keeps
its parent current, so a changed onOpenExternalURL closure is used.
CaseIterable only requires AllCases to be a Collection, so a type with
a non-Array AllCases crashed the CaseIterable initializers with a
failed `as! [T]` cast. Array(T.allCases) works for every collection.
"None" in SearchableGridPicker and "Done" in the macOS sheet of
MultiSelector were looked up in the app's main bundle, so they stayed
English although the package's catalog has them in 37 languages. They
now use bundle: .module.

The empty search result showed "No emojis match", which is wrong when
the picker lists SF Symbols, and was not localized either. It now uses
the system's ContentUnavailableView.search(text:), which is available
wherever SearchableGridPicker is and uses Apple's own wording in every
language. The two catalog keys it replaces are removed as unused.
The native SwiftPM build system of Xcode 26 copies Localizable.xcstrings
without compiling it into .lproj folders, so the new localization tests
found no languages on the runner. The swiftbuild build system compiles
the catalog the way Xcode does.
toHex read the components from cgColor, which is nil for dynamic
colors like .primary or .systemBlue and has only two components for
colors in a gray color space, so it returned nil for all of them. It
now reads sRGB components through UIColor/NSColor, which resolves
dynamic colors against the current appearance, and clamps wide-gamut
components to the sRGB range hex can represent.
change(.opacity, …) converted the color to HLC and back just to set
the opacity, which shifted the RGB components by up to about 1e-6
(e.g. green 0 became 1.7e-6). Opacity is the same in every color
space, so it now goes through the RGB path. The never reached
.opacity branches of the HSB and HLC helpers are removed.
highlightMatchingTokenizedPrefixes searched a folded copy of the
string and applied the found range to the original. Folding changes
the length of characters like "é" or full-width letters, so with
"Café Olé" and the search "ole" it highlighted " Ol" instead of
"Olé". It now searches the original string with the folding as
comparison options, passes the locale on to the tokenization, and
drops the @available attribute that only repeated the package's
minimum versions.
The documentation said the model "exists in the database", but the
property only checks whether it was inserted into a ModelContext,
which is true before the context is saved. It now says so and points
to persistentModelID.isTemporary for the saved state.
The initializer used HandyRegex, which HandySwift deprecated, and
joined the parts with Text's + operator, which is deprecated starting
with iOS 26. It now uses a Swift Regex literal and interpolates the
parts into one text. The rendering stays the same, which tests compare
pixel by pixel against texts concatenated by hand.
The four #warning directives were notes for the package itself, but
every app depending on HandySwiftUI saw them as build warnings.
CachedAsyncImage and LimitedTextField used onChange(of:perform:),
which is deprecated starting with iOS 17 and from the start on
visionOS, so every visionOS build warned. Its replacement needs
iOS 17, above the package minimum, so a small internal helper picks
the variant that fits the running OS.
@Jeehut Jeehut changed the title Fix bugs in eight existing helpers Fix bugs in existing helpers Oct 13, 2026
After a failed download the view was back in its initial state, which
looks exactly like a load that never started. The loading now lives
in a small loader object with explicit idle, loading, loaded and
failed phases: a failure shows a neutral placeholder and is not
retried automatically, only when the view appears again or the URL
changes (.task(id:), which also cancels the download when the view
disappears). The tests run the loader itself with stubbed downloads.
The tests now call the representable's update method with a web view
that records load requests, so they cover the loaded-URL bookkeeping
and the delegate and parent updates, not only the comparison. The
suite runs on the main actor, which removes the isolation warning.
The localization test hard-coded 37 languages. It now reads the
languages from the catalog file and compares them with the compiled
bundle, so adding a language needs no test change.
Text(format:) looks up its "%@%@…" key in the module bundle on
purpose, which a comment now states so that the key never gets a
catalog entry. toHex resolves dynamic colors against the traits
current at the time of the call, which outside of trait-aware
callbacks are usually the default ones; the docs now say so instead of
"current appearance of the app".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant