Skip to content
Go back

Creating Developer Tool - Part 2

Building upon the foundation of the draggable overlay button detailed in the first part of this series, we now move forward to constructing the actual developer tool. This next phase involves diving into the intricacies of the SpyDashboardModel core and designing core model objects like tags, categories, integrations, and entries.

SpyDashboard demo

Generic Models

/// Tag for the spy message
public protocol SpyTag: Equatable, CaseIterable, Identifiable {
    var name: String? { get }
    var displayable: any View { get }
}

/// Category of the spy message
public protocol SpyCategory: Equatable, CaseIterable {
    var name: String { get }
    var displayable: any View { get }
}

public protocol SpyIntegration {
    /// Provides prefixes that we should allow ignoring for a given `SpyEntry`
    func ignorablePrefixes<Tag: SpyTag, Category: SpyCategory>(for entry: SpyEntry<Tag, Category>) -> [String]
}


/// Entry log containing all neccesary information for the console
public struct SpyEntry<Tag: SpyTag, Category: SpyCategory>: Identifiable {
    public init(
        id: UUID = .init(),
        category: Category,
        tags: [Tag],
        timestamp: Date,
        message: String,
        detail: String?
    ) {
        self.id = id
        self.category = category
        self.tags = tags
        self.timestamp = timestamp
        self.message = message
        self.detail = detail
    }

    public var id: UUID = .init()

    public var tags: [Tag]
    public var category: Category
    public var timestamp: Date
    public var message: String
    public var detail: String?
}

Generic Models

  1. Tag (SpyTag)

The SpyTag protocol defines a tag for the spy message. Tags can be used to label and categorize different messages for easy identification and filtering.

2. Category (SpyCategory)

The SpyCategory protocol defines a category for the spy message. Categories allow you to group messages into different sections, providing a higher level of organization.

3. Integration (SpyIntegration)

The SpyIntegration protocol offers a method for determining which prefixes should be ignored in a given SpyEntry. This functionality allows for more precise control and filtering of the displayed messages.

4. Entry (SpyEntry)

The SpyEntry structure represents an individual log entry containing all necessary information for the console. This includes details such as tags, category, timestamp, and message content.

These models form the backbone of the spy dashboard, providing the essential building blocks for organizing, filtering, and displaying log information. By clearly defining these protocols and structure, you enable a flexible and robust system for managing and interacting with spy messages.

Categories and models visualized together

Example configuration:

enum Categories: SpyCategory {
    case info
    case error

    var displayable: any View {
        switch self {
        case .info:
            Image(systemName: "info.circle.fill")
                .foregroundStyle(.gray)
        case .error:
            Image(systemName: "exclamationmark.circle.fill")
                .foregroundStyle(.red)
        }
    }

    var name: String {
        switch self {
        case .info:
            "Info"
        case .error:
            "Error"
        }
    }
}

enum Tags: String, SpyTag {
    case none
    case tca
    case todos

    @ViewBuilder
    var displayable: any View {
        switch self {
        case .none:
            EmptyView()
        case .tca:
            Image(systemName: "storefront")
        case .todos:
            Image(systemName: "checkmark.message.fill")
        }
    }

    var name: String? {
        switch self {
        case .tca:
            "tca"
        case .todos:
            "todos"
        case .none:
            nil
        }
    }

    var id: String {
        rawValue
    }
}

Sample configuration for tags and categories

DashboardModel

The SpyDashboardModel is a centerpiece of functionality that acts as a powerful log manager with the ability to filter, sort, and display log entries. Here’s an exploration of its main features and design:

Always compiled

Dashboard states

The idea behind SpyDashboard is that it’s always in the code (not just debug), the way this works is by defining 3 states it can be in:

🤔

Normally I’d use contextMenu for the overlay modes but there is a SwiftUI bug that causes all touches to be swallowed by the overlay, even if it’s just 40x40 px, to workaround this I simply added a custom overlay manually.

Factoring the right views

To simplify the integration point we expose a factory from our model, and since our model is a ObservableObject this factory will be called whenever we need to update the UI.

@ViewBuilder
    public var contents: some View {
        ZStack {
            overlayToggle
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            
            dashboard
        }
    }

This means that we can easily integrate the tool in root of our view hierarchy by adding overlay modifier

 .overlay {
   dashboardModel
     .contents
}

Filters

Filters play a crucial role in the SpyDashboardModel by allowing users to quickly find specific entries, categories, or tags. They work by applying certain conditions or criteria to the displayed data.

We implement them as simple value type:

public struct Filter: Identifiable {
        public enum Mode {
            case contains(String)
            case prefix(String)
            case tag(Tag)
            case category(Category)
        }

        public var id: UUID = .init()
        public var mode: Mode

        func excludes(_ entry: Entry) -> Bool {
            switch mode {
            case let .contains(value):
                return entry.message.contains(value)
            case let .prefix(prefix):
                return entry.message.hasPrefix(prefix)
            case let .tag(tag):
                return entry.tags.contains(tag)
            case let .category(category):
                return entry.category == category
            }
        }
    }

Filter definition

We’ll also display those filters later on in detail view by simply adding an extension in View Layer:

extension SpyDashboardModel.Filter {
    @ViewBuilder
    var label: some View {
        switch mode {
        case let .contains(value):
            Text("Message contains \"\(Text(value.trimmingCharacters(in: .whitespacesAndNewlines)).bold())\"")
        case let .prefix(value):
            Text("Message starts with \"\(Text(value.trimmingCharacters(in: .whitespacesAndNewlines)).bold())\"")
        case let .tag(value):
            HStack {
                Text("Tag is \(Text(value.name ?? "").bold())")
                AnyView(value.displayable)
            }
        case let .category(value):
            Text("Category is \(Text(value.name).bold())")
        }
    }
}

Filters visual extension

Resulting in:

Active filters

Category filtering

It’s very convenient to filter by categories, for that we can leverage simple Menu code to list all categories and allow user to select which ones they want to see, but we also want this to fit into existing filtering setup:

Menu("Categories") {
  ForEach(Array(Category.allCases), id: \.name) { category in
    Toggle(isOn: model.binding(for: category), label: {
      Text(category.name)
    })
  }
}

But for that to work we need to create a binding helper:

func binding(for category: Category) -> Binding<Bool> {
    .init(get: {
        !self.filters.contains(where: { filter in
            if case let .category(test) = filter.mode {
                return test == category
            }
            return false
        })
    }, set: { enabled in
        if !enabled {
            self.filters.append(.init(mode: .category(category)))
        } else {
            self.filters.removeAll(where: { filter in
                if case let .category(test) = filter.mode {
                    return test == category
                }
                return false
            })
        }
    })
}

Binding code

get

The get closure is used to determine the current state of the binding (i.e., whether the category is filtered or not). It checks whether the filters array contains a filter with the given category.

If it does contain a filter with the category, the method returns false, meaning the category is filtered out.

set

The set closure is used to modify the underlying data based on user interaction with the UI:

If enabled is set to false, a new filter is added to the filters array with the given category. This means that the category will be filtered out.

Conclusion

We’ve took some time exploring the SpyDashboardModel! From crafting tags and categories to creating filters that let us zoom in on just what we need, we’ve built a developer tool that’s all about understanding what’s going on in our app.

And guess what? We’ve only just begun! Next week, we’re rolling up our sleeves to polish the UI, connect it through TCA, and then it’s off to GitHub for everyone to use.


Share this post on:

Previous Post
Introducing Swifty Stack
Next Post
Creating a developer tool - part 1