diff --git a/docs/components/canvas.md b/docs/components/canvas.md index c3365ad7..9352e20c 100644 --- a/docs/components/canvas.md +++ b/docs/components/canvas.md @@ -68,7 +68,8 @@ Selection controls: — enough to follow what each agent is doing). These also appear as toolbar buttons. There's a `?` help popover (bottom-left) -explaining pan/zoom/expand. +explaining pan/zoom/expand — hover the button to reveal it, move away to dismiss, +or click to pin it open. ## Visual cues diff --git a/supacode/Features/Canvas/Views/CanvasHelpButton.swift b/supacode/Features/Canvas/Views/CanvasHelpButton.swift new file mode 100644 index 00000000..b3269e29 --- /dev/null +++ b/supacode/Features/Canvas/Views/CanvasHelpButton.swift @@ -0,0 +1,125 @@ +import SwiftUI + +/// The canvas navigation help affordance shown in the bottom-leading corner. +/// +/// Mirrors the toolbar notifications bell: hovering the button reveals the help +/// popover, moving away dismisses it after a short grace period (so the cursor +/// can travel onto the popover), and clicking pins it open until clicked again. +struct CanvasHelpButton: View { + @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @State private var isPresented = false + @State private var isPinnedOpen = false + @State private var isHoveringButton = false + @State private var isHoveringPopover = false + @State private var closeTask: Task? + + var body: some View { + Button { + togglePresentation() + } label: { + Image(systemName: "questionmark.circle") + .font(.body) + .accessibilityLabel("Canvas navigation help") + } + .buttonStyle(.bordered) + .help("Canvas navigation help. Hover or click to show.") + .onHover { hovering in + isHoveringButton = hovering + updatePresentation() + } + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + canvasHelpContent + .onHover { hovering in + isHoveringPopover = hovering + updatePresentation() + } + .onDisappear { + isHoveringPopover = false + isPinnedOpen = false + } + } + .onDisappear { + closeTask?.cancel() + } + .padding() + } + + private var canvasHelpContent: some View { + let expandShortcut = AppShortcuts.display( + for: AppShortcuts.CommandID.expandCanvasCard, + in: resolvedKeybindings + ) + return VStack(alignment: .leading, spacing: 14) { + Text("Canvas Navigation") + .font(.headline) + + VStack(alignment: .leading, spacing: 12) { + canvasHelpRow( + icon: "plus.magnifyingglass", + title: "Zoom in/out", + detail: "⌘ + scroll, or pinch gesture" + ) + canvasHelpRow( + icon: "hand.draw", + title: "Pan canvas", + detail: "Drag empty area, middle-click drag, or two-finger swipe" + ) + canvasHelpRow( + icon: "arrow.up.left.and.arrow.down.right", + title: "Expand / restore card", + detail: expandShortcut.map { "\($0), or the card's title-bar button" } + ?? "Use the card's title-bar button" + ) + } + } + .padding() + .frame(width: 320, alignment: .leading) + } + + private func canvasHelpRow(icon: String, title: String, detail: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 18) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.callout).fontWeight(.medium) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func togglePresentation() { + if isPinnedOpen { + closePopover() + return + } + closeTask?.cancel() + isPinnedOpen = true + isPresented = true + } + + private func updatePresentation() { + if isPinnedOpen || isHoveringButton || isHoveringPopover { + closeTask?.cancel() + isPresented = true + return + } + closeTask?.cancel() + closeTask = Task { @MainActor in + try? await ContinuousClock().sleep(for: .milliseconds(150)) + if !Task.isCancelled { + isPresented = false + } + } + } + + private func closePopover() { + closeTask?.cancel() + isPinnedOpen = false + isPresented = false + } +} diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index e8f2c8dd..f3d6d112 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -36,7 +36,6 @@ struct CanvasView: View { @State var hasPerformedInitialFit = false @State var hasSeenCanvasCards = false @State var viewportSize: CGSize = .zero - @State var showsCanvasHelp = false @State var configReloadCounter = 0 @State var focusViewportAnimationID = 0 /// The tab currently expanded in place (near-fullscreen overlay) on canvas, @@ -187,7 +186,7 @@ struct CanvasView: View { canvasToolbar } .overlay(alignment: .bottomLeading) { - canvasHelpButton + CanvasHelpButton() } .onKeyPress(.escape) { guard selectionState.isBroadcasting else { return .ignored } @@ -742,70 +741,6 @@ struct CanvasView: View { layoutStore.prune(to: visibleKeys) } - var canvasHelpButton: some View { - Button { - showsCanvasHelp.toggle() - } label: { - Image(systemName: "questionmark.circle") - .font(.body) - .accessibilityLabel("Canvas navigation help") - } - .buttonStyle(.bordered) - .help("Canvas navigation help") - .popover(isPresented: $showsCanvasHelp, arrowEdge: .bottom) { - canvasHelpContent - } - .padding() - } - - var canvasHelpContent: some View { - let expandShortcut = AppShortcuts.display( - for: AppShortcuts.CommandID.expandCanvasCard, - in: resolvedKeybindings - ) - return VStack(alignment: .leading, spacing: 14) { - Text("Canvas Navigation") - .font(.headline) - - VStack(alignment: .leading, spacing: 12) { - canvasHelpRow( - icon: "plus.magnifyingglass", - title: "Zoom in/out", - detail: "⌘ + scroll, or pinch gesture" - ) - canvasHelpRow( - icon: "hand.draw", - title: "Pan canvas", - detail: "Drag empty area, middle-click drag, or two-finger swipe" - ) - canvasHelpRow( - icon: "arrow.up.left.and.arrow.down.right", - title: "Expand / restore card", - detail: expandShortcut.map { "\($0), or the card's title-bar button" } - ?? "Use the card's title-bar button" - ) - } - } - .padding() - .frame(width: 320, alignment: .leading) - } - - func canvasHelpRow(icon: String, title: String, detail: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Image(systemName: icon) - .foregroundStyle(.secondary) - .frame(width: 18) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.callout).fontWeight(.medium) - Text(detail) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - var canvasToolbar: some View { // Two visual groups: selection (broadcast / select-all) and the three layout // actions. A wider gap separates the groups; the layout trio is tucked tight.