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
4 changes: 2 additions & 2 deletions App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
mainGroup = 4C23D0D82F5DFFE700666984;
minimizedProjectReferenceProxies = 1;
packageReferences = (
4C23D0F32F5E005000666984 /* XCLocalSwiftPackageReference "../../DevConfiguration" */,
4C23D0F32F5E005000666984 /* XCLocalSwiftPackageReference "../" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 4C23D0E22F5DFFE700666984 /* Products */;
Expand Down Expand Up @@ -356,7 +356,7 @@
/* Begin XCLocalSwiftPackageReference section */
4C23D0F32F5E005000666984 /* XCLocalSwiftPackageReference "../../DevConfiguration" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../DevConfiguration;
relativePath = ../;
};
/* End XCLocalSwiftPackageReference section */

Expand Down
15 changes: 14 additions & 1 deletion App/Sources/App/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,20 @@ final class ContentViewModel {

let boolArrayVariable = ConfigVariable(key: "bool_array", defaultValue: [false, true, true, false])
.metadata(\.displayName, "Bool Array Example")
.metadata(\.group, .arrays)
let float64ArrayVariable = ConfigVariable(key: "float64_array", defaultValue: [0, 1, 2.78182, 3.14159])
.metadata(\.displayName, "Float Array Example")
.metadata(\.group, .arrays)
let intArrayVariable = ConfigVariable(key: "int_array", defaultValue: [1, 2, 4, 8, 16, 32])
.metadata(\.displayName, "Int Array Example")
.metadata(\.isEditable, false)
.metadata(\.group, .arrays)
let stringArrayVariable = ConfigVariable(
key: "string_array",
defaultValue: ["Thom", "Jonny", "Ed", "Colin", "Phil"],
).metadata(\.displayName, "String Array Example")
)
.metadata(\.displayName, "String Array Example")
.metadata(\.group, .arrays)

let jsonVariable = ConfigVariable(
key: "complexConfig",
Expand All @@ -43,9 +48,11 @@ final class ContentViewModel {

let intBackedVariable = ConfigVariable(key: "favoriteCardSuit", defaultValue: CardSuit.spades, isSecret: true)
.metadata(\.displayName, "Favorite Card Suit")
.metadata(\.group, .valueBacked)

let stringBackedVariable = ConfigVariable(key: "favoriteBeatle", defaultValue: Beatle.john)
.metadata(\.displayName, "Favorite Beatle")
.metadata(\.group, .valueBacked)


init() {
Expand Down Expand Up @@ -90,6 +97,12 @@ final class ContentViewModel {
}


extension ConfigVariableGroup {
static let arrays = ConfigVariableGroup("Arrays")
static let valueBacked = ConfigVariableGroup("Value-Backed Variables")
}


struct ComplexConfiguration: Codable, Hashable, Sendable {
let field1: String
let field2: Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ struct ConfigVariableListView<ViewModel: ConfigVariableListViewModeling, CustomC
private let customContent: CustomContent

@Environment(\.dismiss) private var dismiss
@FocusState var focusState: Bool
@State private var isPresentingSearch: Bool = false


/// Creates a new list view.
Expand All @@ -42,22 +44,24 @@ struct ConfigVariableListView<ViewModel: ConfigVariableListViewModeling, CustomC
List {
customContent

Section(localizedStringResource("editorView.variablesSection.header")) {
ForEach(viewModel.variables, id: \.key) { item in
NavigationLink(value: item.key) {
VariableRow(item: item)
}
}
}
variablesSection
}
.navigationTitle(localizedStringResource("editorView.navigationTitle"))
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: ConfigKey.self) { key in
ConfigVariableDetailView(viewModel: viewModel.makeDetailViewModel(for: key))
}
.interactiveDismissDisabled(viewModel.isDirty)
.searchable(text: $viewModel.searchText)
.searchable(text: $viewModel.searchText, isPresented: $isPresentingSearch)
.searchFocused($focusState)
.onChange(of: isPresentingSearch) { oldValue, newValue in
Task {
focusState = newValue
}
}
.toolbar { toolbarContent }
.animation(.default, value: viewModel.showOverridesOnly)
.animation(.default, value: isPresentingSearch)
.alert(localizedStringResource("editorView.saveAlert.title"), isPresented: $viewModel.isShowingSaveAlert) {
Button(localizedStringResource("editorView.saveAlert.saveButton")) {
viewModel.saveAndDismiss { dismiss() }
Expand Down Expand Up @@ -86,6 +90,20 @@ struct ConfigVariableListView<ViewModel: ConfigVariableListViewModeling, CustomC
}
}
}


@ViewBuilder
var variablesSection: some View {
ForEach(viewModel.variableSections, id: \.title) { section in
Section(section.title) {
ForEach(section.items, id: \.key) { item in
NavigationLink(value: item.key) {
VariableRow(item: item)
}
}
}
}
}
}


Expand Down Expand Up @@ -127,6 +145,16 @@ extension ConfigVariableListView {
}
.disabled(!viewModel.canRedo)

Button {
viewModel.showOverridesOnly.toggle()
} label: {
Label(
localizedStringResource("editorView.showOverridesOnlyButton"),
systemImage: viewModel.showOverridesOnly ? "checkmark.circle.fill" : "circle",
)
}
.disabled(!viewModel.hasAnyOverrides)

Divider()

Button(role: .destructive) {
Expand All @@ -138,6 +166,49 @@ extension ConfigVariableListView {
Label(localizedStringResource("editorView.overflowMenu.label"), systemImage: "ellipsis")
}
}

ToolbarItemGroup(placement: .bottomBar) {
if !focusState {
Toggle(isOn: $viewModel.showOverridesOnly) {
Label(
localizedStringResource("editorView.showOverridesOnlyButton"),
systemImage: "line.3.horizontal.decrease",
)
.labelStyle(.iconOnly)
}
.toggleStyle(.button)
.disabled(!viewModel.hasAnyOverrides)

if viewModel.showOverridesOnly {
Text(showingOverridesCountText)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize()
.padding(.trailing, 4)
}
}
}

ToolbarSpacer(placement: .bottomBar)

if viewModel.showOverridesOnly && !isPresentingSearch {
ToolbarItem(placement: .bottomBar) {
Button(localizedString("editorView.search"), systemImage: "magnifyingglass") {
isPresentingSearch = true
}
}
} else {
DefaultToolbarItem(kind: .search, placement: .bottomBar)
}
}


private var showingOverridesCountText: String {
String(
format: localizedString("editorView.showingOverridesCountLabel"),
viewModel.visibleVariableCount,
viewModel.totalVariableCount,
)
}
}

Expand All @@ -151,9 +222,10 @@ extension ConfigVariableListView {


var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text(item.displayName)
.font(.headline)
.font(.subheadline)
.bold()

Text(item.key.description)
.font(.caption.monospaced())
Expand All @@ -169,9 +241,8 @@ extension ConfigVariableListView {
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(.top, 8)
.padding(.top, 6)
}
.padding(.vertical, 2)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling {
var searchText = ""
var isShowingSaveAlert = false
var isShowingClearAlert = false
var showOverridesOnly = false


/// Creates a new list view model.
Expand All @@ -46,7 +47,7 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling {

// MARK: - Variables

var variables: [VariableListItem] {
private var variables: [VariableListItem] {
let items = document.registeredVariables.values.map { variable -> VariableListItem in
let displayName = variable.displayName ?? variable.key.description
let resolved = document.resolvedValue(forKey: variable.key)
Expand All @@ -59,11 +60,12 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling {
providerIndex: resolved?.providerIndex,
isSecret: variable.isSecret,
hasOverride: document.hasOverride(forKey: variable.key),
group: variable.group,
editorControl: variable.editorControl,
)
}

let filtered: [VariableListItem]
var filtered: [VariableListItem]
if searchText.isEmpty {
filtered = items
} else {
Expand All @@ -73,7 +75,46 @@ final class ConfigVariableListViewModel: ConfigVariableListViewModeling {
}
}

return filtered.sorted { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
if showOverridesOnly {
filtered = filtered.filter { $0.hasOverride }
}

return filtered.sorted {
$0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending
}
}


var hasAnyOverrides: Bool {
document.registeredVariables.values.contains { document.hasOverride(forKey: $0.key) }
}


var visibleVariableCount: Int {
variables.count
}


var totalVariableCount: Int {
document.registeredVariables.count
}


var variableSections: [VariableSection] {
let grouped = Dictionary(grouping: variables, by: \.group)
let sortedGroups = grouped.keys.compactMap { $0 }.sorted()

var sections = sortedGroups.map { VariableSection(title: $0.rawValue, items: grouped[$0]!) }

if let remainder = grouped[nil], !remainder.isEmpty {
let title =
sections.isEmpty
? localizedString("editorView.variablesSection.header")
: localizedString("editorView.remainderSection.header")
sections.append(VariableSection(title: title, items: remainder))
}

return sections
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,27 @@ protocol ConfigVariableListViewModeling: Observable {
/// The associated detail view model type.
associatedtype DetailViewModel: ConfigVariableDetailViewModeling

/// The filtered and sorted list of variable items to display.
var variables: [VariableListItem] { get }

/// The current search text for filtering variables.
var searchText: String { get set }

/// Whether the list is filtered to show only variables with overrides.
var showOverridesOnly: Bool { get set }

/// Whether any variable in the document has an active override.
var hasAnyOverrides: Bool { get }

/// The number of variables currently visible in the list, reflecting the search text and override filter.
var visibleVariableCount: Int { get }

/// The total number of registered variables, regardless of the search text or override filter.
var totalVariableCount: Int { get }

/// The filtered, sorted, and titled sections of variables to display.
///
/// `variableSections` reflects the current search text and override filter. Each section's title is already
/// resolved for display, so the view can render the sections directly.
var variableSections: [VariableSection] { get }

/// Whether the working copy has unsaved changes.
var isDirty: Bool { get }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ struct VariableListItem: Hashable, Sendable {
/// Whether an editor override is active for this variable in the working copy.
let hasOverride: Bool

/// The logical group for this variable, used for sectioning in the list view.
let group: ConfigVariableGroup?

/// The editor control to use when editing this variable's value.
let editorControl: EditorControl?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// VariableSection.swift
// DevConfiguration
//
// Created by Duncan Lewis on 7/23/2026.
//

/// A titled section of variables to display in the configuration variable list view.
///
/// Each section corresponds to either a metadata group or the collection of variables that have no group. The
/// title is resolved by the view model, so the view can render it directly without any conditional logic.
struct VariableSection: Hashable, Sendable {
/// The section's display title.
let title: String

/// The variables in this section.
let items: [VariableListItem]
}
Loading
Loading