Merge some UI

This commit is contained in:
Kaede Akino committed 2026-09-23 20:40:07 +08:00
1 parent 41f1a97cf9
commit 487cfa5a35
10 files changed
+334 -264

No files matched your search

+105
View File
@@ -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
)
}
}
@@ -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)
}
}
}
@@ -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<Bool>, level: Binding<String>) {
_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)
}
}
}
@@ -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 }
}
}
@@ -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
}
}
+8 -106
View File
@@ -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
}
}
+2 -26
View File
@@ -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 {
+9 -8
View File
@@ -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 {
+8 -95
View File
@@ -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"]
@@ -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)
}