diff --git a/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift b/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift index a6112c5..9e89456 100644 --- a/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift +++ b/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift @@ -33,7 +33,7 @@ struct ChatWorkspaceView: View { .padding(24) .frame(maxWidth: .infinity, alignment: .leading) } - .background(.background) + .background(Color(nsColor: .textBackgroundColor)) .onChange(of: messages.count) { _, _ in guard let lastMessageID = messages.last?.id else { return } withAnimation(.snappy) { @@ -49,7 +49,8 @@ struct ChatWorkspaceView: View { isFocused: $isComposerFocused, sendAction: sendMessage ) - .padding(16) + .padding(.horizontal, 20) + .padding(.vertical, 14) } .frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) @@ -89,36 +90,60 @@ private struct ChatWorkspaceHeader: View { let project: SongProject var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "music.note") + .font(.title3.weight(.semibold)) + .foregroundStyle(.tint) + .frame(width: 42, height: 42) + .background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 4) { Text(project.title) - .font(.title.weight(.semibold)) + .font(.title2.weight(.semibold)) .lineLimit(1) - Text("Song Project") + Text(project.idea.isEmpty ? "Song Project" : project.idea) .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(2) } - Spacer() + Spacer(minLength: 12) Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption) .foregroundStyle(.secondary) + .lineLimit(1) } - HStack(spacing: 10) { - ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath") - ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3") - ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys") - ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward") - ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2") + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + projectMetrics + } + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 104), spacing: 10)], + alignment: .leading, + spacing: 8 + ) { + projectMetrics + } } } - .padding(.horizontal, 24) - .padding(.vertical, 18) + .padding(.horizontal, 22) + .padding(.vertical, 20) .frame(maxWidth: .infinity, alignment: .leading) + .background(.bar) + } + + @ViewBuilder + private var projectMetrics: some View { + ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath") + ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3") + ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys") + ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward") + ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2") } } @@ -137,6 +162,7 @@ private struct ProjectMetricLabel: View { .foregroundStyle(.secondary) .lineLimit(1) .labelStyle(.titleAndIcon) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } } @@ -144,11 +170,15 @@ private struct ChatMessageBubble: View { let message: ChatWorkspaceMessage var body: some View { - HStack { + HStack(alignment: .bottom, spacing: 8) { if message.role == .user { Spacer(minLength: 64) } + if message.role == .assistant { + ChatRoleMark(role: message.role) + } + Text(message.text) .font(.body) .foregroundStyle(message.role == .user ? .white : .primary) @@ -157,6 +187,10 @@ private struct ChatMessageBubble: View { .background(message.role == .user ? Color.accentColor : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) .frame(maxWidth: 620, alignment: message.role == .user ? .trailing : .leading) + if message.role == .user { + ChatRoleMark(role: message.role) + } + if message.role == .assistant { Spacer(minLength: 64) } @@ -165,6 +199,19 @@ private struct ChatMessageBubble: View { } } +private struct ChatRoleMark: View { + let role: ChatWorkspaceMessage.Role + + var body: some View { + Image(systemName: role == .assistant ? "sparkles" : "person.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(role == .assistant ? Color.teal : Color.accentColor) + .frame(width: 28, height: 28) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 7)) + .accessibilityHidden(true) + } +} + private struct ChatComposer: View { @Binding var text: String var isFocused: FocusState.Binding @@ -173,7 +220,7 @@ private struct ChatComposer: View { var body: some View { HStack(alignment: .bottom, spacing: 10) { TextField("Describe the song idea, lyrics, mood, or arrangement", text: $text, axis: .vertical) - .textFieldStyle(.roundedBorder) + .textFieldStyle(.plain) .lineLimit(1...5) .focused(isFocused) .onSubmit(sendAction) @@ -187,6 +234,8 @@ private struct ChatComposer: View { .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .help("Send") } + .padding(10) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) } } diff --git a/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift b/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift index 677a810..b482e03 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift @@ -13,48 +13,45 @@ struct ProjectBrowserView: View { var body: some View { NavigationSplitView { - List(selection: $selectedProjectID) { - Section("Projects") { - ForEach(viewModel.projects) { project in - ProjectListRow(project: project) { - projectPendingDeletion = project + VStack(spacing: 0) { + ProjectLibraryHeader( + projectCount: viewModel.projects.count, + isCreatingProject: viewModel.isCreatingProject + ) { + Task { + if let projectID = await viewModel.createProject() { + selectedProjectID = projectID } - .tag(project.id) - .contextMenu { - Button(role: .destructive) { - projectPendingDeletion = project - } label: { - Label("Delete Project", systemImage: "trash") + } + } + + List(selection: $selectedProjectID) { + Section("Projects") { + ForEach(viewModel.projects) { project in + ProjectListRow(project: project) { + projectPendingDeletion = project + } + .tag(project.id) + .contextMenu { + Button(role: .destructive) { + projectPendingDeletion = project + } label: { + Label("Delete Project", systemImage: "trash") + } } - } - } - } - } - .navigationTitle("AI Music Studio") - .overlay { - if viewModel.isLoading { - ProgressView() - } else if viewModel.projects.isEmpty { - ContentUnavailableView( - "No Projects", - systemImage: "music.note.list", - description: Text("Create a song project to begin.") - ) - } - } - .toolbar { - ToolbarItem { - Button { - Task { - if let projectID = await viewModel.createProject() { - selectedProjectID = projectID - } } - } label: { - Label("New Project", systemImage: "plus") } - .keyboardShortcut("n", modifiers: .command) - .disabled(viewModel.isCreatingProject) + } + .overlay { + if viewModel.isLoading { + ProgressView() + } else if viewModel.projects.isEmpty { + ContentUnavailableView( + "No Projects", + systemImage: "music.note.list", + description: Text("Create a song project to begin.") + ) + } } } } detail: { @@ -96,6 +93,7 @@ struct ProjectBrowserView: View { } message: { Text("This permanently removes the project and its saved versions from this Mac.") } + .navigationSplitViewStyle(.balanced) } private var deletionAlertBinding: Binding { @@ -124,12 +122,57 @@ struct ProjectBrowserView: View { } } +private struct ProjectLibraryHeader: View { + let projectCount: Int + let isCreatingProject: Bool + let createAction: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "music.note.list") + .font(.title3.weight(.semibold)) + .foregroundStyle(.tint) + .frame(width: 34, height: 34) + .background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text("AI Music Studio") + .font(.headline) + Text("\(projectCount) saved \(projectCount == 1 ? "project" : "projects")") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Button(action: createAction) { + Image(systemName: "plus") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .keyboardShortcut("n", modifiers: .command) + .disabled(isCreatingProject) + .help("New project") + .accessibilityLabel("New project") + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(.bar) + } +} + private struct ProjectListRow: View { let project: SongProjectListItem let deleteAction: () -> Void var body: some View { HStack(alignment: .top, spacing: 8) { + Image(systemName: project.idea.isEmpty ? "music.note" : "music.note.house") + .font(.body.weight(.semibold)) + .foregroundStyle(project.idea.isEmpty ? Color.secondary : Color.accentColor) + .frame(width: 32, height: 32) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 4) { Text(project.title) .font(.headline) diff --git a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift index be72d73..2674961 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift @@ -15,9 +15,20 @@ struct ProjectInspectorView: View { var body: some View { VStack(spacing: 0) { - HStack { - Label("Inspector", systemImage: "slider.horizontal.3") - .font(.headline) + HStack(spacing: 10) { + Image(systemName: "slider.horizontal.3") + .font(.body.weight(.semibold)) + .foregroundStyle(.tint) + .frame(width: 32, height: 32) + .background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text("Inspector") + .font(.headline) + Text("Song controls") + .font(.caption) + .foregroundStyle(.secondary) + } Spacer() @@ -31,7 +42,9 @@ struct ProjectInspectorView: View { .keyboardShortcut("s", modifiers: .command) .disabled(isSaving) } - .padding(16) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(.bar) Divider() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 904ac0b..75fbcbc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,7 +74,8 @@ does not use external services or APIs. Before processing, it protects the user's exact preserved spellings and dialect phrases so normalization or tanween removal cannot alter them. The Presentation layer presents both the original and processed lyrics in final review; the user may edit -and explicitly apply the processed text before any Suno handoff. +and explicitly apply the processed text before preparing the final local +output. ## Security diff --git a/docs/TASKS.md b/docs/TASKS.md index 5b58749..6b3b69e 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -134,3 +134,4 @@ lyrics and style prompt in the project. - [x] Add a worldwide genre and style selector with checkbox multi-select. - [x] Add a vocalist preset selector with checkbox multi-select. - [x] Add a language selector with checkbox multi-select. +- [x] Refine the macOS visual hierarchy for projects, chat and inspector.