Files
luma/Luma/NotebookView.swift
Ole André Vadla Ravnås c94525fb8a ui: Match the machines empty state to the notebook
Share the notebook's heading, add a button that boots a machine, and
leave out the panel title while there is nothing under it.
2026-09-29 15:38:58 +02:00

699 lines
24 KiB
Swift

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<UUID> = []
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()
}
}
}