Created
May 24, 2025 04:04
-
-
Save hanrw/4fbb048a758b82bdf92288dfdc87dee9 to your computer and use it in GitHub Desktop.
CodeWindowView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct CodeWindowView: View { | |
@State var visibleLines: [(text: String, lineNumber: Int)] = [] | |
@State private var scrollProxy: ScrollViewProxy? | |
var body: some View { | |
ZStack { | |
// Window chrome | |
VStack(spacing: 0) { | |
// Title bar | |
HStack(spacing: 6) { | |
Circle() | |
.fill(Color.red.opacity(0.8)) | |
.frame(width: 12, height: 12) | |
Circle() | |
.fill(Color.yellow.opacity(0.8)) | |
.frame(width: 12, height: 12) | |
Circle() | |
.fill(Color.green.opacity(0.8)) | |
.frame(width: 12, height: 12) | |
Spacer() | |
Text("code.tsx") | |
.font(.system(size: 11)) | |
.foregroundColor(.secondary) | |
Spacer() | |
} | |
.padding(.horizontal, 12) | |
.padding(.vertical, 8) | |
.background(Color(NSColor.windowBackgroundColor)) | |
Divider() | |
// Code area | |
ScrollViewReader { proxy in | |
ScrollView(.vertical, showsIndicators: false) { | |
VStack(alignment: .leading, spacing: 0) { | |
ForEach(Array(visibleLines.enumerated()), id: \.offset) { index, line in | |
HStack(spacing: 16) { | |
// Line number with gutter | |
Text("\(line.lineNumber)") | |
.font(.system(size: 11, weight: .regular, design: .monospaced)) | |
.foregroundColor(Color(NSColor.tertiaryLabelColor)) | |
.frame(width: 30, alignment: .trailing) | |
.padding(.trailing, 8) | |
.background( | |
Rectangle() | |
.fill(Color(NSColor.separatorColor).opacity(0.1)) | |
) | |
// Code line | |
Text(line.text.isEmpty ? " " : line.text) | |
.font(.system(size: 12, weight: .regular, design: .monospaced)) | |
.foregroundColor(Color(NSColor.labelColor)) | |
.frame(maxWidth: .infinity, alignment: .leading) | |
.textSelection(.enabled) | |
} | |
.frame(height: 18) | |
.id(index) | |
} | |
} | |
.padding(.vertical, 8) | |
} | |
.frame(width: 600, height: 80) | |
.background(Color(NSColor.textBackgroundColor)) | |
.onAppear { | |
scrollProxy = proxy | |
} | |
} | |
} | |
.cornerRadius(8) | |
.overlay( | |
RoundedRectangle(cornerRadius: 8) | |
.stroke(Color(NSColor.separatorColor), lineWidth: 0.5) | |
) | |
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) | |
// Gradient overlay | |
VStack(spacing: 0) { | |
Spacer() | |
.frame(height: 36) // Account for title bar | |
LinearGradient( | |
gradient: Gradient(stops: [ | |
.init(color: Color(NSColor.textBackgroundColor), location: 0), | |
.init(color: Color(NSColor.textBackgroundColor).opacity(0.7), location: 0.4), | |
.init(color: Color(NSColor.textBackgroundColor).opacity(0), location: 1) | |
]), | |
startPoint: .top, | |
endPoint: .bottom | |
) | |
.frame(height: 25) | |
.allowsHitTesting(false) | |
Spacer() | |
// Bottom gradient | |
LinearGradient( | |
gradient: Gradient(stops: [ | |
.init(color: Color(NSColor.textBackgroundColor).opacity(0), location: 0), | |
.init(color: Color(NSColor.textBackgroundColor).opacity(0.7), location: 0.6), | |
.init(color: Color(NSColor.textBackgroundColor), location: 1) | |
]), | |
startPoint: .top, | |
endPoint: .bottom | |
) | |
.frame(height: 25) | |
.allowsHitTesting(false) | |
} | |
} | |
.frame(width: 600, height: 124) | |
} | |
} | |
#Preview { | |
let codeLines = """ | |
import React from 'react'; | |
import { View, Text } from 'react-native'; | |
import { styles } from './styles'; | |
const MyComponent: React.FC = () => { | |
return ( | |
<View style={styles.container}> | |
<Text style={styles.text}>Hello, World!</Text> | |
</View> | |
); | |
}; | |
""".trimmingCharacters(in: .whitespaces) | |
CodeWindowView(visibleLines: codeLines | |
.split(separator: "\n") | |
.enumerated() | |
.map { (index, line) in | |
(text: String(line), lineNumber: index + 1) | |
} | |
) | |
} |
Author
hanrw
commented
May 24, 2025
import SwiftUI
import Combine
// MARK: - View Model for Streaming
class StreamingViewModel: ObservableObject {
@Published var visibleLines: [(text: String, lineNumber: Int, id: UUID)] = []
@Published var isAutoScrollEnabled = true
@Published var shouldScrollToBottom = false
@Published var scrollChunkSize = 3
private var pendingLines: [(text: String, lineNumber: Int, id: UUID)] = []
private var updateTimer: Timer?
init() {
// Start a timer to batch updates
updateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
self.flushPendingLines()
}
}
deinit {
updateTimer?.invalidate()
}
/// Append a new line to the stream
func appendLine(_ text: String) {
let newLineNumber = (visibleLines.last?.lineNumber ?? pendingLines.last?.lineNumber ?? 0) + 1
pendingLines.append((text: text, lineNumber: newLineNumber, id: UUID()))
// If we have enough lines for a chunk, flush immediately
if pendingLines.count >= scrollChunkSize {
flushPendingLines()
}
}
/// Append multiple lines to the stream
func appendLines(_ lines: [String]) {
let startLineNumber = (visibleLines.last?.lineNumber ?? pendingLines.last?.lineNumber ?? 0) + 1
let newLines = lines.enumerated().map { index, text in
(text: text, lineNumber: startLineNumber + index, id: UUID())
}
pendingLines.append(contentsOf: newLines)
// Flush in chunks
while pendingLines.count >= scrollChunkSize {
flushPendingLines()
}
}
/// Flush pending lines in chunks
private func flushPendingLines() {
guard !pendingLines.isEmpty else { return }
// Take up to scrollChunkSize lines
let linesToAdd = Array(pendingLines.prefix(scrollChunkSize))
pendingLines.removeFirst(min(scrollChunkSize, pendingLines.count))
// Add them all at once
visibleLines.append(contentsOf: linesToAdd)
if isAutoScrollEnabled {
// Trigger scroll after the view updates
DispatchQueue.main.async {
self.shouldScrollToBottom = true
}
}
}
/// Force flush any remaining lines
func forceFlush() {
if !pendingLines.isEmpty {
visibleLines.append(contentsOf: pendingLines)
pendingLines.removeAll()
if isAutoScrollEnabled {
DispatchQueue.main.async {
self.shouldScrollToBottom = true
}
}
}
}
/// Clear all lines
func clearLines() {
visibleLines.removeAll()
pendingLines.removeAll()
}
/// Enable or disable auto-scrolling
func setAutoScroll(_ enabled: Bool) {
isAutoScrollEnabled = enabled
if enabled {
forceFlush()
shouldScrollToBottom = true
}
}
/// Set the chunk size for scrolling
func setScrollChunkSize(_ size: Int) {
scrollChunkSize = max(1, size)
}
}
struct CodeWindowView: View {
@ObservedObject var viewModel: StreamingViewModel
@State private var scrollViewHeight: CGFloat = 0
@State private var contentHeight: CGFloat = 0
@Namespace private var scrollNamespace
init(viewModel: StreamingViewModel = StreamingViewModel()) {
self.viewModel = viewModel
}
var body: some View {
ZStack {
// Window chrome
VStack(spacing: 0) {
// Title bar
HStack(spacing: 6) {
Circle()
.fill(Color.red.opacity(0.8))
.frame(width: 12, height: 12)
Circle()
.fill(Color.yellow.opacity(0.8))
.frame(width: 12, height: 12)
Circle()
.fill(Color.green.opacity(0.8))
.frame(width: 12, height: 12)
Spacer()
Text("live-stream.log")
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
// Chunk size indicator
Text("×\(viewModel.scrollChunkSize)")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.secondary)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color(NSColor.separatorColor).opacity(0.2))
)
// Auto-scroll toggle button
Button(action: {
viewModel.setAutoScroll(!viewModel.isAutoScrollEnabled)
}) {
Image(systemName: viewModel.isAutoScrollEnabled ? "arrow.down.circle.fill" : "arrow.down.circle")
.foregroundColor(viewModel.isAutoScrollEnabled ? .accentColor : .secondary)
.font(.system(size: 12))
}
.buttonStyle(PlainButtonStyle())
.help("Toggle auto-scroll")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(NSColor.windowBackgroundColor))
Divider()
// Code area with custom scroll handling
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 0) {
// Group lines into chunks for visual coherence
ForEach(Array(viewModel.visibleLines.enumerated()), id: \.element.id) { index, line in
HStack(spacing: 16) {
// Line number with gutter
Text("\(line.lineNumber)")
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundColor(Color(NSColor.tertiaryLabelColor))
.frame(width: 30, alignment: .trailing)
.padding(.trailing, 8)
.background(
Rectangle()
.fill(Color(NSColor.separatorColor).opacity(0.1))
)
// Code line
Text(line.text.isEmpty ? " " : line.text)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundColor(Color(NSColor.labelColor))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(height: 18)
.id(line.id)
// Add a subtle separator every chunk
.overlay(
Group {
if (index + 1) % viewModel.scrollChunkSize == 0 && index < viewModel.visibleLines.count - 1 {
VStack {
Spacer()
Divider()
.opacity(0.1)
}
}
}
)
}
// Invisible anchor for smooth scrolling
Color.clear
.frame(height: 1)
.id("bottom")
}
.padding(.vertical, 8)
}
.onChange(of: viewModel.shouldScrollToBottom) { shouldScroll in
if shouldScroll {
// Smooth animation with spring effect
withAnimation(.interpolatingSpring(stiffness: 120, damping: 20)) {
proxy.scrollTo("bottom", anchor: .bottom)
}
// Reset the flag
DispatchQueue.main.async {
viewModel.shouldScrollToBottom = false
}
}
}
.onAppear {
if viewModel.isAutoScrollEnabled && !viewModel.visibleLines.isEmpty {
proxy.scrollTo("bottom", anchor: .bottom)
}
}
}
}
.frame(width: 600, height: 80)
.background(Color(NSColor.textBackgroundColor))
}
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(NSColor.separatorColor), lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
// Gradient overlay (only show when not at bottom)
if !viewModel.isAutoScrollEnabled {
VStack(spacing: 0) {
Spacer()
.frame(height: 36) // Account for title bar
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(NSColor.textBackgroundColor), location: 0),
.init(color: Color(NSColor.textBackgroundColor).opacity(0.7), location: 0.4),
.init(color: Color(NSColor.textBackgroundColor).opacity(0), location: 1)
]),
startPoint: .top,
endPoint: .bottom
)
.frame(height: 25)
.allowsHitTesting(false)
Spacer()
// Bottom gradient
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(NSColor.textBackgroundColor).opacity(0), location: 0),
.init(color: Color(NSColor.textBackgroundColor).opacity(0.7), location: 0.6),
.init(color: Color(NSColor.textBackgroundColor), location: 1)
]),
startPoint: .top,
endPoint: .bottom
)
.frame(height: 25)
.allowsHitTesting(false)
}
}
}
.frame(width: 600, height: 124)
}
}
#Preview {
struct StreamingDemo: View {
@StateObject private var streamingModel = StreamingViewModel()
@State private var timer: Timer?
@State private var rapidTimer: Timer?
var body: some View {
VStack(spacing: 20) {
CodeWindowView(viewModel: streamingModel)
HStack(spacing: 10) {
Button("Start Streaming") {
startStreaming()
}
.disabled(timer != nil)
Button("Stop Streaming") {
stopStreaming()
}
.disabled(timer == nil)
Button("Rapid Stream") {
startRapidStreaming()
}
.disabled(rapidTimer != nil)
Button("Stop Rapid") {
stopRapidStreaming()
}
.disabled(rapidTimer == nil)
Button("Clear") {
streamingModel.clearLines()
}
Button("Flush") {
streamingModel.forceFlush()
}
}
HStack(spacing: 10) {
Text("Chunk Size:")
ForEach([1, 3, 5], id: \.self) { size in
Button("\(size)") {
streamingModel.setScrollChunkSize(size)
}
.buttonStyle(.bordered)
.disabled(streamingModel.scrollChunkSize == size)
}
}
}
.padding()
}
private func startStreaming() {
// Initialize with some sample messages
let initialMessages = [
"[INFO] Application started",
"[DEBUG] Loading configuration...",
"[INFO] Database connection established",
"[DEBUG] User authentication enabled",
"[INFO] Server listening on port 8080",
"[DEBUG] Cache initialized"
]
streamingModel.appendLines(initialMessages)
// Start streaming new messages
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
let messages = [
"[INFO] Processing request from user_\(Int.random(in: 1000...9999))",
"[DEBUG] Cache hit for key: session_\(UUID().uuidString.prefix(8))",
"[WARN] High memory usage detected: \(Int.random(in: 70...95))%",
"[INFO] Background task completed successfully",
"[ERROR] Network timeout after 30s",
"[INFO] Retrying operation...",
"[DEBUG] API response time: \(Int.random(in: 50...500))ms"
]
let randomMessage = messages.randomElement() ?? "System message"
let timestamp = Date().formatted(.dateTime.hour().minute().second())
streamingModel.appendLine("[\(timestamp)] \(randomMessage)")
}
}
private func stopStreaming() {
timer?.invalidate()
timer = nil
streamingModel.forceFlush()
}
private func startRapidStreaming() {
// Rapid streaming to test smooth scrolling
rapidTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
let timestamp = Date().formatted(.dateTime.hour().minute().second())
streamingModel.appendLine("[RAPID] Data packet received at \(timestamp)")
}
}
private func stopRapidStreaming() {
rapidTimer?.invalidate()
rapidTimer = nil
streamingModel.forceFlush()
}
}
return StreamingDemo()
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment