diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index 9b4228a..7fc714b 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -95,6 +95,9 @@ A1B2C3D401000051 /* FileWatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000004D /* FileWatcherTests.swift */; }; A1B2C3D401000052 /* FileWatchManager.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000049 /* FileWatchManager.swift */; }; A1B2C3D401000053 /* ExternalEditorManager.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000004A /* ExternalEditorManager.swift */; }; + A1B2C3D401000066 /* ChromeHoverState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000066 /* ChromeHoverState.swift */; }; + A1B2C3D401000067 /* ChromeHoverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000067 /* ChromeHoverTests.swift */; }; + A1B2C3D401000068 /* ChromeHoverState.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000066 /* ChromeHoverState.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -179,6 +182,8 @@ A1B2C3D40000004B /* ExternalEditorPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalEditorPickerView.swift; sourceTree = ""; }; A1B2C3D40000004C /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A1B2C3D40000004D /* FileWatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcherTests.swift; sourceTree = ""; }; + A1B2C3D400000066 /* ChromeHoverState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeHoverState.swift; sourceTree = ""; }; + A1B2C3D400000067 /* ChromeHoverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromeHoverTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -218,6 +223,7 @@ A1B2C3D400000046 /* SearchTests.swift */, A1B2C3D40000004D /* FileWatcherTests.swift */, A1B2C3D400000064 /* FontTests.swift */, + A1B2C3D400000067 /* ChromeHoverTests.swift */, ); path = QuickMDTests; sourceTree = ""; @@ -271,6 +277,7 @@ A1B2C3D400000019 /* MermaidBlockView.swift */, A1B2C3D40000003C /* RecentDocumentsSidebar.swift */, A1B2C3D400000040 /* ChromeButtons.swift */, + A1B2C3D400000066 /* ChromeHoverState.swift */, A1B2C3D400000047 /* TextBlockView.swift */, A1B2C3D400000060 /* AlertBlockView.swift */, A1B2C3D40000004B /* ExternalEditorPickerView.swift */, @@ -489,6 +496,7 @@ A1B2C3D40100003A /* DocumentSearch.swift in Sources */, A1B2C3D40100003B /* WindowTabbing.swift in Sources */, A1B2C3D40100003C /* ChromeButtons.swift in Sources */, + A1B2C3D401000066 /* ChromeHoverState.swift in Sources */, A1B2C3D40100004A /* TextBlockView.swift in Sources */, A1B2C3D401000060 /* AlertBlockView.swift in Sources */, A1B2C3D401000061 /* MermaidPDFRenderer.swift in Sources */, @@ -525,6 +533,8 @@ A1B2C3D401000053 /* ExternalEditorManager.swift in Sources (Tests) */, A1B2C3D401000064 /* FontTests.swift in Sources */, A1B2C3D401000065 /* DocumentFonts.swift in Sources (Tests) */, + A1B2C3D401000067 /* ChromeHoverTests.swift in Sources */, + A1B2C3D401000068 /* ChromeHoverState.swift in Sources (Tests) */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index 3d148de..efa99e7 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -229,6 +229,7 @@ struct MarkdownView: View { copyToClipboard(currentText) } } + .chromeHoverCluster() .padding(.top, isSearchVisible ? 44 : 8) .padding(.trailing, 24) } diff --git a/QuickMD/QuickMD/Views/ChromeButtons.swift b/QuickMD/QuickMD/Views/ChromeButtons.swift index 7af8f0a..09105c0 100644 --- a/QuickMD/QuickMD/Views/ChromeButtons.swift +++ b/QuickMD/QuickMD/Views/ChromeButtons.swift @@ -56,40 +56,181 @@ struct HeadingBlockView: View { } } -// MARK: - Copy Source Button +// MARK: - Shared hover-expand capsule -/// Subtle top-right button to copy the entire raw markdown -struct CopySourceButton: View { +/// How a chrome pill decides whether to show its label. +/// +/// `.local` — the pill tracks the pointer itself (Support, Tip Jar). +/// `.cluster` — a parent `chromeHoverCluster()` owns hover so siblings +/// expand and collapse together and do not shove each other sideways. +private enum ChromeExpansionSource: Equatable { + case local + case cluster(expanded: Bool) +} + +private struct ChromeExpansionSourceKey: EnvironmentKey { + static let defaultValue: ChromeExpansionSource = .local +} + +private extension EnvironmentValues { + var chromeExpansionSource: ChromeExpansionSource { + get { self[ChromeExpansionSourceKey.self] } + set { self[ChromeExpansionSourceKey.self] = newValue } + } +} + +/// Pointer tracking for one hover region. Cancels an in-flight collapse on +/// re-entry and on disappear so a late timer cannot mutate a gone view. +private struct ChromeHoverTracking: ViewModifier { + @Binding var hover: ChromeHoverState + @State private var collapseWork: DispatchWorkItem? + + func body(content: Content) -> some View { + content + .onHover { hovering in + collapseWork?.cancel() + collapseWork = nil + var next = hover + if hovering { + next.pointerEntered() + hover = next + } else { + let generation = next.pointerExited() + hover = next + let work = DispatchWorkItem { + var collapsed = hover + collapsed.applyScheduledCollapse(generation: generation) + hover = collapsed + } + collapseWork = work + DispatchQueue.main.asyncAfter( + deadline: .now() + ChromeHoverState.collapseDelay, + execute: work + ) + } + } + .onDisappear { + collapseWork?.cancel() + collapseWork = nil + } + } +} + +/// Shared hover for a group of pills. Independent per-button expansion +/// shifts siblings and makes the pointer miss the other control. +struct ChromeHoverCluster: ViewModifier { + @State private var hover = ChromeHoverState() + + func body(content: Content) -> some View { + content + // Grow the hover bounds (gap + 4pt slop) without shifting layout. + // onHover tracks layout bounds, not contentShape, so padding is + // what actually keeps the pointer "inside" while crossing pills. + .padding(4) + .contentShape(Rectangle()) + .environment(\.chromeExpansionSource, .cluster(expanded: hover.isExpanded)) + .modifier(ChromeHoverTracking(hover: $hover)) + .padding(-4) + } +} + +extension View { + func chromeHoverCluster() -> some View { + modifier(ChromeHoverCluster()) + } +} + +/// Visual capsule used by every document-chrome pill. Expansion comes from +/// a parent cluster when present, otherwise from local hover. +struct ChromePillLabel: View { let theme: MarkdownTheme - let action: () -> Void - @State private var isHovered = false + let title: String + var tint: Color = .secondary + @ViewBuilder var icon: () -> Icon + + @Environment(\.chromeExpansionSource) private var expansionSource + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var localHover = ChromeHoverState() + + private var expanded: Bool { + switch expansionSource { + case .cluster(let expanded): return expanded + case .local: return localHover.isExpanded + } + } var body: some View { - Button { - action() - } label: { - HStack(spacing: 4) { - Image(systemName: "doc.on.doc") - .font(.system(size: 11)) - if isHovered { - Text("Copy source") - .font(.system(size: 11, weight: .medium)) - } + HStack(spacing: 4) { + icon() + if expanded { + Text(title) + .font(.system(size: 11, weight: .medium)) } - .padding(.horizontal, isHovered ? 10 : 6) - .padding(.vertical, 4) - .background(theme.codeBackgroundColor.opacity(isHovered ? 0.9 : 0.6)) - .clipShape(Capsule()) + } + .padding(.horizontal, expanded ? 10 : 6) + .padding(.vertical, 4) + .background(theme.codeBackgroundColor.opacity(expanded ? 0.9 : 0.6)) + .clipShape(Capsule()) + .contentShape(Capsule()) + .foregroundColor(tint) + .opacity(expanded ? 1.0 : 0.5) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: expanded) + .modifier(LocalHoverIfNeeded(enabled: isLocal, hover: $localHover)) + } + + private var isLocal: Bool { + if case .local = expansionSource { return true } + return false + } +} + +private struct LocalHoverIfNeeded: ViewModifier { + let enabled: Bool + @Binding var hover: ChromeHoverState + + @ViewBuilder + func body(content: Content) -> some View { + if enabled { + content.modifier(ChromeHoverTracking(hover: $hover)) + } else { + content + } + } +} + +/// Button-wrapped chrome pill. Keeps a stable accessibility name even +/// when the visible label is collapsed. +struct ChromePill: View { + let theme: MarkdownTheme + let title: String + let help: String + var tint: Color = .secondary + let action: () -> Void + @ViewBuilder var icon: () -> Icon + + var body: some View { + Button(action: action) { + ChromePillLabel(theme: theme, title: title, tint: tint, icon: icon) } .buttonStyle(.plain) .focusable(false) - .foregroundColor(.secondary) - .opacity(isHovered ? 1.0 : 0.5) - .animation(.easeInOut(duration: 0.2), value: isHovered) - .onHover { hovering in - isHovered = hovering + .help(help) + .accessibilityLabel(title) + } +} + +// MARK: - Copy Source Button + +/// Subtle top-right button to copy the entire raw markdown +struct CopySourceButton: View { + let theme: MarkdownTheme + let action: () -> Void + + var body: some View { + ChromePill(theme: theme, title: "Copy source", help: "Copy source", action: action) { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) } - .help("Copy source") } } @@ -99,34 +240,17 @@ struct CopySourceButton: View { struct EditInEditorButton: View { let theme: MarkdownTheme let action: () -> Void - @State private var isHovered = false var body: some View { - Button { - action() - } label: { - HStack(spacing: 4) { - Image(systemName: "pencil") - .font(.system(size: 11)) - if isHovered { - Text("Edit") - .font(.system(size: 11, weight: .medium)) - } - } - .padding(.horizontal, isHovered ? 10 : 6) - .padding(.vertical, 4) - .background(theme.codeBackgroundColor.opacity(isHovered ? 0.9 : 0.6)) - .clipShape(Capsule()) + ChromePill( + theme: theme, + title: "Edit", + help: "Open in external editor (⌘E)", + action: action + ) { + Image(systemName: "pencil") + .font(.system(size: 11)) } - .buttonStyle(.plain) - .focusable(false) - .foregroundColor(.secondary) - .opacity(isHovered ? 1.0 : 0.5) - .animation(.easeInOut(duration: 0.2), value: isHovered) - .onHover { hovering in - isHovered = hovering - } - .help("Open in external editor (⌘E)") } } @@ -136,34 +260,18 @@ struct EditInEditorButton: View { struct TipJarButton: View { let theme: MarkdownTheme @Environment(\.openWindow) private var openWindow - @State private var isHovered = false var body: some View { - Button { - openWindow(id: "tip-jar") - } label: { - HStack(spacing: 4) { - Image(systemName: "heart.fill") - .font(.system(size: 11)) - if isHovered { - Text("Tip Jar") - .font(.system(size: 11, weight: .medium)) - } - } - .padding(.horizontal, isHovered ? 10 : 6) - .padding(.vertical, 4) - .background(theme.codeBackgroundColor.opacity(isHovered ? 0.9 : 0.6)) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - .focusable(false) - .foregroundColor(.pink) - .opacity(isHovered ? 1.0 : 0.5) - .animation(.easeInOut(duration: 0.2), value: isHovered) - .onHover { hovering in - isHovered = hovering + ChromePill( + theme: theme, + title: "Tip Jar", + help: "Support QuickMD", + tint: .pink, + action: { openWindow(id: "tip-jar") } + ) { + Image(systemName: "heart.fill") + .font(.system(size: 11)) } - .help("Support QuickMD") } } #endif @@ -173,7 +281,6 @@ struct TipJarButton: View { #if !APPSTORE struct SupportButton: View { let theme: MarkdownTheme - @State private var isHovered = false var body: some View { Menu { @@ -194,26 +301,14 @@ struct SupportButton: View { Label("Ko-fi", systemImage: "heart.fill") } } label: { - HStack(spacing: 4) { + ChromePillLabel(theme: theme, title: "Support") { Text("☕") .font(.system(size: 12)) - if isHovered { - Text("Support") - .font(.system(size: 11, weight: .medium)) - } } - .padding(.horizontal, isHovered ? 10 : 6) - .padding(.vertical, 4) - .background(theme.codeBackgroundColor.opacity(isHovered ? 0.9 : 0.6)) - .clipShape(Capsule()) } .menuStyle(.button) - .opacity(isHovered ? 1.0 : 0.5) - .animation(.easeInOut(duration: 0.2), value: isHovered) - .onHover { hovering in - isHovered = hovering - } .help("Support QuickMD development") + .accessibilityLabel("Support") } } #endif diff --git a/QuickMD/QuickMD/Views/ChromeHoverState.swift b/QuickMD/QuickMD/Views/ChromeHoverState.swift new file mode 100644 index 0000000..56c5e40 --- /dev/null +++ b/QuickMD/QuickMD/Views/ChromeHoverState.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Generation-counted hover expansion. Views own the timer; this owns whether +/// the chrome pills should show their labels. +/// +/// A delayed collapse must not clobber a later re-entry: `pointerExited()` +/// returns a generation that `applyScheduledCollapse(generation:)` honours +/// only if nothing has happened since. +struct ChromeHoverState: Equatable { + static let collapseDelay: TimeInterval = 0.25 + + private(set) var isExpanded = false + private var collapseGeneration = 0 + + mutating func pointerEntered() { + collapseGeneration += 1 + isExpanded = true + } + + /// Does not collapse immediately — schedule `applyScheduledCollapse` + /// after `collapseDelay` with the returned generation. + @discardableResult + mutating func pointerExited() -> Int { + collapseGeneration += 1 + return collapseGeneration + } + + mutating func applyScheduledCollapse(generation: Int) { + guard generation == collapseGeneration else { return } + isExpanded = false + } +} diff --git a/QuickMD/QuickMDTests/ChromeHoverTests.swift b/QuickMD/QuickMDTests/ChromeHoverTests.swift new file mode 100644 index 0000000..f212572 --- /dev/null +++ b/QuickMD/QuickMDTests/ChromeHoverTests.swift @@ -0,0 +1,67 @@ +import XCTest + +/// Hover-cluster state machine for the top-right chrome pills. +/// The views own the collapse timer; these tests own the generation rules +/// that stop a late collapse from fighting a re-entry. +final class ChromeHoverTests: XCTestCase { + + func testStartsCollapsed() { + XCTAssertFalse(ChromeHoverState().isExpanded) + } + + func testPointerEnteredExpands() { + var hover = ChromeHoverState() + hover.pointerEntered() + XCTAssertTrue(hover.isExpanded) + } + + func testPointerExitedDoesNotCollapseImmediately() { + var hover = ChromeHoverState() + hover.pointerEntered() + hover.pointerExited() + XCTAssertTrue(hover.isExpanded) + } + + func testScheduledCollapseHonoursMatchingGeneration() { + var hover = ChromeHoverState() + hover.pointerEntered() + let generation = hover.pointerExited() + hover.applyScheduledCollapse(generation: generation) + XCTAssertFalse(hover.isExpanded) + } + + func testStaleCollapseAfterReentryIsIgnored() { + var hover = ChromeHoverState() + hover.pointerEntered() + let stale = hover.pointerExited() + hover.pointerEntered() + hover.applyScheduledCollapse(generation: stale) + XCTAssertTrue(hover.isExpanded) + } + + func testOnlyLatestExitGenerationCollapses() { + var hover = ChromeHoverState() + hover.pointerEntered() + let firstExit = hover.pointerExited() + hover.pointerEntered() + let secondExit = hover.pointerExited() + hover.applyScheduledCollapse(generation: firstExit) + XCTAssertTrue(hover.isExpanded) + hover.applyScheduledCollapse(generation: secondExit) + XCTAssertFalse(hover.isExpanded) + } + + func testUnknownGenerationIsANoOp() { + var hover = ChromeHoverState() + hover.pointerEntered() + hover.applyScheduledCollapse(generation: .min) + XCTAssertTrue(hover.isExpanded) + } + + func testCollapseDelayMatchesHeadingCopyBallpark() { + // Snappier than the heading copy button (0.4s) — this is toolbar chrome. + XCTAssertEqual(ChromeHoverState.collapseDelay, 0.25) + XCTAssertGreaterThan(ChromeHoverState.collapseDelay, 0) + XCTAssertLessThan(ChromeHoverState.collapseDelay, 0.4) + } +}