PadXcode-iPad/Layout/EnhancedConsoleView.swift
2026-04-12 22:07:35 -07:00

134 lines
6.2 KiB
Swift

import SwiftUI
import UIKit
struct EnhancedConsoleView: View {
let lines: [ConsoleLine]
var onClear: (() -> Void)? = nil
@State private var filterMode: FilterMode = .all
@State private var searchText = ""
@State private var showSearch = false
@State private var isPinned = true
@State private var fontSize: CGFloat = 11
enum FilterMode: String, CaseIterable {
case all, errors, warnings, info
var label: String { rawValue.capitalized }
var icon: String {
switch self {
case .all: return "list.bullet"; case .errors: return "xmark.circle"
case .warnings: return "exclamationmark.triangle"; case .info: return "info.circle"
}
}
}
private var filtered: [ConsoleLine] {
var result = lines
switch filterMode {
case .errors: result = result.filter { $0.type == .error }
case .warnings: result = result.filter { $0.type == .warning }
case .info: result = result.filter { $0.type == .info || $0.type == .standard }
case .all: break
}
if !searchText.isEmpty {
result = result.filter { $0.text.localizedCaseInsensitiveContains(searchText) }
}
return result
}
private var errorCount: Int { lines.filter { $0.type == .error }.count }
private var warningCount: Int { lines.filter { $0.type == .warning }.count }
var body: some View {
VStack(spacing: 0) {
// Toolbar
HStack(spacing: 6) {
ForEach(FilterMode.allCases, id: \.self) { mode in
Button {
filterMode = mode
} label: {
HStack(spacing: 3) {
Image(systemName: mode.icon).font(.system(size: 10))
if mode == .errors && errorCount > 0 { Text("\(errorCount)").font(.system(.caption2, design: .monospaced).bold()) }
if mode == .warnings && warningCount > 0 { Text("\(warningCount)").font(.system(.caption2, design: .monospaced).bold()) }
}
.padding(.horizontal, 7).padding(.vertical, 3)
.background(filterMode == mode ? pillColor(mode).opacity(0.2) : Color(.tertiarySystemFill),
in: RoundedRectangle(cornerRadius: 5))
.foregroundStyle(filterMode == mode ? pillColor(mode) : .secondary)
}
.buttonStyle(.plain)
}
Spacer()
Button { withAnimation { showSearch.toggle(); if !showSearch { searchText = "" } } }
label: { Image(systemName: "magnifyingglass").foregroundStyle(showSearch ? Color.accentColor : Color.secondary) }
.buttonStyle(.plain)
Button { UIPasteboard.general.string = filtered.map(\.text).joined(separator: "\n") }
label: { Image(systemName: "doc.on.doc").foregroundStyle(.secondary) }.buttonStyle(.plain)
Button { isPinned.toggle() }
label: { Image(systemName: isPinned ? "pin.fill" : "pin.slash").foregroundStyle(isPinned ? Color.accentColor : Color.secondary) }
.buttonStyle(.plain)
if let clear = onClear {
Button(action: clear) { Image(systemName: "trash").foregroundStyle(.secondary) }.buttonStyle(.plain)
}
}
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Color(.secondarySystemBackground))
if showSearch {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary).font(.caption)
TextField("Filter output…", text: $searchText)
.font(.system(.callout, design: .monospaced)).autocorrectionDisabled().autocapitalization(.none)
if !searchText.isEmpty {
Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) }.buttonStyle(.plain)
Text("\(filtered.count)").font(.caption2).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 10).padding(.vertical, 6).background(Color(.systemBackground))
Divider()
}
Divider()
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(filtered) { line in
HStack(alignment: .top, spacing: 4) {
Rectangle().fill(gutterColor(line.type)).frame(width: 2).padding(.vertical, 1)
Text(line.text)
.font(.system(size: fontSize, design: .monospaced))
.foregroundStyle(line.color)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 1)
.id(line.id)
}
}
.padding(.horizontal, 8).padding(.vertical, 4)
}
.background(Color(.systemBackground))
.onChange(of: filtered.count) { _, _ in
if isPinned, let last = filtered.last {
withAnimation(.linear(duration: 0.08)) { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
}
}
private func pillColor(_ mode: FilterMode) -> Color {
switch mode {
case .errors: return .red; case .warnings: return .orange
case .info: return .blue; case .all: return Color.accentColor
}
}
private func gutterColor(_ type: ConsoleLine.LineType) -> Color {
switch type {
case .error: return .red; case .warning: return .orange
case .success: return .green; case .info: return Color.accentColor; default: return .clear
}
}
}