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:
maksarsanjeev
2026-10-06 22:19:27 +03:00
parent fbf90fd4fb
commit a4d5032959
5 changed files with 119 additions and 16 deletions
+71 -5
View File
@@ -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 {