Stop macOS crashes while opening and stepping through exercises.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-06 20:51:06 +02:00
commit c561c0acb5
3 changed files with 42 additions and 19 deletions

View file

@ -10,7 +10,9 @@ final class AppModel: ObservableObject {
@Published var mode: ColorMode @Published var mode: ColorMode
@Published var lang: UiLang @Published var lang: UiLang
@Published var tab: AppTab = .practice @Published var tab: AppTab = .practice
@Published var practicePath: [Route] = [] @Published var practicePath = NavigationPath()
/// Bumps whenever a full path replace is scheduled, so a queued push cannot land afterwards.
private var pathGeneration = 0
@Published var searchQuery = "" @Published var searchQuery = ""
@Published var tick = 0 @Published var tick = 0
@Published var update = UpdateState() @Published var update = UpdateState()
@ -48,13 +50,17 @@ final class AppModel: ObservableObject {
case .stats: case .stats:
tab = .progress tab = .progress
case .welcome, .subjects: case .welcome, .subjects:
tab = .practice showPracticeRoot()
practicePath = []
default: default:
applyPracticeRoute(r, replace: practicePath.isEmpty) applyPracticeRoute(r, replace: practicePath.isEmpty)
} }
} }
func showPracticeRoot() {
tab = .practice
schedulePracticePath(NavigationPath())
}
func goPage(_ page: String?) { func goPage(_ page: String?) {
guard let page, let r = routeFromPage(page) else { return } guard let page, let r = routeFromPage(page) else { return }
go(r) go(r)
@ -66,8 +72,7 @@ final class AppModel: ObservableObject {
case .stats: case .stats:
tab = .progress tab = .progress
case .welcome, .subjects: case .welcome, .subjects:
tab = .practice showPracticeRoot()
practicePath = []
default: default:
// Switching tabs and assigning a deep NavigationStack path in the // Switching tabs and assigning a deep NavigationStack path in the
// same turn crashes SwiftUI on macOS (boundPathChange / unexpectedError). // same turn crashes SwiftUI on macOS (boundPathChange / unexpectedError).
@ -76,18 +81,31 @@ final class AppModel: ObservableObject {
} }
/// Sets the practice tab path safely, especially when jumping from Search. /// Sets the practice tab path safely, especially when jumping from Search.
///
/// The sidebar-style tab view keeps one navigation column for every tab.
/// A typed `[Route]` path does not match the other tabs, so the column
/// traps in `boundPathChange` while an exercise is being opened. The path
/// is therefore type-erased, and a push is applied on the next turn instead
/// of replacing the whole stack during the click.
private func applyPracticeRoute(_ r: Route, replace: Bool) { private func applyPracticeRoute(_ r: Route, replace: Bool) {
let stack = replace ? r.stack : (practicePath + [r])
if !replace, practicePath.last == r { return }
let switching = tab != .practice let switching = tab != .practice
tab = .practice tab = .practice
if switching || replace { if switching || replace {
practicePath = [] schedulePracticePath(NavigationPath(r.stack))
DispatchQueue.main.async { [weak self] in
self?.practicePath = stack
}
} else { } else {
practicePath = stack let generation = pathGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.pathGeneration == generation else { return }
self.practicePath.append(r)
}
}
}
private func schedulePracticePath(_ path: NavigationPath) {
pathGeneration += 1
practicePath = NavigationPath()
DispatchQueue.main.async { [weak self] in
self?.practicePath = path
} }
} }

View file

@ -17,6 +17,9 @@ struct GraduatelyApp: App {
struct NativeRoot: View { struct NativeRoot: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
@State private var progressPath = NavigationPath()
@State private var settingsPath = NavigationPath()
@State private var searchPath = NavigationPath()
var body: some View { var body: some View {
Group { Group {
@ -49,17 +52,17 @@ struct NativeRoot: View {
} }
} }
Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: .progress) { Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: .progress) {
NavigationStack { NavigationStack(path: $progressPath) {
StatsScreen(vm: vm) StatsScreen(vm: vm)
} }
} }
Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: .settings) { Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: .settings) {
NavigationStack { NavigationStack(path: $settingsPath) {
SettingsScreen(vm: vm) SettingsScreen(vm: vm)
} }
} }
Tab(vm.tr("search"), systemImage: "magnifyingglass", value: .search) { Tab(vm.tr("search"), systemImage: "magnifyingglass", value: .search) {
NavigationStack { NavigationStack(path: $searchPath) {
SearchScreen(vm: vm) SearchScreen(vm: vm)
} }
} }

View file

@ -16,8 +16,7 @@ struct GraduatelyApp: App {
CommandGroup(replacing: .newItem) {} CommandGroup(replacing: .newItem) {}
CommandGroup(after: .sidebar) { CommandGroup(after: .sidebar) {
Button(vm.tr("search_home")) { Button(vm.tr("search_home")) {
vm.tab = .practice vm.showPracticeRoot()
vm.practicePath = []
} }
.keyboardShortcut("1", modifiers: [.command]) .keyboardShortcut("1", modifiers: [.command])
Button(vm.tr("stats")) { Button(vm.tr("stats")) {
@ -47,6 +46,9 @@ struct GraduatelyApp: App {
struct MacRoot: View { struct MacRoot: View {
@ObservedObject var vm: AppModel @ObservedObject var vm: AppModel
@State private var progressPath = NavigationPath()
@State private var settingsPath = NavigationPath()
@State private var searchPath = NavigationPath()
var body: some View { var body: some View {
tabs tabs
@ -77,17 +79,17 @@ struct MacRoot: View {
} }
} }
Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) { Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) {
NavigationStack { NavigationStack(path: $progressPath) {
StatsScreen(vm: vm) StatsScreen(vm: vm)
} }
} }
Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) { Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) {
NavigationStack { NavigationStack(path: $settingsPath) {
SettingsScreen(vm: vm) SettingsScreen(vm: vm)
} }
} }
Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) { Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
NavigationStack { NavigationStack(path: $searchPath) {
SearchScreen(vm: vm) SearchScreen(vm: vm)
} }
} }