import Combine import LumaCore import SwiftUI import SwiftyPharo struct NotebookView: View { let engine: Engine @Binding var selection: SidebarItemID? #if canImport(UIKit) @Environment(\.horizontalSizeClass) private var horizontalSizeClass private var horizontalInset: CGFloat { horizontalSizeClass == .compact ? 6 : 16 } #else private var horizontalInset: CGFloat { 16 } #endif private var entries: [LumaCore.NotebookEntry] { engine.notebookEntries.sorted { a, b in if a.position != b.position { return a.position < b.position } return a.id.uuidString < b.id.uuidString } } @State private var drillPath = PharoColumnPath() @State private var inspected: UUID? @State private var centers: [UUID: CGFloat] = [:] @State private var drillWidth: CGFloat = 520 @State private var resizingFrom: CGFloat? private let runtime = PharoRuntime.shared var body: some View { GeometryReader { page in HStack(spacing: 0) { self.page .frame(maxWidth: .infinity) if !drillPath.objects.isEmpty { let widestDrillLeavingAPageToRead = page.size.width / 2 let width = min(drillWidth, widestDrillLeavingAPageToRead) Divider() drillSide(width: width).frame(width: width) } } } .coordinateSpace(name: pharoPageSpace) .background(.pharoGutter) .pharoMaximizedPane(runtime: runtime, path: drillPath, onCloseAll: closeDrill) } private var page: some View { Group { if entries.isEmpty { NotebookEmptyStateView( engine: engine, onAddNote: { addEntry(kind: .note, after: nil) }, onAddPharoCell: { addEntry(kind: .pharo, after: nil) }) } else { content } } } private var content: some View { ZStack(alignment: .bottomTrailing) { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 16) { let ordered = entries TopDropZone(engine: engine, firstEntry: ordered.first) .padding(.horizontal, horizontalInset) ForEach(Array(ordered.enumerated()), id: \.element.id) { index, entry in NotebookEntryRow( entry: entry, previous: index > 0 ? ordered[index - 1] : nil, next: index < ordered.count - 1 ? ordered[index + 1] : nil, engine: engine, selection: $selection, autoBeginEditing: entry.id == lastInsertedID, drillPath: drillPath, inspected: $inspected, centers: $centers, onEditingChanged: { editing in if editing { editingEntryIDs.insert(entry.id) } else { editingEntryIDs.remove(entry.id) } }, addNoteBelow: { addEntry(kind: .note, after: entry) }, addPharoCellBelow: { addEntry(kind: .pharo, after: entry) }, deleteAction: { engine.deleteNotebookEntry(entry) } ) .id(entry.id) } .padding(.horizontal, horizontalInset) Color.clear .frame(height: 80) .id("notebook-bottom-anchor") } } .onChange(of: lastInsertedID) { _, newID in guard newID != nil else { return } withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo("notebook-bottom-anchor", anchor: .bottom) } } .onChange(of: entries.map(\.id)) { _, newIDs in editingEntryIDs.formIntersection(Set(newIDs)) } } HStack(spacing: 8) { Button { addEntry(kind: .note, after: nil) } label: { Label("New Note", systemImage: "plus") .font(.callout.weight(.medium)) .padding(.horizontal, 10) .padding(.vertical, 4) } .accessibilityIdentifier("notebook.newNote") .buttonStyle(.borderedProminent) Button { addEntry(kind: .pharo, after: nil) } label: { Label("New Pharo Cell", systemImage: "chevron.left.forwardslash.chevron.right") .font(.callout.weight(.medium)) .padding(.horizontal, 10) .padding(.vertical, 4) } .accessibilityIdentifier("notebook.newPharoCell") .buttonStyle(.bordered) } .buttonBorderShape(.capsule) #if canImport(UIKit) .controlSize(.regular) #else .controlSize(.small) #endif .shadow(radius: 3, y: 1) .padding() .opacity(isAnyEditing ? 0 : 1) .allowsHitTesting(!isAnyEditing) .animation(.easeInOut(duration: 0.15), value: isAnyEditing) } } @State private var editingEntryIDs: Set = [] private var isAnyEditing: Bool { !editingEntryIDs.isEmpty } @State private var lastInsertedID: UUID? private func addEntry(kind: LumaCore.NotebookEntry.Kind, after entry: LumaCore.NotebookEntry?) { let added = LumaCore.NotebookEntry( kind: kind, title: "", details: "", binaryData: nil, processName: entry?.processName ) engine.addNotebookEntry(added, after: entry) lastInsertedID = added.id } /// A cell drills into the pane beside the page, its columns riding a scroller /// of their own with the arrow that points across from the cell they came /// from. It is here only while a drill is open, so the page is the whole /// width the rest of the time. private func drillSide(width: CGFloat) -> some View { VStack(spacing: 0) { PharoOverviewStrip(path: drillPath) Divider() ScrollView(.horizontal) { HStack(spacing: 0) { if !drillPath.isFirstColumnCollapsed { PharoPointingArrow(pointsFrom: inspected.flatMap { centers[$0] }) } pharoColumns( runtime: runtime, path: drillPath, width: pharoColumnWidth(fitting: width), onCloseAll: closeDrill) } .scrollTargetLayout() } .contentMargins(8, for: .scrollContent) .pharoColumnScrolling(drillPath) } .overlay(alignment: .leading) { drillResizeHandle } } private func closeDrill() { drillPath.clear() inspected = nil } private var drillResizeHandle: some View { Rectangle() .fill(.clear) .frame(width: 8) .contentShape(Rectangle()) .platformPointer(.columnResize) .gesture( // Global, since the handle rides the pane's edge as it resizes. DragGesture(coordinateSpace: .global) .onChanged { drag in let base = resizingFrom ?? drillWidth resizingFrom = base drillWidth = min(max(base - drag.translation.width, 320), 1000) } .onEnded { _ in resizingFrom = nil }) } } /// Thin drop strip above the first entry so drags can actually land at /// position 0. The per-row `.dropDestination` only fires inside the /// row's frame; drops into the empty space above the first row used /// to fall on the floor. private struct TopDropZone: View { let engine: Engine let firstEntry: LumaCore.NotebookEntry? @State private var isTargeted = false var body: some View { Rectangle() .fill(Color.clear) .frame(height: 6) .overlay(alignment: .bottom) { if isTargeted { Color.accentColor.frame(height: 2) } } .dropDestination(for: String.self) { items, _ in guard let first = items.first, let sourceID = UUID(uuidString: first), let source = engine.notebookEntries.first(where: { $0.id == sourceID }) else { return false } if source.id == firstEntry?.id { return false } engine.reorderNotebookEntry( source, between: nil, and: firstEntry, ) return true } isTargeted: { isTargeted = $0 } } } struct NotebookEntryRow: View { let entry: LumaCore.NotebookEntry let previous: LumaCore.NotebookEntry? let next: LumaCore.NotebookEntry? let engine: Engine @Binding var selection: SidebarItemID? let autoBeginEditing: Bool let drillPath: PharoColumnPath @Binding var inspected: UUID? @Binding var centers: [UUID: CGFloat] let onEditingChanged: (Bool) -> Void let addNoteBelow: () -> Void let addPharoCellBelow: () -> Void let deleteAction: () -> Void @FocusState private var isTitleFocused: Bool @FocusState private var isBodyFocused: Bool @State private var isEditingUserNote: Bool = false @State private var editTitle: String = "" @State private var editDetails: String = "" @State private var dropHighlight: DropHighlight = .none @State private var rowHeight: CGFloat = 0 private enum DropHighlight { case none, above, below } private struct RowHeightKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } private var isNote: Bool { entry.kind == .note } private var isPharo: Bool { entry.kind == .pharo } var body: some View { VStack(alignment: .leading, spacing: 6) { header if isNote { if isEditingUserNote { editableUserNoteBody } else { readOnlyUserNoteBody } } else if isPharo { PharoNotebookCell( entry: entry, engine: engine, autoFocus: autoBeginEditing, drillPath: drillPath, inspected: $inspected, centers: $centers) } else { systemEntryBody } if let data = entry.binaryData, !data.isEmpty { HexView(data: data) .font(.system(.footnote, design: .monospaced)) .fixedSize(horizontal: false, vertical: true) } } .padding(10) .background(Color.secondary.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 8)) .background( GeometryReader { proxy in Color.clear.preference(key: RowHeightKey.self, value: proxy.size.height) } ) .onPreferenceChange(RowHeightKey.self) { rowHeight = $0 } .overlay(alignment: .top) { if dropHighlight == .above { Color.accentColor.frame(height: 2) } } .overlay(alignment: .bottom) { if dropHighlight == .below { Color.accentColor.frame(height: 2) } } .draggable(entry.id.uuidString) .dropDestination(for: String.self) { items, location in handleDrop(items: items, location: location) } isTargeted: { targeted in if !targeted { dropHighlight = .none } } .contextMenu { if isNote { Button { beginEditing() } label: { Label("Edit Note", systemImage: "pencil") } } Button { addNoteBelow() } label: { Label("Insert Note Below", systemImage: "plus") } Button { addPharoCellBelow() } label: { Label("Insert Pharo Cell Below", systemImage: "chevron.left.forwardslash.chevron.right") } Divider() Button(role: .destructive) { deleteAction() } label: { Label("Delete Entry", systemImage: "trash") } } .onAppear { editTitle = entry.title editDetails = entry.details if autoBeginEditing && isNote && entry.details.isEmpty && entry.title.isEmpty { isEditingUserNote = true DispatchQueue.main.async { isTitleFocused = true } } } .onChange(of: isEditingUserNote) { _, newValue in onEditingChanged(newValue) } .onDisappear { if isEditingUserNote { onEditingChanged(false) } } .onTapGesture(count: 2) { guard isNote else { return } beginEditing(focusBody: true) } } @ViewBuilder private var header: some View { HStack(alignment: .center, spacing: 10) { editorStack if let processName = entry.processName { Text(processName) .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.accentColor.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 4)) } if !(isNote && isEditingUserNote) { Text(isNote && entry.title.isEmpty ? "Note" : entry.title) .font(.headline) .lineLimit(1) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } else { Spacer(minLength: 0) } NotebookTimestampLabel(date: entry.timestamp) .help(entry.timestamp.formatted()) } } @ViewBuilder private var editorStack: some View { AuthorAvatarStack(authors: entry.editors) } private func handleDrop(items: [String], location: CGPoint) -> Bool { defer { dropHighlight = .none } guard let first = items.first, let sourceID = UUID(uuidString: first), sourceID != entry.id else { return false } guard let source = engine.notebookEntries.first(where: { $0.id == sourceID }) else { return false } let ordered = engine.notebookEntries.sorted { a, b in if a.position != b.position { return a.position < b.position } return a.id.uuidString < b.id.uuidString } guard let targetIndex = ordered.firstIndex(where: { $0.id == entry.id }) else { return false } let midpoint = rowHeight > 0 ? rowHeight / 2 : 0 let dropAbove = location.y < midpoint let neighborA: LumaCore.NotebookEntry? let neighborB: LumaCore.NotebookEntry? if dropAbove { neighborA = targetIndex > 0 ? ordered[targetIndex - 1] : nil neighborB = ordered[targetIndex] } else { neighborA = ordered[targetIndex] neighborB = targetIndex < ordered.count - 1 ? ordered[targetIndex + 1] : nil } if neighborA?.id == source.id || neighborB?.id == source.id { return false } engine.reorderNotebookEntry(source, between: neighborA, and: neighborB) return true } @ViewBuilder private var editableUserNoteBody: some View { VStack(alignment: .leading, spacing: 6) { TextField("Title", text: $editTitle) .font(.subheadline.weight(.semibold)) .focused($isTitleFocused) .onSubmit { saveEdits() } .accessibilityIdentifier("notebook.note.title") TextEditor(text: $editDetails) .font(.system(.body, design: .default)) .frame(minHeight: 80) .focused($isBodyFocused) .accessibilityIdentifier("notebook.note.body") .overlay(alignment: .topLeading) { if editDetails.isEmpty { Text("Write something\u{2026}") .font(.body) .foregroundStyle(.secondary) .padding(.horizontal, 5) .padding(.vertical, 8) .allowsHitTesting(false) } } .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.2)) ) HStack(spacing: 8) { Spacer() Button("Cancel") { cancelEdits() } Button("Save") { saveEdits() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } } } @ViewBuilder private var readOnlyUserNoteBody: some View { if !entry.details.isEmpty { MarkdownView(entry.details) } } @ViewBuilder private var systemEntryBody: some View { if let jsValue = entry.jsValue { JSInspectValueView( value: jsValue, sessionID: entry.sessionID ?? UUID(), engine: engine, selection: $selection ) .font(.system(.footnote, design: .monospaced)) } else if let styled = entry.styledDetails { Text(styled.attributed) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) } else if !entry.details.isEmpty { Text(entry.details) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) } } private func saveEdits() { var updated = entry updated.title = editTitle updated.details = editDetails withAnimation(.easeOut(duration: 0.15)) { isEditingUserNote = false } engine.updateNotebookEntry(updated) } private func cancelEdits() { if isFreshPlaceholder { engine.deleteNotebookEntry(entry) return } editTitle = entry.title editDetails = entry.details withAnimation(.easeOut(duration: 0.15)) { isEditingUserNote = false } } private var isFreshPlaceholder: Bool { entry.title.isEmpty && entry.details.isEmpty } private func beginEditing(focusBody: Bool = false) { guard isNote else { return } editTitle = entry.title editDetails = entry.details withAnimation(.easeInOut(duration: 0.15)) { isEditingUserNote = true } DispatchQueue.main.async { if focusBody { isBodyFocused = true } else { isTitleFocused = true } } } } private let instructionsMaxWidth: CGFloat = 440 struct NotebookEmptyStateView: View { let engine: Engine let onAddNote: () -> Void let onAddPharoCell: () -> Void #if canImport(UIKit) @Environment(\.horizontalSizeClass) private var horizontalSizeClass private var isCompact: Bool { horizontalSizeClass == .compact } #else private var isCompact: Bool { false } #endif var body: some View { GeometryReader { geo in VStack { Spacer(minLength: 0) if engine.collaboration.status == .connecting { joiningContent } else { defaultContent } Spacer(minLength: 0) } .frame(width: geo.size.width, height: geo.size.height) } } private var joiningContent: some View { VStack(spacing: 16) { ProgressView() .controlSize(.large) Text("Joining lab\u{2026}") .font(.title3.weight(.semibold)) Text("Syncing this project's shared state.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.horizontal, 24) } private var defaultContent: some View { VStack(spacing: 24) { EmptyStateHeading( title: "Notebook", systemImage: "book.pages", subtitle: "Capture interesting findings here." ) if !isCompact { walkthrough } HStack(spacing: 8) { Button(action: onAddNote) { Label("New Note", systemImage: "plus") } .buttonStyle(.borderedProminent) Button(action: onAddPharoCell) { Label("New Pharo Cell", systemImage: "chevron.left.forwardslash.chevron.right") } .buttonStyle(.bordered) } .controlSize(.large) } .padding(.horizontal, 24) } private var walkthrough: some View { HStack { Spacer(minLength: 0) VStack(alignment: .leading, spacing: 8) { walkthroughStep( number: 1, text: Text("Use the \(Image(systemName: "target")) button to attach to a running app or process.") ) walkthroughStep( number: 2, text: Text("Then use the \(Image(systemName: "waveform.path.ecg")) button to add instruments.") ) walkthroughStep( number: 3, text: Text("Pin any event from the bottom event stream to save it here.") ) } .font(.callout) .frame(maxWidth: instructionsMaxWidth, alignment: .leading) .padding(.leading, 72) Spacer(minLength: 0) } } @ViewBuilder private func walkthroughStep(number: Int, text: Text) -> some View { HStack(alignment: .firstTextBaseline, spacing: 8) { Text("\(number).") .monospacedDigit() .foregroundStyle(.secondary) .frame(width: 18, alignment: .trailing) text .fixedSize(horizontal: false, vertical: true) } } } struct NotebookTimestampLabel: View { let date: Date var body: some View { TimelineView(.periodic(from: .now, by: 60)) { context in Text(NotebookTimestamp.string(from: date, now: context.date)) .font(.caption2) .foregroundStyle(.tertiary) .monospacedDigit() } } }