Merge some UI
This commit is contained in:
1 parent
41f1a97cf9
commit
487cfa5a35
10 files changed
+334
-264
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user