From f2f6c4e50bbfa8823fdac52b014ffa7d39e20634 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 13:24:23 +0200 Subject: [PATCH] M7 #9+#10: Memory editor keyboard + Saved pill above keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-1 complaints: - Typing near the bottom of MEMORY.md → keyboard covered the cursor, user lost track of where they were editing (M7 #9). - Tapping Save → "Saved" pill was never visible because it sat at .bottom with a fixed 16pt padding, behind the still-raised keyboard (M7 #10). Fixes: - `.scrollDismissesKeyboard(.interactively)` on the TextEditor so scrolling the editor drags the keyboard down smoothly. - Move the error banner + Saved pill into `.safeAreaInset(edge: .bottom)` so SwiftUI draws them above whatever is presenting the keyboard. The pill is now a full-width material strip (easier to hit/notice) instead of a floating capsule. - Saved pill holds for 2.5s (up from 1.5s — the old timer was too tight to read mid-thought). - Any in-flight hide task is cancelled when a new save lands, so rapid-fire saves don't produce stacked fade timers. No Mac equivalent needed — Mac memory editor is a separate MemoryView with different layout and a non-mobile keyboard concern. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/Scarf iOS/Memory/MemoryEditorView.swift | 119 +++++++++++------- 1 file changed, 75 insertions(+), 44 deletions(-) diff --git a/scarf/Scarf iOS/Memory/MemoryEditorView.swift b/scarf/Scarf iOS/Memory/MemoryEditorView.swift index f3707d4..ac5b5fa 100644 --- a/scarf/Scarf iOS/Memory/MemoryEditorView.swift +++ b/scarf/Scarf iOS/Memory/MemoryEditorView.swift @@ -1,29 +1,66 @@ import SwiftUI import ScarfCore -/// Editor for a single memory file (MEMORY.md or USER.md). Owns an -/// `IOSMemoryViewModel` instance, renders its `text` in a TextEditor, -/// and exposes Save + Revert toolbar buttons. +/// Editor for a single memory file (MEMORY.md / USER.md / SOUL.md). +/// Owns an `IOSMemoryViewModel` instance, renders its `text` in a +/// TextEditor, and exposes Save + Revert toolbar buttons. +/// +/// Keyboard layout (pass-1 M7 #9 + #10): +/// - TextEditor uses `.scrollDismissesKeyboard(.interactively)` so +/// the keyboard tracks the user's drag, keeping the cursor visible +/// when editing near the bottom. +/// - The error banner + Saved pill live in `.safeAreaInset(edge: .bottom)` +/// so they're drawn ABOVE the keyboard, not behind it. The Saved +/// pill now holds for 2.5s (up from 1.5s) and any in-flight hide +/// task is cancelled when a new save lands so rapid saves stack +/// predictably. struct MemoryEditorView: View { @State private var vm: IOSMemoryViewModel @State private var showSavedConfirmation = false + @State private var savedHideTask: Task? init(kind: IOSMemoryViewModel.Kind, context: ServerContext) { _vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context)) } var body: some View { - VStack(spacing: 0) { + Group { if vm.isLoading { - Spacer() - ProgressView("Loading \(vm.kind.displayName)…") - Spacer() + VStack { + Spacer() + ProgressView("Loading \(vm.kind.displayName)…") + Spacer() + } } else { TextEditor(text: $vm.text) .font(.system(.body, design: .monospaced)) .autocorrectionDisabled() .textInputAutocapitalization(.never) + .scrollDismissesKeyboard(.interactively) .padding(.horizontal, 8) + } + } + .navigationTitle(vm.kind.displayName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { + Task { await performSave() } + } + .disabled(!vm.hasUnsavedChanges || vm.isSaving) + } + ToolbarItem(placement: .topBarLeading) { + if vm.hasUnsavedChanges { + Button("Revert") { vm.revert() } + } + } + } + // Pin feedback + error strips to the bottom safe area so they + // draw above the keyboard. Previously they floated inside the + // VStack and the keyboard covered both the save pill and the + // cursor the user was typing into. + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { if let err = vm.lastError { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") @@ -34,47 +71,41 @@ struct MemoryEditorView: View { Spacer() } .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(.regularMaterial) - } - } - } - .navigationTitle(vm.kind.displayName) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Save") { - Task { - let ok = await vm.save() - if ok { - showSavedConfirmation = true - Task { - try? await Task.sleep(nanoseconds: 1_500_000_000) - showSavedConfirmation = false - } - } - } - } - .disabled(!vm.hasUnsavedChanges || vm.isSaving) - } - ToolbarItem(placement: .topBarLeading) { - if vm.hasUnsavedChanges { - Button("Revert") { vm.revert() } - } - } - } - .overlay(alignment: .bottom) { - if showSavedConfirmation { - Label("Saved", systemImage: "checkmark.circle.fill") - .font(.callout) - .padding(.horizontal, 14) .padding(.vertical, 8) - .background(.thinMaterial, in: Capsule()) - .padding(.bottom, 16) - .transition(.move(edge: .bottom).combined(with: .opacity)) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.12)) + } + if showSavedConfirmation { + Label("Saved", systemImage: "checkmark.circle.fill") + .font(.callout) + .foregroundStyle(.green) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(.thinMaterial) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } } .animation(.easeInOut(duration: 0.2), value: showSavedConfirmation) + .animation(.easeInOut(duration: 0.2), value: vm.lastError) .task { await vm.load() } + .onDisappear { savedHideTask?.cancel() } + } + + private func performSave() async { + let ok = await vm.save() + guard ok else { return } + // Cancel any in-flight hide task so rapid saves don't drop + // the pill mid-fade (the previous implementation stacked + // overlapping sleep tasks). + savedHideTask?.cancel() + showSavedConfirmation = true + savedHideTask = Task { + try? await Task.sleep(nanoseconds: 2_500_000_000) + if !Task.isCancelled { + showSavedConfirmation = false + } + } } }