Share the notebook's heading, add a button that boots a machine, and leave out the panel title while there is nothing under it.
699 lines
24 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|