From 51dc2359e65255be1c3bf62392fd2ab36865d149 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 00:18:08 -0500 Subject: [PATCH 01/21] Fix build on newer Swift 6 compilers Xcode 16.4's compiler rejects several spots that earlier toolchains accepted under the pinned Swift 6 language mode: - PluginWidgetEntry declares Sendable while storing an NSImage; mark the image nonisolated(unsafe), since entries are built and consumed on the main actor. - The analog clock's inline tick-mark arithmetic exceeds the type checker's expression time limit; extract a helper with explicit CGFloat conversions. - The edit-mode toggle mutates main-actor state from a @Sendable zone action; hop to the main actor explicitly. - The plugin notify path sends non-Sendable UNNotificationSettings across isolation; extract just the authorization status via the completion-handler API. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 41a6d72ed5d0e22531abe12f9f9492938ae33e26) --- Sources/EdgeControl/UI/DashboardShell.swift | 8 +++++-- .../Widgets/Info/ClockWidget.swift | 23 +++++++++++++------ .../Widgets/Plugin/PluginWebWidget.swift | 9 ++++++-- .../Providers/PluginWidgetProvider.swift | 4 +++- 4 files changed, 32 insertions(+), 12 deletions(-) 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/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index d6c6adc..038d98b 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 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/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( From 636398fd2641953b3b2abf0643968d12c83a5dd0 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 20:05:42 -0500 Subject: [PATCH 02/21] Fix the test target under Swift 6 concurrency The test target did not compile, so the suite had never run. Two distinct isolation errors: tests calling the main-actor-isolated CICDSettingsView.quotaText from nonisolated test methods, and LayoutEngineTests building main-actor state inside setUpWithError / tearDownWithError, whose XCTest signatures are nonisolated even on a @MainActor test case. Annotate the two quota tests @MainActor, and move the layout fixtures to the async set-up hooks, which do inherit the class's isolation. The super calls go with them: awaiting the non-Sendable superclass across the actor hop is itself an error, and XCTest already invokes the empty base implementations. All 87 tests now build and pass. --- .../CI/RateLimitTrackingTransportTests.swift | 4 ++-- Tests/EdgeControlTests/LayoutEngineTests.swift | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) 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 } From 9333f3143b07caafc40e8c76ef7f02f77df960ca Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 00:18:08 -0500 Subject: [PATCH 03/21] Show workflow name in CI/CD run rows One commit can fan out to several workflows, and some runs share a constant display title ("pages build and deployment"), so rows showing only repository and title render as indistinguishable duplicates. Append the workflow name, dimmed like the host label, so such rows tell apart. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit e7f7a7b6afb6d01500d47d4344f86be4c86ee27f) --- Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift b/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift index a3239a5..4d45c69 100644 --- a/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift +++ b/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift @@ -190,6 +190,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)) From 2baf36b7ca845187a6c1adb205c44854db3e5cce Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 00:44:51 -0500 Subject: [PATCH 04/21] Collapse CI/CD rows to latest run per workflow Repeat runs of one workflow (re-runs, successive Pages deploys with their constant title) rendered as indistinguishable rows. The widget reads better as a status board than an activity log: show each repository workflow once with its most recent run's result, add a compact age label to every row, and make the header badge count the workflows actually listed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 209fb62b24a5af417b71e643fdb7e9b12488ba7d) --- .../Widgets/DevTools/CICDRunsWidget.swift | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift b/Sources/EdgeControl/Widgets/DevTools/CICDRunsWidget.swift index 4d45c69..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) } } @@ -204,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)) From a0bbc7272a6dc206a159d47d70bb8d6f7bdfb871 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 02:19:44 -0500 Subject: [PATCH 05/21] Offer currently shown repositories in the CI/CD hide list Hiding a repository required typing its exact owner/name into the Hidden list. The settings pane now also offers a menu of the repositories the widget is showing right now; picking one adds it to the hidden set, which the service already persists and excludes from polling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit adb1fe21b145afc7bb01b31bd284c8c9dad0a9f6) --- .../UI/Settings/CICDSettingsView.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) 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) + } } } From 3ed20660327b852e064a87e0d1fa447b07b76f5e Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 00:47:26 -0500 Subject: [PATCH 06/21] Allow 1-row sizes for glanceable widgets Disk I/O, Storage, Network Stats, WiFi Info, Audio and Day Progress all render single-row content in their compact layouts yet declared 2-row minimums, wasting half their footprint on a 6-row strip display. Lower the minimums to height 1; give Network Stats a side-by-side variant at that height (its stacked rows could overflow the cell), keep Day Progress's ring out of 1-row placements, and clip widget content to its cell so a compact layout can never paint over neighbors. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 1bf2297022cb1f8da96d9698a5fa4649b2d29364) --- Sources/EdgeControl/UI/GridPageView.swift | 3 + .../Widgets/Info/DayProgressWidget.swift | 6 +- .../Widgets/Media/AudioDevicesWidget.swift | 2 +- .../Widgets/Network/NetworkStatsWidget.swift | 81 +++++++++++-------- .../Widgets/Network/WiFiInfoWidget.swift | 2 +- .../Widgets/System/DiskIOWidget.swift | 2 +- .../Widgets/System/StorageBarsWidget.swift | 2 +- 7 files changed, 59 insertions(+), 39 deletions(-) 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/Widgets/Info/DayProgressWidget.swift b/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift index a04deb1..034e058 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,7 +16,9 @@ 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) } } 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/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index 1adabdf..db54dc6 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(3, 1), max: .size(8, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [] @@ -21,7 +21,7 @@ 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) } } @@ -29,6 +29,9 @@ 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 var body: some View { let primary = Theme.widgetPrimary("network-stats", ts: ts, default: .green) @@ -39,38 +42,18 @@ private struct NetworkStatsWidgetView: View { WidgetHeader(title: "NETWORK", color: primary) } - 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) - } - } - - 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) + if isBar { + HStack(spacing: 12) { + barGroup(icon: "arrow.down.circle.fill", color: primary, + speed: service.downloadSpeed) + barGroup(icon: "arrow.up.circle.fill", color: secondary, + speed: service.uploadSpeed) } + } else { + speedRow(icon: "arrow.down.circle.fill", color: primary, + label: "DOWN", speed: service.downloadSpeed) + speedRow(icon: "arrow.up.circle.fill", color: secondary, + label: "UP", speed: service.uploadSpeed) } if !isCompact { @@ -86,6 +69,38 @@ private struct NetworkStatsWidgetView: View { .widgetCard() } + private func speedRow(icon: String, color: Color, label: String, speed: Double) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: (isCompact ? 14 : 20) * ts.fontScale)) + .foregroundStyle(color) + Text(label) + .font(Theme.label(ts)) + .foregroundStyle(Theme.text3(ts)) + Spacer() + Text(NetworkMonitorService.formatSpeed(speed)) + .font(Theme.value(ts)) + .foregroundStyle(Theme.text1(ts)) + .monospacedDigit() + .minimumScaleFactor(0.5) + } + } + + private func barGroup(icon: String, color: Color, speed: Double) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 14 * ts.fontScale)) + .foregroundStyle(color) + Text(NetworkMonitorService.formatSpeed(speed)) + .font(Theme.value(ts)) + .foregroundStyle(Theme.text1(ts)) + .monospacedDigit() + .minimumScaleFactor(0.5) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + 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..1928e66 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] = [] diff --git a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift index a4b7e9b..5eaf25b 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(2, 1), max: .size(8, 4)) public let defaultSize = WidgetSize.size(4, 3) public let configSchema: [ConfigSchemaEntry] = [] diff --git a/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift b/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift index 8ca5694..6d06d21 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] = [] From 001eeeb36b74a0bb8e361ff54f34124d4bb86c78 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 00:59:53 -0500 Subject: [PATCH 07/21] Optimize widget layouts for density and small sizes An audit of every size-adaptive widget, aimed at fitting more on a 6-row strip without premature degradation: - Radial gauges draw their type label inside the ring instead of a caption row below, so the ring fills the cell and a 2x2 placement works; temp widgets keep the gauge at 2x2 rather than degrading to icon+number. - Top Processes keeps its MEM column at every height (it was keyed to height though columns are a width concern) and fills available rows from measured height instead of a fixed 4/8, with the process service cap raised from 5 to 12. - Storage keeps its ring layout down to 2 rows when wide, sizes the ring to the cell instead of a 140px cap, and centers vertically; Disk I/O and Day Progress center instead of floating above dead space. - Per-core temp and CPU cores pick column count from height as well as width, so tall placements fill and short ones avoid needless scrolling. - Weather's compact layout was unreachable (threshold below the minimum size); it now serves 4-row placements. World Clocks distributes cards across the available height. - Bluetooth's device list scrolls instead of clipping; WiFi and Network Stats keep their info chips in compact when width allows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit ab8b8ccb7e09489f050d5c6f1cb6943dfdfe9d1b) --- .../Services/ProcessMonitorService.swift | 4 +- .../UI/Components/RadialGaugeView.swift | 89 +++++++++---------- .../Widgets/Info/DayProgressWidget.swift | 9 +- .../Widgets/Info/WeatherWidget.swift | 4 +- .../Widgets/Info/WorldClocksWidget.swift | 42 +++++++-- .../Widgets/Network/BluetoothWidget.swift | 61 ++++++++----- .../Widgets/Network/NetworkStatsWidget.swift | 12 ++- .../Widgets/Network/WiFiInfoWidget.swift | 41 +++++++-- .../Widgets/System/CPUCoresWidget.swift | 21 ++++- .../Widgets/System/CPUGaugeWidget.swift | 2 +- .../Widgets/System/DiskIOWidget.swift | 2 + .../Widgets/System/MemoryGaugeWidget.swift | 2 +- .../Widgets/System/MemoryPressureWidget.swift | 2 +- .../Widgets/System/ProcessListWidget.swift | 55 ++++++------ .../Widgets/System/StorageBarsWidget.swift | 76 +++++++++------- .../Widgets/Temperature/CPUTempWidget.swift | 39 +++----- .../Temperature/PerCoreTempWidget.swift | 21 ++++- .../Widgets/Temperature/SSDTempWidget.swift | 36 +++----- 18 files changed, 310 insertions(+), 208 deletions(-) diff --git a/Sources/EdgeControl/Services/ProcessMonitorService.swift b/Sources/EdgeControl/Services/ProcessMonitorService.swift index 2b81859..624628b 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(12)) + 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..5eea414 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 && !isCompact { + 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/Widgets/Info/DayProgressWidget.swift b/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift index 034e058..71129d8 100644 --- a/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/DayProgressWidget.swift @@ -18,12 +18,13 @@ public final class DayProgressWidget: DashboardWidget { public func body(size: WidgetSize, config: WidgetConfig) -> any View { // 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) + 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() @@ -46,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 { @@ -65,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)) @@ -92,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/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 db54dc6..9347bd3 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -21,7 +21,12 @@ public final class NetworkStatsWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - NetworkStatsWidgetView(service: service, isCompact: size.height <= 2, isBar: size.height <= 1) + NetworkStatsWidgetView( + service: service, + isCompact: size.height <= 2, + isBar: size.height <= 1, + showCompactTotals: size.width >= 5 && size.height >= 2 + ) } } @@ -32,6 +37,9 @@ private struct NetworkStatsWidgetView: View { // 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 + // 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) @@ -56,7 +64,7 @@ private struct NetworkStatsWidgetView: View { label: "UP", speed: service.uploadSpeed) } - 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) diff --git a/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift b/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift index 1928e66..738809d 100644 --- a/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/WiFiInfoWidget.swift @@ -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/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..4ecccbc 100644 --- a/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift +++ b/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift @@ -49,7 +49,7 @@ private struct CPUGaugeWidgetView: View { displayValue: String(format: "%.0f%%", cpu), unit: isCompact ? "" : 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 5eaf25b..f692f8f 100644 --- a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift +++ b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift @@ -36,6 +36,8 @@ private struct DiskIOWidgetView: View { WidgetHeader(title: "DISK I/O", color: Theme.widgetPrimary("disk-io", ts: ts, default: .blue)) } + Spacer(minLength: 0) + HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { diff --git a/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift b/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift index a9b5a37..a0b153e 100644 --- a/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift +++ b/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift @@ -59,7 +59,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..607bb20 100644 --- a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift +++ b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift @@ -24,14 +24,16 @@ 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) } } private struct ProcessListWidgetView: View { @ObservedObject var service: ProcessMonitorService @Environment(\.themeSettings) private var ts - let isCompact: Bool + + // Row = 26pt icon + 2x8pt vertical padding + 1pt divider. + private let rowHeight: CGFloat = 43 var body: some View { VStack(spacing: 0) { @@ -44,10 +46,8 @@ private struct ProcessListWidgetView: View { .frame(maxWidth: .infinity, alignment: .leading) Text("CPU") .frame(width: 70, alignment: .trailing) - if !isCompact { - Text("MEM") - .frame(width: 70, alignment: .trailing) - } + Text("MEM") + .frame(width: 70, alignment: .trailing) } .font(Theme.label(ts)) .foregroundStyle(Theme.text3(ts)) @@ -56,22 +56,27 @@ 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 { + // Service publishes at most 5 processes, so cap there rather than 12. + let maxCount = min(max(Int(geo.size.height / rowHeight), 3), 12) + let visible = Array(service.topProcesses.prefix(maxCount)) + 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) } } } - - Spacer(minLength: 0) } .widgetCard() } @@ -102,13 +107,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 6d06d21..f96f0d7 100644 --- a/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift +++ b/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift @@ -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) + // Full ring needs 3 rows, or 2 rows when at least 4 columns wide; + // smaller cells fall back to the bar-only layout. + StorageBarsWidgetView( + metricsService: metricsService, + isCompact: size.height == 1 || (size.height == 2 && size.width <= 3), + 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..f52bdcf 100644 --- a/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift +++ b/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift @@ -31,8 +31,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 ) } } @@ -68,8 +67,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 +88,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 +185,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)) From e7bd06d5eb3d394c948cc33cb1ea21086ccd2c81 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 02:01:38 -0500 Subject: [PATCH 08/21] Keep identifying labels at small widget sizes Small sizes were shedding the text that says what a widget is: gauges dropped their subtitle ("M4 MAX", "96.3 / 128 GB") below 150px, Disk I/O lost its header in compact and 1-row layouts, and Storage fell back to an anonymous bar at 3x2 although its ring layout scales to the cell. Gauge subtitles now render at every size, Disk I/O keeps its name down to 1 row when width allows, Storage keeps the ring at every multi-row size, and the Network 1-row layout gains DOWN/UP labels matching Disk I/O. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 61caf4945c6c6734f587e820934cecc50f98cefe) --- .../UI/Components/RadialGaugeView.swift | 2 +- .../Widgets/Network/NetworkStatsWidget.swift | 21 ++++++++++++------- .../Widgets/System/CPUGaugeWidget.swift | 2 +- .../Widgets/System/DiskIOWidget.swift | 13 ++++++++++-- .../Widgets/System/MemoryGaugeWidget.swift | 1 - .../Widgets/System/StorageBarsWidget.swift | 6 +++--- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Sources/EdgeControl/UI/Components/RadialGaugeView.swift b/Sources/EdgeControl/UI/Components/RadialGaugeView.swift index 5eea414..ab32b92 100644 --- a/Sources/EdgeControl/UI/Components/RadialGaugeView.swift +++ b/Sources/EdgeControl/UI/Components/RadialGaugeView.swift @@ -72,7 +72,7 @@ struct RadialGaugeView: View { .foregroundStyle(Theme.text1(ts)) .contentTransition(.numericText()) .minimumScaleFactor(0.5) - if !unit.isEmpty && !isCompact { + if !unit.isEmpty { Text(unit) .font(.system(size: unitFontSize, weight: .semibold, design: design)) .foregroundStyle(Theme.text2(ts)) diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index 9347bd3..714fd30 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -53,9 +53,9 @@ private struct NetworkStatsWidgetView: View { if isBar { HStack(spacing: 12) { barGroup(icon: "arrow.down.circle.fill", color: primary, - speed: service.downloadSpeed) + label: "DOWN", speed: service.downloadSpeed) barGroup(icon: "arrow.up.circle.fill", color: secondary, - speed: service.uploadSpeed) + label: "UP", speed: service.uploadSpeed) } } else { speedRow(icon: "arrow.down.circle.fill", color: primary, @@ -94,11 +94,18 @@ private struct NetworkStatsWidgetView: View { } } - private func barGroup(icon: String, color: Color, speed: Double) -> some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 14 * ts.fontScale)) - .foregroundStyle(color) + // 1-row group: icon+label line over the value line, matching Disk I/O's + // single-row arrangement so the two widgets read as one family. + private func barGroup(icon: String, color: Color, label: String, speed: Double) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 12 * ts.fontScale)) + .foregroundStyle(color) + Text(label) + .font(Theme.label(ts)) + .foregroundStyle(Theme.text3(ts)) + } Text(NetworkMonitorService.formatSpeed(speed)) .font(Theme.value(ts)) .foregroundStyle(Theme.text1(ts)) diff --git a/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift b/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift index 4ecccbc..e42e626 100644 --- a/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift +++ b/Sources/EdgeControl/Widgets/System/CPUGaugeWidget.swift @@ -47,7 +47,7 @@ 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 ) diff --git a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift index f692f8f..ab645fe 100644 --- a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift +++ b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift @@ -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: size.width >= 3, isBar: size.height <= 1) } } @@ -29,11 +29,20 @@ 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 var body: some View { VStack(spacing: isCompact ? 6 : 12) { - if !isCompact { + if !isCompact || (showTitle && !isBar) { 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)) + .frame(maxWidth: .infinity, alignment: .leading) } Spacer(minLength: 0) diff --git a/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift b/Sources/EdgeControl/Widgets/System/MemoryGaugeWidget.swift index a0b153e..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 "" }() diff --git a/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift b/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift index f96f0d7..052b4e8 100644 --- a/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift +++ b/Sources/EdgeControl/Widgets/System/StorageBarsWidget.swift @@ -21,11 +21,11 @@ public final class StorageBarsWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - // Full ring needs 3 rows, or 2 rows when at least 4 columns wide; - // smaller cells fall back to the bar-only layout. + // 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 || (size.height == 2 && size.width <= 3), + isCompact: size.height == 1, isBar: size.height == 1 ) } From a9007253c6a72a8cdde873ad7e385eb0025155a6 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 02:03:11 -0500 Subject: [PATCH 09/21] Render rate pairs through one shared component Network and Disk I/O both show a pair of labeled rates, yet at identical box sizes one stacked top-aligned rows over dead space while the other centered side-by-side groups. Extract RatePairView and render both widgets through it, so widgets of the same shape look the same at every size. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit f96ec803d4ae7c0ca280abd144c6f139da439922) --- .../UI/Components/WidgetHeader.swift | 47 +++++++++++++ .../Widgets/Network/NetworkStatsWidget.swift | 66 +++++-------------- .../Widgets/System/DiskIOWidget.swift | 50 ++++---------- 3 files changed, 76 insertions(+), 87 deletions(-) diff --git a/Sources/EdgeControl/UI/Components/WidgetHeader.swift b/Sources/EdgeControl/UI/Components/WidgetHeader.swift index 566d912..82a9fac 100644 --- a/Sources/EdgeControl/UI/Components/WidgetHeader.swift +++ b/Sources/EdgeControl/UI/Components/WidgetHeader.swift @@ -19,3 +19,50 @@ 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 + + @Environment(\.themeSettings) private var ts + + var body: some View { + 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(entry.value) + .font(Theme.value(ts)) + .foregroundStyle(Theme.text1(ts)) + .monospacedDigit() + .minimumScaleFactor(0.5) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index 714fd30..ff01430 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -50,19 +50,22 @@ private struct NetworkStatsWidgetView: View { WidgetHeader(title: "NETWORK", color: primary) } - if isBar { - HStack(spacing: 12) { - barGroup(icon: "arrow.down.circle.fill", color: primary, - label: "DOWN", speed: service.downloadSpeed) - barGroup(icon: "arrow.up.circle.fill", color: secondary, - label: "UP", speed: service.uploadSpeed) - } - } else { - speedRow(icon: "arrow.down.circle.fill", color: primary, - label: "DOWN", speed: service.downloadSpeed) - speedRow(icon: "arrow.up.circle.fill", color: secondary, - label: "UP", speed: service.uploadSpeed) - } + if !isBar { Spacer(minLength: 0) } + + // 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 + ) if !isBar && (!isCompact || showCompactTotals) { HStack(spacing: 10) { @@ -77,44 +80,7 @@ private struct NetworkStatsWidgetView: View { .widgetCard() } - private func speedRow(icon: String, color: Color, label: String, speed: Double) -> some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: (isCompact ? 14 : 20) * ts.fontScale)) - .foregroundStyle(color) - Text(label) - .font(Theme.label(ts)) - .foregroundStyle(Theme.text3(ts)) - Spacer() - Text(NetworkMonitorService.formatSpeed(speed)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .monospacedDigit() - .minimumScaleFactor(0.5) - } - } - // 1-row group: icon+label line over the value line, matching Disk I/O's - // single-row arrangement so the two widgets read as one family. - private func barGroup(icon: String, color: Color, label: String, speed: Double) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 12 * ts.fontScale)) - .foregroundStyle(color) - Text(label) - .font(Theme.label(ts)) - .foregroundStyle(Theme.text3(ts)) - } - Text(NetworkMonitorService.formatSpeed(speed)) - .font(Theme.value(ts)) - .foregroundStyle(Theme.text1(ts)) - .monospacedDigit() - .minimumScaleFactor(0.5) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - } private func totalChip(_ label: String, value: String, color: Color) -> some View { HStack(spacing: 6) { diff --git a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift index ab645fe..b7dc6c2 100644 --- a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift +++ b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift @@ -47,43 +47,19 @@ private struct DiskIOWidgetView: View { Spacer(minLength: 0) - 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) - - 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 + ) Spacer(minLength: 0) } From 8b911cb5014124d5dad0d3a023437b947e7e0b27 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 02:19:44 -0500 Subject: [PATCH 10/21] Fix stub config dropdowns; wire Sort By and add Rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unit and Sort By pickers rendered blank and would not open: their schema entries declared no options, and nothing consumed their values. Unit duplicated the global Units setting, so the dead entries are removed. Sort By is now real (CPU or memory) and Top Processes gains a Rows choice — auto fills the widget height as before, a number pins the count and scrolls past it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit a925501e118714a354a3b57e7ceef1102f2a2daa) --- .../UI/Settings/WidgetConfigEditor.swift | 2 +- .../Widgets/System/ProcessListWidget.swift | 37 +++++++++++++------ .../Widgets/Temperature/CPUTempWidget.swift | 2 - 3 files changed, 27 insertions(+), 14 deletions(-) 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/System/ProcessListWidget.swift b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift index 607bb20..b9a6c06 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"]), ] public let defaultColors = WidgetColors(primary: .purple, secondary: .cyan) @@ -24,13 +27,20 @@ public final class ProcessListWidget: DashboardWidget { @MainActor public func body(size: WidgetSize, config: WidgetConfig) -> any View { - ProcessListWidgetView(service: service) + 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 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 @@ -63,17 +73,22 @@ private struct ProcessListWidgetView: View { .foregroundStyle(Theme.text3(ts)) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - // Service publishes at most 5 processes, so cap there rather than 12. - let maxCount = min(max(Int(geo.size.height / rowHeight), 3), 12) - let visible = Array(service.topProcesses.prefix(maxCount)) - VStack(spacing: 0) { - ForEach(visible) { proc in - processRow(proc) - if proc.id != visible.last?.id { - Divider().background(Theme.border(ts)).padding(.leading, 50) + let fitCount = min(max(Int(geo.size.height / rowHeight), 3), 12) + let maxCount = fixedRows ?? fitCount + let ranked = sortByMemory + ? service.topProcesses.sorted { $0.memoryMB > $1.memoryMB } + : service.topProcesses + 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) } } } diff --git a/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift b/Sources/EdgeControl/Widgets/Temperature/CPUTempWidget.swift index f52bdcf..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) @@ -47,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) From 8af8d8ef1448d89f9b8712970014249b710a9929 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 02:19:44 -0500 Subject: [PATCH 11/21] Match Network titles to Disk I/O; grow Day Bar day strip Network now keeps its name at the same sizes and spot Disk I/O does (full header to 2 rows, caption at 1 row). The Day Bar clock's day-of-week strip was 9-11pt beside a double-height time row; raise it to 12-15pt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 0501f5c712a855dbca29ff870a70f50334fca5c8) --- Sources/EdgeControl/Widgets/Info/ClockWidget.swift | 2 +- .../Widgets/Network/NetworkStatsWidget.swift | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index 038d98b..38e1818 100644 --- a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift @@ -428,7 +428,7 @@ private struct ClockContainer: View { 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(.vertical, isCompact ? 3 : 5) diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index ff01430..d928f9f 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -25,6 +25,7 @@ public final class NetworkStatsWidget: DashboardWidget { service: service, isCompact: size.height <= 2, isBar: size.height <= 1, + showTitle: size.width >= 3, showCompactTotals: size.width >= 5 && size.height >= 2 ) } @@ -37,6 +38,9 @@ private struct NetworkStatsWidgetView: View { // 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 + // 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 @@ -46,8 +50,13 @@ private struct NetworkStatsWidgetView: View { let secondary = Theme.widgetSecondary("network-stats", ts: ts, default: .cyan) ?? Theme.accentCyan VStack(spacing: isCompact ? 6 : 12) { - if !isCompact { + if !isCompact || (showTitle && !isBar) { WidgetHeader(title: "NETWORK", color: primary) + } else if showTitle { + Text("NETWORK") + .font(Theme.caption(ts)) + .foregroundStyle(Theme.text3(ts)) + .frame(maxWidth: .infinity, alignment: .leading) } if !isBar { Spacer(minLength: 0) } From 6e885aefe81649c182a568d8bdb428fea4f749e1 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 08:37:45 -0500 Subject: [PATCH 12/21] Raise the process list capacity to 16 rows A full-height 6-row placement fits 16 rows of 43px, but both the service's published list and the widget's auto clamp stopped at 12, stranding a four-row strip at the bottom. Raise both, and offer 16 in the Rows picker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 67d97a471e6d4a37f095817c7a0a1a8a3554b3b7) --- Sources/EdgeControl/Services/ProcessMonitorService.swift | 2 +- Sources/EdgeControl/Widgets/System/ProcessListWidget.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/EdgeControl/Services/ProcessMonitorService.swift b/Sources/EdgeControl/Services/ProcessMonitorService.swift index 624628b..5171107 100644 --- a/Sources/EdgeControl/Services/ProcessMonitorService.swift +++ b/Sources/EdgeControl/Services/ProcessMonitorService.swift @@ -100,7 +100,7 @@ public final class ProcessMonitorService: ObservableObject { } // Sort, take top 5, resolve icons - let top = Array(results.sorted { $0.cpuPercent > $1.cpuPercent }.prefix(12)) + let top = Array(results.sorted { $0.cpuPercent > $1.cpuPercent }.prefix(16)) resolveIcons(for: top) } diff --git a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift index b9a6c06..9a784ff 100644 --- a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift +++ b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift @@ -15,7 +15,7 @@ public final class ProcessListWidget: DashboardWidget { 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"]), + 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) @@ -73,7 +73,7 @@ private struct ProcessListWidgetView: View { .foregroundStyle(Theme.text3(ts)) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - let fitCount = min(max(Int(geo.size.height / rowHeight), 3), 12) + let fitCount = min(max(Int(geo.size.height / rowHeight), 3), 16) let maxCount = fixedRows ?? fitCount let ranked = sortByMemory ? service.topProcesses.sorted { $0.memoryMB > $1.memoryMB } From 28dddb881ad26fcd43b99e18bc7226eacea1e3df Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 08:37:45 -0500 Subject: [PATCH 13/21] Center Network's 1-row layout like Disk I/O's The bar layout skipped the leading spacer Disk I/O has, pinning the rate pair under the caption instead of centering it in the card. Same spacers now, so the two widgets align row for row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 7b0eeada2af4dae10640c206347389a6927613f6) --- Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index d928f9f..c76b76c 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -59,7 +59,7 @@ private struct NetworkStatsWidgetView: View { .frame(maxWidth: .infinity, alignment: .leading) } - if !isBar { Spacer(minLength: 0) } + Spacer(minLength: 0) // Same component Disk I/O renders — equal boxes, equal layout. RatePairView( From b66b868ff0c30949f1ab505e88256fb9138cff31 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 08:52:51 -0500 Subject: [PATCH 14/21] Stretch the Day Bar time to the width of the day strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The time row rendered at a fixed multiple of the theme font, so it never matched the day strip's width. It is now one concatenated Text (colored colons, dimmed seconds and AM/PM scale as a single unit) at a deliberately oversized base size that minimumScaleFactor shrinks to exactly fill the container — the SwiftUI way to grow type to fit. Day Bar spacing opens up a step to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 5b8f184e24deb106393a21c75d32292ea90ab219) --- .../Widgets/Info/ClockWidget.swift | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index 38e1818..78c5b80 100644 --- a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift @@ -423,7 +423,7 @@ private struct ClockContainer: View { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ private var dayBarStyle: some View { - VStack(spacing: isCompact ? 6 : 10) { + VStack(spacing: isCompact ? 8 : 14) { // Day bar HStack(spacing: 0) { ForEach(Array(dayNames.enumerated()), id: \.offset) { i, name in @@ -439,8 +439,10 @@ private struct ClockContainer: View { } } - // 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. + fittedTimeRow(weight: .semibold) + .frame(maxWidth: .infinity, maxHeight: .infinity) if showDate && !isCompact { Text(fullDate) @@ -577,6 +579,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) { From 32425858932305ac6dd2efd8f39bd20aa2b9baaf Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 08:57:12 -0500 Subject: [PATCH 15/21] Justify Day Bar chips so strip and clock edges align MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Day cells were equal-width columns with the highlight capsule filling the whole cell, so the strip's visible edges depended on which day was highlighted — inset the clock to match the labels and a Saturday capsule would overhang it. Chips now hug their labels and justify across the row: the first and last chip sit flush against the container edges the clock also fills, so the two rows share edges on every day of the week. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit d1b16e620fdf4195850f631f678dee993bd1d20b) --- Sources/EdgeControl/Widgets/Info/ClockWidget.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index 78c5b80..c461e69 100644 --- a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift @@ -424,18 +424,22 @@ private struct ClockContainer: View { private var dayBarStyle: some View { VStack(spacing: isCompact ? 8 : 14) { - // Day bar + // 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 ? 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) } } } From 9f528225e9c8fc5ce518beb4b17a4f8cae65d349 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 09:00:42 -0500 Subject: [PATCH 16/21] Center the Day Bar chips above the clock Replace the edge-justified distribution with a centered cluster at fixed chip spacing, sitting over the width-filling clock with the existing row spacing between them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 2a223d70e6859fa9d6edf2f74cf4de3eed944199) --- Sources/EdgeControl/Widgets/Info/ClockWidget.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index c461e69..f42651f 100644 --- a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift @@ -424,11 +424,9 @@ private struct ClockContainer: View { private var dayBarStyle: some View { 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) { + // Day bar: a centered cluster of label-hugging chips above the + // clock, rather than a justified or column-stretched strip. + HStack(spacing: isCompact ? 3 : 6) { ForEach(Array(dayNames.enumerated()), id: \.offset) { i, name in Text(name) .font(.system(size: (isCompact ? 12 : 15) * ts.fontScale, weight: .heavy, design: ts.fontFamily.design)) @@ -439,9 +437,9 @@ private struct ClockContainer: View { weekday == i + 1 ? primary.opacity(0.3) : Color.clear, in: RoundedRectangle(cornerRadius: 4, style: .continuous) ) - if i < dayNames.count - 1 { Spacer(minLength: 2) } } } + .frame(maxWidth: .infinity) // Time — stretches to the same width as the day strip above: // fittedTimeRow starts oversized and scales down to fit. From f97102cde5c8ba8da8e787485b3a4081ad096769 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 09:02:27 -0500 Subject: [PATCH 17/21] Center the Day Bar group vertically Restore the justified day strip (the width was right) and drop the time row's greedy height, which was pinning the days to the top edge. The strip-plus-clock group now hugs its content and the container centers it vertically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 7b112a03820350a489c0cac58509a3651a107d9b) --- .../EdgeControl/Widgets/Info/ClockWidget.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift index f42651f..c3ea875 100644 --- a/Sources/EdgeControl/Widgets/Info/ClockWidget.swift +++ b/Sources/EdgeControl/Widgets/Info/ClockWidget.swift @@ -424,9 +424,11 @@ private struct ClockContainer: View { private var dayBarStyle: some View { VStack(spacing: isCompact ? 8 : 14) { - // Day bar: a centered cluster of label-hugging chips above the - // clock, rather than a justified or column-stretched strip. - HStack(spacing: isCompact ? 3 : 6) { + // 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 ? 12 : 15) * ts.fontScale, weight: .heavy, design: ts.fontFamily.design)) @@ -437,14 +439,16 @@ private struct ClockContainer: View { weekday == i + 1 ? primary.opacity(0.3) : Color.clear, in: RoundedRectangle(cornerRadius: 4, style: .continuous) ) + if i < dayNames.count - 1 { Spacer(minLength: 2) } } } - .frame(maxWidth: .infinity) // Time — stretches to the same width as the day strip above: - // fittedTimeRow starts oversized and scales down to fit. + // 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, maxHeight: .infinity) + .frame(maxWidth: .infinity) if showDate && !isCompact { Text(fullDate) From 99a067b78723d4862cd06f202e2dc281bdc2a43a Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 09:07:45 -0500 Subject: [PATCH 18/21] Hold rate-pair type at a constant size Values like "3.5 KB/s" and "216.5 KB/s" measured differently, so minimumScaleFactor re-picked the scale on every tick and the type visibly pulsed with live data. Pad every value to the formatters' widest possible output (11 figures) with figure spaces: constant measured width, constant rendered size, sized for the worst realistic reading instead of the current one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 94be637b1d46bdfdfbd938633297537d3a26fa62) --- .../EdgeControl/UI/Components/WidgetHeader.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/EdgeControl/UI/Components/WidgetHeader.swift b/Sources/EdgeControl/UI/Components/WidgetHeader.swift index 82a9fac..0f4b263 100644 --- a/Sources/EdgeControl/UI/Components/WidgetHeader.swift +++ b/Sources/EdgeControl/UI/Components/WidgetHeader.swift @@ -56,7 +56,7 @@ struct RatePairView: View { .font(Theme.label(ts)) .foregroundStyle(Theme.text3(ts)) } - Text(entry.value) + Text(Self.padded(entry.value)) .font(Theme.value(ts)) .foregroundStyle(Theme.text1(ts)) .monospacedDigit() @@ -65,4 +65,15 @@ struct RatePairView: View { } .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) + } } From 7db1211ecc4c1caa8e2ef3df783e1113e0ff3bbe Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 09:23:27 -0500 Subject: [PATCH 19/21] Sort Top Processes by tapping its column headers CPU and MEM headers are live sort buttons (touch and mouse): tap to sort by that column, tap again to flip direction, with the active column showing its arrow. The configured Sort By remains the default the widget opens with. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 60b43b0504bbfc1da2dc941581796ee76aaf76dd) --- .../Widgets/System/ProcessListWidget.swift | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift index 9a784ff..1d8f771 100644 --- a/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift +++ b/Sources/EdgeControl/Widgets/System/ProcessListWidget.swift @@ -38,6 +38,10 @@ public final class ProcessListWidget: DashboardWidget { private struct ProcessListWidgetView: View { @ObservedObject var service: ProcessMonitorService @Environment(\.themeSettings) private var ts + @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? @@ -54,10 +58,8 @@ private struct ProcessListWidgetView: View { HStack { Text("APP") .frame(maxWidth: .infinity, alignment: .leading) - Text("CPU") - .frame(width: 70, alignment: .trailing) - Text("MEM") - .frame(width: 70, alignment: .trailing) + sortHeader("CPU", memory: false) + sortHeader("MEM", memory: true) } .font(Theme.label(ts)) .foregroundStyle(Theme.text3(ts)) @@ -75,9 +77,12 @@ private struct ProcessListWidgetView: View { } else { let fitCount = min(max(Int(geo.size.height / rowHeight), 3), 16) let maxCount = fixedRows ?? fitCount - let ranked = sortByMemory - ? service.topProcesses.sorted { $0.memoryMB > $1.memoryMB } - : service.topProcesses + 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 { @@ -96,6 +101,24 @@ private struct ProcessListWidgetView: View { .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 { From a1a9f3f5464cf3a84e53513e5f9f35657924cdf9 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 10:05:08 -0500 Subject: [PATCH 20/21] Keep Disk I/O and Network titles at 2-column widths The caption was gated on width >= 3, so a 1x2 placement showed an unlabeled pair of numbers. One small-caps line fits a 2-column cell; show it at every width. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit 3ff6ea5d3316145b3922a6476f96c0b183580090) --- Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift | 2 +- Sources/EdgeControl/Widgets/System/DiskIOWidget.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index c76b76c..a33e0c6 100644 --- a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift +++ b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift @@ -25,7 +25,7 @@ public final class NetworkStatsWidget: DashboardWidget { service: service, isCompact: size.height <= 2, isBar: size.height <= 1, - showTitle: size.width >= 3, + showTitle: true, showCompactTotals: size.width >= 5 && size.height >= 2 ) } diff --git a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift index b7dc6c2..98cb5a5 100644 --- a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift +++ b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift @@ -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, showTitle: size.width >= 3, isBar: size.height <= 1) + DiskIOWidgetView(service: service, isCompact: size.height <= 2, showTitle: true, isBar: size.height <= 1) } } From 2ef5cb0afbb7489c2de703642941a5c3aa36173e Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 16 Aug 2026 10:10:59 -0500 Subject: [PATCH 21/21] Allow 1-column Disk I/O and Network placements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RatePairView gains a vertical mode — groups stacked instead of side by side — used when either widget is one column wide, under a slim caption title (the full header can't fit ~120px). Minimum sizes open to 1x1 accordingly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BcS3YNvZzHypLREnxBU5sV (cherry picked from commit a6f1f75e1a4d420b4f6fd07b2659ce8f9d094f7a) --- .../EdgeControl/UI/Components/WidgetHeader.swift | 15 ++++++++++++--- .../Widgets/Network/NetworkStatsWidget.swift | 12 +++++++++--- .../EdgeControl/Widgets/System/DiskIOWidget.swift | 13 +++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Sources/EdgeControl/UI/Components/WidgetHeader.swift b/Sources/EdgeControl/UI/Components/WidgetHeader.swift index 0f4b263..200cf9f 100644 --- a/Sources/EdgeControl/UI/Components/WidgetHeader.swift +++ b/Sources/EdgeControl/UI/Components/WidgetHeader.swift @@ -36,13 +36,22 @@ struct RatePairView: View { 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 { - HStack(spacing: 12) { - group(first) - group(second) + if vertical { + VStack(alignment: .leading, spacing: 8) { + group(first) + group(second) + } + } else { + HStack(spacing: 12) { + group(first) + group(second) + } } } diff --git a/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift b/Sources/EdgeControl/Widgets/Network/NetworkStatsWidget.swift index a33e0c6..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, 1), 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] = [] @@ -25,6 +25,7 @@ public final class NetworkStatsWidget: DashboardWidget { service: service, isCompact: size.height <= 2, isBar: size.height <= 1, + isNarrow: size.width <= 1, showTitle: true, showCompactTotals: size.width >= 5 && size.height >= 2 ) @@ -38,6 +39,8 @@ private struct NetworkStatsWidgetView: View { // 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 @@ -50,12 +53,14 @@ private struct NetworkStatsWidgetView: View { let secondary = Theme.widgetSecondary("network-stats", ts: ts, default: .cyan) ?? Theme.accentCyan VStack(spacing: isCompact ? 6 : 12) { - if !isCompact || (showTitle && !isBar) { + 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) } @@ -73,7 +78,8 @@ private struct NetworkStatsWidgetView: View { value: NetworkMonitorService.formatSpeed(service.uploadSpeed), color: secondary ), - compact: isCompact + compact: isCompact, + vertical: isNarrow ) if !isBar && (!isCompact || showCompactTotals) { diff --git a/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift b/Sources/EdgeControl/Widgets/System/DiskIOWidget.swift index 98cb5a5..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(2, 1), 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, showTitle: true, isBar: size.height <= 1) + DiskIOWidgetView(service: service, isCompact: size.height <= 2, showTitle: true, isBar: size.height <= 1, isNarrow: size.width <= 1) } } @@ -33,15 +33,19 @@ private struct DiskIOWidgetView: View { // 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 || (showTitle && !isBar) { + 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) } @@ -58,7 +62,8 @@ private struct DiskIOWidgetView: View { value: formatSpeed(service.writeBytesPerSec), color: Theme.widgetTertiary("disk-io", ts: ts, default: .orange) ?? Theme.accentOrange ), - compact: isCompact + compact: isCompact, + vertical: isNarrow ) Spacer(minLength: 0)