Chat: drag handle with detents (fit, 1/3, 1/2, full screen), chat width setting; tall transparent panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,11 +32,7 @@ struct IslandContainer: View {
|
||||
private let openSpring = Animation.spring(response: 0.5, dampingFraction: 0.72)
|
||||
private let closeEase = Animation.timingCurve(0.45, 0, 0.2, 1, duration: 0.34)
|
||||
|
||||
private var chatPromptHeight: CGFloat {
|
||||
let base: CGFloat = 240
|
||||
let perMsg: CGFloat = 40
|
||||
return min(300, base + CGFloat(state.chatHistory.count) * perMsg)
|
||||
}
|
||||
private var chatPromptHeight: CGFloat { state.chatIslandHeight }
|
||||
|
||||
/// Pixels the content must be pushed down to clear the concave ear transparent area.
|
||||
/// = 0 in expanded mode (no ears), = earRadius in compact/notch mode.
|
||||
@@ -106,6 +102,13 @@ struct IslandContainer: View {
|
||||
|
||||
CountdownBar(state: state, islandW: islandWidth)
|
||||
|
||||
// Pull handle: drag the bottom edge to grow the chat (⅓, ½, full screen).
|
||||
if state.mode == .expanded && [.prompt, .overview, .empty].contains(state.view) {
|
||||
ChatResizeHandle(state: state)
|
||||
.position(x: islandWidth / 2, y: islandHeight - 7)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
Group {
|
||||
if state.mode == .compact {
|
||||
CompactMiniGrid(state: state)
|
||||
@@ -151,6 +154,18 @@ struct IslandContainer: View {
|
||||
guard state.mode == .expanded, state.view == .prompt else { return }
|
||||
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
||||
}
|
||||
.onChange(of: state.chatDragHeight) { _, h in
|
||||
guard state.mode == .expanded, state.view == .prompt, let h else { return }
|
||||
islandHeight = h // follow the pointer, no spring
|
||||
}
|
||||
.onChange(of: state.chatDetent) { _, _ in
|
||||
guard state.mode == .expanded, state.view == .prompt else { return }
|
||||
withAnimation(openSpring) { islandHeight = chatPromptHeight }
|
||||
}
|
||||
.onChange(of: state.chatWidth) { _, w in
|
||||
guard state.mode == .expanded, state.view == .prompt else { return }
|
||||
withAnimation(openSpring) { islandWidth = w }
|
||||
}
|
||||
.onAppear {
|
||||
let (w, h) = islandSize(mode: state.mode, view: state.view,
|
||||
progress: state.uploadProgress,
|
||||
@@ -366,6 +381,11 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
|
||||
let cy: CGFloat
|
||||
if let fixedY = layout.botY {
|
||||
cy = fixedY
|
||||
} else if view == .prompt {
|
||||
// Tall chat: keep the character near the top instead of mid-screen.
|
||||
let headerBottom: CGFloat = 42
|
||||
let cardH: CGFloat = 84
|
||||
cy = min(171, headerBottom + (islandH - headerBottom - cardH) / 2 + cardH / 2)
|
||||
} else {
|
||||
// Center of the fixed 84pt card (VStack top=8, header=34 → content starts at y=42)
|
||||
let headerBottom: CGFloat = 42
|
||||
@@ -376,6 +396,52 @@ func botPosition(mode: IslandMode, view: IslandView, islandW: CGFloat, islandH:
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat resize handle
|
||||
|
||||
/// Thin pill at the bottom of the island. Drag down: the chat follows the pointer and
|
||||
/// snaps to the nearest detent (fits messages, ⅓, ½, full screen). From the home view
|
||||
/// it opens the chat first.
|
||||
struct ChatResizeHandle: View {
|
||||
@ObservedObject var state: AppState
|
||||
@State private var startHeight: CGFloat? = nil
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(isHovered || startHeight != nil ? 0.55 : 0.22))
|
||||
.frame(width: isHovered ? 54 : 40, height: 4)
|
||||
.frame(width: 160, height: 14)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { h in
|
||||
withAnimation(.easeOut(duration: 0.15)) { isHovered = h }
|
||||
if h { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 2, coordinateSpace: .global)
|
||||
.onChanged { g in
|
||||
if startHeight == nil {
|
||||
if state.view != .prompt {
|
||||
state.chatDetent = 0
|
||||
state.view = .prompt
|
||||
}
|
||||
startHeight = state.chatDetentHeight(state.chatDetent)
|
||||
}
|
||||
let h = (startHeight ?? 0) + g.translation.height
|
||||
state.chatDragHeight = min(state.chatDetentHeight(3), max(state.chatDetentHeight(0), h))
|
||||
}
|
||||
.onEnded { g in
|
||||
let target = (startHeight ?? 0) + g.predictedEndTranslation.height
|
||||
let best = (0...3).min { abs(state.chatDetentHeight($0) - target) < abs(state.chatDetentHeight($1) - target) } ?? 0
|
||||
state.chatDetent = best
|
||||
state.chatDragHeight = nil
|
||||
startHeight = nil
|
||||
SoundEngine.shared.play("tick")
|
||||
}
|
||||
)
|
||||
.help("Потяни, чтобы развернуть чат")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Countdown bar
|
||||
|
||||
struct CountdownBar: View {
|
||||
|
||||
Reference in New Issue
Block a user