Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,24 @@ struct TooltipPresenter: ViewModifier {
!bubbleFrame.contains(point) && !(isTap && viewFrame.contains(point))
}

/// Whether focus holds the bubble up: only focus the keyboard brought, so a
/// bubble a pointer opened on a view a click focused hides when the pointer
/// leaves, as core's does.
nonisolated static func holdsByFocus(isFocused: Bool, focusIsKeyboard: Bool) -> Bool {
isFocused && focusIsKeyboard
}

/// Whether the bubble should be up now, as `update()` decides it: words,
/// and the pointer on the view or the bubble, or focus the keyboard brought.
nonisolated static func showsBubble(
hasText: Bool, isHovering: Bool, isHoveringBubble: Bool, isFocused: Bool, focusIsKeyboard: Bool,
focus: TooltipFocus
) -> Bool {
wantsBubble(
hasText: hasText, isHovering: isHovering, isHoveringBubble: isHoveringBubble,
isFocused: holdsByFocus(isFocused: isFocused, focusIsKeyboard: focusIsKeyboard), focus: focus)
}

/// Whether focus came from the keyboard, as core's `:focus-visible`: not
/// while a pointer rests on the view, and not within a press of it.
nonisolated static func isKeyboardFocus(isHovering: Bool, sincePress: TimeInterval) -> Bool {
Expand Down Expand Up @@ -247,10 +265,10 @@ struct TooltipPresenter: ViewModifier {
/// Shows the bubble after the delay; hides it after the bridge.
private func update() {
pending?.cancel()
let keyboardFocused = isFocused && focusIsKeyboard
let wants = Self.wantsBubble(
hasText: text != nil, isHovering: isHovering, isHoveringBubble: isHoveringBubble,
isFocused: keyboardFocused || (isShown && isFocused), focus: focus)
let keyboardFocused = Self.holdsByFocus(isFocused: isFocused, focusIsKeyboard: focusIsKeyboard)
let wants = Self.showsBubble(
hasText: text != nil, isHovering: isHovering, isHoveringBubble: isHoveringBubble, isFocused: isFocused,
focusIsKeyboard: focusIsKeyboard, focus: focus)
guard wants != isShown else { return }
let byFocus = keyboardFocused && focus != .none && !isHovering && !isHoveringBubble
let wait = Self.wait(showing: wants, byFocus: byFocus)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,13 @@ import Testing
#expect(P.slide(viewMinX: 760, viewWidth: 40, bubbleWidth: 100, windowWidth: 800, margin: 60, trailing: 16) == -46)
}

@Test func aClickedViewsBubbleHidesWhenThePointerLeaves() {
typealias P = TooltipPresenter
// Hovered: up, whatever brought focus.
#expect(P.showsBubble(hasText: true, isHovering: true, isHoveringBubble: false, isFocused: true, focusIsKeyboard: false, focus: .tabStop))
// The pointer leaves a view a click focused: down.
#expect(!P.showsBubble(hasText: true, isHovering: false, isHoveringBubble: false, isFocused: true, focusIsKeyboard: false, focus: .tabStop))
// Keyboard focus holds it.
#expect(P.showsBubble(hasText: true, isHovering: false, isHoveringBubble: false, isFocused: true, focusIsKeyboard: true, focus: .tabStop))
}
Comment on lines +161 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this composes holdsByFocus and wantsBubble the way update() does, but it never calls update(), so putting || (isShown && isFocused) back there would still pass. A static that returns the composed decision, which update() then calls, would let this test guard the fix.


25 changes: 14 additions & 11 deletions packages/native/specs/swift/library/tooltip.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ shows with the system's own look and delay, never on keyboard focus or a tap.
300-point width. The bubble draws in the view's own layer, so a scroll view
or another clipping container around the view cuts it off, where core's top
layer is never clipped.
On its first show, before the window's safe area and the bubble's own size are known, the bubble opens above the view, or below it when even the gap does not fit under the `space.s4` floor, with no slide, and moves into place on the next frame.
- **INV2 — Pointer.** A pointer resting on the view shows the bubble after
core's 200 ms. It stays while the pointer is on the view or on the bubble,
hides 100 ms after the pointer leaves both, and a press hides it.
Expand All @@ -79,6 +80,8 @@ shows with the system's own look and delay, never on keyboard focus or a tap.
that arrives while a pointer rests on the view, or within half a second of a
press, shows nothing until focus leaves and returns. SwiftUI does not say who
moved focus, so focus code moves counts as the keyboard's.
A bubble a pointer opened on a view a click focused hides when the pointer
leaves; only keyboard focus holds it.
- **INV4 — Escape.** Escape hides a shown bubble wherever focus is and goes no
further, as core's top-most layer takes the press.
- **INV5 — Touch.** `auto`, core's default, opens the tooltip on a tap unless
Expand Down Expand Up @@ -117,14 +120,14 @@ tooltip tests prove it stays current.

## Verification

| Invariant | Evidence | Failure signal |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| INV1 | `TooltipPresenterTests.swift`: above unless no room; slides inside the window; long words wrap within 300 points | a bubble below with room above, or one line past 300 points |
| INV2 | `TooltipPresenterTests.swift`: delay and bridge; the pointer on the view or the bubble keeps it up | a bubble that hides while hovered or shows at once on a pointer |
| INV3 | `TooltipPresenterTests.swift`: focus shows at once for `control` and `tabStop`, never for `none`; only keyboard focus | a delayed focus bubble, one on a view focus does not reach, or one a pointer's focus opens |
| INV5 | `TooltipPresenterTests.swift`: `auto` resolves as core's; only a direct touch on a tap trigger opens it; a gesture elsewhere closes it, one on the view or the bubble does not | a bubble from a pointer click, under VoiceOver, or on a control under `auto`; one a gesture elsewhere leaves open |
| INV6 | `TooltipPresenterTests.swift`: blank words and a disabled view show nothing | a tooltip on a disabled view |
| INV8 | `TooltipPresenterTests.swift`: the bubble's colors and padding in two themes | a bubble in other colors |
| INV9 | `TooltipPresenterTests.swift`: a view with a tooltip draws as it did without | a decorated view that draws differently |
| No `.help` | `TooltipPresenterTests.swift`: no Swift source uses `.help(` | a view that shows tooltip words through `.help` |
| INV2–INV7 | Not measured: hover, focus, Escape, and tap need an app-hosted UI test, which the package lacks | none until that test exists |
| Invariant | Evidence | Failure signal |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| INV1 | `TooltipPresenterTests.swift`: above unless no room; slides inside the window; long words wrap within 300 points | a bubble below with room above, or one line past 300 points |
| INV2 | `TooltipPresenterTests.swift`: delay and bridge; the pointer on the view or the bubble keeps it up | a bubble that hides while hovered or shows at once on a pointer |
| INV3 | `TooltipPresenterTests.swift`: focus shows at once for `control` and `tabStop`, never for `none`; only keyboard focus opens or holds it | a delayed focus bubble, one on a view focus does not reach, or one a click's focus opens or holds |
| INV5 | `TooltipPresenterTests.swift`: `auto` resolves as core's; only a direct touch on a tap trigger opens it; a tap elsewhere closes it, a tap on the view toggles it, a scroll or pinch on the view closes it, and a gesture on the bubble does not | a bubble from a pointer click, under VoiceOver, or on a control under `auto`; one a gesture elsewhere or a scroll on the view leaves open |
| INV6 | `TooltipPresenterTests.swift`: blank words and a disabled view show nothing | a tooltip on a disabled view |
| INV8 | `TooltipPresenterTests.swift`: the bubble's colors and padding in two themes | a bubble in other colors |
| INV9 | `TooltipPresenterTests.swift`: a view with a tooltip draws as it did without | a decorated view that draws differently |
| No `.help` | `TooltipPresenterTests.swift`: no Swift source uses `.help(` | a view that shows tooltip words through `.help` |
| INV2–INV7 | Not measured: hover, focus, Escape, and tap need an app-hosted UI test, which the package lacks | none until that test exists |
Loading