mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
M7 #9+#10: Memory editor keyboard + Saved pill above keyboard
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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,66 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
|
|
||||||
/// Editor for a single memory file (MEMORY.md or USER.md). Owns an
|
/// Editor for a single memory file (MEMORY.md / USER.md / SOUL.md).
|
||||||
/// `IOSMemoryViewModel` instance, renders its `text` in a TextEditor,
|
/// Owns an `IOSMemoryViewModel` instance, renders its `text` in a
|
||||||
/// and exposes Save + Revert toolbar buttons.
|
/// 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 {
|
struct MemoryEditorView: View {
|
||||||
@State private var vm: IOSMemoryViewModel
|
@State private var vm: IOSMemoryViewModel
|
||||||
@State private var showSavedConfirmation = false
|
@State private var showSavedConfirmation = false
|
||||||
|
@State private var savedHideTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(kind: IOSMemoryViewModel.Kind, context: ServerContext) {
|
init(kind: IOSMemoryViewModel.Kind, context: ServerContext) {
|
||||||
_vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context))
|
_vm = State(initialValue: IOSMemoryViewModel(kind: kind, context: context))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
Group {
|
||||||
if vm.isLoading {
|
if vm.isLoading {
|
||||||
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView("Loading \(vm.kind.displayName)…")
|
ProgressView("Loading \(vm.kind.displayName)…")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
TextEditor(text: $vm.text)
|
TextEditor(text: $vm.text)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.padding(.horizontal, 8)
|
.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 {
|
if let err = vm.lastError {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
@@ -34,47 +71,41 @@ struct MemoryEditorView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 8)
|
||||||
.background(.regularMaterial)
|
.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(vm.kind.displayName)
|
.animation(.easeInOut(duration: 0.2), value: showSavedConfirmation)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.animation(.easeInOut(duration: 0.2), value: vm.lastError)
|
||||||
.toolbar {
|
.task { await vm.load() }
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
.onDisappear { savedHideTask?.cancel() }
|
||||||
Button("Save") {
|
}
|
||||||
Task {
|
|
||||||
|
private func performSave() async {
|
||||||
let ok = await vm.save()
|
let ok = await vm.save()
|
||||||
if ok {
|
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
|
showSavedConfirmation = true
|
||||||
Task {
|
savedHideTask = Task {
|
||||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||||
|
if !Task.isCancelled {
|
||||||
showSavedConfirmation = false
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: showSavedConfirmation)
|
|
||||||
.task { await vm.load() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user