Atelier Clockwork

Building Minesweeper 5

Wrapping up (for now)

With all of the basic UI in place, the last thing to get in place is figuring out how to get the right click gesture working. I came up with a [proof of concept]() so now it's just a matter of putting the pieces together.

The first step was to split the cell based on the OS involved, and I had to change the structure to get the behavior to work correctly. In particular, I needed to set an explicit frame, and set the foreground color before disabling the view to make sure that un-clickable cells kept the correct colors.

struct Cell: View {
    let value: Game.SpaceState
    let click: () -> Void
    let flag: () -> Void

    @ViewBuilder var body: some View {
        #if os(macOS)
            AppKitCell(state: value,
                       click: click,
                       flag: flag)
            .frame(width: 30, height: 30)
            .foregroundColor(value.foregroundColor)
            .disabled(isDisabled)
        #else
            if isDisabled {
                buttonContent
            } else {
                Button(action: click) {
                    buttonContent
                }
                .foregroundColor(value.foregroundColor)
                .highPriorityGesture(flagGesture)
                .buttonStyle(.plain)
            }
        #endif
    }
}

I then got to re-use the updating gesture recognizer from my proof of concept, but there was a lot of book keeping involved. Also of note, this is created as a NSHostingController that wraps a SwiftUI View, so all of the UI is still driven by SwiftUI, but with a thin enough shim of AppKit exposed that we can add some gesture recognizers.

final class ButtonHostingViewController: NSHostingController<ButtonHostingViewController.Body> {
    class ButtonViewModel: ObservableObject {
        @Published var state: Game.SpaceState = .unrevealed
        @Published var isPrimaryDown = false
        @Published var isSecondaryDown = false
        @Published var primaryAction: (() -> Void)? = nil
        @Published var secondaryAction: (() -> Void)? = nil

        var isButtonDown: Bool {
            isPrimaryDown || isSecondaryDown
        }
    }

    struct Body: View {
        @ObservedObject var viewModel: ButtonViewModel

        var body: some View {
            viewModel.state.content
                .frame(width: 30, height: 30)
                .background(viewModel.state.fillColor)
                .opacity(viewModel.isButtonDown ? 0.3 : 1)
        }
    }

    private let viewModel = ButtonViewModel()

    private lazy var primaryGestureRecognizer: NSClickGestureRecognizer = {
        let primaryGestureRecognizer = UpdatingGestureRecognizer(target: self, action: #selector(primaryEvent))
        primaryGestureRecognizer.onMouseUp = { [viewModel] in
            viewModel.isPrimaryDown = false
        }
        primaryGestureRecognizer.onMouseDown = { [viewModel] in
            viewModel.isPrimaryDown = true
        }
        return primaryGestureRecognizer
    }()

    private lazy var secondaryGestureRecognizer: NSClickGestureRecognizer = {
        let secondaryGestureRecognizer = UpdatingGestureRecognizer(target: self, action: #selector(secondaryEvent))
        secondaryGestureRecognizer.onMouseUp = { [viewModel] in
            viewModel.isSecondaryDown = false
        }
        secondaryGestureRecognizer.onMouseDown = { [viewModel] in
            viewModel.isSecondaryDown = true
        }
        secondaryGestureRecognizer.buttonMask = 0x2
        return secondaryGestureRecognizer
    }()

    init(state: Game.SpaceState,
         primaryAction: @escaping () -> Void,
         secondaryAction: @escaping () -> Void) {
        super.init(rootView: Body(viewModel: viewModel))
        view.addGestureRecognizer(primaryGestureRecognizer)
        view.addGestureRecognizer(secondaryGestureRecognizer)
        update(state: state,
               primaryAction: primaryAction,
               secondaryAction: secondaryAction)
    }

    @MainActor required dynamic init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func update(state: Game.SpaceState,
                primaryAction: @escaping () -> Void,
                secondaryAction: @escaping () -> Void) {
        viewModel.state = state
        viewModel.primaryAction = primaryAction
        viewModel.secondaryAction = secondaryAction
    }

    @objc private func primaryEvent() -> Void {
        viewModel.primaryAction?()
    }

    @objc private func secondaryEvent() -> Void {
        viewModel.secondaryAction?()
    }
}

This handles creating the view, adding the gesture recognizers that update the state as it changes, and updating the SwiftUI view as needed. It feels like a lot of code for a really small behavioral change, but I'm thrilled that I got it working.