diff --git a/Sources/EdgeControl/Services/ProcessMonitorService.swift b/Sources/EdgeControl/Services/ProcessMonitorService.swift index 2b81859..5171107 100644 --- a/Sources/EdgeControl/Services/ProcessMonitorService.swift +++ b/Sources/EdgeControl/Services/ProcessMonitorService.swift @@ -100,8 +100,8 @@ public final class ProcessMonitorService: ObservableObject { } // Sort, take top 5, resolve icons - let top5 = Array(results.sorted { $0.cpuPercent > $1.cpuPercent }.prefix(5)) - resolveIcons(for: top5) + let top = Array(results.sorted { $0.cpuPercent > $1.cpuPercent }.prefix(16)) + resolveIcons(for: top) } /// Resolve app icons on MainActor. diff --git a/Sources/EdgeControl/UI/Components/RadialGaugeView.swift b/Sources/EdgeControl/UI/Components/RadialGaugeView.swift index 94507fc..ab32b92 100644 --- a/Sources/EdgeControl/UI/Components/RadialGaugeView.swift +++ b/Sources/EdgeControl/UI/Components/RadialGaugeView.swift @@ -36,63 +36,60 @@ struct RadialGaugeView: View { // Dampened ratio (0.5x) — gauge already has large proportional sizes, full ratio overshoots. let valueRatio = 1.0 + (ts.fontSizeValue / 28.0 - 1.0) * 0.5 let captionRatio = 1.0 + (ts.fontSizeCaption / 11.0 - 1.0) * 0.5 - let titleRatio = 1.0 + (ts.fontSizeTitle / 18.0 - 1.0) * 0.5 let valueFontSize = (isCompact ? minDim * 0.28 : minDim * 0.20) * scale * valueRatio let unitFontSize = (isCompact ? minDim * 0.10 : minDim * 0.07) * scale * captionRatio - let labelFontSize = (isCompact ? minDim * 0.10 : minDim * 0.08) * scale * titleRatio + let labelFontSize = (isCompact ? minDim * 0.09 : minDim * 0.065) * scale * captionRatio let lwScaled = isCompact ? lineWidth * 0.7 : lineWidth let design = ts.fontFamily.design - VStack(spacing: isCompact ? 2 : 6) { - ZStack { - Circle() - .fill(Theme.glowGradient(gaugeColor)) - .scaleEffect(1.3) - .opacity(0.4) + // Label lives inside the ring so the arc fills min(width, height) with no caption row below. + ZStack { + Circle() + .fill(Theme.glowGradient(gaugeColor)) + .scaleEffect(1.3) + .opacity(0.4) - ArcShape(startAngle: startAngle, endAngle: endAngle) - .stroke(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: lwScaled, lineCap: .round)) + ArcShape(startAngle: startAngle, endAngle: endAngle) + .stroke(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: lwScaled, lineCap: .round)) - ArcShape(startAngle: startAngle, endAngle: startAngle + (endAngle - startAngle) * progress) - .stroke( - AngularGradient( - stops: [ - .init(color: accentColor, location: 0.0), - .init(color: gaugeColor, location: 1.0) - ], - center: .center, - startAngle: .degrees(startAngle), - endAngle: .degrees(startAngle + (endAngle - startAngle) * progress) - ), - style: StrokeStyle(lineWidth: lwScaled, lineCap: .round) - ) + ArcShape(startAngle: startAngle, endAngle: startAngle + (endAngle - startAngle) * progress) + .stroke( + AngularGradient( + stops: [ + .init(color: accentColor, location: 0.0), + .init(color: gaugeColor, location: 1.0) + ], + center: .center, + startAngle: .degrees(startAngle), + endAngle: .degrees(startAngle + (endAngle - startAngle) * progress) + ), + style: StrokeStyle(lineWidth: lwScaled, lineCap: .round) + ) - VStack(spacing: 2) { - Text(displayValue) - .font(.system(size: valueFontSize, weight: .bold, design: design)) - .foregroundStyle(Theme.text1(ts)) - .contentTransition(.numericText()) - .minimumScaleFactor(0.5) - if !unit.isEmpty && !isCompact { - Text(unit) - .font(.system(size: unitFontSize, weight: .semibold, design: design)) - .foregroundStyle(Theme.text2(ts)) - .textCase(.uppercase) - .minimumScaleFactor(0.4) - .lineLimit(1) - } + VStack(spacing: 2) { + Text(displayValue) + .font(.system(size: valueFontSize, weight: .bold, design: design)) + .foregroundStyle(Theme.text1(ts)) + .contentTransition(.numericText()) + .minimumScaleFactor(0.5) + if !unit.isEmpty { + Text(unit) + .font(.system(size: unitFontSize, weight: .semibold, design: design)) + .foregroundStyle(Theme.text2(ts)) + .textCase(.uppercase) + .minimumScaleFactor(0.4) + .lineLimit(1) + } + if showLabel { + Text(label) + .font(.system(size: labelFontSize, weight: .bold, design: design)) + .foregroundStyle(Theme.text3(ts)) + .textCase(.uppercase) + .lineLimit(1) } - } - .aspectRatio(1, contentMode: .fit) - - if showLabel { - Text(label) - .font(.system(size: labelFontSize, weight: .bold, design: design)) - .foregroundStyle(Theme.text2(ts)) - .textCase(.uppercase) - .lineLimit(1) } } + .aspectRatio(1, contentMode: .fit) .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Sources/EdgeControl/UI/Components/WidgetHeader.swift b/Sources/EdgeControl/UI/Components/WidgetHeader.swift index 566d912..200cf9f 100644 --- a/Sources/EdgeControl/UI/Components/WidgetHeader.swift +++ b/Sources/EdgeControl/UI/Components/WidgetHeader.swift @@ -19,3 +19,70 @@ struct WidgetHeader: View { } } } + +// MARK: - Rate Pair + +/// Two labeled rates (down/up, read/write) rendered identically wherever a +/// widget shows them, so equal box sizes produce equal layouts across +/// Network, Disk I/O and friends. +struct RatePairView: View { + struct Entry { + let icon: String + let label: String + let value: String + let color: Color + } + + let first: Entry + let second: Entry + var compact: Bool = false + /// One-column cells stack the groups; anything wider sits side by side. + var vertical: Bool = false + + @Environment(\.themeSettings) private var ts + + var body: some View { + if vertical { + VStack(alignment: .leading, spacing: 8) { + group(first) + group(second) + } + } else { + HStack(spacing: 12) { + group(first) + group(second) + } + } + } + + private func group(_ entry: Entry) -> some View { + VStack(alignment: .leading, spacing: compact ? 2 : 4) { + HStack(spacing: 4) { + Image(systemName: entry.icon) + .font(.system(size: (compact ? 12 : 18) * ts.fontScale)) + .foregroundStyle(entry.color) + Text(entry.label) + .font(Theme.label(ts)) + .foregroundStyle(Theme.text3(ts)) + } + Text(Self.padded(entry.value)) + .font(Theme.value(ts)) + .foregroundStyle(Theme.text1(ts)) + .monospacedDigit() + .minimumScaleFactor(0.5) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Live values arrive at varying widths ("3.5 KB/s" vs "216.5 KB/s"), and + /// letting each width pick its own scale made the type pulse with the + /// data. Padding every value to the formatters' widest possible output + /// ("1023.9 KB/s", 11 figures) with figure spaces holds the measured + /// width — and therefore the rendered size — perfectly still. + private static func padded(_ value: String) -> String { + let target = 11 + guard value.count < target else { return value } + return value + String(repeating: "\u{2007}", count: target - value.count) + } +} diff --git a/Sources/EdgeControl/UI/DashboardShell.swift b/Sources/EdgeControl/UI/DashboardShell.swift index b677033..83894cb 100644 --- a/Sources/EdgeControl/UI/DashboardShell.swift +++ b/Sources/EdgeControl/UI/DashboardShell.swift @@ -185,8 +185,12 @@ struct DashboardShell: View { activeColor: accent, registry: model.touchService.zoneRegistry ) { - withAnimation(.easeInOut(duration: 0.2)) { - editMode.toggle() + // The zone registry runs actions on the main actor, but the + // closure itself is @Sendable, so hop explicitly for Swift 6. + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.2)) { + editMode.toggle() + } } } .overlay { diff --git a/Sources/EdgeControl/UI/GridPageView.swift b/Sources/EdgeControl/UI/GridPageView.swift index 392d971..4785403 100644 --- a/Sources/EdgeControl/UI/GridPageView.swift +++ b/Sources/EdgeControl/UI/GridPageView.swift @@ -98,6 +98,9 @@ struct GridPageView: View { .padding(CGFloat(layoutEngine.document.globalSettings.theme.widgetGap)) } .frame(width: w, height: h) + // Compact layouts at large font scales can overflow a + // 1-row cell; never let a widget paint over neighbors. + .clipped() .opacity(isDragging ? 0.5 : isResizing ? 0.7 : 1) .overlay { if editMode { diff --git a/Sources/EdgeControl/UI/Settings/CICDSettingsView.swift b/Sources/EdgeControl/UI/Settings/CICDSettingsView.swift index 6f2a1ec..831f7cf 100644 --- a/Sources/EdgeControl/UI/Settings/CICDSettingsView.swift +++ b/Sources/EdgeControl/UI/Settings/CICDSettingsView.swift @@ -16,6 +16,23 @@ struct CICDSettingsView: View { @State private var importError: String? private var service: CICDService { model.cicdService } + + /// Repositories currently contributing runs to the widget, ready to hide. + /// run.id is "///". + private var shownRepositories: [CIRepositoryRef] { + let hidden = service.settings.hiddenRepositories + var seen = Set() + var result: [CIRepositoryRef] = [] + for run in service.runs { + let parts = run.id.split(separator: "/") + guard parts.count >= 4, + let accountID = UUID(uuidString: String(parts[0])), + let host = store.accounts.first(where: { $0.id == accountID })?.host else { continue } + let ref = CIRepositoryRef(host: host, fullName: parts.dropFirst().dropLast().joined(separator: "/")) + if !hidden.contains(ref), seen.insert(ref).inserted { result.append(ref) } + } + return result.sorted() + } private var store: CIAccountStore { model.accountStore } private var cliAvailable: Bool { @@ -246,6 +263,22 @@ struct CICDSettingsView: View { host: $hiddenHost, text: $hiddenDraft ) + + // The typed entry above requires knowing the exact owner/name; + // this menu offers exactly what the widget is showing right now. + if !shownRepositories.isEmpty { + Menu { + ForEach(shownRepositories, id: \.self) { ref in + Button(ref.displayName) { + service.settings.hiddenRepositories.insert(ref) + } + } + } label: { + Label("Hide a repository currently shown…", systemImage: "eye.slash") + .font(Theme.label(ts)) + } + .frame(maxWidth: 320) + } } } diff --git a/Sources/EdgeControl/UI/Settings/WidgetConfigEditor.swift b/Sources/EdgeControl/UI/Settings/WidgetConfigEditor.swift index 82c0d95..3a887c3 100644 --- a/Sources/EdgeControl/UI/Settings/WidgetConfigEditor.swift +++ b/Sources/EdgeControl/UI/Settings/WidgetConfigEditor.swift @@ -71,7 +71,7 @@ struct WidgetConfigEditor: View { set: { config[entry.key] = .string($0) } )) { ForEach(options, id: \.self) { option in - Text(option.capitalized.replacingOccurrences(of: "Daybar", with: "Day Bar").replacingOccurrences(of: "Dotmatrix", with: "Dot Matrix")) + Text(option.capitalized.replacingOccurrences(of: "Daybar", with: "Day Bar").replacingOccurrences(of: "Dotmatrix", with: "Dot Matrix").replacingOccurrences(of: "Cpu", with: "CPU")) .tag(option) } } diff --git a/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift b/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift index a3239a5..b90fc4a 100644 --- a/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift +++ b/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift @@ -90,6 +90,41 @@ private struct CICDRunsWidgetView: View { ) } + /// Latest run per (account, repository, workflow). The widget is a status + /// board, not an activity log: re-runs and repeat deployments (Pages runs + /// all share one title) collapse to the workflow's current state. + private func latestPerWorkflow(_ runs: [CIRun]) -> [CIRun] { + var latest: [String: CIRun] = [:] + for run in runs { + // run.id is "//"; dropping + // the run number yields a key that survives same-named repos in + // different orgs, which repositoryName (the short name) would not. + let repoKey = run.id[..<(run.id.lastIndex(of: "/") ?? run.id.endIndex)] + let key = "\(repoKey)|\(run.workflowName)" + if let existing = latest[key], existing.startedAt >= run.startedAt { continue } + latest[key] = run + } + return latest.values.sorted { $0.startedAt > $1.startedAt } + } + + /// The header badge counts what the list shows: distinct workflows, not + /// raw runs, once collapsing is in effect. + private var headerCount: Int { + if case .runs(let runs, _) = state { return latestPerWorkflow(runs).count } + return service.runs.count + } + + /// Compact age label ("2h"). Empty for runs whose start time is unknown + /// (the provider falls back to .distantPast). + private func relativeAge(_ date: Date) -> String { + guard date != .distantPast else { return "" } + let seconds = max(0, Date().timeIntervalSince(date)) + if seconds < 60 { return "now" } + if seconds < 3600 { return "\(Int(seconds / 60))m" } + if seconds < 86400 { return "\(Int(seconds / 3600))h" } + return "\(Int(seconds / 86400))d" + } + var body: some View { VStack(spacing: 6) { header @@ -123,7 +158,7 @@ private struct CICDRunsWidgetView: View { Task { @MainActor in SettingsWindowController.shared.show() } } } - Text("\(service.runs.count)") + Text("\(headerCount)") .font(Theme.body(ts)) .foregroundStyle(Theme.text3(ts)) } @@ -152,7 +187,7 @@ private struct CICDRunsWidgetView: View { // the list to 50. TouchScrollView { VStack(spacing: 4) { - ForEach(runs) { run in + ForEach(latestPerWorkflow(runs)) { run in runRow(run) } } @@ -190,6 +225,13 @@ private struct CICDRunsWidgetView: View { .font(Theme.label(ts)) .foregroundStyle(Theme.text3(ts).opacity(0.6)) } + // One commit can fan out to several workflows, and some runs + // share a constant title ("pages build and deployment"), so + // without the workflow name such rows are indistinguishable. + Text("· \(run.workflowName)") + .font(Theme.label(ts)) + .foregroundStyle(Theme.text3(ts).opacity(0.6)) + .lineLimit(1) } Text(run.title) .font(Theme.body(ts)) @@ -197,6 +239,9 @@ private struct CICDRunsWidgetView: View { .lineLimit(1) } Spacer() + Text(relativeAge(run.startedAt)) + .font(Theme.label(ts)) + .foregroundStyle(Theme.text3(ts).opacity(0.7)) Text(statusLabel(run)) .font(Theme.label(ts)) .foregroundStyle(statusColor(run)) diff --git a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index d6c6adc..c3ea875 100644 --- a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift @@ -167,13 +167,7 @@ private struct ClockContainer: View { Circle().stroke(primary.opacity(0.2), lineWidth: 2) // Hour ticks ForEach(0..<12, id: \.self) { i in - let angle = Double(i) / 12 * 2 * .pi - .pi / 2 - let inner = r * (i % 3 == 0 ? 0.75 : 0.85) - Path { p in - p.move(to: CGPoint(x: center.x + cos(angle) * inner, y: center.y + sin(angle) * inner)) - p.addLine(to: CGPoint(x: center.x + cos(angle) * r, y: center.y + sin(angle) * r)) - } - .stroke(i % 3 == 0 ? primary.opacity(0.6) : Theme.text3(ts), lineWidth: i % 3 == 0 ? 2 : 1) + hourTick(i, center: center, r: r) } // Hour hand clockHand(center: center, length: r * 0.5, width: 3, @@ -210,6 +204,21 @@ private struct ClockContainer: View { } } + // Extracted from analogStyle's ZStack: inline, the mixed CGFloat/Double + // arithmetic pushes the type checker past its expression time limit on + // Xcode 16.4's Swift compiler. + private func hourTick(_ i: Int, center: CGPoint, r: CGFloat) -> some View { + let angle = Double(i) / 12 * 2 * .pi - .pi / 2 + let inner = r * (i % 3 == 0 ? 0.75 : 0.85) + let cosA = CGFloat(cos(angle)) + let sinA = CGFloat(sin(angle)) + return Path { p in + p.move(to: CGPoint(x: center.x + cosA * inner, y: center.y + sinA * inner)) + p.addLine(to: CGPoint(x: center.x + cosA * r, y: center.y + sinA * r)) + } + .stroke(i % 3 == 0 ? primary.opacity(0.6) : Theme.text3(ts), lineWidth: i % 3 == 0 ? 2 : 1) + } + private func clockHand(center: CGPoint, length: CGFloat, width: CGFloat, angle: Double, color: Color) -> some View { let rad = (angle - 90) * .pi / 180 return Path { p in @@ -414,24 +423,32 @@ private struct ClockContainer: View { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ private var dayBarStyle: some View { - VStack(spacing: isCompact ? 6 : 10) { - // Day bar + VStack(spacing: isCompact ? 8 : 14) { + // Day bar: justified chips that hug their labels, so the strip's + // visible edges equal the container's — and therefore the clock's + // — on every day. A first/last-day highlight capsule ends exactly + // at the edge instead of overhanging an inset clock. HStack(spacing: 0) { ForEach(Array(dayNames.enumerated()), id: \.offset) { i, name in Text(name) - .font(.system(size: (isCompact ? 9 : 11) * ts.fontScale, weight: .heavy, design: ts.fontFamily.design)) + .font(.system(size: (isCompact ? 12 : 15) * ts.fontScale, weight: .heavy, design: ts.fontFamily.design)) .foregroundStyle(weekday == i + 1 ? .white : Theme.text3(ts)) - .frame(maxWidth: .infinity) + .padding(.horizontal, isCompact ? 5 : 7) .padding(.vertical, isCompact ? 3 : 5) .background( weekday == i + 1 ? primary.opacity(0.3) : Color.clear, in: RoundedRectangle(cornerRadius: 4, style: .continuous) ) + if i < dayNames.count - 1 { Spacer(minLength: 2) } } } - // Time - timeRow(size: isCompact ? ts.fontSizeValue * 2.0 : ts.fontSizeValue * 2.5, weight: .semibold) + // Time — stretches to the same width as the day strip above: + // fittedTimeRow starts oversized and scales down to fit. Width + // only: a greedy height would pin the day strip to the top edge, + // and the container centers the hugging group vertically instead. + fittedTimeRow(weight: .semibold) + .frame(maxWidth: .infinity) if showDate && !isCompact { Text(fullDate) @@ -568,6 +585,30 @@ private struct ClockContainer: View { .lineLimit(1) } + /// The time as ONE concatenated Text, so minimumScaleFactor scales every + /// segment together: at a deliberately oversized base size it always + /// shrinks to exactly fill its container's width (or height, whichever is + /// tighter) — no fixed font size involved. + private func fittedTimeRow(weight: Font.Weight) -> some View { + var text = Text(hourStr).foregroundStyle(Theme.text1(ts)) + + Text(":").foregroundStyle(primary) + + Text(minStr).foregroundStyle(Theme.text1(ts)) + if showSeconds { + text = text + Text(":").foregroundStyle(primary.opacity(0.4)) + + Text(secStr).foregroundStyle(Theme.text3(ts)) + } + if !use24h { + // Sized relative to the base so the ratio survives scaling. + text = text + Text(" " + ampm) + .font(Theme.font(size: 160, weight: .semibold, settings: ts)) + .foregroundStyle(primary.opacity(0.6)) + } + return text + .font(Theme.font(size: 400, weight: weight, settings: ts).monospacedDigit()) + .minimumScaleFactor(0.02) + .lineLimit(1) + } + private var secondsBar: some View { GeometryReader { geo in ZStack(alignment: .leading) { diff --git a/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift b/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift index a04deb1..71129d8 100644 --- a/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift @@ -6,7 +6,7 @@ public final class DayProgressWidget: DashboardWidget { public let description = "Visual progress of the current day with time remaining" public let iconName = "sun.max" public let category: WidgetCategory = .info - public let supportedSizes = WidgetSizeRange(min: .size(2, 2), max: .size(6, 3)) + public let supportedSizes = WidgetSizeRange(min: .size(2, 1), max: .size(6, 3)) public let defaultSize = WidgetSize.size(4, 2) public let configSchema: [ConfigSchemaEntry] = [] @@ -16,12 +16,15 @@ public final class DayProgressWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - DayProgressWidgetView(isCompact: size.width <= 3) + // The ring needs two rows of height; a 1-row placement always gets the + // linear title+bar layout, which fits a single 120px grid row. + DayProgressWidgetView(isCompact: size.width <= 3 && size.height >= 2, isTall: size.height >= 2) } } private struct DayProgressWidgetView: View { let isCompact: Bool + let isTall: Bool @Environment(\.themeSettings) private var ts @State private var now = Date() @@ -44,7 +47,7 @@ private struct DayProgressWidgetView: View { } var body: some View { - VStack(spacing: isCompact ? 6 : 10) { + VStack(spacing: isCompact ? 6 : (isTall ? 20 : 10)) { if isCompact { // Compact: circular progress ZStack { @@ -63,6 +66,8 @@ private struct DayProgressWidgetView: View { .aspectRatio(1, contentMode: .fit) .padding(4) } else { + // Center the linear stack in 2+ row cells; 1-row keeps its top-aligned fit. + if isTall { Spacer(minLength: 0) } HStack(spacing: 8) { Image(systemName: "sun.max.fill") .font(.system(size: 18 * ts.fontScale)) @@ -90,7 +95,7 @@ private struct DayProgressWidgetView: View { .frame(width: geo.size.width * dayProgress) } } - .frame(height: 10) + .frame(height: isTall ? 14 : 10) HStack { Text("REMAINING") diff --git a/Sources/EdgeControl/Widgets/Info/WeatherWidget.swift b/Sources/EdgeControl/Widgets/Info/WeatherWidget.swift index 83b9e93..b585901 100644 --- a/Sources/EdgeControl/Widgets/Info/WeatherWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/WeatherWidget.swift @@ -26,7 +26,9 @@ public final class WeatherWidget: DashboardWidget { WeatherWidgetBody( service: service, showForecast: config.bool("showForecast", default: true), - isCompact: size.height <= 3 + // Minimum size is 4x4, so the compact layout must trigger at 4 or + // it never renders; the full 64pt/72pt layout needs 5+ rows. + isCompact: size.height <= 4 ) } } diff --git a/Sources/EdgeControl/Widgets/Info/WorldClocksWidget.swift b/Sources/EdgeControl/Widgets/Info/WorldClocksWidget.swift index 9cb606a..6cb0418 100644 --- a/Sources/EdgeControl/Widgets/Info/WorldClocksWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/WorldClocksWidget.swift @@ -18,10 +18,17 @@ public final class WorldClocksWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - WorldClocksWidgetView( + let maxByWidth = size.width >= 8 ? 3 : 2 + // Card rows the placement can hold: grid rows are ~120px, a clock card + // plus grid spacing is ~88px, and header/padding overhead is ~60px. + let rowsThatFit = max(1, (size.height * 120 - 60) / 88) + // Fewer columns on tall placements so the 6 clocks span the height + // instead of top-stacking; integer ceiling of 6 / rowsThatFit. + let columns = min(maxByWidth, max(1, (6 + rowsThatFit - 1) / rowsThatFit)) + return WorldClocksWidgetView( use24h: config.bool("use24h", default: true), isCompact: size.height <= 2, - columns: size.width >= 8 ? 3 : 2 + columns: columns ) } } @@ -50,20 +57,37 @@ private struct WorldClocksWidgetView: View { WidgetHeader(title: "WORLD CLOCKS", color: Theme.widgetPrimary("world-clocks", ts: ts, default: .cyan)) } - let cols = Array(repeating: GridItem(.flexible(), spacing: 8), count: columns) - LazyVGrid(columns: cols, spacing: 8) { - ForEach(worldClocks, id: \.tz) { clock in - clockCard(clock) + // Explicit rows instead of LazyVGrid: grid rows hug their content, + // which top-stacks the cards and leaves dead space on tall + // placements. Stretching each row distributes the leftover height. + VStack(spacing: 8) { + ForEach(Array(clockRows.enumerated()), id: \.offset) { _, row in + HStack(spacing: 8) { + ForEach(row, id: \.tz) { clock in + clockCard(clock) + } + if row.count < columns { + ForEach(0..<(columns - row.count), id: \.self) { _ in + Color.clear.frame(maxWidth: .infinity) + } + } + } + .frame(maxHeight: .infinity) } } - - Spacer(minLength: 0) + .frame(maxHeight: .infinity) } .padding(isCompact ? Theme.compactPadding : Theme.widgetPadding) .widgetCard() .onReceive(timer) { now = $0 } } + private var clockRows: [[(city: String, tz: String, flag: String)]] { + stride(from: 0, to: worldClocks.count, by: columns).map { + Array(worldClocks[$0.. some View { let tz = TimeZone(identifier: clock.tz) ?? .current let formatter = DateFormatter() @@ -85,7 +109,7 @@ private struct WorldClocksWidgetView: View { .foregroundStyle(.white) .monospacedDigit() } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, isCompact ? 4 : 8) .background(Color.white.opacity(0.03), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } diff --git a/Sources/EdgeControl/Widgets/Media/AudioDevicesWidget.swift b/Sources/EdgeControl/Widgets/Media/AudioDevicesWidget.swift index 545b872..abb3dc2 100644 --- a/Sources/EdgeControl/Widgets/Media/AudioDevicesWidget.swift +++ b/Sources/EdgeControl/Widgets/Media/AudioDevicesWidget.swift @@ -7,7 +7,7 @@ public final class AudioDevicesWidget: DashboardWidget { public let iconName = "speaker.wave.2" public let category: WidgetCategory = .media public let requiredServices: Set = [.audio] - public let supportedSizes = WidgetSizeRange(min: .size(3, 2), max: .size(6, 4)) + public let supportedSizes = WidgetSizeRange(min: .size(3, 1), max: .size(6, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [ diff --git a/Sources/EdgeControl/Widgets/Network/BluetoothWidget.swift b/Sources/EdgeControl/Widgets/Network/BluetoothWidget.swift index 12c68c8..67d8e55 100644 --- a/Sources/EdgeControl/Widgets/Network/BluetoothWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/BluetoothWidget.swift @@ -70,33 +70,17 @@ private struct BluetoothWidgetView: View { .frame(maxWidth: .infinity) Spacer() } else { - ForEach(connectedDevices) { device in - HStack(spacing: 8) { - Image(systemName: device.icon) - .font(.system(size: (isCompact ? 14 : 18) * ts.fontScale)) - .foregroundStyle(Theme.widgetPrimary("bluetooth", ts: ts, default: .blue)) - .frame(width: 24) - - Text(device.name) - .font(Theme.body(ts)) - .foregroundStyle(Theme.text1(ts)) - .lineLimit(1) - - Spacer() - - if showBattery, let battery = device.batteryLevel { - HStack(spacing: 4) { - Image(systemName: batteryIcon(battery)) - .font(.system(size: 14 * ts.fontScale)) - .foregroundStyle(batteryColor(battery)) - Text("\(battery)%") - .font(Theme.label(ts)) - .foregroundStyle(batteryColor(battery)) - .monospacedDigit() + // Overflow scrolls; a short list centers in the viewport via + // minHeight so the card doesn't end in a void under the rows. + GeometryReader { geo in + TouchScrollView { + VStack(spacing: 0) { + ForEach(connectedDevices) { device in + deviceRow(device) } } + .frame(minHeight: geo.size.height, alignment: .center) } - .padding(.vertical, 4) } } @@ -106,6 +90,35 @@ private struct BluetoothWidgetView: View { .widgetCard() } + private func deviceRow(_ device: BTDevice) -> some View { + HStack(spacing: 8) { + Image(systemName: device.icon) + .font(.system(size: (isCompact ? 14 : 18) * ts.fontScale)) + .foregroundStyle(Theme.widgetPrimary("bluetooth", ts: ts, default: .blue)) + .frame(width: 24) + + Text(device.name) + .font(Theme.body(ts)) + .foregroundStyle(Theme.text1(ts)) + .lineLimit(1) + + Spacer() + + if showBattery, let battery = device.batteryLevel { + HStack(spacing: 4) { + Image(systemName: batteryIcon(battery)) + .font(.system(size: 14 * ts.fontScale)) + .foregroundStyle(batteryColor(battery)) + Text("\(battery)%") + .font(Theme.label(ts)) + .foregroundStyle(batteryColor(battery)) + .monospacedDigit() + } + } + } + .padding(.vertical, 4) + } + private func batteryIcon(_ level: Int) -> String { if level > 75 { return "battery.100" } if level > 50 { return "battery.75" } diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index 1adabdf..53ac2f2 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -7,7 +7,7 @@ public final class NetworkStatsWidget: DashboardWidget { public let iconName = "network" public let category: WidgetCategory = .network public let requiredServices: Set = [.network] - public let supportedSizes = WidgetSizeRange(min: .size(3, 2), max: .size(8, 4)) + public let supportedSizes = WidgetSizeRange(min: .size(1, 1), max: .size(8, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [] @@ -21,7 +21,14 @@ public final class NetworkStatsWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - NetworkStatsWidgetView(service: service, isCompact: size.height <= 2) + NetworkStatsWidgetView( + service: service, + isCompact: size.height <= 2, + isBar: size.height <= 1, + isNarrow: size.width <= 1, + showTitle: true, + showCompactTotals: size.width >= 5 && size.height >= 2 + ) } } @@ -29,51 +36,53 @@ private struct NetworkStatsWidgetView: View { @ObservedObject var service: NetworkMonitorService @Environment(\.themeSettings) private var ts let isCompact: Bool + // Single grid row: the stacked DOWN/UP rows would overflow ~112px of + // interior height at larger font scales, so render them side by side. + let isBar: Bool + // One grid column: stacked groups under a slim caption. + let isNarrow: Bool + // Keep the widget's name visible wherever it fits — full header down to + // 2 rows, a bare caption in the 1-row layout — matching Disk I/O. + let showTitle: Bool + // Wide-and-tall compact (width >= 5, height >= 2) has room for the + // DL/UL totals row; the 1-row bar layout never does. + let showCompactTotals: Bool var body: some View { let primary = Theme.widgetPrimary("network-stats", ts: ts, default: .green) let secondary = Theme.widgetSecondary("network-stats", ts: ts, default: .cyan) ?? Theme.accentCyan VStack(spacing: isCompact ? 6 : 12) { - if !isCompact { + if (!isCompact || (showTitle && !isBar)) && !isNarrow { WidgetHeader(title: "NETWORK", color: primary) + } else if showTitle { + Text("NETWORK") + .font(Theme.caption(ts)) + .foregroundStyle(Theme.text3(ts)) + .minimumScaleFactor(0.6) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) } - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle.fill") - .font(.system(size: (isCompact ? 14 : 20) * ts.fontScale)) - .foregroundStyle(primary) - Text("DOWN") - .font(Theme.label(ts)) - .foregroundStyle(Theme.text3(ts)) - Spacer() - Text(NetworkMonitorService.formatSpeed(service.downloadSpeed)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .monospacedDigit() - .minimumScaleFactor(0.5) - } - } + Spacer(minLength: 0) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: (isCompact ? 14 : 20) * ts.fontScale)) - .foregroundStyle(secondary) - Text("UP") - .font(Theme.label(ts)) - .foregroundStyle(Theme.text3(ts)) - Spacer() - Text(NetworkMonitorService.formatSpeed(service.uploadSpeed)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .monospacedDigit() - .minimumScaleFactor(0.5) - } - } + // Same component Disk I/O renders — equal boxes, equal layout. + RatePairView( + first: .init( + icon: "arrow.down.circle.fill", label: "DOWN", + value: NetworkMonitorService.formatSpeed(service.downloadSpeed), + color: primary + ), + second: .init( + icon: "arrow.up.circle.fill", label: "UP", + value: NetworkMonitorService.formatSpeed(service.uploadSpeed), + color: secondary + ), + compact: isCompact, + vertical: isNarrow + ) - if !isCompact { + if !isBar && (!isCompact || showCompactTotals) { HStack(spacing: 10) { totalChip("DL", value: NetworkMonitorService.formatBytes(service.totalDownloaded), color: primary) totalChip("UL", value: NetworkMonitorService.formatBytes(service.totalUploaded), color: secondary) @@ -86,6 +95,8 @@ private struct NetworkStatsWidgetView: View { .widgetCard() } + + private func totalChip(_ label: String, value: String, color: Color) -> some View { HStack(spacing: 6) { Text(label) diff --git a/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift b/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift index 446885f..738809d 100644 --- a/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift @@ -7,7 +7,7 @@ public final class WiFiInfoWidget: DashboardWidget { public let iconName = "wifi" public let category: WidgetCategory = .network public let requiredServices: Set = [.wifi] - public let supportedSizes = WidgetSizeRange(min: .size(3, 2), max: .size(6, 4)) + public let supportedSizes = WidgetSizeRange(min: .size(3, 1), max: .size(6, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [] @@ -21,7 +21,11 @@ public final class WiFiInfoWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - WiFiInfoWidgetView(service: service, isCompact: size.height <= 2) + WiFiInfoWidgetView( + service: service, + isCompact: size.height <= 2, + showInlineChips: size.width >= 5 + ) } } @@ -29,6 +33,8 @@ private struct WiFiInfoWidgetView: View { @ObservedObject var service: WiFiService @Environment(\.themeSettings) private var ts let isCompact: Bool + // Wide compact cells (width >= 5) keep SPEED/CH inline next to the SSID. + let showInlineChips: Bool var body: some View { VStack(alignment: .leading, spacing: isCompact ? 6 : 10) { @@ -50,11 +56,21 @@ private struct WiFiInfoWidgetView: View { } if service.isConnected { - Text(service.ssid ?? "Unknown") - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .lineLimit(1) - .minimumScaleFactor(0.6) + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(service.ssid ?? "Unknown") + .font(Theme.value(ts)) + .foregroundStyle(Theme.text1(ts)) + .lineLimit(1) + .minimumScaleFactor(0.6) + + // Inline single-line chips share the SSID row, so they + // add no height a 1-2 row cell can't afford. + if isCompact && showInlineChips { + Spacer(minLength: 8) + inlineChip("SPEED", value: String(format: "%.0f Mbps", service.txRate)) + inlineChip("CH", value: "\(service.channel)") + } + } if !isCompact { HStack(spacing: 12) { @@ -98,6 +114,19 @@ private struct WiFiInfoWidgetView: View { return index < strength ? Theme.widgetPrimary("wifi-info", ts: ts, default: .green) : Color.white.opacity(0.1) } + private func inlineChip(_ label: String, value: String) -> some View { + HStack(spacing: 4) { + Text(label) + .font(Theme.caption(ts)) + .foregroundStyle(Theme.text3(ts)) + Text(value) + .font(Theme.body(ts)) + .foregroundStyle(Theme.text1(ts)) + .monospacedDigit() + .lineLimit(1) + } + } + private func detailChip(_ label: String, value: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(label) diff --git a/Sources/EdgeControl/Widgets/Plugin/PluginWebWidget.swift b/Sources/EdgeControl/Widgets/Plugin/PluginWebWidget.swift index f052c7b..77decd6 100644 --- a/Sources/EdgeControl/Widgets/Plugin/PluginWebWidget.swift +++ b/Sources/EdgeControl/Widgets/Plugin/PluginWebWidget.swift @@ -816,9 +816,14 @@ private struct PluginWebViewRepresentable: NSViewRepresentable { Task { let center = UNUserNotificationCenter.current() - let settings = await center.notificationSettings() + // UNNotificationSettings is not Sendable, so extract the one + // needed value via the completion-handler API instead of + // sending the settings object across isolation. + let status = await withCheckedContinuation { (continuation: CheckedContinuation) in + center.getNotificationSettings { continuation.resume(returning: $0.authorizationStatus) } + } - switch settings.authorizationStatus { + switch status { case .authorized, .provisional: break case .notDetermined: diff --git a/Sources/EdgeControl/Widgets/System/CPUCoresWidget.swift b/Sources/EdgeControl/Widgets/System/CPUCoresWidget.swift index 8571262..80d0e48 100644 --- a/Sources/EdgeControl/Widgets/System/CPUCoresWidget.swift +++ b/Sources/EdgeControl/Widgets/System/CPUCoresWidget.swift @@ -21,14 +21,31 @@ public final class CPUCoresWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - CPUCoresWidgetView(metricsService: metricsService, columns: size.width >= 8 ? 2 : 1) + CPUCoresWidgetView(metricsService: metricsService, size: size) } } private struct CPUCoresWidgetView: View { @ObservedObject var metricsService: SystemMetricsService @Environment(\.themeSettings) private var ts - let columns: Int + let size: WidgetSize + + // Row geometry: coreRow fixed frame + VStack spacing; header title line + vertical padding. + private static let rowHeight: CGFloat = 18 + private static let rowSpacing: CGFloat = 2 + private static let headerChrome: CGFloat = 34 + + private var columns: Int { + let count = metricsService.perCoreUsage.count + let available = CGFloat(size.height) * GridConstants.cellHeight - Self.headerChrome + let rowsThatFit = max(1, Int((available + Self.rowSpacing) / (Self.rowHeight + Self.rowSpacing))) + let maxByWidth = size.width >= 6 ? 2 : 1 + for cols in 1...maxByWidth where (count + cols - 1) / cols <= rowsThatFit { + return cols + } + // Even maxByWidth columns overflow: keep width-only choice, TouchScrollView scrolls the rest. + return size.width >= 8 ? 2 : 1 + } private var primary: Color { Theme.widgetPrimary("cpu-cores", ts: ts, default: .purple) } diff --git a/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift b/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift index b478f55..e42e626 100644 --- a/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift +++ b/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift @@ -47,9 +47,9 @@ private struct CPUGaugeWidgetView: View { maxValue: 100, label: "CPU", displayValue: String(format: "%.0f%%", cpu), - unit: isCompact ? "" : abbreviate(brand), + unit: abbreviate(brand), accentColor: Theme.widgetPrimary("cpu-gauge", ts: ts, default: .cyan), - showLabel: showLabel && !isCompact + showLabel: showLabel ) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(Theme.compactPadding) diff --git a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift index a4b7e9b..8c456ec 100644 --- a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift +++ b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift @@ -7,7 +7,7 @@ public final class DiskIOWidget: DashboardWidget { public let iconName = "internaldrive" public let category: WidgetCategory = .system public let requiredServices: Set = [.diskIO] - public let supportedSizes = WidgetSizeRange(min: .size(3, 2), max: .size(8, 4)) + public let supportedSizes = WidgetSizeRange(min: .size(1, 1), max: .size(8, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [] @@ -21,7 +21,7 @@ public final class DiskIOWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - DiskIOWidgetView(service: service, isCompact: size.height <= 2) + DiskIOWidgetView(service: service, isCompact: size.height <= 2, showTitle: true, isBar: size.height <= 1, isNarrow: size.width <= 1) } } @@ -29,50 +29,42 @@ private struct DiskIOWidgetView: View { @ObservedObject var service: DiskIOService @Environment(\.themeSettings) private var ts let isCompact: Bool + // Keep the widget's name visible wherever it fits: the full header down + // to 2 rows, a bare caption in the 1-row layout. + let showTitle: Bool + let isBar: Bool + // One grid column: stacked groups under a slim caption. + let isNarrow: Bool var body: some View { VStack(spacing: isCompact ? 6 : 12) { - if !isCompact { + if (!isCompact || (showTitle && !isBar)) && !isNarrow { WidgetHeader(title: "DISK I/O", color: Theme.widgetPrimary("disk-io", ts: ts, default: .blue)) + } else if showTitle { + Text("DISK I/O") + .font(Theme.caption(ts)) + .foregroundStyle(Theme.text3(ts)) + .minimumScaleFactor(0.6) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) } - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle.fill") - .font(.system(size: (isCompact ? 14 : 18) * ts.fontScale)) - .foregroundStyle(Theme.widgetSecondary("disk-io", ts: ts, default: .green) ?? Theme.accentGreen) - Text("READ") - .font(Theme.label(ts)) - .foregroundStyle(Theme.text3(ts)) - } - Text(formatSpeed(service.readBytesPerSec)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .monospacedDigit() - .minimumScaleFactor(0.5) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 0) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: (isCompact ? 14 : 18) * ts.fontScale)) - .foregroundStyle(Theme.widgetTertiary("disk-io", ts: ts, default: .orange) ?? Theme.accentOrange) - Text("WRITE") - .font(Theme.label(ts)) - .foregroundStyle(Theme.text3(ts)) - } - Text(formatSpeed(service.writeBytesPerSec)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .monospacedDigit() - .minimumScaleFactor(0.5) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - } + RatePairView( + first: .init( + icon: "arrow.down.circle.fill", label: "READ", + value: formatSpeed(service.readBytesPerSec), + color: Theme.widgetSecondary("disk-io", ts: ts, default: .green) ?? Theme.accentGreen + ), + second: .init( + icon: "arrow.up.circle.fill", label: "WRITE", + value: formatSpeed(service.writeBytesPerSec), + color: Theme.widgetTertiary("disk-io", ts: ts, default: .orange) ?? Theme.accentOrange + ), + compact: isCompact, + vertical: isNarrow + ) Spacer(minLength: 0) } diff --git a/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift b/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift index a9b5a37..bc7ab20 100644 --- a/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift +++ b/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift @@ -47,7 +47,6 @@ private struct MemoryGaugeWidgetView: View { let totalGB = mem?.memoryTotalGB ?? 0 let unitText: String = { - if isCompact { return "" } if showUsedGB { return String(format: "%.1f / %.0f GB", usedGB, totalGB) } return "" }() @@ -59,7 +58,7 @@ private struct MemoryGaugeWidgetView: View { displayValue: String(format: "%.0f%%", percent), unit: unitText, accentColor: Theme.widgetPrimary("memory-gauge", ts: ts, default: .purple), - showLabel: showLabel && !isCompact + showLabel: showLabel ) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(Theme.compactPadding) diff --git a/Sources/EdgeControl/Widgets/System/MemoryPressureWidget.swift b/Sources/EdgeControl/Widgets/System/MemoryPressureWidget.swift index 26fa6f5..d103458 100644 --- a/Sources/EdgeControl/Widgets/System/MemoryPressureWidget.swift +++ b/Sources/EdgeControl/Widgets/System/MemoryPressureWidget.swift @@ -60,7 +60,7 @@ private struct MemoryPressureWidgetView: View { displayValue: String(format: "%.0f%%", pressure), unit: swapText, accentColor: primary, - showLabel: showLabel && !isCompact + showLabel: showLabel ) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(Theme.compactPadding) diff --git a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift index 13088f7..1d8f771 100644 --- a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift +++ b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift @@ -12,7 +12,10 @@ public final class ProcessListWidget: DashboardWidget { public let defaultSize = WidgetSize.size(6, 4) public let configSchema: [ConfigSchemaEntry] = [ - ConfigSchemaEntry(key: "sortBy", label: "Sort By", type: .picker, defaultValue: .string("cpu")), + ConfigSchemaEntry(key: "sortBy", label: "Sort By", type: .picker, defaultValue: .string("cpu"), options: ["cpu", "memory"]), + // "auto" fills whatever height the widget has; a number pins the row + // count and scrolls past it. + ConfigSchemaEntry(key: "rows", label: "Rows", type: .picker, defaultValue: .string("auto"), options: ["auto", "4", "6", "8", "10", "12", "16"]), ] public let defaultColors = WidgetColors(primary: .purple, secondary: .cyan) @@ -24,14 +27,27 @@ public final class ProcessListWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - ProcessListWidgetView(service: service, isCompact: size.height <= 3) + ProcessListWidgetView( + service: service, + sortByMemory: config.string("sortBy", default: "cpu") == "memory", + fixedRows: Int(config.string("rows", default: "auto")) + ) } } private struct ProcessListWidgetView: View { @ObservedObject var service: ProcessMonitorService @Environment(\.themeSettings) private var ts - let isCompact: Bool + @EnvironmentObject private var model: AppModel + // Tapping a column header re-sorts live, overriding the configured + // default for this on-screen session; tap again to flip direction. + @State private var tappedSort: (memory: Bool, ascending: Bool)? + let sortByMemory: Bool + /// nil = fit rows to the widget height; a number pins the count. + let fixedRows: Int? + + // Row = 26pt icon + 2x8pt vertical padding + 1pt divider. + private let rowHeight: CGFloat = 43 var body: some View { VStack(spacing: 0) { @@ -42,12 +58,8 @@ private struct ProcessListWidgetView: View { HStack { Text("APP") .frame(maxWidth: .infinity, alignment: .leading) - Text("CPU") - .frame(width: 70, alignment: .trailing) - if !isCompact { - Text("MEM") - .frame(width: 70, alignment: .trailing) - } + sortHeader("CPU", memory: false) + sortHeader("MEM", memory: true) } .font(Theme.label(ts)) .foregroundStyle(Theme.text3(ts)) @@ -56,26 +68,57 @@ private struct ProcessListWidgetView: View { Divider().background(Theme.border(ts)) - if service.topProcesses.isEmpty { - Text("Loading...") - .font(Theme.body(ts)) - .foregroundStyle(Theme.text3(ts)) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - let maxCount = isCompact ? 4 : 8 - ForEach(Array(service.topProcesses.prefix(maxCount))) { proc in - processRow(proc) - if proc.id != service.topProcesses.prefix(maxCount).last?.id { - Divider().background(Theme.border(ts)).padding(.leading, 50) + GeometryReader { geo in + if service.topProcesses.isEmpty { + Text("Loading...") + .font(Theme.body(ts)) + .foregroundStyle(Theme.text3(ts)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + let fitCount = min(max(Int(geo.size.height / rowHeight), 3), 16) + let maxCount = fixedRows ?? fitCount + let memSort = tappedSort?.memory ?? sortByMemory + let ascending = tappedSort?.ascending ?? false + let ranked = service.topProcesses.sorted { + let (a, b) = memSort ? ($0.memoryMB, $1.memoryMB) : ($0.cpuPercent, $1.cpuPercent) + return ascending ? a < b : a > b + } + let visible = Array(ranked.prefix(maxCount)) + // Scrolls only when a pinned row count exceeds the height. + TouchScrollView { + VStack(spacing: 0) { + ForEach(visible) { proc in + processRow(proc) + if proc.id != visible.last?.id { + Divider().background(Theme.border(ts)).padding(.leading, 50) + } + } + } } } } - - Spacer(minLength: 0) } .widgetCard() } + private func sortHeader(_ label: String, memory: Bool) -> some View { + let memSort = tappedSort?.memory ?? sortByMemory + let ascending = tappedSort?.ascending ?? false + let active = memSort == memory + return Text(active ? label + (ascending ? " ▲" : " ▼") : label) + .foregroundStyle(active ? Theme.text1(ts) : Theme.text3(ts)) + .frame(width: 70, alignment: .trailing) + .touchTappable(id: "proc-sort-\(label)", registry: model.touchService.zoneRegistry) { + Task { @MainActor in + if active { + tappedSort = (memory, !ascending) + } else { + tappedSort = (memory, false) + } + } + } + } + private func processRow(_ proc: ProcessInfo_EC) -> some View { HStack(spacing: 10) { if let icon = proc.icon { @@ -102,13 +145,11 @@ private struct ProcessListWidgetView: View { .monospacedDigit() .frame(width: 70, alignment: .trailing) - if !isCompact { - Text(String(format: "%.0f MB", proc.memoryMB)) - .font(Theme.body(ts)) - .foregroundStyle(proc.memoryMB > 1024 ? Theme.accentOrange : Theme.text2(ts)) - .monospacedDigit() - .frame(width: 70, alignment: .trailing) - } + Text(String(format: "%.0f MB", proc.memoryMB)) + .font(Theme.body(ts)) + .foregroundStyle(proc.memoryMB > 1024 ? Theme.accentOrange : Theme.text2(ts)) + .monospacedDigit() + .frame(width: 70, alignment: .trailing) } .padding(.horizontal, 14) .padding(.vertical, 8) diff --git a/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift b/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift index 8ca5694..052b4e8 100644 --- a/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift +++ b/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift @@ -7,7 +7,7 @@ public final class StorageBarsWidget: DashboardWidget { public let iconName = "externaldrive" public let category: WidgetCategory = .system public let requiredServices: Set = [.metrics] - public let supportedSizes = WidgetSizeRange(min: .size(3, 2), max: .size(8, 4)) + public let supportedSizes = WidgetSizeRange(min: .size(2, 1), max: .size(8, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [] @@ -21,7 +21,13 @@ public final class StorageBarsWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - StorageBarsWidgetView(metricsService: metricsService, isCompact: size.height <= 2) + // The ring scales itself to the cell, so every multi-row size keeps + // the ring look; only single-row placements fall back to the bar. + StorageBarsWidgetView( + metricsService: metricsService, + isCompact: size.height == 1, + isBar: size.height == 1 + ) } } @@ -29,6 +35,7 @@ private struct StorageBarsWidgetView: View { @ObservedObject var metricsService: SystemMetricsService @Environment(\.themeSettings) private var ts let isCompact: Bool + let isBar: Bool var body: some View { let m = metricsService.latest @@ -43,6 +50,7 @@ private struct StorageBarsWidgetView: View { if let m { if isCompact { + if !isBar { Spacer(minLength: 0) } VStack(spacing: 4) { GeometryReader { geo in ZStack(alignment: .leading) { @@ -62,42 +70,50 @@ private struct StorageBarsWidgetView: View { .foregroundStyle(Theme.text2(ts)) } } + Spacer(minLength: 0) } else { - HStack(spacing: 16) { - ZStack { - Circle() - .trim(from: 0, to: 1) - .stroke(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 14, lineCap: .round)) - .rotationEffect(.degrees(-90)) - Circle() - .trim(from: 0, to: m.storageUsedPercent / 100) - .stroke( - AngularGradient(colors: [primary, secondary], center: .center), - style: StrokeStyle(lineWidth: 14, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - VStack(spacing: 2) { - Text(String(format: "%.0f%%", m.storageUsedPercent)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - Text("USED") - .font(Theme.caption(ts)) - .foregroundStyle(Theme.text2(ts)) + GeometryReader { geo in + // Ring fills the cell height, leaving ~45% of the width for labels. + let side = min(geo.size.height, geo.size.width * 0.55) + let ringWidth = min(max(12, side * 0.1), 22) + HStack(spacing: 16) { + ZStack { + Circle() + .trim(from: 0, to: 1) + .stroke(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Circle() + .trim(from: 0, to: m.storageUsedPercent / 100) + .stroke( + AngularGradient(colors: [primary, secondary], center: .center), + style: StrokeStyle(lineWidth: ringWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + VStack(spacing: 2) { + Text(String(format: "%.0f%%", m.storageUsedPercent)) + .font(Theme.value(ts)) + .foregroundStyle(Theme.text1(ts)) + Text("USED") + .font(Theme.caption(ts)) + .foregroundStyle(Theme.text2(ts)) + } } - } - .frame(maxWidth: 140, maxHeight: 140) - .aspectRatio(1, contentMode: .fit) + // Stroke centers on the path; inset so it stays inside the frame. + .padding(ringWidth / 2) + .frame(width: side, height: side) - VStack(alignment: .leading, spacing: 8) { - storageLabel("USED", value: String(format: "%.0f GB", m.storageUsedGB), color: primary) - storageLabel("FREE", value: String(format: "%.0f GB", m.storageTotalGB - m.storageUsedGB), color: tertiary) - storageLabel("TOTAL", value: String(format: "%.0f GB", m.storageTotalGB), color: Theme.text2(ts)) + VStack(alignment: .leading, spacing: 8) { + storageLabel("USED", value: String(format: "%.0f GB", m.storageUsedGB), color: primary) + storageLabel("FREE", value: String(format: "%.0f GB", m.storageTotalGB - m.storageUsedGB), color: tertiary) + storageLabel("TOTAL", value: String(format: "%.0f GB", m.storageTotalGB), color: Theme.text2(ts)) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } + } else { + Spacer(minLength: 0) } - - Spacer(minLength: 0) } .padding(isCompact ? Theme.compactPadding : Theme.widgetPadding) .widgetCard() diff --git a/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift b/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift index 84f939f..7a38f8a 100644 --- a/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift +++ b/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift @@ -11,7 +11,6 @@ public final class CPUTempWidget: DashboardWidget { public let defaultSize = WidgetSize.size(3, 3) public let configSchema: [ConfigSchemaEntry] = [ - ConfigSchemaEntry(key: "unit", label: "Unit", type: .picker, defaultValue: .string("C")), ConfigSchemaEntry(key: "warningThreshold", label: "Warning Threshold", type: .stepper, defaultValue: .int(85)), ] public let defaultColors = WidgetColors(primary: .cyan) @@ -31,8 +30,7 @@ public final class CPUTempWidget: DashboardWidget { label: "CPU", defaultCoolColor: .cyan, isBar: size.height <= 1, - isChart: size.height == 2 && size.width >= 3, - isCompact: size.width <= 2 && size.height <= 2 + isChart: size.height == 2 && size.width >= 3 ) } } @@ -48,7 +46,6 @@ public final class GPUTempWidget: DashboardWidget { public let defaultSize = WidgetSize.size(3, 3) public let configSchema: [ConfigSchemaEntry] = [ - ConfigSchemaEntry(key: "unit", label: "Unit", type: .picker, defaultValue: .string("C")), ConfigSchemaEntry(key: "warningThreshold", label: "Warning Threshold", type: .stepper, defaultValue: .int(90)), ] public let defaultColors = WidgetColors(primary: .orange) @@ -68,8 +65,7 @@ public final class GPUTempWidget: DashboardWidget { label: "GPU", defaultCoolColor: .orange, isBar: size.height <= 1, - isChart: size.height == 2 && size.width >= 3, - isCompact: size.width <= 2 && size.height <= 2 + isChart: size.height == 2 && size.width >= 3 ) } } @@ -90,7 +86,6 @@ private struct TempGaugeWidgetView: View { let defaultCoolColor: ThemeColor let isBar: Bool let isChart: Bool - let isCompact: Bool private var temp: Double? { switch sensor { @@ -188,31 +183,19 @@ private struct TempGaugeWidgetView: View { // MARK: - Gauge Layout (2x2+) private var gaugeLayout: some View { - VStack(spacing: isCompact ? 4 : 8) { + VStack(spacing: 8) { if let temp { let color = tempColor(temp) - if isCompact { - VStack(spacing: 2) { - Image(systemName: sensor == .cpu ? "cpu" : "gpu") - .font(.system(size: 18 * ts.fontScale)) - .foregroundStyle(color) - Text(units.degrees(fromCelsius: temp)) - .font(Theme.value(ts)) - .foregroundStyle(color) - .monospacedDigit() - } - } else { - RadialGaugeView( - value: temp, - maxValue: 110, - label: label, - displayValue: units.temperatureText(fromCelsius: temp), - unit: "", - accentColor: color, - showLabel: true - ) - } + RadialGaugeView( + value: temp, + maxValue: 110, + label: label, + displayValue: units.temperatureText(fromCelsius: temp), + unit: "", + accentColor: color, + showLabel: true + ) } else { Image(systemName: sensor == .cpu ? "cpu" : "gpu") .font(.system(size: 24 * ts.fontScale)) diff --git a/Sources/EdgeControl/Widgets/Temperature/PerCoreTempWidget.swift b/Sources/EdgeControl/Widgets/Temperature/PerCoreTempWidget.swift index aac44b2..d3ac68c 100644 --- a/Sources/EdgeControl/Widgets/Temperature/PerCoreTempWidget.swift +++ b/Sources/EdgeControl/Widgets/Temperature/PerCoreTempWidget.swift @@ -21,7 +21,7 @@ public final class PerCoreTempWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - PerCoreTempWidgetView(service: service, columns: size.width >= 8 ? 2 : 1) + PerCoreTempWidgetView(service: service, size: size) } } @@ -29,7 +29,24 @@ private struct PerCoreTempWidgetView: View { @ObservedObject var service: SMCService @Environment(\.themeSettings) private var ts @Environment(\.unitSystem) private var units - let columns: Int + let size: WidgetSize + + // Row geometry: coreRow fixed frame + VStack spacing; header title line + vertical padding. + private static let rowHeight: CGFloat = 18 + private static let rowSpacing: CGFloat = 2 + private static let headerChrome: CGFloat = 34 + + private var columns: Int { + let count = service.cpuCoreTemps.count + let available = CGFloat(size.height) * GridConstants.cellHeight - Self.headerChrome + let rowsThatFit = max(1, Int((available + Self.rowSpacing) / (Self.rowHeight + Self.rowSpacing))) + let maxByWidth = size.width >= 6 ? 2 : 1 + for cols in 1...maxByWidth where (count + cols - 1) / cols <= rowsThatFit { + return cols + } + // Even maxByWidth columns overflow: keep width-only choice, TouchScrollView scrolls the rest. + return size.width >= 8 ? 2 : 1 + } private var primary: Color { Theme.widgetPrimary("per-core-temp", ts: ts, default: .cyan) } private var secondary: Color { Theme.widgetSecondary("per-core-temp", ts: ts, default: .green) ?? Theme.accentGreen } diff --git a/Sources/EdgeControl/Widgets/Temperature/SSDTempWidget.swift b/Sources/EdgeControl/Widgets/Temperature/SSDTempWidget.swift index 988ceae..2cfd065 100644 --- a/Sources/EdgeControl/Widgets/Temperature/SSDTempWidget.swift +++ b/Sources/EdgeControl/Widgets/Temperature/SSDTempWidget.swift @@ -27,8 +27,7 @@ public final class SSDTempWidget: DashboardWidget { service: service, showLabel: config.bool("showLabel", default: true), isBar: size.height <= 1, - isChart: size.height == 2 && size.width >= 3, - isCompact: size.width <= 2 && size.height <= 2 + isChart: size.height == 2 && size.width >= 3 ) } } @@ -40,7 +39,6 @@ private struct SSDTempWidgetView: View { let showLabel: Bool let isBar: Bool let isChart: Bool - let isCompact: Bool private var temp: Double? { service.ssdTemperature } @@ -121,31 +119,19 @@ private struct SSDTempWidgetView: View { } private var gaugeLayout: some View { - VStack(spacing: isCompact ? 4 : 8) { + VStack(spacing: 8) { if let temp { let color = tempColor(temp) - if isCompact { - VStack(spacing: 2) { - Image(systemName: "internaldrive") - .font(.system(size: 18 * ts.fontScale)) - .foregroundStyle(color) - Text(units.degrees(fromCelsius: temp)) - .font(Theme.value(ts)) - .foregroundStyle(color) - .monospacedDigit() - } - } else { - RadialGaugeView( - value: temp, - maxValue: 100, - label: "SSD", - displayValue: units.temperatureText(fromCelsius: temp), - unit: "", - accentColor: color, - showLabel: showLabel - ) - } + RadialGaugeView( + value: temp, + maxValue: 100, + label: "SSD", + displayValue: units.temperatureText(fromCelsius: temp), + unit: "", + accentColor: color, + showLabel: showLabel + ) } else { Image(systemName: "internaldrive") .font(.system(size: 24 * ts.fontScale)) diff --git a/Sources/EdgeControlWidgets/Providers/PluginWidgetProvider.swift b/Sources/EdgeControlWidgets/Providers/PluginWidgetProvider.swift index d337766..15ec5ec 100644 --- a/Sources/EdgeControlWidgets/Providers/PluginWidgetProvider.swift +++ b/Sources/EdgeControlWidgets/Providers/PluginWidgetProvider.swift @@ -55,7 +55,9 @@ struct PluginWidgetEntry: TimelineEntry, Sendable { let date: Date let pluginId: String? let pluginName: String? - let snapshotImage: NSImage? + // NSImage is not Sendable; entries are only ever built and consumed on the + // main actor, so suppress the check rather than drop the conformance. + nonisolated(unsafe) let snapshotImage: NSImage? let isPlaceholder: Bool static let placeholder = PluginWidgetEntry( diff --git a/Tests/EdgeControlTests/CI/RateLimitTrackingTransportTests.swift b/Tests/EdgeControlTests/CI/RateLimitTrackingTransportTests.swift index 02eaea7..bda6083 100644 --- a/Tests/EdgeControlTests/CI/RateLimitTrackingTransportTests.swift +++ b/Tests/EdgeControlTests/CI/RateLimitTrackingTransportTests.swift @@ -72,7 +72,7 @@ final class RateLimitTrackingTransportTests: XCTestCase { XCTAssertNil(a.rateLimit(forHost: "git.example.dev")) } - func testSettingsRendersQuotaWithReset() { + @MainActor func testSettingsRendersQuotaWithReset() { // Fixed `now`, so the assertion cannot depend on how long the test took // to reach this line. let now = Date(timeIntervalSince1970: 1_800_000_000) @@ -89,7 +89,7 @@ final class RateLimitTrackingTransportTests: XCTestCase { } /// Under a minute must read as "resetting", not "0m". - func testQuotaResetWithinAMinute() { + @MainActor func testQuotaResetWithinAMinute() { let now = Date(timeIntervalSince1970: 1_800_000_000) let soon = CIRateLimit(limit: 60, remaining: 0, resetsAt: now.addingTimeInterval(20)) XCTAssertEqual(CICDSettingsView.quotaText(soon, now: now), "0/60 · resetting") diff --git a/Tests/EdgeControlTests/LayoutEngineTests.swift b/Tests/EdgeControlTests/LayoutEngineTests.swift index 85a00ce..998bf3f 100644 --- a/Tests/EdgeControlTests/LayoutEngineTests.swift +++ b/Tests/EdgeControlTests/LayoutEngineTests.swift @@ -10,8 +10,10 @@ final class LayoutEngineTests: XCTestCase { private var engine: LayoutEngine! private var pageId: String! - override func setUpWithError() throws { - try super.setUpWithError() + // XCTest's throwing set-up hooks are nonisolated, so on a @MainActor + // test case they cannot touch the main-actor state they exist to build. + // The async hooks inherit the class's isolation, so use those instead. + override func setUp() async throws { directory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("LayoutEngineTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -22,11 +24,10 @@ final class LayoutEngineTests: XCTestCase { pageId = engine.document.pages[0].id } - override func tearDownWithError() throws { + override func tearDown() async throws { engine = nil try? FileManager.default.removeItem(at: directory) directory = nil - try super.tearDownWithError() } private var widgets: [WidgetPlacement] { engine.document.pages[0].widgets }