import Frida import LumaCore import SwiftUI let sidebarRowLeadingPad: CGFloat = 3 let sidebarChevronWidth: CGFloat = 14 let sidebarChevronToIconSpacing: CGFloat = 5 let sidebarIconToLabelSpacing: CGFloat = 6 let sidebarParentIconWidth: CGFloat = 24 let sidebarChildIconWidth: CGFloat = 16 let sidebarChildIndent: CGFloat = sidebarRowLeadingPad + sidebarChevronWidth + sidebarChevronToIconSpacing + sidebarParentIconWidth let sidebarGrandchildIndent: CGFloat = sidebarChildIndent + sidebarChildIconWidth private let subrowIconWidth: CGFloat = sidebarChildIconWidth struct SidebarView: View { let engine: Engine @Binding var selection: SidebarItemID? var sessions: [LumaCore.ProcessSession] { engine.sessions } var packages: [LumaCore.InstalledPackage] { engine.installedPackages } var missions: [LumaCore.Mission] { engine.missions } var body: some View { List(selection: $selection) { Section { SidebarNotebookRow() .tag(SidebarItemID.notebook) SidebarPharoRow() .tag(SidebarItemID.pharo) SidebarMissionsRow(count: missions.count) .tag(SidebarItemID.missions) ForEach(missions) { mission in SidebarMissionRow(mission: mission, engine: engine, selection: $selection) .tag(SidebarItemID.mission(mission.id)) } PatternsSidebarRows(engine: engine, selection: $selection) } Section("Sessions") { ForEach(sessions) { session in let node = engine.node(forSessionID: session.id) let instruments = engine.instrumentsBySession[session.id] ?? [] let insights = engine.insightsBySession[session.id] ?? [] let traces = engine.tracesBySession[session.id] ?? [] let isExpanded = engine.sidebarExpansion(forSessionID: session.id) == .expanded SidebarSessionHeaderRow( session: session, node: node, engine: engine, selection: $selection, isExpanded: isExpanded, onToggleExpansion: { toggleSessionExpansion(sessionID: session.id) } ) .tag(SidebarItemID.session(session.id)) if isExpanded { moduleGroup(session: session) threadGroup(session: session) SidebarSessionREPLRow(sessionID: session.id) .tag(SidebarItemID.repl(session.id)) ForEach(instruments) { instance in let hasChildren = hasInstrumentChildren(instance: instance) let hooksExpanded = engine.hooksExpansion(forSessionID: session.id, instrumentID: instance.id) == .expanded SidebarInstrumentRow( session: session, node: node, instance: instance, engine: engine, hasChildren: hasChildren, isExpanded: hooksExpanded, onToggle: { toggleHooksExpansion(sessionID: session.id, instrumentID: instance.id) }, selection: $selection ) .tag(SidebarItemID.instrument(session.id, instance.id)) if hasChildren, hooksExpanded { instrumentChildren(sessionID: session.id, instance: instance) } } ForEach(insights.sorted(by: { $0.createdAt < $1.createdAt })) { insight in SidebarInsightRow( session: session, insight: insight, engine: engine, selection: $selection ) .tag(SidebarItemID.insight(session.id, insight.id)) } ForEach(traces.sorted(by: { $0.startedAt < $1.startedAt })) { trace in SidebarITraceRow( session: session, trace: trace, engine: engine, selection: $selection ) .tag(SidebarItemID.itrace(session.id, trace.id)) } } } } CustomInstrumentsSidebarSection(engine: engine, selection: $selection) if !packages.isEmpty { Section("Packages") { ForEach(packages) { pkg in SidebarPackageRow(package: pkg) .tag(SidebarItemID.package(pkg.id)) } } } } .listStyle(.sidebar) .onChange(of: selection) { _, newSelection in ensureSessionExpanded(for: newSelection) } } private func ensureSessionExpanded(for newSelection: SidebarItemID?) { guard let sessionID = sessionID(in: newSelection), engine.sidebarExpansion(forSessionID: sessionID) == .collapsed else { return } engine.setSidebarExpansion(sessionID: sessionID, .expanded) } private func sessionID(in selection: SidebarItemID?) -> UUID? { switch selection { case .session(let id), .repl(let id), .module(let id, _), .thread(let id, _), .instrument(let id, _), .instrumentComponent(let id, _, _), .insight(let id, _), .itrace(let id, _): return id default: return nil } } @ViewBuilder private func moduleGroup(session: LumaCore.ProcessSession) -> some View { let count = session.lastKnownModules?.count ?? 0 let isExpanded = engine.sidebarExpansion(forSessionID: session.id, group: .modules) == .expanded SidebarGroupHeaderRow( title: "Modules", systemImage: "shippingbox", count: count, isExpanded: isExpanded, onToggle: { toggleGroupExpansion(sessionID: session.id, group: .modules) } ) if isExpanded, count > 0 { ModuleSidebarChildren(sessionID: session.id, engine: engine, selection: $selection) } } @ViewBuilder private func threadGroup(session: LumaCore.ProcessSession) -> some View { let count = session.lastKnownThreads?.count ?? 0 let isExpanded = engine.sidebarExpansion(forSessionID: session.id, group: .threads) == .expanded SidebarGroupHeaderRow( title: "Threads", systemImage: "cpu", count: count, isExpanded: isExpanded, onToggle: { toggleGroupExpansion(sessionID: session.id, group: .threads) } ) if isExpanded, count > 0 { ThreadSidebarChildren(sessionID: session.id, engine: engine, selection: $selection) } } private func toggleGroupExpansion(sessionID: UUID, group: SessionSidebarGroup) { let current = engine.sidebarExpansion(forSessionID: sessionID, group: group) let next: SidebarExpansion = current == .expanded ? .collapsed : .expanded withAnimation(.easeInOut(duration: 0.18)) { engine.setSidebarExpansion(sessionID: sessionID, group: group, next) } } private func toggleSessionExpansion(sessionID: UUID) { let current = engine.sidebarExpansion(forSessionID: sessionID) let next: SidebarExpansion = current == .expanded ? .collapsed : .expanded withAnimation(.easeInOut(duration: 0.18)) { engine.setSidebarExpansion(sessionID: sessionID, next) } } @ViewBuilder private func instrumentChildren(sessionID: UUID, instance: LumaCore.InstrumentInstance) -> some View { if let ui = InstrumentUIRegistry.shared.ui(for: instance) { ui.sidebarChildren( sessionID: sessionID, instance: instance, engine: engine, selection: $selection ) } } private func hasInstrumentChildren(instance: LumaCore.InstrumentInstance) -> Bool { InstrumentUIRegistry.shared.ui(for: instance)?.hasSidebarChildren(instance: instance) ?? false } private func toggleHooksExpansion(sessionID: UUID, instrumentID: UUID) { let current = engine.hooksExpansion(forSessionID: sessionID, instrumentID: instrumentID) let next: SidebarExpansion = current == .expanded ? .collapsed : .expanded withAnimation(.easeInOut(duration: 0.18)) { engine.setHooksExpansion(sessionID: sessionID, instrumentID: instrumentID, next) } } } private struct SidebarNotebookRow: View { var body: some View { HStack(spacing: 8) { Image(systemName: "book.pages") .frame(width: 18, alignment: .center) Text("Notebook") } .accessibilityElement(children: .combine) .accessibilityIdentifier("sidebar.notebook") } } private struct SidebarPharoRow: View { var body: some View { HStack(spacing: 8) { Image(systemName: "curlybraces.square") .frame(width: 18, alignment: .center) Text("Playground") } .accessibilityElement(children: .combine) .accessibilityIdentifier("sidebar.pharo") } } private struct SidebarMissionsRow: View { let count: Int var body: some View { HStack(spacing: 8) { Image(systemName: "scope") .frame(width: 18, alignment: .center) Text("Missions") Spacer() if count > 0 { Text("\(count)").font(.caption).foregroundStyle(.secondary) } } .accessibilityElement(children: .combine) .accessibilityIdentifier("sidebar.missions") } } private struct SidebarMissionRow: View { let mission: LumaCore.Mission let engine: Engine @Binding var selection: SidebarItemID? @State private var isShowingDeleteConfirmation = false var body: some View { HStack(spacing: 8) { Image(systemName: iconName) .foregroundStyle(iconColor) .frame(width: subrowIconWidth) Text(displayTitle) .lineLimit(1) .truncationMode(.tail) Spacer(minLength: 0) } .padding(.leading, sidebarChildIndent) .accessibilityElement(children: .combine) .accessibilityIdentifier("sidebar.mission.\(mission.id.uuidString)") .contextMenu { Button(role: .destructive) { isShowingDeleteConfirmation = true } label: { Label("Delete Mission", systemImage: "trash") } } .confirmationDialog( "Delete Mission?", isPresented: $isShowingDeleteConfirmation, titleVisibility: .visible ) { Button("Delete Mission", role: .destructive) { deleteMission() } Button("Cancel", role: .cancel) {} } message: { Text("This will remove the mission and all of its turns, tool calls, and findings.") } } private func deleteMission() { if selection == .mission(mission.id) { selection = .missions } engine.deleteMission(missionID: mission.id) } private var displayTitle: String { if let title = mission.title, !title.isEmpty { return title } return mission.goalText.isEmpty ? "(untitled mission)" : mission.goalText } private var iconName: String { switch mission.status { case .running, .drafting: return "circle.dotted" case .awaitingApproval: return "hourglass" case .paused: return "pause.circle" case .completed: return "checkmark.circle.fill" case .failed: return "xmark.octagon" case .cancelled: return "stop.circle" } } private var iconColor: Color { switch mission.status { case .running: return .blue case .awaitingApproval: return .orange case .completed: return .green case .failed: return .red default: return .secondary } } } struct SidebarDisclosure: View { let isExpanded: Bool var canToggle: Bool = true let onToggle: () -> Void var body: some View { chevron .padding(.leading, sidebarChildIndent - sidebarChevronWidth - sidebarChevronToIconSpacing) .padding(.trailing, sidebarChevronToIconSpacing) } @ViewBuilder private var chevron: some View { if canToggle { Button(action: onToggle) { Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(isExpanded ? 90 : 0)) .frame(width: sidebarChevronWidth, height: sidebarChevronWidth) .contentShape(Rectangle()) } .buttonStyle(.plain) } else { Color.clear.frame(width: sidebarChevronWidth, height: sidebarChevronWidth) } } } private struct SidebarGroupHeaderRow: View { let title: String let systemImage: String let count: Int let isExpanded: Bool let onToggle: () -> Void var body: some View { HStack(spacing: 0) { SidebarDisclosure(isExpanded: isExpanded, onToggle: onToggle) .accessibilityLabel(isExpanded ? "Collapse \(title)" : "Expand \(title)") Image(systemName: systemImage) .frame(width: sidebarChildIconWidth, alignment: .center) .font(.system(size: 12)) .foregroundStyle(.secondary) .padding(.trailing, sidebarIconToLabelSpacing) Text(title) .font(.callout) Spacer() if count > 0 { Text("\(count)") .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 2) .contentShape(Rectangle()) .onTapGesture { onToggle() } } } private struct SidebarSessionHeaderRow: View { let session: LumaCore.ProcessSession let node: LumaCore.ProcessNode? let engine: Engine @Binding var selection: SidebarItemID? let isExpanded: Bool let onToggleExpansion: () -> Void @Environment(TargetPicker.self) private var picker @State private var isShowingConfirmation = false @State private var confirmationTitle: String = "" @State private var confirmationMessage: String? @State private var confirmationDestructiveLabel: String = "Confirm" @State private var pendingConfirmation: (() -> Void)? @State private var isShowingArmPrompt = false @State private var armPatternDraft: String = "" var body: some View { HStack(spacing: 0) { Button(action: onToggleExpansion) { Image(systemName: "chevron.right") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(isExpanded ? 90 : 0)) .frame(width: sidebarChevronWidth, height: sidebarChevronWidth) .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.leading, sidebarRowLeadingPad) .padding(.trailing, sidebarChevronToIconSpacing) .accessibilityLabel(isExpanded ? "Collapse session" : "Expand session") iconView .frame(width: sidebarParentIconWidth, height: sidebarParentIconWidth) .padding(.trailing, sidebarIconToLabelSpacing) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(displayProcessName).font(.headline) if isArmed { Image(systemName: "scope") .font(.caption2) .help("Armed for next matching launch") } } if session.phase == .attaching, let stage = engine.connectionActivity.stage(for: session.deviceID) { Text("\(stage.status)…") .font(.caption) .foregroundStyle(.secondary) } else { Text(displayDeviceName) .font(.caption) .foregroundStyle(.secondary) } } Spacer() hostBadge if isDetached { detachedIndicator } } .padding(.vertical, 4) .contextMenu { if !engine.localUserHosts(session.id) { if engine.canTakeHosting(session) { Button { rehost() } label: { Label("Run on My Device…", systemImage: "rectangle.connected.to.line.below") } } } else if let node { Button(role: .destructive) { presentConfirmation( title: "Kill Process?", message: "This will force-terminate \"\(displayProcessName)\".", destructiveLabel: "Kill Process" ) { killProcess() } } label: { Label("Kill Process", systemImage: "xmark.circle") } Button { engine.removeNode(node) } label: { Label("Detach Session", systemImage: "bolt.slash") } Divider() armingMenuButton Divider() Button(role: .destructive) { presentConfirmation( title: "Delete Session?", message: "This will remove the session and its history.", destructiveLabel: "Delete Session" ) { deleteSession() } } label: { Label("Delete Session", systemImage: "trash") } } else { if session.lastAttachedAt != nil, engine.canTakeHosting(session) { Button { reestablish() } label: { Label("\(session.kind.reestablishLabel)…", systemImage: "arrow.clockwise") } } armingMenuButton Divider() Button(role: .destructive) { presentConfirmation( title: "Delete Session?", message: "This will remove the session and its history.", destructiveLabel: "Delete Session" ) { deleteSession() } } label: { Label("Delete Session", systemImage: "trash") } } } .confirmationDialog( confirmationTitle, isPresented: $isShowingConfirmation, titleVisibility: .visible ) { Button(confirmationDestructiveLabel, role: .destructive) { pendingConfirmation?() pendingConfirmation = nil } Button("Cancel", role: .cancel) { pendingConfirmation = nil } } message: { if let confirmationMessage { Text(confirmationMessage) } } .alert("Arm for Next Launch", isPresented: $isShowingArmPrompt) { TextField("Identifier regex", text: $armPatternDraft) .disableAutocorrection(true) Button("Arm") { commitArm() } .disabled(armPatternDraft.trimmingCharacters(in: .whitespaces).isEmpty) Button("Cancel", role: .cancel) {} } message: { Text("Match the next spawn whose identifier matches this regex on \(session.deviceName).") } } @ViewBuilder private var armingMenuButton: some View { if isArmed { Button { disarm() } label: { Label("Disarm", systemImage: "scope") } } else if session.supportsArmForNextLaunch { Button { presentArmPrompt() } label: { Label("Arm for Next Launch…", systemImage: "scope") } } } private var isArmed: Bool { if case .armed = session.armingState { return true } return false } private var isDetached: Bool { guard node == nil, !isArmed else { return false } if isHostedRemotelyLive { return false } if session.lastAttachedAt != nil { return true } if case .attach = session.kind { return true } return false } private var isHostedRemotelyLive: Bool { engine.isHostedRemotelyLive(session.id) } @ViewBuilder private var detachedIndicator: some View { if session.phase == .attaching { ProgressView() .controlSize(.small) .help(session.kind.inProgressLabel) } else { Button(action: reestablish) { Image(systemName: "arrow.clockwise.circle.fill") .font(.system(size: 14)) .foregroundStyle(detachedTint) } .buttonStyle(.plain) .help("\(session.kind.reestablishLabel)") } } private var detachedTint: Color { switch session.detachReason { case .applicationRequested: return .orange default: return .red } } private func presentArmPrompt() { armPatternDraft = engine.defaultArmPattern(for: session) isShowingArmPrompt = true } private func commitArm() { let pattern = armPatternDraft.trimmingCharacters(in: .whitespacesAndNewlines) guard !pattern.isEmpty else { return } let sessionID = session.id Task { @MainActor in await engine.armSession(id: sessionID, matchPattern: pattern) } } private func disarm() { let sessionID = session.id Task { @MainActor in await engine.disarmSession(id: sessionID) } } private var displayProcessName: String { node?.processName ?? session.processName } private var displayDeviceName: String { node?.deviceName ?? session.deviceName } @ViewBuilder private var iconView: some View { if let data = session.iconPNGData { Icon.png(data: Array(data)).swiftUIImage .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .cornerRadius(4) } else { IconPlaceholderView( seed: placeholderSeed, displayName: displayProcessName, cornerRadius: 4 ) } } @ViewBuilder private var hostBadge: some View { if let host = session.host, !engine.isHostingNode(session.id), host.id != engine.collaboration.localUser?.id { UserAvatarView(user: host, size: 16) .help("Hosted by @\(host.id) on \(session.deviceName)") } } private var placeholderSeed: String { "\(session.deviceID)/\(displayProcessName)" } private func reestablish() { Task { @MainActor in let result: Engine.ReestablishResult if localUserOwnsHost { result = await engine.reestablishSession(id: session.id) } else { result = await engine.reHost(sessionID: session.id) } if case .needsUserInput(let reason, let session) = result { picker.context = .reestablish(session: session, reason: reason) } } } private func rehost() { Task { @MainActor in let result = await engine.reHost(sessionID: session.id) if case .needsUserInput(let reason, let session) = result { picker.context = .reestablish(session: session, reason: reason) } } } private var localUserOwnsHost: Bool { guard let host = session.host else { return true } return host.id == engine.collaboration.localUser?.id } private func killProcess() { guard let node else { return } Task { @MainActor in do { try await node.kill() } catch { engine.updateSession(id: session.id) { $0.lastError = error.localizedDescription } } } } private func deleteSession() { if let node { engine.removeNode(node) } let sessionID = session.id try? engine.store.deleteSession(id: sessionID) switch selection { case .session(let id) where id == sessionID, .repl(let id) where id == sessionID, .instrument(let id, _) where id == sessionID, .insight(let id, _) where id == sessionID, .itrace(let id, _) where id == sessionID: selection = .notebook default: break } } private func presentConfirmation( title: String, message: String? = nil, destructiveLabel: String, action: @escaping () -> Void ) { confirmationTitle = title confirmationMessage = message confirmationDestructiveLabel = destructiveLabel pendingConfirmation = action isShowingConfirmation = true } } private struct SidebarSessionREPLRow: View { let sessionID: UUID private let iconWidth: CGFloat = 16 var body: some View { HStack(spacing: 6) { Image(systemName: "terminal") .frame(width: iconWidth, alignment: .center) .font(.system(size: 12)) Text("REPL") Spacer() } .font(.callout) .padding(.leading, sidebarChildIndent) .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityIdentifier("sidebar.repl") } } private struct SidebarInstrumentRow: View { let session: LumaCore.ProcessSession let node: LumaCore.ProcessNode? let instance: LumaCore.InstrumentInstance let engine: Engine let hasChildren: Bool let isExpanded: Bool let onToggle: () -> Void @Binding var selection: SidebarItemID? @State private var isShowingDeleteConfirm = false var body: some View { HStack(spacing: 0) { SidebarDisclosure(isExpanded: isExpanded, canToggle: hasChildren, onToggle: onToggle) InstrumentIconView(icon: descriptor.icon, pointSize: 12) .frame(width: subrowIconWidth, alignment: .center) .padding(.trailing, sidebarIconToLabelSpacing) Text(descriptor.displayName) if let status = runtimeStatus { InstrumentStatusIndicator(status: status) .padding(.leading, 6) } Spacer() } .font(.callout) .contentShape(Rectangle()) .opacity(instance.state == .enabled ? 1 : 0.3) .contextMenu { Button { let newState: LumaCore.InstrumentState = instance.state == .enabled ? .disabled : .enabled Task { @MainActor in await engine.setInstrumentState(instance, state: newState) } } label: { Label( instance.state == .enabled ? "Disable \"\(descriptor.displayName)\"" : "Enable \"\(descriptor.displayName)\"", systemImage: instance.state == .enabled ? "pause.circle" : "play.circle" ) } Divider() Button(role: .destructive) { isShowingDeleteConfirm = true } label: { Label("Delete Instrument", systemImage: "trash") } } .confirmationDialog( "Delete Instrument?", isPresented: $isShowingDeleteConfirm, titleVisibility: .visible ) { Button("Delete Instrument", role: .destructive) { deleteInstrument() } Button("Cancel", role: .cancel) {} } message: { Text("This will remove \"\(descriptor.displayName)\" from this session.") } } private var descriptor: InstrumentDescriptor { engine.descriptor(for: instance) } private var runtimeStatus: InstrumentStatus? { node?.instruments.first(where: { $0.id == instance.id })?.status } private func deleteInstrument() { Task { await engine.removeInstrument(instance) } if selection == .instrument(session.id, instance.id) { selection = .repl(session.id) } } } private struct SidebarInsightRow: View { let session: LumaCore.ProcessSession let insight: LumaCore.AddressInsight let engine: Engine @Binding var selection: SidebarItemID? @State private var isRenaming = false @State private var renameDraft = "" var body: some View { HStack(spacing: 6) { Image(systemName: insight.kind == .memory ? "memorychip" : "cpu") .frame(width: subrowIconWidth, alignment: .center) .font(.system(size: 12)) Text(engine.displayTitle(for: insight)) Spacer() } .font(.callout) .contentShape(Rectangle()) .padding(.leading, sidebarChildIndent) .help(insight.anchor.displayString) .contextMenu { Button { renameDraft = engine.displayTitle(for: insight) isRenaming = true } label: { Label("Rename…", systemImage: "pencil") } Divider() Button { Task { await engine.rereadBytes(of: insight) } } label: { Label("Reread Bytes", systemImage: "arrow.clockwise") } .disabled(!isAttached) Button { engine.reanalyzeModule(of: insight) } label: { Label("Reanalyze Module", systemImage: "arrow.triangle.2.circlepath") } .disabled(!isAttached || engine.enclosingModule(of: insight) == nil) Divider() Button(role: .destructive) { deleteInsight() } label: { Label("Delete Insight", systemImage: "trash") } } .alert("Rename Insight", isPresented: $isRenaming) { TextField("Name", text: $renameDraft) Button("Save") { let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != engine.displayTitle(for: insight) else { return } engine.renameInsight(insight, to: trimmed) } Button("Cancel", role: .cancel) {} } } private var isAttached: Bool { engine.node(forSessionID: session.id) != nil } private func deleteInsight() { try? engine.store.deleteInsight(id: insight.id) if selection == .insight(session.id, insight.id) { selection = .repl(session.id) } } } private struct SidebarITraceRow: View { let session: LumaCore.ProcessSession let trace: LumaCore.ITrace let engine: Engine @Binding var selection: SidebarItemID? @State private var isShowingDeleteConfirm = false var body: some View { HStack(spacing: 6) { Image(systemName: trace.isRunning ? "record.circle" : "waveform.path") .frame(width: subrowIconWidth, alignment: .center) .font(.system(size: 12)) .foregroundStyle(trace.isRunning ? .red : .primary) Text(trace.displayName) Spacer() } .font(.callout) .contentShape(Rectangle()) .padding(.leading, sidebarChildIndent) .contextMenu { Button(role: .destructive) { isShowingDeleteConfirm = true } label: { Label("Delete Trace", systemImage: "trash") } } .confirmationDialog( "Delete trace \(trace.displayName)?", isPresented: $isShowingDeleteConfirm, titleVisibility: .visible ) { Button("Delete", role: .destructive) { deleteTrace() } Button("Cancel", role: .cancel) {} } message: { Text("This removes the recorded ITrace data from the project.") } } private func deleteTrace() { engine.deleteITrace(id: trace.id, sessionID: session.id) if selection == .itrace(session.id, trace.id) { selection = .repl(session.id) } } } private struct SidebarPackageRow: View { let package: LumaCore.InstalledPackage var body: some View { HStack(spacing: 8) { Image(systemName: "shippingbox") VStack(alignment: .leading, spacing: 2) { Text(package.name) .font(.headline) Text(package.version) .font(.caption) .foregroundStyle(.secondary) } Spacer() } .padding(.vertical, 2) } }