mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-08 11:45:58 +00:00
Better animations for welcome view
This commit is contained in:
parent
997b4ab416
commit
bb462c8826
11 changed files with 319 additions and 223 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
175
mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift
Normal file
175
mac/VibeTunnel/Presentation/Views/Shared/GlowingAppIcon.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue