Stack a List detail's metadata under its markdown (#1119)

* Stack a list detail's metadata under its markdown, as Raycast does

* Drop metadata separators from a list detail, as Raycast does

* Align a list detail's metadata label with the first line of its value
This commit is contained in:
Jonas List
2026-09-24 17:12:01 +06:00
committed by GitHub
parent 47485d743a
commit be6fbf36f7
3 changed files with 60 additions and 19 deletions
@@ -7,11 +7,15 @@ struct ExtensionDetailBody: View {
let metadata: RenderNode?
let isLoading: Bool
let assetsPath: String?
/// A list's detail pane is too narrow for a metadata sidebar.
var stacksMetadata = false
private static let stackedInset: CGFloat = 16
var body: some View {
HStack(spacing: 0) {
markdownPane
if let metadata {
markdownPane(trailing: stacksMetadata ? metadata : nil)
if !stacksMetadata, let metadata {
Rectangle().fill(Theme.Colors.separator).frame(width: 1)
metadataPane(metadata)
}
@@ -19,7 +23,7 @@ struct ExtensionDetailBody: View {
.frame(maxWidth: .infinity)
}
private var markdownPane: some View {
private func markdownPane(trailing metadata: RenderNode?) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: metrics.spacing.md) {
if isLoading && (markdown ?? "").isEmpty {
@@ -28,9 +32,14 @@ struct ExtensionDetailBody: View {
if let markdown, !markdown.isEmpty {
ExtensionMarkdownView(markdown: markdown)
}
if let metadata {
ExtensionMetadataView(metadata: metadata, assetsPath: assetsPath, inline: true)
.padding(.top, (markdown ?? "").isEmpty ? 0 : metrics.spacing.xxl)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, metrics.spacing.lg)
.padding(
.horizontal, stacksMetadata ? metrics.scaled(Self.stackedInset) : metrics.spacing.lg)
.padding(.vertical, metrics.spacing.md)
.hideNativeScrollers()
}
@@ -62,13 +71,23 @@ struct ExtensionMetadataView: View {
@Environment(\.isDarkAppearance) private var isDark
let metadata: RenderNode
let assetsPath: String?
var inline = false
private static let inlineRowHeight: CGFloat = 28
/// Stripes must count only real rows, so separators are dropped here.
private var visibleChildren: [RenderNode] {
inline
? metadata.children.filter { $0.type != "Detail.Metadata.Separator" }
: metadata.children
}
var body: some View {
VStack(alignment: .leading, spacing: metrics.spacing.lg) {
ForEach(metadata.children) { child in
VStack(alignment: .leading, spacing: inline ? 0 : metrics.spacing.lg) {
ForEach(Array(visibleChildren.enumerated()), id: \.element.id) { index, child in
switch child.type {
case "Detail.Metadata.Label":
row(title: child.string("title")) {
row(title: child.string("title"), index: index) {
HStack(spacing: metrics.spacing.xs) {
if let icon = child.props["icon"] {
ExtensionIconView(
@@ -82,7 +101,7 @@ struct ExtensionMetadataView: View {
}
}
case "Detail.Metadata.Link":
row(title: child.string("title")) {
row(title: child.string("title"), index: index) {
if let target = child.string("target"), let url = URL(string: target) {
Link(child.string("text") ?? target, destination: url)
.font(metrics.typography.rowTitle)
@@ -91,7 +110,7 @@ struct ExtensionMetadataView: View {
}
}
case "Detail.Metadata.TagList":
row(title: child.string("title")) {
row(title: child.string("title"), index: index) {
ExtensionTagListView(tags: child.children, assetsPath: assetsPath)
}
case "Detail.Metadata.Separator":
@@ -111,14 +130,35 @@ struct ExtensionMetadataView: View {
}
@ViewBuilder
private func row<Content: View>(title: String?, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 2) {
if let title, !title.isEmpty {
Text(title)
.font(metrics.typography.sectionHeader)
private func row<Content: View>(
title: String?, index: Int, @ViewBuilder content: () -> Content
) -> some View {
if inline {
HStack(alignment: .firstTextBaseline, spacing: metrics.spacing.xl) {
Text(title ?? "")
.font(metrics.typography.rowTitle)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
content()
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, metrics.spacing.md)
.padding(.vertical, metrics.spacing.xs)
.frame(minHeight: metrics.scaled(Self.inlineRowHeight))
.background(
RoundedRectangle(cornerRadius: metrics.radius.menu, style: .continuous)
.fill(index.isMultiple(of: 2) ? ExtensionColors.detailCardFill : .clear))
} else {
VStack(alignment: .leading, spacing: 2) {
if let title, !title.isEmpty {
Text(title)
.font(metrics.typography.sectionHeader)
.foregroundStyle(.secondary)
}
content()
}
content()
}
}
}
@@ -13,8 +13,7 @@ struct ExtensionListView: View {
let onActivate: (Int) -> Void
let onActions: (Int) -> Void
/// The row column beside a detail pane; narrower than Clipboard's so the markdown stays widest.
private static let detailListWidth: CGFloat = 220
private static let detailListWidth: CGFloat = 290
var body: some View {
Group {
@@ -159,7 +158,8 @@ struct ExtensionListView: View {
{
ExtensionDetailBody(
markdown: detail.string("markdown"), metadata: detail.node("metadata"),
isLoading: detail.bool("isLoading") ?? false, assetsPath: assetsPath)
isLoading: detail.bool("isLoading") ?? false, assetsPath: assetsPath,
stacksMetadata: true)
} else {
Color.clear
}
+2 -1
View File
@@ -270,7 +270,8 @@ screens hold (see [palette.md](palette.md)).
A tile may be a bare `{color}` swatch instead of an image, stated in any notation `ColorValue`
reads — a colour picker writes `oklch()`, not hex.
- **Detail** — markdown rendered block-by-block (headings, lists, code fences, quotes, rules, tables, fetched
and inline images) with `AttributedString` handling inline styling, plus `Detail.Metadata`. An image
and inline images) with `AttributedString` handling inline styling, plus `Detail.Metadata` — a
sidebar on a Detail screen, appended below the markdown in a `List`'s detail pane. An image
is full-width and at most 220pt tall; `?raycast-width=` / `?raycast-height=` on its URL, read by
`ExtensionImageSize`, can only shrink it within that, never lift the cap. A rowless Detail's screen
actions remain available through the primary ⏎ action and the ⌘K Actions panel.