fix: unify sidebar interactions and window limits
This commit is contained in:
1 parent
d10816165f
commit
0250a384d1
5 files changed
+142
-68
No files matched your search
@@ -39,7 +39,7 @@ struct RootView: View {
|
||||
}
|
||||
.overlay { ExternalAuthWindowPresenter() }
|
||||
.frame(
|
||||
minWidth: Metrics.Window.detailMin,
|
||||
minWidth: Metrics.Window.minWidth,
|
||||
maxWidth: .infinity,
|
||||
minHeight: Metrics.Window.minHeight,
|
||||
maxHeight: .infinity
|
||||
@@ -136,7 +136,7 @@ struct RootView: View {
|
||||
.frame(
|
||||
minWidth: Metrics.Window.detailMin,
|
||||
maxWidth: .infinity,
|
||||
minHeight: Metrics.Window.minHeight,
|
||||
minHeight: Metrics.Window.detailMinHeight,
|
||||
maxHeight: .infinity
|
||||
)
|
||||
.onGeometryChange(for: CGFloat.self) {
|
||||
|
||||
@@ -70,8 +70,8 @@ struct TermindApp: App {
|
||||
#endif
|
||||
}
|
||||
.defaultSize(width: Metrics.Window.defaultWidth, height: Metrics.Window.defaultHeight)
|
||||
// Lets the window shrink to what the columns actually need, instead of
|
||||
// AppKit's default of never going below the size it launched at.
|
||||
// RootView publishes the calculated two-column floor. Active optional
|
||||
// panes may raise it, but the window can never shrink below the base UI.
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands { TermindCommands(app: app) }
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ nonisolated enum Metrics {
|
||||
/// Color stripe on the leading edge of a host row.
|
||||
static let stripeWidth: CGFloat = 2.5
|
||||
static let statusDot: CGFloat = 7
|
||||
/// Vault and Assistant navigation use one comfortable target so every
|
||||
/// destination, chat, archive, and new-chat action keeps a steady rhythm.
|
||||
static let navigationRowHeight: CGFloat = 36
|
||||
/// Shared content inset for the leading glyph and trailing accessory.
|
||||
static let navigationRowInset: CGFloat = 8
|
||||
/// The fixed column every sidebar row's leading glyph sits in.
|
||||
///
|
||||
/// Fixed rather than left to `Label`, whose icon takes the symbol's own
|
||||
@@ -260,17 +265,21 @@ nonisolated enum Metrics {
|
||||
}
|
||||
|
||||
enum Window {
|
||||
/// The detail column's own floor, applied to the detail `VStack` itself.
|
||||
///
|
||||
/// Deliberately *not* summed into a window-level `minWidth`: wrapping a
|
||||
/// `NavigationSplitView` in a `.frame` takes the width negotiation away
|
||||
/// from the split view, which then lays out at the sum of its columns'
|
||||
/// ideal widths and centres that inside the window — clipping the
|
||||
/// sidebar on the left. The window
|
||||
/// minimum comes from `.windowResizability(.contentMinSize)` instead,
|
||||
/// which reads these three floors through the view tree.
|
||||
/// The detail column's own floors. The window-level values below add
|
||||
/// the chrome that sits beside or above it; keeping the two concepts
|
||||
/// separate prevents accidentally counting the tab strip twice.
|
||||
static let detailMin: CGFloat = 320
|
||||
static let minHeight: CGFloat = 440
|
||||
static let detailMinHeight: CGFloat = 440
|
||||
|
||||
/// The narrowest permanent two-column layout is Vault: one minimum
|
||||
/// sidebar, the native split-view divider, and Vault's main surface.
|
||||
/// Optional Vault work details keep contributing their own active
|
||||
/// minimum through `.contentMinSize` when one is presented.
|
||||
static let minWidth: CGFloat = Metrics.Sidebar.min + 1 + Metrics.VaultContent.min
|
||||
|
||||
/// Root content includes the pinned tab strip and its divider above the
|
||||
/// detail floor. The macOS title bar is outside this measurement.
|
||||
static let minHeight: CGFloat = Metrics.TabBar.height + 1 + detailMinHeight
|
||||
static let defaultWidth: CGFloat = 1180
|
||||
static let defaultHeight: CGFloat = 740
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ struct AssistantSidebarView: View {
|
||||
window.openAssistant()
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "plus.bubble")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Metrics.Sidebar.navigationRowHeight,
|
||||
alignment: .leading
|
||||
)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -23,7 +27,10 @@ struct AssistantSidebarView: View {
|
||||
window.showAssistantArchive()
|
||||
} label: {
|
||||
Image(systemName: "archivebox")
|
||||
.frame(width: 28, height: 24)
|
||||
.frame(
|
||||
width: Metrics.Sidebar.navigationRowHeight,
|
||||
height: Metrics.Sidebar.navigationRowHeight
|
||||
)
|
||||
.background(
|
||||
window.isAssistantArchivePresented
|
||||
? Color.accentColor.opacity(0.16) : Color.clear,
|
||||
@@ -34,7 +41,8 @@ struct AssistantSidebarView: View {
|
||||
.help(Text("Archived"))
|
||||
.accessibilityLabel(Text("Archived"))
|
||||
}
|
||||
.padding(Metrics.Space.m)
|
||||
.padding(.horizontal, Metrics.Space.m)
|
||||
.padding(.vertical, Metrics.Space.s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,43 +222,52 @@ private struct AssistantConversationList: View {
|
||||
}
|
||||
|
||||
private var activeList: some View {
|
||||
List {
|
||||
if !window.assistantLibrary.pinnedConversations.isEmpty {
|
||||
Section("Pinned") {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
if !window.assistantLibrary.pinnedConversations.isEmpty {
|
||||
sectionTitle("Pinned")
|
||||
rows(window.assistantLibrary.pinnedConversations)
|
||||
}
|
||||
}
|
||||
if !window.assistantLibrary.activeConversations.isEmpty {
|
||||
Section("Chats") {
|
||||
if !window.assistantLibrary.activeConversations.isEmpty {
|
||||
sectionTitle("Chats")
|
||||
rows(window.assistantLibrary.activeConversations)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.background(Color.clear)
|
||||
}
|
||||
|
||||
private var archivedList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Metrics.Space.xs) {
|
||||
rows(window.assistantLibrary.archivedConversations, isArchiveManagement: true)
|
||||
LazyVStack(spacing: 0) {
|
||||
rows(window.assistantLibrary.archivedConversations)
|
||||
}
|
||||
.frame(maxWidth: archiveMaxWidth ?? .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(.horizontal, Metrics.Space.xl)
|
||||
.padding(
|
||||
.horizontal,
|
||||
archiveMaxWidth == nil ? 0 : Metrics.Space.xl
|
||||
)
|
||||
.padding(.vertical, Metrics.Space.m)
|
||||
}
|
||||
.background(Color.clear)
|
||||
}
|
||||
|
||||
private func sectionTitle(_ title: LocalizedStringKey) -> some View {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
|
||||
.padding(.top, Metrics.Space.m)
|
||||
.padding(.bottom, Metrics.Space.xs)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rows(
|
||||
_ conversations: [ConversationModel], isArchiveManagement: Bool = false
|
||||
) -> some View {
|
||||
private func rows(_ conversations: [ConversationModel]) -> some View {
|
||||
ForEach(conversations) { conversation in
|
||||
AssistantConversationRow(
|
||||
conversation: conversation,
|
||||
isSelected: selectedID == conversation.id,
|
||||
isArchiveManagement: isArchiveManagement,
|
||||
onOpen: { onOpen(conversation.id) },
|
||||
onDelete: { deleteTarget = conversation }
|
||||
)
|
||||
@@ -318,7 +335,6 @@ private struct AssistantArchiveView: View {
|
||||
private struct AssistantConversationRow: View {
|
||||
let conversation: ConversationModel
|
||||
let isSelected: Bool
|
||||
var isArchiveManagement = false
|
||||
let onOpen: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
@@ -329,6 +345,46 @@ private struct AssistantConversationRow: View {
|
||||
@FocusState private var titleFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(
|
||||
isSelected
|
||||
? Color.accentColor.opacity(0.16)
|
||||
: hovering
|
||||
? Color.primary.opacity(0.055)
|
||||
: Color.clear
|
||||
)
|
||||
|
||||
rowContent
|
||||
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: Metrics.Sidebar.navigationRowHeight)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { if !renaming { onOpen() } }
|
||||
.onHover { hovering = $0 }
|
||||
.focusable(false)
|
||||
.focusEffectDisabled()
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
.overlay {
|
||||
ConversationContextMenuHost(
|
||||
isArchived: conversation.isArchived,
|
||||
isPinned: conversation.isPinned,
|
||||
managementDisabled: conversation.isStreaming || conversation.isCompacting,
|
||||
onTogglePin: { window.toggleAssistantPin(conversation.id) },
|
||||
onRename: beginRename,
|
||||
onToggleArchive: {
|
||||
window.setAssistantArchived(
|
||||
conversation.id, archived: !conversation.isArchived
|
||||
)
|
||||
},
|
||||
onDelete: onDelete
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: conversation.isArchived ? "archivebox" : "sparkles")
|
||||
.foregroundStyle(.tint)
|
||||
@@ -355,38 +411,6 @@ private struct AssistantConversationRow: View {
|
||||
.help(Text(conversation.isArchived ? "Unarchive" : "Archive"))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, isArchiveManagement ? Metrics.Space.l : Metrics.Space.s)
|
||||
.padding(.vertical, isArchiveManagement ? Metrics.Space.m : Metrics.Space.xs)
|
||||
.background(rowFill, in: .rect(cornerRadius: Metrics.Radius.medium))
|
||||
.contentShape(.rect(cornerRadius: Metrics.Radius.medium))
|
||||
.onTapGesture { if !renaming { onOpen() } }
|
||||
.onHover { hovering = $0 }
|
||||
.focusable(false)
|
||||
.focusEffectDisabled()
|
||||
.listRowBackground(Color.clear)
|
||||
.overlay {
|
||||
ConversationContextMenuHost(
|
||||
isArchived: conversation.isArchived,
|
||||
isPinned: conversation.isPinned,
|
||||
managementDisabled: conversation.isStreaming || conversation.isCompacting,
|
||||
onTogglePin: { window.toggleAssistantPin(conversation.id) },
|
||||
onRename: beginRename,
|
||||
onToggleArchive: {
|
||||
window.setAssistantArchived(
|
||||
conversation.id, archived: !conversation.isArchived
|
||||
)
|
||||
},
|
||||
onDelete: onDelete
|
||||
)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
if isSelected { return Color.accentColor.opacity(0.14) }
|
||||
if hovering { return Color.primary.opacity(0.055) }
|
||||
return .clear
|
||||
}
|
||||
|
||||
private func beginRename() {
|
||||
|
||||
@@ -6,6 +6,8 @@ struct VaultSidebarView: View {
|
||||
@Environment(WindowModel.self) private var window
|
||||
@AppStorage(SettingsKey.compactSidebar) private var compactSidebar = false
|
||||
@AppStorage(SettingsKey.showRecents) private var showRecents = true
|
||||
@State private var hoveredPage: VaultPage?
|
||||
@State private var hoveredRecentHostID: Host.ID?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -29,7 +31,9 @@ struct VaultSidebarView: View {
|
||||
}
|
||||
|
||||
private func row(_ page: VaultPage, count: Int = 0) -> some View {
|
||||
Button {
|
||||
let isSelected = window.vaultDestination.page == page
|
||||
|
||||
return Button {
|
||||
window.openPage(page)
|
||||
} label: {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
@@ -42,14 +46,18 @@ struct VaultSidebarView: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: Metrics.Sidebar.navigationRowHeight)
|
||||
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { setPageHover($0, page: page) }
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
.listRowBackground(
|
||||
window.vaultDestination.page == page
|
||||
? Color.accentColor.opacity(0.16)
|
||||
: Color.clear
|
||||
rowFill(isSelected: isSelected, isHovering: hoveredPage == page)
|
||||
)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
|
||||
private var recentHosts: [Host] {
|
||||
@@ -76,9 +84,20 @@ struct VaultSidebarView: View {
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(minHeight: Metrics.Sidebar.navigationRowHeight)
|
||||
.padding(.horizontal, Metrics.Sidebar.navigationRowInset)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { setRecentHover($0, hostID: host.id) }
|
||||
.listRowBackground(
|
||||
rowFill(
|
||||
isSelected: false,
|
||||
isHovering: hoveredRecentHostID == host.id
|
||||
)
|
||||
)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.contextMenu {
|
||||
HostContextMenu(host: host)
|
||||
}
|
||||
@@ -86,6 +105,28 @@ struct VaultSidebarView: View {
|
||||
.accessibilityHint("Quick Connect with SSH")
|
||||
}
|
||||
|
||||
private func rowFill(isSelected: Bool, isHovering: Bool) -> Color {
|
||||
if isSelected { return Color.accentColor.opacity(0.16) }
|
||||
if isHovering { return Color.primary.opacity(0.055) }
|
||||
return .clear
|
||||
}
|
||||
|
||||
private func setPageHover(_ hovering: Bool, page: VaultPage) {
|
||||
if hovering {
|
||||
hoveredPage = page
|
||||
} else if hoveredPage == page {
|
||||
hoveredPage = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setRecentHover(_ hovering: Bool, hostID: Host.ID) {
|
||||
if hovering {
|
||||
hoveredRecentHostID = hostID
|
||||
} else if hoveredRecentHostID == hostID {
|
||||
hoveredRecentHostID = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The first pinned tab's main content. Its navigator is mounted by the
|
||||
|
||||
Reference in new issue
Block a user