135 lines
6.1 KiB
Swift
135 lines
6.1 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 ? .accentColor : .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 ? .accentColor : .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 .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 .accentColor; default: return .clear
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|