Better animations for welcome view

This commit is contained in:
Peter Steinberger 2025-06-22 11:02:16 +02:00
parent 997b4ab416
commit bb462c8826
11 changed files with 319 additions and 223 deletions

View file

@ -37,8 +37,14 @@ struct AboutView: View {
private var appInfoSection: some View {
VStack(spacing: 16) {
InteractiveAppIcon()
.padding(.bottom, 20)
GlowingAppIcon(
size: 128,
enableFloating: true,
enableInteraction: true,
glowIntensity: 0.3,
action: openWebsite
)
.padding(.bottom, 20)
Text(appName)
.font(.largeTitle)
@ -50,6 +56,12 @@ struct AboutView: View {
}
.padding(.top, 40)
}
@MainActor
private func openWebsite() {
guard let url = URL(string: "https://vibetunnel.sh") else { return }
NSWorkspace.shared.open(url)
}
private var descriptionSection: some View {
Text("Turn any browser into your terminal & command your agents on the go.")
@ -133,120 +145,6 @@ struct HoverableLink: View {
}
}
/// Interactive app icon component with shadow effects and website link.
///
/// This component displays the VibeTunnel app icon with dynamic shadow effects that respond
/// to user interaction. It includes hover effects for visual feedback and opens the
/// VibeTunnel website when clicked.
struct InteractiveAppIcon: View {
@State private var isHovering = false
@State private var isPressed = false
@State private var floatingOffset: CGFloat = 0
@Environment(\.colorScheme)
private var colorScheme
var body: some View {
Button(action: openWebsite) {
ZStack {
// Glow effect layers (multiple shadows for a more intense glow)
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 22))
.opacity(0.3)
.blur(radius: 20)
.scaleEffect(1.2)
.shadow(color: glowColor, radius: 30, x: 0, y: 0)
.allowsHitTesting(false)
// Secondary glow layer
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 22))
.opacity(0.2)
.blur(radius: 10)
.scaleEffect(1.1)
.shadow(color: glowColor, radius: 20, x: 0, y: 0)
.allowsHitTesting(false)
// Main icon with shadow
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 22))
.scaleEffect(isPressed ? 0.95 : (isHovering ? 1.05 : 1.0))
.shadow(
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowOffset
)
.animation(.easeInOut(duration: 0.2), value: isHovering)
.animation(.easeInOut(duration: 0.1), value: isPressed)
}
}
.buttonStyle(PlainButtonStyle())
.offset(y: floatingOffset)
.pointingHandCursor()
.onHover { hovering in
isHovering = hovering
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
isPressed = true
}
.onEnded { _ in
isPressed = false
}
)
.onAppear {
startFloatingAnimation()
}
}
private var glowColor: Color {
if colorScheme == .dark {
// Greenish-gold glow for dark mode
Color(red: 0.6, green: 0.8, blue: 0.4).opacity(isHovering ? 0.8 : 0.6)
} else {
// Softer golden glow for light mode
Color(red: 0.8, green: 0.7, blue: 0.3).opacity(isHovering ? 0.6 : 0.4)
}
}
private var shadowColor: Color {
if colorScheme == .dark {
.black.opacity(isHovering ? 0.8 : 0.6)
} else {
.black.opacity(isHovering ? 0.4 : 0.3)
}
}
private var shadowRadius: CGFloat {
isHovering ? 25 : 15
}
private var shadowOffset: CGFloat {
isHovering ? 10 : 6
}
private func startFloatingAnimation() {
withAnimation(
Animation.easeInOut(duration: 3.0)
.repeatForever(autoreverses: true)
) {
floatingOffset = -8
}
}
@MainActor
private func openWebsite() {
guard let url = URL(string: "https://vibetunnel.sh") else { return }
NSWorkspace.shared.open(url)
}
}
// MARK: - Preview

View file

@ -231,7 +231,7 @@ private struct PermissionsSection: View {
.foregroundColor(.green)
} else {
Text(
"Terminals can be controlled without permissions, however new sessions won't load."
"Terminals can be captured without permissions, however new sessions won't load."
)
.font(.caption)
.frame(maxWidth: .infinity)

View file

@ -0,0 +1,175 @@
import SwiftUI
/// Shared glowing app icon component with configurable animation and effects.
///
/// This component displays the VibeTunnel app icon with customizable glow effects,
/// floating animation, and interactive behaviors. It can be used in both the Welcome
/// and About views with different configurations.
struct GlowingAppIcon: View {
// Configuration
let size: CGFloat
let enableFloating: Bool
let enableInteraction: Bool
let glowIntensity: Double
let action: (() -> Void)?
// State
@State private var isHovering = false
@State private var isPressed = false
@State private var breathingScale: CGFloat = 1.0
@State private var breathingPhase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
init(
size: CGFloat = 128,
enableFloating: Bool = true,
enableInteraction: Bool = true,
glowIntensity: Double = 0.3,
action: (() -> Void)? = nil
) {
self.size = size
self.enableFloating = enableFloating
self.enableInteraction = enableInteraction
self.glowIntensity = glowIntensity
self.action = action
}
var body: some View {
Group {
if enableInteraction {
Button(action: { action?() }) {
iconContent
}
.buttonStyle(PlainButtonStyle())
.pointingHandCursor()
.onHover { hovering in
isHovering = hovering
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in isPressed = true }
.onEnded { _ in isPressed = false }
)
} else {
iconContent
}
}
.scaleEffect(breathingScale)
.onAppear {
if enableFloating {
startBreathingAnimation()
}
}
}
private var iconContent: some View {
ZStack {
// Subtle glow effect that changes with breathing
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.opacity(dynamicGlowOpacity)
.blur(radius: dynamicGlowBlur)
.scaleEffect(1.15)
.allowsHitTesting(false)
// Main icon with shadow
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.scaleEffect(iconScale)
.shadow(
color: dynamicShadowColor,
radius: dynamicShadowRadius,
x: 0,
y: dynamicShadowOffset
)
.animation(.easeInOut(duration: 0.2), value: isHovering)
.animation(.easeInOut(duration: 0.1), value: isPressed)
}
}
private var cornerRadius: CGFloat {
size * 0.172 // Maintains the same corner radius ratio as original (22/128)
}
private var iconScale: CGFloat {
if !enableInteraction { return 1.0 }
return isPressed ? 0.95 : (isHovering ? 1.05 : 1.0)
}
// Dynamic properties that change with breathing
private var dynamicGlowOpacity: Double {
let baseOpacity = glowIntensity * 0.5
// Glow gets stronger when "coming forward" (breathingPhase > 0)
return baseOpacity + (breathingPhase * glowIntensity * 0.3)
}
private var dynamicGlowBlur: CGFloat {
// Blur increases when coming forward for a softer, larger glow
return 15 + (breathingPhase * 5)
}
private var dynamicShadowColor: Color {
let baseOpacity = colorScheme == .dark ? 0.4 : 0.2
let hoverOpacity = colorScheme == .dark ? 0.6 : 0.3
let opacity = isHovering ? hoverOpacity : baseOpacity
// Shadow gets stronger when coming forward
let breathingOpacity = opacity + (breathingPhase * 0.1)
return .black.opacity(breathingOpacity)
}
private var dynamicShadowRadius: CGFloat {
let baseRadius = size * 0.117
let hoverMultiplier: CGFloat = enableInteraction && isHovering ? 1.5 : 1.0
// Shadow gets softer/larger when coming forward
let breathingMultiplier = 1.0 + (breathingPhase * 0.2)
return baseRadius * hoverMultiplier * breathingMultiplier
}
private var dynamicShadowOffset: CGFloat {
let baseOffset = size * 0.047
let hoverMultiplier: CGFloat = enableInteraction && isHovering ? 1.5 : 1.0
// Shadow moves down more when coming forward
let breathingMultiplier = 1.0 + (breathingPhase * 0.3)
return baseOffset * hoverMultiplier * breathingMultiplier
}
private func startBreathingAnimation() {
withAnimation(
Animation.easeInOut(duration: 4.0)
.repeatForever(autoreverses: true)
) {
breathingScale = 1.04 // Very subtle scale change
breathingPhase = 1.0 // Used to calculate dynamic effects
}
}
}
// MARK: - Preview
#Preview("Glowing App Icons") {
VStack(spacing: 40) {
// Welcome style - larger, subtle floating
GlowingAppIcon(
size: 156,
enableFloating: true,
enableInteraction: false,
glowIntensity: 0.3
)
// About style - smaller, interactive
GlowingAppIcon(
size: 128,
enableFloating: true,
enableInteraction: true,
glowIntensity: 0.3
) {
print("Icon clicked!")
}
}
.padding()
.frame(width: 400, height: 600)
}

View file

@ -27,12 +27,6 @@ struct AccessDashboardPageView: View {
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Accessing Your Dashboard")
.font(.largeTitle)
@ -89,8 +83,8 @@ struct AccessDashboardPageView: View {
CreditLink(name: "@steipete", url: "https://steipete.me")
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}

View file

@ -31,12 +31,6 @@ struct ProtectDashboardPageView: View {
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Protect Your Dashboard")
.font(.largeTitle)
@ -100,8 +94,8 @@ struct ProtectDashboardPageView: View {
.foregroundColor(.secondary)
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}

View file

@ -42,12 +42,6 @@ struct RequestPermissionsPageView: View {
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Request Permissions")
.font(.largeTitle)
@ -105,8 +99,8 @@ struct RequestPermissionsPageView: View {
}
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.task {
// Check permissions before first render to avoid UI flashing

View file

@ -22,23 +22,19 @@ struct SelectTerminalPageView: View {
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Select Terminal")
.font(.largeTitle)
.fontWeight(.semibold)
Text("VibeTunnel can spawn new sessions and open a terminal for you.\nThis will require permissions.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text(
"VibeTunnel can spawn new sessions and open a terminal for you.\nSelect your preferred Terminal and test permissions."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Terminal selector and test button
VStack(spacing: 16) {
@ -66,8 +62,8 @@ struct SelectTerminalPageView: View {
.frame(width: 200)
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.alert(errorTitle, isPresented: $showingError) {
Button("OK") {}

View file

@ -23,12 +23,6 @@ struct VTCommandPageView: View {
var body: some View {
VStack(spacing: 30) {
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Capturing Terminal Apps")
.font(.largeTitle)
@ -88,8 +82,8 @@ struct VTCommandPageView: View {
}
}
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
.onAppear {
// Check installation status synchronously on appear

View file

@ -0,0 +1,34 @@
import SwiftUI
/// Content-only version of the welcome page for use in the scrolling view.
///
/// This view displays only the textual content of the welcome page,
/// excluding the app icon which is shown in the fixed header.
struct WelcomeContentView: View {
var body: some View {
VStack(spacing: 30) {
VStack(spacing: 16) {
Text("Welcome to VibeTunnel")
.font(.largeTitle)
.fontWeight(.semibold)
Text("Turn any browser into your terminal. Command your agents on the go.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
}
Text(
"You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
.padding()
}
}

View file

@ -25,53 +25,89 @@ struct WelcomeView: View {
private var welcomeVersion = 0
@State private var cliInstaller = CLIInstaller()
@State private var permissionManager = SystemPermissionManager.shared
private let pageWidth: CGFloat = 640
private let contentHeight: CGFloat = 468 // Total height minus navigation area
var body: some View {
VStack(spacing: 0) {
// Custom page view implementation for macOS
ZStack {
// Page 1: Welcome
if currentPage == 0 {
WelcomePageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 2: VT Command
if currentPage == 1 {
// Fixed header with animated app icon
GlowingAppIcon(
size: 156,
enableFloating: true,
enableInteraction: false,
glowIntensity: 0.3
)
.padding(.top, 40)
.padding(.bottom, 20) // Add padding below icon
.frame(height: 240)
// Scrollable content area
GeometryReader { geometry in
HStack(spacing: 0) {
// Page 1: Welcome content (without icon)
WelcomeContentView()
.frame(width: pageWidth)
// Page 2: VT Command
VTCommandPageView(cliInstaller: cliInstaller)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 3: Request Permissions
if currentPage == 2 {
.frame(width: pageWidth)
// Page 3: Request Permissions
RequestPermissionsPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 4: Select Terminal
if currentPage == 3 {
.frame(width: pageWidth)
// Page 4: Select Terminal
SelectTerminalPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 5: Protect Your Dashboard
if currentPage == 4 {
.frame(width: pageWidth)
// Page 5: Protect Your Dashboard
ProtectDashboardPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 6: Accessing Dashboard
if currentPage == 5 {
.frame(width: pageWidth)
// Page 6: Accessing Dashboard
AccessDashboardPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
.frame(width: pageWidth)
}
.offset(x: CGFloat(-currentPage) * pageWidth)
.animation(.interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), value: currentPage)
}
.frame(maxHeight: .infinity)
.animation(.easeInOut, value: currentPage)
.frame(height: 260) // Total height (560) - header (240) - navigation (60)
.clipped()
// Custom page indicators and navigation - Fixed height container
VStack(spacing: 0) {
// Page indicators
// Navigation bar with dots and buttons in same row
HStack(spacing: 20) {
// Back button - only visible when not on first page
// Back button with consistent space reservation
ZStack(alignment: .leading) {
// Invisible placeholder that's always there
Button(action: {}) {
Label("Back", systemImage: "chevron.left")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
.opacity(0)
.disabled(true)
// Actual back button when needed
if currentPage > 0 {
Button(action: handleBackAction) {
Label("Back", systemImage: "chevron.left")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
.opacity(0.7)
.pointingHandCursor()
.help("Go back to previous page")
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
}
.frame(minWidth: 80, alignment: .leading) // Same width as Next button, left-aligned
Spacer()
// Page indicators centered
HStack(spacing: 8) {
ForEach(0..<6) { index in
Button {
@ -87,37 +123,18 @@ struct WelcomeView: View {
.pointingHandCursor()
}
}
.frame(height: 32) // Fixed height for indicator area
Spacer()
// Navigation buttons
HStack {
// Back button - only visible when not on first page
if currentPage > 0 {
Button(action: handleBackAction) {
Label("Back", systemImage: "chevron.left")
.labelStyle(.iconOnly)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
.opacity(0.7)
.pointingHandCursor()
.help("Go back to previous page")
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
Spacer()
Button(action: handleNextAction) {
Text(buttonTitle)
.frame(minWidth: 80)
}
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
Button(action: handleNextAction) {
Text(buttonTitle)
.frame(minWidth: 80)
}
.padding(.horizontal, 20)
.frame(height: 60) // Fixed height for button area
.keyboardShortcut(.return)
.buttonStyle(.borderedProminent)
}
.frame(height: 92) // Total fixed height: 32 + 60
.padding(.horizontal, 20)
.frame(height: 60)
}
.frame(width: 640, height: 560)
.background(Color(NSColor.windowBackgroundColor))

View file

@ -17,7 +17,7 @@ final class WelcomeWindowController: NSWindowController, NSWindowDelegate {
let hostingController = NSHostingController(rootView: welcomeView)
let window = NSWindow(contentViewController: hostingController)
window.title = ""
window.title = "Welcome to VibeTunnel"
window.styleMask = [.titled, .closable, .fullSizeContentView]
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden