diff --git a/FrameworkTests/TagFieldPolicyTests.swift b/FrameworkTests/TagFieldPolicyTests.swift new file mode 100644 index 00000000..ba00df19 --- /dev/null +++ b/FrameworkTests/TagFieldPolicyTests.swift @@ -0,0 +1,105 @@ +import SwiftUI +import Testing + +@testable import TermindPresentation + +@Suite +struct TagFieldPolicyTests { + @Test + func rowsExcludeSelectedSuggestionsAndKeepStableOrder() { + #expect( + TagFieldPolicy.rows( + tags: ["production"], suggestions: ["web", "production", "api"], query: "" + ) == [.existing("web"), .existing("api")] + ) + } + + @Test + func exactSuggestionMatchIgnoresCaseAndDoesNotOfferCreate() { + #expect( + TagFieldPolicy.rows(tags: [], suggestions: ["api", "web"], query: " API ") == [ + .existing("api") + ] + ) + } + + @Test + func partialSuggestionMatchStillOffersCreate() { + #expect( + TagFieldPolicy.rows(tags: [], suggestions: ["api", "web"], query: "PI") == [ + .existing("api"), .create("PI"), + ] + ) + } + + @Test + func whitespaceOnlyQueryDoesNotOfferCreate() { + #expect(TagFieldPolicy.rows(tags: [], suggestions: ["api"], query: " ") == [.existing("api")]) + #expect(TagFieldPolicy.submission(query: " ", suggestions: ["api"]) == nil) + } + + @Test + func addingTrimsSpacesPreservesOrderAndRejectsCaseInsensitiveDuplicates() { + #expect(TagFieldPolicy.adding(" api ", to: ["production"]) == ["production", "api"]) + #expect(TagFieldPolicy.adding(" Production ", to: ["production"]) == ["production"]) + #expect(TagFieldPolicy.adding("\napi\n", to: []) == ["\napi\n"]) + } + + @Test + func removingUsesExactMatching() { + #expect(TagFieldPolicy.removing("api", from: ["API", "api", "web"]) == ["API", "web"]) + } + + @Test + func submissionPreservesExistingSuggestionSpelling() { + #expect(TagFieldPolicy.submission(query: " API ", suggestions: ["Api", "web"]) == "Api") + } +} + +@Suite +struct TagFlowGeometryTests { + @Test + func emptyContentHasZeroHeight() { + #expect(TagFlowGeometry.size(sizes: [], width: 100, spacing: 6) == CGSize(width: 100, height: 0)) + } + + @Test + func finiteWidthWrapsButExactBoundaryDoesNot() { + let sizes = [CGSize(width: 4, height: 10), CGSize(width: 4, height: 20)] + #expect(TagFlowGeometry.rows(sizes: sizes, width: 10, spacing: 2).map(\.indices) == [[0, 1]]) + #expect(TagFlowGeometry.rows(sizes: sizes, width: 9, spacing: 2).map(\.indices) == [[0], [1]]) + } + + @Test + func oversizedFirstChipIsKeptAndVariableRowHeightsAreSummed() { + let sizes = [ + CGSize(width: 12, height: 7), + CGSize(width: 4, height: 11), + CGSize(width: 4, height: 5), + ] + let rows = TagFlowGeometry.rows(sizes: sizes, width: 10, spacing: 2) + #expect(rows.map(\.indices) == [[0], [1, 2]]) + #expect(TagFlowGeometry.size(sizes: sizes, width: 10, spacing: 2).height == 20) + } + + @Test + func unspecifiedWidthRetainsEachPlatformsSizingPolicy() { + let proposal = ProposedViewSize(width: nil, height: nil) + #expect(TagFlowGeometry.width(proposal: proposal, naturalWidth: 42, sizing: .proposed) == 10) + #expect(TagFlowGeometry.width(proposal: proposal, naturalWidth: 42, sizing: .intrinsic) == 42) + } + + @Test + func intrinsicSizingHandlesNonFiniteAndNegativeProposals() { + #expect( + TagFlowGeometry.width( + proposal: ProposedViewSize(width: .infinity, height: nil), naturalWidth: 42, sizing: .intrinsic + ) == 42 + ) + #expect( + TagFlowGeometry.width( + proposal: ProposedViewSize(width: -5, height: nil), naturalWidth: 42, sizing: .intrinsic + ) == 0 + ) + } +} diff --git a/Frameworks/TermindPresentation/LoggingFileLabel.swift b/Frameworks/TermindPresentation/LoggingFileLabel.swift new file mode 100644 index 00000000..6a6db46c --- /dev/null +++ b/Frameworks/TermindPresentation/LoggingFileLabel.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftUI +import TermindApplication + +public struct LoggingFileLabel: View { + private let file: ApplicationLogFile + private let dateFormat: Date.FormatStyle + + public init(file: ApplicationLogFile, dateFormat: Date.FormatStyle) { + self.file = file + self.dateFormat = dateFormat + } + + public var body: some View { + HStack { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(file.createdAt, format: dateFormat) + if file.isCurrent { + Text(TermindCopy.Common.current).font(.caption).foregroundStyle(.secondary) + } + } + Text(ByteCountFormatter.string(fromByteCount: file.byteCount, countStyle: .file)) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "square.and.arrow.up").foregroundStyle(.secondary) + } + } +} diff --git a/Frameworks/TermindPresentation/LoggingSettingsSection.swift b/Frameworks/TermindPresentation/LoggingSettingsSection.swift new file mode 100644 index 00000000..c1a23d5a --- /dev/null +++ b/Frameworks/TermindPresentation/LoggingSettingsSection.swift @@ -0,0 +1,27 @@ +import SwiftUI +import TermindApplication + +public struct LoggingSettingsSection: View { + @Binding private var saving: Bool + @Binding private var level: String + + public init(saving: Binding, level: Binding) { + _saving = saving + _level = level + } + + public var body: some View { + Section { + Toggle(TermindCopy.Common.save.swiftUIKey, isOn: $saving) + Picker(TermindCopy.Settings.level.swiftUIKey, selection: $level) { + ForEach(ApplicationLogLevel.allCases) { level in + Text(verbatim: level.localizedName).tag(level.rawValue) + } + } + } header: { + Text(TermindCopy.Settings.logging) + } footer: { + Text(TermindCopy.Settings.logSharingWarning) + } + } +} diff --git a/Frameworks/TermindPresentation/TagFieldPolicy.swift b/Frameworks/TermindPresentation/TagFieldPolicy.swift new file mode 100644 index 00000000..e8f44eb3 --- /dev/null +++ b/Frameworks/TermindPresentation/TagFieldPolicy.swift @@ -0,0 +1,41 @@ +import Foundation + +public nonisolated enum TagFieldPolicy { + /// A single row type keeps selection, activation, and submission on the same ordered list. + public enum Row: Hashable, Sendable { + case existing(String) + case create(String) + } + + public static func rows(tags: [String], suggestions: [String], query: String) -> [Row] { + let trimmedQuery = query.trimmingCharacters(in: .whitespaces) + let taken = Set(tags.map { $0.lowercased() }) + let matches = + suggestions + .filter { !taken.contains($0.lowercased()) } + .filter { trimmedQuery.isEmpty || $0.localizedCaseInsensitiveContains(trimmedQuery) } + + guard !trimmedQuery.isEmpty else { return matches.map(Row.existing) } + let spokenFor = taken.union(suggestions.map { $0.lowercased() }) + guard !spokenFor.contains(trimmedQuery.lowercased()) else { return matches.map(Row.existing) } + return matches.map(Row.existing) + [.create(trimmedQuery)] + } + + public static func submission(query: String, suggestions: [String]) -> String? { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + return suggestions.first { $0.caseInsensitiveCompare(trimmed) == .orderedSame } ?? trimmed + } + + public static func adding(_ tag: String, to tags: [String]) -> [String] { + let trimmed = tag.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, + !tags.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) + else { return tags } + return tags + [trimmed] + } + + public static func removing(_ tag: String, from tags: [String]) -> [String] { + tags.filter { $0 != tag } + } +} diff --git a/Frameworks/TermindPresentation/TagFlowLayout.swift b/Frameworks/TermindPresentation/TagFlowLayout.swift new file mode 100644 index 00000000..990916b8 --- /dev/null +++ b/Frameworks/TermindPresentation/TagFlowLayout.swift @@ -0,0 +1,98 @@ +import SwiftUI + +public struct TagFlowLayout: Layout { + public enum WidthSizing: Sendable { + case proposed + case intrinsic + } + + private let spacing: CGFloat + private let widthSizing: WidthSizing + + public init(spacing: CGFloat, widthSizing: WidthSizing) { + self.spacing = spacing + self.widthSizing = widthSizing + } + + public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let width = TagFlowGeometry.width( + proposal: proposal, + naturalWidth: TagFlowGeometry.naturalWidth(sizes: sizes, spacing: spacing), + sizing: widthSizing + ) + return TagFlowGeometry.size(sizes: sizes, width: width, spacing: spacing) + } + + public func placeSubviews( + in bounds: CGRect, proposal _: ProposedViewSize, subviews: Subviews, cache: inout () + ) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + var y = bounds.minY + for row in TagFlowGeometry.rows(sizes: sizes, width: bounds.width, spacing: spacing) { + var x = bounds.minX + for index in row.indices { + subviews[index].place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(sizes[index]) + ) + x += sizes[index].width + spacing + } + y += row.height + spacing + } + } +} + +nonisolated enum TagFlowGeometry { + struct RowRun: Equatable { + var indices: [Int] = [] + var height: CGFloat = 0 + } + + static func naturalWidth(sizes: [CGSize], spacing: CGFloat) -> CGFloat { + sizes.reduce(CGFloat.zero) { width, size in + width + size.width + (width == 0 ? 0 : spacing) + } + } + + static func width( + proposal: ProposedViewSize, naturalWidth: CGFloat, sizing: TagFlowLayout.WidthSizing + ) -> CGFloat { + switch sizing { + case .proposed: + return proposal.replacingUnspecifiedDimensions().width + case .intrinsic: + guard let proposedWidth = proposal.width else { return naturalWidth } + return proposedWidth.isFinite ? max(0, proposedWidth) : naturalWidth + } + } + + static func size(sizes: [CGSize], width: CGFloat, spacing: CGFloat) -> CGSize { + let rows = rows(sizes: sizes, width: width, spacing: spacing) + let height = rows.reduce(CGFloat.zero) { $0 + $1.height } + spacing * CGFloat(max(rows.count - 1, 0)) + return CGSize(width: width, height: height) + } + + static func rows(sizes: [CGSize], width: CGFloat, spacing: CGFloat) -> [RowRun] { + var rows: [RowRun] = [] + var current = RowRun() + var x: CGFloat = 0 + + for index in sizes.indices { + let size = sizes[index] + // The first chip in a row is always kept, even when it is wider than the available width. + if !current.indices.isEmpty && x + size.width > width { + rows.append(current) + current = RowRun() + x = 0 + } + current.indices.append(index) + current.height = max(current.height, size.height) + x += size.width + spacing + } + + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} diff --git a/TFI/Design/MobileTagField.swift b/TFI/Design/MobileTagField.swift index 95b8b750..d4f5b84a 100644 --- a/TFI/Design/MobileTagField.swift +++ b/TFI/Design/MobileTagField.swift @@ -11,10 +11,10 @@ struct MobileTagField: View { var body: some View { VStack(alignment: .leading, spacing: 8) { if !tags.isEmpty { - MobileTagFlowLayout(spacing: 6) { + TagFlowLayout(spacing: 6, widthSizing: .intrinsic) { ForEach(tags, id: \.self) { tag in MobileRemovableTagChip(tag: tag) { - tags = MobileTagFieldPolicy.removing(tag, from: tags) + tags = TagFieldPolicy.removing(tag, from: tags) } } } @@ -65,19 +65,19 @@ struct MobileTagField: View { } } - private var rows: [MobileTagFieldPolicy.Row] { - MobileTagFieldPolicy.rows(tags: tags, suggestions: suggestions, query: query) + private var rows: [TagFieldPolicy.Row] { + TagFieldPolicy.rows(tags: tags, suggestions: suggestions, query: query) } private func commitQuery() { - guard let tag = MobileTagFieldPolicy.submission(query: query, suggestions: suggestions) else { + guard let tag = TagFieldPolicy.submission(query: query, suggestions: suggestions) else { isFocused = false return } add(tag) } - private func commit(_ row: MobileTagFieldPolicy.Row) { + private func commit(_ row: TagFieldPolicy.Row) { switch row { case .existing(let tag), .create(let tag): add(tag) @@ -85,53 +85,14 @@ struct MobileTagField: View { } private func add(_ tag: String) { - tags = MobileTagFieldPolicy.adding(tag, to: tags) + tags = TagFieldPolicy.adding(tag, to: tags) query = "" isFocused = true } } -nonisolated enum MobileTagFieldPolicy { - enum Row: Hashable, Sendable { - case existing(String) - case create(String) - } - - static func rows(tags: [String], suggestions: [String], query: String) -> [Row] { - let trimmedQuery = query.trimmingCharacters(in: .whitespaces) - let taken = Set(tags.map { $0.lowercased() }) - let matches = - suggestions - .filter { !taken.contains($0.lowercased()) } - .filter { trimmedQuery.isEmpty || $0.localizedCaseInsensitiveContains(trimmedQuery) } - - guard !trimmedQuery.isEmpty else { return matches.map(Row.existing) } - let spokenFor = taken.union(suggestions.map { $0.lowercased() }) - guard !spokenFor.contains(trimmedQuery.lowercased()) else { return matches.map(Row.existing) } - return matches.map(Row.existing) + [.create(trimmedQuery)] - } - - static func submission(query: String, suggestions: [String]) -> String? { - let trimmed = query.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return nil } - return suggestions.first { $0.caseInsensitiveCompare(trimmed) == .orderedSame } ?? trimmed - } - - static func adding(_ tag: String, to tags: [String]) -> [String] { - let trimmed = tag.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, - !tags.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) - else { return tags } - return tags + [trimmed] - } - - static func removing(_ tag: String, from tags: [String]) -> [String] { - tags.filter { $0 != tag } - } -} - private struct MobileTagSuggestionRow: View { - let row: MobileTagFieldPolicy.Row + let row: TagFieldPolicy.Row var body: some View { HStack(spacing: 8) { @@ -181,62 +142,3 @@ private struct MobileRemovableTagChip: View { .background(.quaternary.opacity(0.6), in: .capsule) } } - -private struct MobileTagFlowLayout: Layout { - let spacing: CGFloat - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let naturalWidth = subviews.reduce(CGFloat.zero) { width, subview in - width + subview.sizeThatFits(.unspecified).width + (width == 0 ? 0 : spacing) - } - let proposedWidth = proposal.width ?? naturalWidth - let width = proposedWidth.isFinite ? max(0, proposedWidth) : naturalWidth - let rows = arrange(subviews: subviews, in: width) - let height = - rows.reduce(CGFloat.zero) { $0 + $1.height } - + spacing * CGFloat(max(rows.count - 1, 0)) - return CGSize(width: width, height: height) - } - - func placeSubviews( - in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () - ) { - var y = bounds.minY - for row in arrange(subviews: subviews, in: bounds.width) { - var x = bounds.minX - for index in row.indices { - let size = subviews[index].sizeThatFits(.unspecified) - subviews[index].place( - at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size)) - x += size.width + spacing - } - y += row.height + spacing - } - } - - private struct RowRun { - var indices: [Int] = [] - var height: CGFloat = 0 - } - - private func arrange(subviews: Subviews, in width: CGFloat) -> [RowRun] { - var rows: [RowRun] = [] - var current = RowRun() - var x: CGFloat = 0 - - for index in subviews.indices { - let size = subviews[index].sizeThatFits(.unspecified) - if !current.indices.isEmpty && x + size.width > width { - rows.append(current) - current = RowRun() - x = 0 - } - current.indices.append(index) - current.height = max(current.height, size.height) - x += size.width + spacing - } - - if !current.indices.isEmpty { rows.append(current) } - return rows - } -} diff --git a/TFI/Features/Settings/MobileLoggingView.swift b/TFI/Features/Settings/MobileLoggingView.swift index 34eb2253..962a2037 100644 --- a/TFI/Features/Settings/MobileLoggingView.swift +++ b/TFI/Features/Settings/MobileLoggingView.swift @@ -70,18 +70,7 @@ struct MobileLoggingView: View { } private var loggingOptionsSection: some View { - Section { - Toggle(TermindCopy.Common.save.swiftUIKey, isOn: savingBinding) - Picker(TermindCopy.Settings.level.swiftUIKey, selection: levelBinding) { - ForEach(ApplicationLogLevel.allCases) { level in - Text(verbatim: level.localizedName).tag(level.rawValue) - } - } - } header: { - Text(TermindCopy.Settings.logging) - } footer: { - Text(TermindCopy.Settings.logSharingWarning) - } + LoggingSettingsSection(saving: savingBinding, level: levelBinding) } @ViewBuilder @@ -101,20 +90,7 @@ struct MobileLoggingView: View { Button { selectedFile = file } label: { - HStack { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(file.createdAt, format: .dateTime.year().month().day().hour().minute()) - if file.isCurrent { - Text(TermindCopy.Common.current).font(.caption).foregroundStyle(.secondary) - } - } - Text(ByteCountFormatter.string(fromByteCount: file.byteCount, countStyle: .file)) - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "square.and.arrow.up").foregroundStyle(.secondary) - } + LoggingFileLabel(file: file, dateFormat: .dateTime.year().month().day().hour().minute()) } .buttonStyle(.plain) .contextMenu { diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 6409860e..197b7c6a 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -3,6 +3,7 @@ import SwiftData import TermindApplication import TermindDomain import TermindPersistence +import TermindPresentation import TermindRemoteInfrastructure import Testing @@ -249,22 +250,22 @@ struct MobileVaultFeatureTests { let suggestions = ["api", "production", "web"] #expect( - MobileTagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "PI") == [ + TagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "PI") == [ .existing("api"), .create("PI"), ]) #expect( - MobileTagFieldPolicy.rows(tags: [], suggestions: suggestions, query: "API") == [ + TagFieldPolicy.rows(tags: [], suggestions: suggestions, query: "API") == [ .existing("api") ]) #expect( - MobileTagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "edge") == [ + TagFieldPolicy.rows(tags: ["production"], suggestions: suggestions, query: "edge") == [ .create("edge") ]) - #expect(MobileTagFieldPolicy.submission(query: " API ", suggestions: suggestions) == "api") - #expect(MobileTagFieldPolicy.adding(" api ", to: ["production"]) == ["production", "api"]) - #expect(MobileTagFieldPolicy.adding(" Production ", to: ["production"]) == ["production"]) - #expect(MobileTagFieldPolicy.adding(" ", to: ["production"]) == ["production"]) - #expect(MobileTagFieldPolicy.removing("production", from: ["production", "api"]) == ["api"]) + #expect(TagFieldPolicy.submission(query: " API ", suggestions: suggestions) == "api") + #expect(TagFieldPolicy.adding(" api ", to: ["production"]) == ["production", "api"]) + #expect(TagFieldPolicy.adding(" Production ", to: ["production"]) == ["production"]) + #expect(TagFieldPolicy.adding(" ", to: ["production"]) == ["production"]) + #expect(TagFieldPolicy.removing("production", from: ["production", "api"]) == ["api"]) } @Test func aTagDisappearsAfterItsLastHostStopsUsingIt() throws { diff --git a/TFM/Design/TagField.swift b/TFM/Design/TagField.swift index 6677949b..c24e411a 100644 --- a/TFM/Design/TagField.swift +++ b/TFM/Design/TagField.swift @@ -67,7 +67,7 @@ struct TagField: View { // MARK: - Pieces private var chips: some View { - ChipFlow(spacing: Metrics.Space.xs) { + TagFlowLayout(spacing: Metrics.Space.xs, widthSizing: .proposed) { ForEach(tags, id: \.self) { tag in RemovableTagChip(tag: tag) { remove(tag) } } @@ -182,32 +182,12 @@ struct TagField: View { // MARK: - Rows - /// What the dropdown can offer. One type so the highlight index, the click, - /// and Return all walk the same list — the alternative is a separate "is the - /// create row highlighted" flag, and two things to keep in step. - nonisolated fileprivate enum Row: Hashable, Sendable { - case existing(String) - case create(String) - } - private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) } - private var rows: [Row] { - let taken = Set(tags.map { $0.lowercased() }) - let matches = - suggestions - .filter { !taken.contains($0.lowercased()) } - .filter { trimmedQuery.isEmpty || $0.localizedCaseInsensitiveContains(trimmedQuery) } - - // The create row is offered only for a word that is not already spoken - // for — by a chip or by a suggestion. So typing the full name of an - // unused tag shows one row to press Return on, not two. - guard !trimmedQuery.isEmpty else { return matches.map(Row.existing) } - let spokenFor = taken.union(suggestions.map { $0.lowercased() }) - guard !spokenFor.contains(trimmedQuery.lowercased()) else { return matches.map(Row.existing) } - return matches.map(Row.existing) + [.create(trimmedQuery)] + private var rows: [TagFieldPolicy.Row] { + TagFieldPolicy.rows(tags: tags, suggestions: suggestions, query: query) } // MARK: - Editing @@ -240,7 +220,7 @@ struct TagField: View { } } - private func commit(_ row: Row) { + private func commit(_ row: TagFieldPolicy.Row) { switch row { case .existing(let tag), .create(let tag): add(tag) } @@ -250,18 +230,15 @@ struct TagField: View { /// host lists them, and a chip that jumps elsewhere the moment it is added /// is a chip nobody can find again to remove. private func add(_ tag: String) { - let trimmed = tag.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return } - if !tags.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { - tags.append(trimmed) - } + guard !tag.trimmingCharacters(in: .whitespaces).isEmpty else { return } + tags = TagFieldPolicy.adding(tag, to: tags) query = "" highlighted = rows.isEmpty ? nil : 0 isFocused = true } private func remove(_ tag: String) { - tags.removeAll { $0 == tag } + tags = TagFieldPolicy.removing(tag, from: tags) highlighted = rows.isEmpty ? nil : 0 } } @@ -269,7 +246,7 @@ struct TagField: View { // MARK: - Row view private struct TagRowView: View { - let row: TagField.Row + let row: TagFieldPolicy.Row let isHighlighted: Bool @State private var isHovering = false @@ -339,70 +316,6 @@ private struct RemovableTagChip: View { } } -// MARK: - Layout - -/// Lays chips out left to right and wraps to a new line when the row is full. -/// -/// A `Layout` rather than nested `HStack`s because the number of chips is not -/// known until the user has typed them, and a fixed grid would either clip a -/// long tag or leave a column of air after a short one. -private struct ChipFlow: Layout { - var spacing: CGFloat - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let width = proposal.replacingUnspecifiedDimensions().width - let rows = arrange(subviews: subviews, in: width) - let height = rows.reduce(0) { $0 + $1.height } + spacing * CGFloat(max(rows.count - 1, 0)) - return CGSize(width: width, height: height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - var y = bounds.minY - for row in arrange(subviews: subviews, in: bounds.width) { - var x = bounds.minX - for index in row.indices { - let size = subviews[index].sizeThatFits(.unspecified) - subviews[index].place( - at: CGPoint(x: x, y: y), - anchor: .topLeading, - proposal: ProposedViewSize(size) - ) - x += size.width + spacing - } - y += row.height + spacing - } - } - - private struct RowRun { - var indices: [Int] = [] - var height: CGFloat = 0 - } - - private func arrange(subviews: Subviews, in width: CGFloat) -> [RowRun] { - var rows: [RowRun] = [] - var current = RowRun() - var x: CGFloat = 0 - - for index in subviews.indices { - let size = subviews[index].sizeThatFits(.unspecified) - // A chip wider than the whole row still gets its own line rather - // than being dropped: `current.indices.isEmpty` keeps the first one - // on every line unconditionally. - if !current.indices.isEmpty && x + size.width > width { - rows.append(current) - current = RowRun() - x = 0 - } - current.indices.append(index) - current.height = max(current.height, size.height) - x += size.width + spacing - } - - if !current.indices.isEmpty { rows.append(current) } - return rows - } -} - #Preview { @Previewable @State var tags = ["nginx", "prod"] diff --git a/TFM/Features/Settings/LoggingFilesSettings.swift b/TFM/Features/Settings/LoggingFilesSettings.swift index 148707b8..a82cf042 100644 --- a/TFM/Features/Settings/LoggingFilesSettings.swift +++ b/TFM/Features/Settings/LoggingFilesSettings.swift @@ -61,19 +61,8 @@ struct LoggingFilesSettings: View { } private var loggingOptionsSection: some View { - Section { - Toggle(TermindCopy.Common.save.swiftUIKey, isOn: savingBinding) - .toggleStyle(.switch) - Picker(TermindCopy.Settings.level.swiftUIKey, selection: levelBinding) { - ForEach(ApplicationLogLevel.allCases) { level in - Text(verbatim: level.localizedName).tag(level.rawValue) - } - } - } header: { - Text(TermindCopy.Settings.logging) - } footer: { - Text(TermindCopy.Settings.logSharingWarning) - } + LoggingSettingsSection(saving: savingBinding, level: levelBinding) + .toggleStyle(.switch) } @ViewBuilder @@ -102,22 +91,10 @@ struct LoggingFilesSettings: View { Button { selectedFile = file } label: { - HStack { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text( - file.createdAt, - format: .dateTime.year().month().day().hour().minute().second()) - if file.isCurrent { - Text(TermindCopy.Common.current).font(.caption).foregroundStyle(.secondary) - } - } - Text(ByteCountFormatter.string(fromByteCount: file.byteCount, countStyle: .file)) - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "square.and.arrow.up").foregroundStyle(.secondary) - } + LoggingFileLabel( + file: file, + dateFormat: .dateTime.year().month().day().hour().minute().second() + ) .contentShape(Rectangle()) .frame(maxWidth: .infinity, alignment: .leading) }