Files
luma/Luma/EventStreamView.swift
Ole André Vadla Ravnås a1508524a0 Draw the author's own scene on a canvas
A scene the image holds by handle and changes; the host draws it.
Each drawable carries its own vertex and fragment stages, its own
attribute layout, named uniforms, buffers, a transform and a
visibility flag -- described as objects rather than staged globals.

A value says what it should do, ramp or oscillate, rather than what
it is each frame: nothing crosses per frame, since the image has no
business in the frame path any more than in the audio callback.
Both renderers work it out on the clock that draws it.
2026-08-13 12:46:09 +02:00

988 lines
31 KiB
Swift

import SwiftUI
import LumaCore
struct EventStreamView: View {
let engine: Engine
@Binding var selection: SidebarItemID?
#if canImport(UIKit)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompactWidth: Bool { horizontalSizeClass == .compact }
#else
private var isCompactWidth: Bool { false }
#endif
var onCollapseRequested: (() -> Void)?
@State private var displayedEvents: [RuntimeEvent] = []
@State private var filteredEvents: [RuntimeEvent] = []
@State private var isPaused: Bool = false
@State private var pendingNewEvents: Int = 0
@State private var lastEventsVersion: Int = 0
@State private var scrollToLastToken: Int = 0
@State private var isAtBottom: Bool = true
@State private var isAutoScrolling: Bool = false
@State private var searchText: String = ""
@State private var searchCache: [RuntimeEvent.ID: String] = [:]
@State private var sourceFilter: EventSourceFilter = .all
@State private var selectedProcessName: String?
@FocusState private var isSearchFocused: Bool
@Environment(\.colorScheme) private var colorScheme
@State private var eventRate = EventRate()
@State private var activity: Float = 0
var body: some View {
VStack(spacing: 0) {
header
Divider()
ShaderEffectView(
program: .builtIn(function: "eventFieldFragment"),
scheme: colorScheme == .light ? 1.0 : 0.0,
activity: activity
)
.frame(height: 3)
.onChange(of: engine.eventLog.totalReceived) { _, total in
let rate = eventRate.observe(totalReceived: total, at: Date.timeIntervalSinceReferenceDate)
activity = rate ?? 0
if let rate {
engine.eventChime.report(activity: rate)
}
}
ScrollViewReader { proxy in
GeometryReader { geo in
ZStack(alignment: .bottomTrailing) {
scrollContent
.environment(\.pauseEventStream, pauseFromRow)
.coordinateSpace(name: "EventScroll")
.onPreferenceChange(BottomRowOffsetPreferenceKey.self) { bottomY in
updateScrollPosition(bottomY: bottomY, viewportHeight: geo.size.height)
}
if let empty = emptyStateReason {
EmptyStateView(reason: empty, isCompactWidth: isCompactWidth)
}
if pendingNewEvents > 0 && (isPaused || !isAtBottom) {
Button {
goLiveAndScrollToBottom()
} label: {
Text("Show \(pendingNewEvents) new event\(pendingNewEvents == 1 ? "" : "s")")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.thinMaterial)
.clipShape(Capsule())
}
.padding()
}
}
.contentShape(Rectangle())
.onTapGesture {
isSearchFocused = false
}
.onAppear {
syncSnapshotFromEngine()
isPaused = false
pendingNewEvents = 0
isAtBottom = true
scrollToLastToken &+= 1
}
.onChange(of: engine.eventLog.flushVersion) { _, _ in
handleEventVersionChange(engine.eventLog.totalReceived)
}
.onChange(of: scrollToLastToken) { _, _ in
guard let last = filteredEvents.last else { return }
isAutoScrolling = true
proxy.scrollTo(last.id, anchor: .bottom)
DispatchQueue.main.async {
isAutoScrolling = false
}
}
.onChange(of: searchText) { _, _ in
rebuildFilteredEvents()
}
.onChange(of: sourceFilter) { _, _ in
rebuildFilteredEvents()
}
.onChange(of: selectedProcessName) { _, _ in
rebuildFilteredEvents()
}
}
}
}
}
private var header: some View {
Group {
if isCompactWidth {
HStack(spacing: 8) {
compactSourceFilter
if !availableProcessNames.isEmpty {
compactProcessFilter
}
TextField("Search", text: $searchText)
.textFieldStyle(.roundedBorder)
.focused($isSearchFocused)
pauseButton
overflowMenu
}
} else {
HStack(spacing: 8) {
Label("Event Stream", systemImage: "waveform")
.font(.footnote)
.foregroundStyle(.secondary)
regularSourceFilter
if !availableProcessNames.isEmpty {
regularProcessFilter
}
TextField("Search", text: $searchText)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 220)
.focused($isSearchFocused)
Spacer()
HStack(spacing: 6) {
Circle()
.frame(width: 6, height: 6)
.foregroundColor(isPaused ? .gray : .green)
Text(isPaused ? "Paused" : "Live")
.font(.caption)
.foregroundStyle(.secondary)
}
pauseButton
overflowMenu
if let onCollapseRequested {
Button {
onCollapseRequested()
} label: {
Image(systemName: "chevron.down")
}
.buttonStyle(.borderless)
.help("Hide the event stream")
}
}
}
}
.padding(.horizontal, isCompactWidth ? 16 : 8)
.padding(.vertical, 4)
}
private var regularSourceFilter: some View {
Picker("", selection: $sourceFilter) {
ForEach(EventSourceFilter.allCases) { filter in
Text(filter.menuTitle).tag(filter)
}
}
.pickerStyle(.menu)
.controlSize(.small)
.help("Filter by event source")
}
private var compactSourceFilter: some View {
Menu {
Picker("Source", selection: $sourceFilter) {
ForEach(EventSourceFilter.allCases) { filter in
Text(filter.menuTitle).tag(filter)
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.symbolVariant(sourceFilter == .all ? .none : .fill)
}
.accessibilityLabel("Filter by event source")
}
private var regularProcessFilter: some View {
Menu {
processFilterContent
} label: {
Label(
selectedProcessName ?? "All Processes",
systemImage: "line.3.horizontal.decrease.circle"
)
}
.controlSize(.small)
.help("Filter by process")
}
private var compactProcessFilter: some View {
Menu {
processFilterContent
} label: {
Image(systemName: "cpu")
.symbolVariant(selectedProcessName == nil ? .none : .fill)
}
.accessibilityLabel("Filter by process")
}
@ViewBuilder
private var processFilterContent: some View {
Button("All Processes") {
selectedProcessName = nil
}
Divider()
ForEach(availableProcessNames, id: \.self) { name in
Button {
selectedProcessName = name
} label: {
if selectedProcessName == name {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
}
private var pauseButton: some View {
Button {
togglePause()
} label: {
Image(systemName: isPaused ? "play.fill" : "pause.fill")
}
.buttonStyle(.borderless)
.help(isPaused ? "Resume live tail" : "Pause event stream")
}
private var overflowMenu: some View {
Menu {
Toggle(isOn: Binding(
get: { engine.eventChime.isEnabled },
set: { engine.eventChime.isEnabled = $0 }
)) {
Label("Sound Events", systemImage: "speaker.wave.2")
}
Divider()
Button(role: .destructive) {
engine.clearEventLog()
resetAllEventState()
isPaused = false
isAtBottom = true
} label: {
Label("Clear Events", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.buttonStyle(.borderless)
.menuIndicator(.hidden)
.help("More actions")
}
private var scrollContent: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(filteredEvents.enumerated()), id: \.1.id) { index, evt in
let previousTimestamp: Date? = index > 0 ? filteredEvents[index - 1].timestamp : nil
EventRow(
evt: evt,
previousTimestamp: previousTimestamp,
engine: engine,
selection: $selection
) {
pin(evt)
}
.id(evt.id)
.accessibilityIdentifier("event.row")
.background(
GeometryReader { rowGeo in
Color.clear
.preference(
key: BottomRowOffsetPreferenceKey.self,
value: index == filteredEvents.count - 1
? rowGeo.frame(in: .named("EventScroll")).maxY
: BottomRowOffsetPreferenceKey.defaultValue
)
}
)
Divider()
}
}
}
.defaultScrollAnchor(.bottom)
}
enum EmptyReason {
case noEvents
case filtered
case search
}
private var emptyStateReason: EmptyReason? {
if !filteredEvents.isEmpty {
return nil
}
if displayedEvents.isEmpty {
return .noEvents
}
if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .search
}
if sourceFilter != .all || selectedProcessName != nil {
return .filtered
}
return .noEvents
}
private func resetAllEventState() {
displayedEvents.removeAll()
filteredEvents.removeAll()
searchCache.removeAll()
pendingNewEvents = 0
lastEventsVersion = 0
}
private func syncSnapshotFromEngine() {
displayedEvents = engine.eventLog.events
lastEventsVersion = engine.eventLog.totalReceived
rebuildFilteredEvents()
}
private func goLiveAndScrollToBottom() {
isPaused = false
pendingNewEvents = 0
syncSnapshotFromEngine()
isAtBottom = true
scrollToLastToken &+= 1
}
private func updateScrollPosition(bottomY: CGFloat, viewportHeight: CGFloat) {
guard !filteredEvents.isEmpty else {
isAtBottom = true
return
}
if bottomY == BottomRowOffsetPreferenceKey.defaultValue { return }
let minMeaningfulViewportHeight: CGFloat = 80
if viewportHeight < minMeaningfulViewportHeight { return }
let threshold: CGFloat = 20
let distanceFromBottom = bottomY - viewportHeight
let atBottomNow = distanceFromBottom <= threshold
if atBottomNow != isAtBottom {
if !atBottomNow && !isPaused && !isAutoScrolling {
isPaused = true
}
isAtBottom = atBottomNow
}
}
private func togglePause() {
if isPaused {
goLiveAndScrollToBottom()
} else {
isPaused = true
syncSnapshotFromEngine()
}
}
private func pauseFromRow() {
guard !isPaused else { return }
isPaused = true
syncSnapshotFromEngine()
}
private func handleEventVersionChange(_ newVersion: Int) {
if newVersion == 0 {
resetAllEventState()
return
}
let delta = max(0, newVersion - lastEventsVersion)
if isPaused {
pendingNewEvents += delta
lastEventsVersion = newVersion
return
}
lastEventsVersion = newVersion
if isAtBottom {
syncSnapshotFromEngine()
pendingNewEvents = 0
scrollToLastToken &+= 1
} else {
pendingNewEvents += delta
}
}
private func rebuildFilteredEvents() {
guard !displayedEvents.isEmpty else {
filteredEvents = []
searchCache = [:]
return
}
let ids = Set(displayedEvents.map { $0.id })
searchCache = searchCache.filter { ids.contains($0.key) }
let trimmedSearch = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
let hasSearch = !trimmedSearch.isEmpty
filteredEvents = displayedEvents.filter { evt in
guard sourceFilter.matches(evt.source) else { return false }
if let name = selectedProcessName {
guard processName(for: evt) == name else { return false }
}
if hasSearch {
let blob: String
if let cached = searchCache[evt.id] {
blob = cached
} else {
let context = prettyContext(evt)
let payload = prettyPayload(evt)
let combined = [payload, context.title, context.process ?? ""]
.joined(separator: " ")
searchCache[evt.id] = combined
blob = combined
}
guard blob.localizedCaseInsensitiveContains(trimmedSearch) else {
return false
}
}
return true
}
}
private var availableProcessNames: [String] {
let names = Set(displayedEvents.map { processName(for: $0) })
return names.sorted()
}
private func pin(_ evt: RuntimeEvent) {
let (processName, title) = prettyContext(evt)
let jsValue: JSInspectValue? = {
if case .jsValue(let v) = evt.payload { return v }
return nil
}()
engine.addNotebookEntry(
LumaCore.NotebookEntry(
title: title,
details: prettyPayload(evt),
jsValue: jsValue,
binaryData: evt.data.map { Data($0) },
sessionID: evt.sessionID ?? UUID(),
processName: processName
))
}
private func processName(for evt: RuntimeEvent) -> String {
engine.session(id: evt.sessionID ?? UUID())?.processName ?? ""
}
private func prettyContext(_ evt: RuntimeEvent) -> (process: String?, title: String) {
let processName = engine.session(id: evt.sessionID ?? UUID())?.processName ?? ""
switch evt.source {
case .processOutput(let fd):
let channel: String = {
switch fd {
case 1: return "stdout"
case 2: return "stderr"
default: return "fd\(fd)"
}
}()
return (processName, "Output on \(channel)")
case .script:
return (processName, "Script Runtime (\(processName))")
case .console:
return (processName, "Console (\(processName))")
case .repl:
return (processName, "REPL (\(processName))")
case .instrument(_, let name):
let displayName = engine.instrument(forEvent: evt).map { engine.descriptor(for: $0).displayName } ?? name
return (processName, displayName)
case .spawnGating(_, let deviceName, _, _, let outcome):
let title: String
switch outcome {
case .captured: title = "Spawn Captured"
case .released: title = "Spawn Released"
}
return (deviceName, title)
case .engine(let subsystem):
return (nil, "Engine (\(subsystem))")
}
}
private func prettyPayload(_ evt: RuntimeEvent) -> String {
switch evt.source {
case .console:
if case .consoleMessage(let message) = evt.payload {
let parts = message.values.map { $0.inlineDescription }
return parts.joined(separator: " ")
}
return String(describing: evt.payload)
case .instrument:
if let instrument = engine.instrument(forEvent: evt) {
return engine.descriptor(for: instrument).summarizeEvent(evt)
}
return String(describing: evt.payload)
case .spawnGating:
if case .raw(let message, _) = evt.payload {
return message as? String ?? String(describing: message)
}
return String(describing: evt.payload)
default:
return String(describing: evt.payload)
}
}
}
private struct PauseEventStreamKey: EnvironmentKey {
static let defaultValue: () -> Void = {}
}
extension EnvironmentValues {
var pauseEventStream: () -> Void {
get { self[PauseEventStreamKey.self] }
set { self[PauseEventStreamKey.self] = newValue }
}
}
private struct BottomRowOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .infinity
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
let new = nextValue()
if new != .infinity {
value = new
}
}
}
private enum EventSourceFilter: String, CaseIterable, Identifiable {
case all
case output
case script
case console
case repl
case instrument
case spawnGating
case engine
var id: String { rawValue }
var menuTitle: String {
switch self {
case .all: return "All Sources"
case .output: return "Process Output"
case .script: return "Script Runtime"
case .console: return "Console"
case .repl: return "REPL"
case .instrument: return "Instruments"
case .spawnGating: return "Spawn Gating"
case .engine: return "Engine"
}
}
func matches(_ source: LumaCore.RuntimeEvent.Source) -> Bool {
switch self {
case .all:
return true
case .output:
if case .processOutput = source { return true }
return false
case .script:
if case .script = source { return true }
return false
case .console:
if case .console = source { return true }
return false
case .repl:
if case .repl = source { return true }
return false
case .instrument:
if case .instrument = source { return true }
return false
case .spawnGating:
if case .spawnGating = source { return true }
return false
case .engine:
if case .engine = source { return true }
return false
}
}
}
private struct EmptyStateView: View {
let reason: EventStreamView.EmptyReason
let isCompactWidth: Bool
var body: some View {
VStack(spacing: isCompactWidth ? 12 : 6) {
switch reason {
case .noEvents:
Text("No events yet")
.font(.headline)
Text("Events from your sessions will appear here.")
.font(.subheadline)
.foregroundStyle(.secondary)
case .filtered:
Text("No events match the current filters")
.font(.headline)
Text("Try adjusting the source or process filters.")
.font(.subheadline)
.foregroundStyle(.secondary)
case .search:
Text("No events match your search")
.font(.headline)
Text("Try a different search term.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.clear)
}
}
struct EventRow: View {
let evt: RuntimeEvent
let previousTimestamp: Date?
let engine: Engine
@Binding var selection: SidebarItemID?
let pinAction: () -> Void
private static let timestampFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
df.locale = .autoupdatingCurrent
df.timeZone = .current
return df
}()
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
if let delta = deltaText {
Text(delta)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
.help(Self.timestampFormatter.string(from: evt.timestamp))
} else {
Text(" ")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 70, alignment: .leading)
.help(Self.timestampFormatter.string(from: evt.timestamp))
}
contentView
Spacer(minLength: 8)
EventSourceBadge(evt: evt, engine: engine)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.background)
.contentShape(Rectangle())
.contextMenu {
Button {
pinAction()
} label: {
Label("Pin to Notebook", systemImage: "pin")
}
ForEach(instrumentMenuItems) { item in
if item.role == .destructive {
Button(role: .destructive) {
item.action()
} label: {
Label(item.title, systemImage: item.systemImage ?? "questionmark.circle")
}
} else {
Button {
item.action()
} label: {
Label(item.title, systemImage: item.systemImage ?? "questionmark.circle")
}
}
}
}
}
private var deltaText: String? {
guard let previousTimestamp else {
return nil
}
let dt = evt.timestamp.timeIntervalSince(previousTimestamp)
guard dt > 0 else { return nil }
let ms = dt * 1000.0
if ms < 1.0 {
return nil
}
if ms < 1000.0 {
return String(format: "+%.0f ms", ms)
} else if dt < 60.0 {
return String(format: "+%.2f s", dt)
} else {
return String(format: "+%.0f s", dt)
}
}
@ViewBuilder
private var contentView: some View {
if let errorView = jsErrorEventView {
errorView
} else if let consoleView = consoleEventView {
consoleView
} else if let instrumentView = instrumentEventView {
instrumentView
} else {
Text(rawEventText)
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.lineLimit(3)
}
}
private var rawEventText: String {
if case .raw(let message, _) = evt.payload {
return message as? String ?? String(describing: message)
}
return String(describing: evt.payload)
}
private var jsErrorEventView: AnyView? {
guard case .jsError(let error) = evt.payload else {
return nil
}
return AnyView(
VStack(alignment: .leading, spacing: 4) {
Text(error.text)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(Color.red)
.textSelection(.enabled)
if let fileName = error.fileName, let line = error.lineNumber {
Text("\(fileName):\(line)\(error.columnNumber.map { ":\($0)" } ?? "")")
.font(.caption2)
.foregroundColor(.secondary)
}
if let stack = error.stack, !stack.isEmpty {
Text(stack)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
}
}
)
}
private var consoleEventView: AnyView? {
guard case .consoleMessage(let message) = evt.payload else {
return nil
}
let allStrings = message.values.compactMap { value -> String? in
if case .string(let s) = value {
return s
}
return nil
}
let valueView: AnyView
if !message.values.isEmpty && allStrings.count == message.values.count {
valueView = AnyView(
Text(allStrings.joined(separator: " "))
.font(.system(.footnote, design: .monospaced))
.textSelection(.enabled)
.lineLimit(3)
)
} else {
valueView = AnyView(
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(message.values.enumerated()), id: \.0) { _, value in
JSInspectValueView(
value: value,
sessionID: evt.sessionID ?? UUID(),
engine: engine,
selection: $selection
)
.font(.system(.footnote, design: .monospaced))
}
}
)
}
return AnyView(
HStack(alignment: .top, spacing: 8) {
Text(message.level.badgeText.uppercased())
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(message.level.badgeColor.opacity(0.15))
.foregroundStyle(message.level.badgeColor)
.clipShape(RoundedRectangle(cornerRadius: 4))
valueView
}
)
}
private var instrumentEventView: AnyView? {
guard let instrument = engine.instrument(forEvent: evt),
let ui = InstrumentUIRegistry.shared.ui(for: instrument)
else {
return nil
}
return ui.renderEvent(evt, engine: engine, selection: $selection)
}
private var instrumentMenuItems: [InstrumentEventMenuItem] {
guard let instrument = engine.instrument(forEvent: evt),
let ui = InstrumentUIRegistry.shared.ui(for: instrument)
else {
return []
}
return ui.makeEventContextMenuItems(evt, engine: engine, selection: $selection)
}
}
private struct EventSourceBadge: View {
let evt: RuntimeEvent
let engine: Engine
var body: some View {
Text(labelText)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(backgroundColor.opacity(0.15))
.foregroundStyle(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
private var processName: String {
engine.session(id: evt.sessionID ?? UUID())?.processName ?? ""
}
private var labelText: String {
switch evt.source {
case .processOutput(let fd):
let channel: String = {
switch fd {
case 1: return "stdout"
case 2: return "stderr"
default: return "fd\(fd)"
}
}()
return "\(processName) • \(channel)"
case .script:
return "\(processName) • Script Runtime"
case .console:
return "\(processName) • Console"
case .repl:
return "\(processName) • REPL"
case .instrument:
let name = engine.instrument(forEvent: evt).map { engine.descriptor(for: $0).displayName } ?? "Instrument"
return "\(name) • \(processName)"
case .spawnGating(_, let deviceName, _, _, let outcome):
let label = outcome == .captured ? "Spawn Captured" : "Spawn Released"
return "\(deviceName) • \(label)"
case .engine(let subsystem):
return "Engine • \(subsystem)"
}
}
private var backgroundColor: Color {
switch evt.source {
case .processOutput(let fd):
switch fd {
case 1: return .gray
case 2: return .orange
default: return .orange
}
case .script:
return .mint
case .console:
return .purple
case .repl:
return .accentColor
case .instrument:
return .green
case .spawnGating(_, _, _, _, let outcome):
return outcome == .captured ? .blue : .gray
case .engine:
return .red
}
}
}
extension ConsoleLevel {
fileprivate var badgeText: String {
switch self {
case .info: return "info"
case .debug: return "debug"
case .warning: return "warn"
case .error: return "error"
}
}
fileprivate var badgeColor: Color {
switch self {
case .info: return .accentColor
case .debug: return .blue
case .warning: return .orange
case .error: return .red
}
}
}