Refine macOS app visual hierarchy

This commit is contained in:
diyaa
2026-09-16 00:18:32 +02:00
parent c8242a844a
commit f9af5c21d8
5 changed files with 167 additions and 60 deletions
@@ -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,37 +90,61 @@ 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) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
projectMetrics
}
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 104), spacing: 10)],
alignment: .leading,
spacing: 8
) {
projectMetrics
}
}
}
.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")
}
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct ProjectMetricLabel: View {
@@ -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<Bool>.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))
}
}
@@ -13,6 +13,18 @@ struct ProjectBrowserView: View {
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
ProjectLibraryHeader(
projectCount: viewModel.projects.count,
isCreatingProject: viewModel.isCreatingProject
) {
Task {
if let projectID = await viewModel.createProject() {
selectedProjectID = projectID
}
}
}
List(selection: $selectedProjectID) {
Section("Projects") {
ForEach(viewModel.projects) { project in
@@ -30,7 +42,6 @@ struct ProjectBrowserView: View {
}
}
}
.navigationTitle("AI Music Studio")
.overlay {
if viewModel.isLoading {
ProgressView()
@@ -42,20 +53,6 @@ struct ProjectBrowserView: View {
)
}
}
.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)
}
}
} detail: {
ProjectBrowserDetailView(
@@ -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<Bool> {
@@ -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)
@@ -15,9 +15,20 @@ struct ProjectInspectorView: View {
var body: some View {
VStack(spacing: 0) {
HStack {
Label("Inspector", systemImage: "slider.horizontal.3")
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()
+2 -1
View File
@@ -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
+1
View File
@@ -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.