Neues natives macOS-Settings-Fenster mit Sprache, Auto-Update-Check,
Farbschema (Standard/Kontrastreich), echter Textgrößen-Skalierung,
Bedienelement-Größe und LAN-Scanner-Refreshraten/Sparkline-Einstellungen.
- AppPreferences.swift: zentrale AppStorage-Keys, ColorTheme/AppTextSize/
UIDensity
- SettingsView.swift: 3-Tab-Settings-Scene
- .environment(\.dynamicTypeSize) erwies sich auf macOS als wirkungslos
(per ImageRenderer-Snapshot bewiesen) — durch eigenen appFontScale-
Mechanismus ersetzt, ~110 .font(...)-Aufrufe app-weit umgestellt
- Farbschema auf Wunsch auch auf Experte-Tab-Sidebar ausgeweitet
- Docs aktualisiert: README/HANDOFF/CHATLOG/Manual.md+PDF, found.md
Live bestätigt nach mehreren Nachbesserungsrunden ("passt, lassen wir so").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
237 lines
9.8 KiB
Swift
237 lines
9.8 KiB
Swift
import SwiftUI
|
|
|
|
/// Central catalog for the "Settings" window (⌘,) — every value here is read/written through
|
|
/// `@AppStorage`/`UserDefaults.standard` under the raw string keys named on each property below,
|
|
/// so a view (`@AppStorage`, live-updating) and a background `Task` loop (`UserDefaults.standard`
|
|
/// reads each tick, no observation needed) can both stay in sync with the same preference without
|
|
/// plumbing it through init parameters. `registerDefaults()` is called once at app launch so a
|
|
/// fresh install has real values before any Settings screen has ever been opened, matching the
|
|
/// literal fallback each `@AppStorage`/`UserDefaults` read site also carries — the two must be
|
|
/// kept in sync by hand since `@AppStorage` requires its default inline.
|
|
enum AppPreferences {
|
|
static let colorThemeKey = "colorTheme"
|
|
static let textSizeKey = "textSize"
|
|
static let uiDensityKey = "uiDensity"
|
|
static let autoCheckUpdatesOnConnectKey = "autoCheckUpdatesOnConnect"
|
|
static let lanScannerPollIntervalSecondsKey = "lanScannerPollIntervalSeconds"
|
|
static let lanScannerSparklineWindowSecondsKey = "lanScannerSparklineWindowSeconds"
|
|
static let lanScannerSparklineWidthKey = "lanScannerSparklineWidth"
|
|
|
|
static func registerDefaults() {
|
|
UserDefaults.standard.register(defaults: [
|
|
colorThemeKey: ColorTheme.standard.rawValue,
|
|
textSizeKey: AppTextSize.standard.rawValue,
|
|
uiDensityKey: UIDensity.standard.rawValue,
|
|
autoCheckUpdatesOnConnectKey: false,
|
|
lanScannerPollIntervalSecondsKey: 0.1,
|
|
lanScannerSparklineWindowSecondsKey: 30.0,
|
|
lanScannerSparklineWidthKey: 200.0,
|
|
])
|
|
}
|
|
}
|
|
|
|
/// Two predefined palettes for the Übersicht diagram's node/edge colors and the LAN-Scanner's
|
|
/// status dots (traffic-active, static/dynamic lease, open/closed port) — a free per-category
|
|
/// color picker was explicitly ruled out in favor of this, so every color lives here rather than
|
|
/// scattered across the views that used to hardcode `Color.blue`/`.red`/etc. directly.
|
|
enum ColorTheme: String, CaseIterable, Identifiable {
|
|
case standard
|
|
case highContrast
|
|
|
|
var id: String { rawValue }
|
|
|
|
func color(for category: OverviewNode.Category) -> Color {
|
|
switch self {
|
|
case .standard:
|
|
switch category {
|
|
case .interface: return .blue
|
|
case .ipAddress: return .teal
|
|
case .service: return .purple
|
|
case .route: return .orange
|
|
case .firewall: return .red
|
|
}
|
|
case .highContrast:
|
|
switch category {
|
|
case .interface: return .yellow
|
|
case .ipAddress: return .green
|
|
case .service: return .pink
|
|
case .route: return .cyan
|
|
case .firewall: return .indigo
|
|
}
|
|
}
|
|
}
|
|
|
|
func color(for kind: OverviewEdgeKind) -> Color {
|
|
switch self {
|
|
case .standard:
|
|
switch kind {
|
|
case .vlan: return .indigo
|
|
case .bridgePort: return .cyan
|
|
case .wireguardPeer: return .pink
|
|
case .ipAddress: return .teal
|
|
case .dhcp: return .purple
|
|
case .route: return .orange
|
|
case .firewallInterface: return .red
|
|
case .addressList: return .yellow
|
|
}
|
|
case .highContrast:
|
|
switch kind {
|
|
case .vlan: return .yellow
|
|
case .bridgePort: return .brown
|
|
case .wireguardPeer: return .mint
|
|
case .ipAddress: return .green
|
|
case .dhcp: return .cyan
|
|
case .route: return .indigo
|
|
case .firewallInterface: return .pink
|
|
case .addressList: return .blue
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Experte-tab sidebar: one dot per `RouterOSMenuCategory` (13 cases — far more granular than
|
|
/// the 5-case `OverviewNode.Category`), reusing that same 5-color palette by bucketing every
|
|
/// firewall sub-menu under `.firewall` and everything without an obvious 1:1 match (VPN,
|
|
/// Wi-Fi/CAPsMAN, Queues, System, Tools) under `.service` — keeps "Firewall is red" etc.
|
|
/// consistent with the Übersicht diagram instead of inventing a second, unrelated palette.
|
|
func color(for menuCategory: RouterOSMenuCategory) -> Color {
|
|
switch menuCategory {
|
|
case .firewallFilter, .firewallNat, .firewallMangle, .firewallRaw, .firewallAddressLists:
|
|
return color(for: OverviewNode.Category.firewall)
|
|
case .interfaces:
|
|
return color(for: OverviewNode.Category.interface)
|
|
case .ipAddressing:
|
|
return color(for: OverviewNode.Category.ipAddress)
|
|
case .routing:
|
|
return color(for: OverviewNode.Category.route)
|
|
case .vpn, .wireless, .queues, .system, .tools:
|
|
return color(for: OverviewNode.Category.service)
|
|
}
|
|
}
|
|
|
|
/// LAN-Scanner: port-header "traffic active" dot, and `DeviceRow`'s static/dynamic lease dot.
|
|
/// First draft used `.mint`/`.yellow`/`.pink` here — too close to their standard counterparts
|
|
/// (green/orange/red) to read as a real change at small dot/icon size, per live feedback.
|
|
/// Now a genuinely different hue, not just a lighter shade of the same one.
|
|
var trafficActive: Color { self == .standard ? .green : .blue }
|
|
var staticLease: Color { self == .standard ? .orange : .purple }
|
|
var dynamicLease: Color { self == .standard ? .green : .blue }
|
|
var portOpen: Color { self == .standard ? .red : .orange }
|
|
var portClosed: Color { self == .standard ? .green : .blue }
|
|
}
|
|
|
|
/// Global text scale factor, read via `.environment(\.appFontScale, ...)` at the window root and
|
|
/// applied by every `.appFont(...)` call site (see below).
|
|
///
|
|
/// NOT built on `.environment(\.dynamicTypeSize, ...)` — that was the first attempt, and it does
|
|
/// nothing on macOS: confirmed empirically (an `ImageRenderer` snapshot of the same `Text` at
|
|
/// `.xSmall` vs `.accessibility3` produced pixel-identical output). Unlike iOS, macOS's SwiftUI
|
|
/// text styles (`.headline`, `.caption`, ...) resolve to fixed point sizes that don't respond to
|
|
/// a Dynamic Type category at all, so that environment key is a no-op here. `.appFont(_:)` instead
|
|
/// gives each text style an explicit base point size (macOS's own approximate defaults) that this
|
|
/// scale factor actually multiplies.
|
|
enum AppTextSize: String, CaseIterable, Identifiable {
|
|
case small
|
|
case standard
|
|
case large
|
|
case extraLarge
|
|
|
|
var id: String { rawValue }
|
|
|
|
var scale: CGFloat {
|
|
switch self {
|
|
case .small: return 0.85
|
|
case .standard: return 1.0
|
|
case .large: return 1.2
|
|
case .extraLarge: return 1.4
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AppFontScaleKey: EnvironmentKey {
|
|
static let defaultValue: CGFloat = 1.0
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var appFontScale: CGFloat {
|
|
get { self[AppFontScaleKey.self] }
|
|
set { self[AppFontScaleKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
/// Named, scalable stand-ins for the semantic `Font.TextStyle` cases actually used in this app —
|
|
/// each carries the same base point size macOS itself uses for that style, so `.standard` (scale
|
|
/// 1.0) renders identically to the plain `Font.TextStyle` it replaces.
|
|
enum AppFontStyle {
|
|
case title2, title3, headline, body, callout, subheadline, caption, caption2
|
|
/// An explicit point size/weight/design not covered by a named style above (rare — only where
|
|
/// the code already used `.font(.system(size:...))` directly).
|
|
case fixed(CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default)
|
|
|
|
fileprivate var baseSize: CGFloat {
|
|
switch self {
|
|
case .title2: return 17
|
|
case .title3: return 15
|
|
case .headline: return 13
|
|
case .body: return 13
|
|
case .callout: return 12
|
|
case .subheadline: return 11
|
|
case .caption: return 10
|
|
case .caption2: return 9
|
|
case .fixed(let size, _, _): return size
|
|
}
|
|
}
|
|
|
|
fileprivate var baseWeight: Font.Weight {
|
|
if case .headline = self { return .semibold }
|
|
if case .fixed(_, let weight, _) = self { return weight }
|
|
return .regular
|
|
}
|
|
|
|
fileprivate var design: Font.Design {
|
|
if case .fixed(_, _, let design) = self { return design }
|
|
return .default
|
|
}
|
|
}
|
|
|
|
private struct AppFontModifier: ViewModifier {
|
|
@Environment(\.appFontScale) private var scale
|
|
let style: AppFontStyle
|
|
let weight: Font.Weight?
|
|
let bold: Bool
|
|
let design: Font.Design?
|
|
|
|
func body(content: Content) -> some View {
|
|
let resolvedWeight = bold ? .bold : (weight ?? style.baseWeight)
|
|
content.font(.system(size: style.baseSize * scale, weight: resolvedWeight, design: design ?? style.design))
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Text-only scaling for the Settings "Textgröße" preference — see `AppTextSize`'s doc comment
|
|
/// for why this exists instead of `.environment(\.dynamicTypeSize, ...)`. `bold`/`weight`/
|
|
/// `design` mirror the `.bold()`/`.weight(_:)`/`.monospaced()` chaining the replaced
|
|
/// `.font(...)` calls used.
|
|
func appFont(_ style: AppFontStyle, weight: Font.Weight? = nil, bold: Bool = false, design: Font.Design? = nil) -> some View {
|
|
modifier(AppFontModifier(style: style, weight: weight, bold: bold, design: design))
|
|
}
|
|
}
|
|
|
|
/// Global control/spacing density ("Responsiveness" from the found.md feature idea, made
|
|
/// concrete as SwiftUI's own `.controlSize`, applied once at the window root) — compact fits more
|
|
/// on screen on a small display, comfortable gives buttons/fields more room to hit on a large one.
|
|
enum UIDensity: String, CaseIterable, Identifiable {
|
|
case compact
|
|
case standard
|
|
case comfortable
|
|
|
|
var id: String { rawValue }
|
|
|
|
var controlSize: ControlSize {
|
|
switch self {
|
|
case .compact: return .small
|
|
case .standard: return .regular
|
|
case .comfortable: return .large
|
|
}
|
|
}
|
|
}
|