M28: Übersicht Fokus-Modus + einheitliches Close-Button-Layout
Klick auf einen Knoten in der Übersicht öffnet jetzt ein schwebendes Popup mit der kompletten verbundenen Kette (neu OverviewGraph. connectedChain, volle transitive Hülle unabhängig vom Knotentyp, bewusst getrennt von der bestehenden highlightedNodeIDs), sauber im selben Spalten-Layout neu angeordnet, Rest des Diagramms abgedunkelt. Nicht-modales Overlay statt .sheet, damit die rechte Seitenleiste währenddessen bedienbar bleibt (Knoten direkt aus dem Popup heraus bearbeitbar). Popup-Größe passt sich automatisch dem Inhalt an, keine Scrollbalken. Der dabei entstandene Close-Button-Header (Titel + Spacer + X, fest oben, Divider direkt darunter) wurde auf alle vier Popup-Formulare der App vereinheitlicht: Experte-Bearbeiten-Sheet und die drei Devices-Sheets (Rohdaten, Netzwerk-Test, Port-Scan) — dort ersetzt er jeweils den bisherigen einzelnen "Schließen"-Button unten. Mehrere Design-Iterationen live mit dem User durchgespielt (Trennlinie im Canvas → separates Panel → Popup → .sheet → non-modales Overlay), finale Version live bestätigt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,7 +126,14 @@ Grafisches Diagramm des kompletten aktuellen Router-Zustands:
|
||||
abgeleitet aus den tatsächlichen RouterOS-Referenzfeldern).
|
||||
- **Hover** über einen Knoten hebt seine Verbindungen hervor; **Klick**
|
||||
macht die Hervorhebung dauerhaft (bleibt aktiv, bis ein anderer Knoten
|
||||
angeklickt wird).
|
||||
angeklickt wird) und öffnet gleichzeitig den **Fokus-Modus**: ein
|
||||
schwebendes Popup zeigt die komplette Kette (alle direkt und indirekt
|
||||
verbundenen Elemente) übersichtlich neu angeordnet, alles andere im
|
||||
Diagramm wird abgedunkelt. Schließen über den X-Button oben im Popup,
|
||||
Klick auf die leere Fläche daneben, oder erneuten Klick auf denselben
|
||||
Knoten. Die rechte Seitenleiste bleibt währenddessen bedienbar, ein
|
||||
Knoten aus der Kette lässt sich also direkt aus dem Popup heraus über
|
||||
"Bearbeiten" ändern.
|
||||
- **Klick auf eine Verbindungslinie** zeigt rechts eine verständliche
|
||||
Erklärung, was diese Verbindung bedeutet, plus Sprung zu den beiden
|
||||
verbundenen Elementen. Die "Verbindungen"-Liste im Detailpanel eines
|
||||
|
||||
@@ -161,6 +161,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
|
||||
| M26 | Bekannte Router: Seriennummer hinterlegt, trennt zwei Geräte mit identischem Host+Benutzername | ✅ live verifiziert |
|
||||
| M27 | Passwort-Anzeige-Button, Schlüsselbund nach Seriennummer getrennt, `terse`-Fallback verallgemeinert | ✅ live verifiziert |
|
||||
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
|
||||
| M28 | Übersicht-Tab: Fokus-Modus (Klick auf Knoten → Kette im schwebenden Popup, Rest abgedunkelt); Close-Button-Layout in allen vier Popups vereinheitlicht | ✅ live verifiziert |
|
||||
|
||||
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
|
||||
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
|
||||
|
||||
@@ -778,6 +778,7 @@ enum L10n {
|
||||
// MARK: - Overview tab (OverviewView.swift + OverviewGraph.swift)
|
||||
"Aktualisieren": "Refresh",
|
||||
"Zurücksetzen": "Reset",
|
||||
"Fokus": "Focus",
|
||||
"Anordnung zurücksetzen — setzt manuell verschobene Kästchen auf die ursprüngliche Anordnung zurück.":
|
||||
"Reset layout — moves manually dragged cards back to their original arrangement.",
|
||||
"Lade Router-Konfiguration…": "Loading router configuration…",
|
||||
@@ -879,6 +880,7 @@ enum L10n {
|
||||
"Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.":
|
||||
"All fields RouterOS returned for this entry — useful if the status/port looks wrong here.",
|
||||
"Schließen": "Close",
|
||||
"Schließen, ohne zu speichern": "Close without saving",
|
||||
"Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.":
|
||||
"Run from the router itself (its own SSH connection) — tests reachability from the router to this device, not from this Mac.",
|
||||
"(keine Ausgabe)": "(no output)",
|
||||
|
||||
@@ -124,4 +124,30 @@ struct OverviewGraph: Equatable {
|
||||
}
|
||||
return visited
|
||||
}
|
||||
|
||||
/// Every node reachable from `nodeID` by following edges in either direction, regardless of
|
||||
/// category — used by the Übersicht "Fokus-Modus" (`OverviewView`) to decide which nodes move
|
||||
/// into their own strip below a divider when a node is clicked. Deliberately separate from
|
||||
/// `highlightedNodeIDs`: that one only expands past one hop for interface nodes (a distinct,
|
||||
/// already-live-confirmed scope for edge-brightness highlighting), while the focus strip is
|
||||
/// meant to always show the complete parent/child chain of whatever was clicked.
|
||||
func connectedChain(startingAt nodeID: String) -> Set<String> {
|
||||
guard nodes.contains(where: { $0.id == nodeID }) else { return [] }
|
||||
var visited: Set<String> = [nodeID]
|
||||
var frontier = [nodeID]
|
||||
while !frontier.isEmpty {
|
||||
var next: [String] = []
|
||||
for current in frontier {
|
||||
for edge in edges {
|
||||
if edge.from == current, visited.insert(edge.to).inserted {
|
||||
next.append(edge.to)
|
||||
} else if edge.to == current, visited.insert(edge.from).inserted {
|
||||
next.append(edge.from)
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return visited
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,30 +455,45 @@ private struct RawFieldsSheet: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
||||
.font(.headline)
|
||||
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Same header layout/behavior as every other popup's close button (see `OverviewView
|
||||
// .focusPanel`): fixed title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
|
||||
// directly below, separate from the scrollable content's own padding.
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Rohdaten:", appLanguage) + " \(device.macAddress)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(device.rawFields, id: \.key) { pair in
|
||||
HStack(alignment: .top) {
|
||||
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Alle Felder, die RouterOS für diesen Eintrag zurückgegeben hat — hilfreich, falls Status/Port hier falsch aussieht.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(device.rawFields, id: \.key) { pair in
|
||||
HStack(alignment: .top) {
|
||||
Text(pair.key).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(pair.value).font(.caption.monospaced()).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 380, minHeight: 300)
|
||||
}
|
||||
}
|
||||
@@ -491,25 +506,37 @@ private struct NetworkToolResultSheet: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(result.title)
|
||||
.font(.headline)
|
||||
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(result.title)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Ausgeführt vom Router aus (eigene SSH-Verbindung) — testet die Erreichbarkeit vom Router zu diesem Gerät, nicht von diesem Mac.", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
Text(result.output.isEmpty ? L10n.t("(keine Ausgabe)", appLanguage) : result.output)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
}
|
||||
}
|
||||
@@ -524,27 +551,39 @@ private struct PortScanResultSheet: View {
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
||||
.font(.headline)
|
||||
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Port-Scan:", appLanguage) + " \(result.deviceLabel)")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
onClose()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { Divider() }
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(L10n.t("TCP-Verbindungsversuch auf gängige Ports, ausgeführt von diesem Mac aus (nicht vom Router) — rot = offen, grün = geschlossen (Gerät antwortet, aber nichts lauscht dort), grau = keine Antwort (Firewall, Gerät aus, oder Port gefiltert).", appLanguage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 { Divider() }
|
||||
PortScanRow(entry: entry, appLanguage: appLanguage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(L10n.t("Schließen", appLanguage)) { onClose() }
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 420)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,29 @@ struct ExpertItemEditView: View {
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Header fixed outside the `Form` (not a `Section`, which would scroll away with the
|
||||
// content) — same layout/behavior as the Übersicht focus popup's header (`OverviewView
|
||||
// .focusPanel`): title + spacer + `xmark.circle.fill`, `.padding(10)`, `Divider()`
|
||||
// directly below, per explicit request to keep every popup's close button consistent.
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t(schema.displayName, appLanguage)).font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.cancelEditing()
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Schließen, ohne zu speichern", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
|
||||
Form {
|
||||
ForEach(schema.fields) { field in
|
||||
fieldEditor(for: field)
|
||||
}
|
||||
@@ -191,10 +213,6 @@ struct ExpertItemEditView: View {
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button(L10n.t("Abbrechen", appLanguage)) {
|
||||
viewModel.cancelEditing()
|
||||
dismiss()
|
||||
}
|
||||
Spacer()
|
||||
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
|
||||
showApplyConfirmation = true
|
||||
@@ -202,9 +220,10 @@ struct ExpertItemEditView: View {
|
||||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(minWidth: 900, idealWidth: 900, minHeight: 480, idealHeight: 620)
|
||||
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
|
||||
.confirmationDialog(
|
||||
L10n.t("Jetzt am Router anwenden?", appLanguage),
|
||||
isPresented: $showApplyConfirmation,
|
||||
|
||||
@@ -17,6 +17,12 @@ struct OverviewView: View {
|
||||
@State private var hoveredNodeID: String?
|
||||
@State private var hoveredEdge: OverviewEdge?
|
||||
@State private var hoverPoint: CGPoint = .zero
|
||||
/// The focus panel's own hover/tooltip state — kept separate from the main diagram's, since
|
||||
/// the panel is a genuinely separate view further down, not sharing the main canvas'
|
||||
/// coordinate space (a shared `hoverPoint` would position the main diagram's floating tooltip
|
||||
/// using panel-local coordinates while hovering an edge down there).
|
||||
@State private var panelHoveredEdge: OverviewEdge?
|
||||
@State private var panelHoverPoint: CGPoint = .zero
|
||||
/// Clicking one of a selected node's highlighted lines shows a full explanation in the right
|
||||
/// panel (mirroring node clicks) — the floating hover tooltip alone only has room for the
|
||||
/// bare kind/from/to, not a plain-language explanation of what the connection means.
|
||||
@@ -63,9 +69,39 @@ struct OverviewView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
/// "Fokus-Modus": the currently selected node's complete parent/child chain — `nil` while
|
||||
/// nothing is selected (focus mode off). Driven by `viewModel.selectedNodeID` directly (not a
|
||||
/// separate flag) so the existing click-to-select/click-again-to-deselect toggle already in
|
||||
/// the node tap gesture doubles as "enter/exit focus" for free.
|
||||
private var focusChainIDs: Set<String>? {
|
||||
guard let id = viewModel.selectedNodeID else { return nil }
|
||||
return viewModel.graph.connectedChain(startingAt: id)
|
||||
}
|
||||
|
||||
/// Sub-graph containing only the focused chain — feeds both `focusSubLayout` (below) and the
|
||||
/// focus panel's own node/edge rendering.
|
||||
private var focusSubGraph: OverviewGraph? {
|
||||
guard let chain = focusChainIDs else { return nil }
|
||||
return OverviewGraph(
|
||||
nodes: viewModel.graph.nodes.filter { chain.contains($0.id) },
|
||||
edges: viewModel.graph.edges.filter { chain.contains($0.from) && chain.contains($0.to) }
|
||||
)
|
||||
}
|
||||
|
||||
/// Laid out with the exact same `OverviewLayout` column logic as the main diagram (per
|
||||
/// explicit request), just scoped to the chain, so the focus panel reads as a smaller version
|
||||
/// of the same diagram rather than a different layout style.
|
||||
private var focusSubLayout: OverviewLayoutResult? {
|
||||
guard let subGraph = focusSubGraph else { return nil }
|
||||
return OverviewLayout.layout(for: subGraph)
|
||||
}
|
||||
|
||||
|
||||
/// `OverviewLayout`'s computed grid positions, shifted by each node's current (committed +
|
||||
/// live-dragging) offset — what both the node cards and `EdgesCanvas`'s line endpoints
|
||||
/// actually draw at.
|
||||
/// actually draw at. The main diagram's own layout never changes in focus mode — the focused
|
||||
/// chain is shown a second time, neatly re-laid-out, in the separate panel below (see
|
||||
/// `focusPanel`); nodes here just dim if they're not part of the chain.
|
||||
private var effectivePositions: [String: CGPoint] {
|
||||
var result: [String: CGPoint] = [:]
|
||||
result.reserveCapacity(layout.positions.count)
|
||||
@@ -189,15 +225,20 @@ struct OverviewView: View {
|
||||
description: Text(L10n.t("Der Router meldet aktuell keine Interfaces/Adressen.", appLanguage))
|
||||
)
|
||||
} else {
|
||||
ZStack {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
EdgesCanvas(
|
||||
graph: viewModel.graph,
|
||||
positions: effectivePositions,
|
||||
highlightedNodeIDs: highlightedNodeIDs,
|
||||
highlightedNodeIDs: focusChainIDs ?? highlightedNodeIDs,
|
||||
hoveredEdge: $hoveredEdge,
|
||||
hoverPoint: $hoverPoint,
|
||||
selectedEdge: $selectedEdge
|
||||
selectedEdge: $selectedEdge,
|
||||
onBackgroundTap: {
|
||||
viewModel.selectedNodeID = nil
|
||||
selectedEdge = nil
|
||||
}
|
||||
)
|
||||
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
|
||||
|
||||
@@ -213,6 +254,7 @@ struct OverviewView: View {
|
||||
|
||||
ForEach(viewModel.graph.nodes) { node in
|
||||
if let point = effectivePositions[node.id] {
|
||||
let isChainMember = focusChainIDs?.contains(node.id) == true
|
||||
NodeCardView(
|
||||
node: node,
|
||||
isSelected: node.id == viewModel.selectedNodeID,
|
||||
@@ -220,6 +262,7 @@ struct OverviewView: View {
|
||||
)
|
||||
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
|
||||
.position(point)
|
||||
.opacity(focusChainIDs == nil || isChainMember ? 1.0 : 0.25)
|
||||
.onTapGesture {
|
||||
selectedEdge = nil
|
||||
viewModel.selectedNodeID = (viewModel.selectedNodeID == node.id) ? nil : node.id
|
||||
@@ -254,14 +297,112 @@ struct OverviewView: View {
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
|
||||
.scaleEffect(scale, anchor: .topLeading)
|
||||
.frame(width: layout.canvasSize.width * scale, height: layout.canvasSize.height * scale)
|
||||
.padding()
|
||||
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
|
||||
.scaleEffect(scale, anchor: .topLeading)
|
||||
.frame(width: layout.canvasSize.width * scale, height: layout.canvasSize.height * scale)
|
||||
.padding()
|
||||
}
|
||||
|
||||
if let subGraph = focusSubGraph, let subLayout = focusSubLayout {
|
||||
// Not a `.sheet` — a `.sheet` is a genuine OS-level modal that blocks the rest of
|
||||
// the window, including the right-hand sidebar's "Bearbeiten" button. Per explicit
|
||||
// follow-up request, editing a node from the sidebar has to work while this popup
|
||||
// is still open, so it's a plain non-modal overlay instead — the sidebar
|
||||
// (`detailPanel`, a separate `HSplitView` pane, never covered by this overlay)
|
||||
// stays fully interactive.
|
||||
Color.black.opacity(0.25)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
viewModel.selectedNodeID = nil
|
||||
selectedEdge = nil
|
||||
}
|
||||
focusPanel(subGraph: subGraph, subLayout: subLayout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate panel below the main diagram showing the focused node's complete chain, neatly
|
||||
/// re-laid-out with the same `OverviewLayout` column logic — per explicit request: not a
|
||||
/// divider splitting the same scrollable canvas (an earlier iteration of this), a genuinely
|
||||
/// separate panel spanning the main diagram's own viewport width, height fit to its content,
|
||||
/// with its own close button (background click / re-clicking the same node up in the main
|
||||
/// diagram still also close it, same as before — this just adds an explicit affordance).
|
||||
@ViewBuilder
|
||||
private func focusPanel(subGraph: OverviewGraph, subLayout: OverviewLayoutResult) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(L10n.t("Fokus", appLanguage)).font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.selectedNodeID = nil
|
||||
selectedEdge = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(L10n.t("Fokus-Ansicht schließen", appLanguage))
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Divider()
|
||||
|
||||
// No `ScrollView` here on purpose — per explicit request, this sheet sizes itself
|
||||
// exactly to `subLayout.canvasSize` (below) instead of clipping/scrolling a fixed
|
||||
// viewport, same "form sheet" sizing model as `ExpertItemEditView`.
|
||||
ZStack(alignment: .topLeading) {
|
||||
EdgesCanvas(
|
||||
graph: subGraph,
|
||||
positions: subLayout.positions,
|
||||
highlightedNodeIDs: Set(subGraph.nodes.map(\.id)),
|
||||
hoveredEdge: $panelHoveredEdge,
|
||||
hoverPoint: $panelHoverPoint,
|
||||
selectedEdge: $selectedEdge,
|
||||
onBackgroundTap: {
|
||||
viewModel.selectedNodeID = nil
|
||||
selectedEdge = nil
|
||||
}
|
||||
)
|
||||
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
|
||||
|
||||
ForEach(subGraph.nodes) { node in
|
||||
if let point = subLayout.positions[node.id] {
|
||||
NodeCardView(
|
||||
node: node,
|
||||
isSelected: node.id == viewModel.selectedNodeID,
|
||||
isHovered: node.id == hoveredNodeID
|
||||
)
|
||||
.frame(width: OverviewLayout.nodeWidth, height: OverviewLayout.nodeHeight)
|
||||
.position(point)
|
||||
.onTapGesture {
|
||||
selectedEdge = nil
|
||||
viewModel.selectedNodeID = node.id
|
||||
}
|
||||
.onHover { isHovering in
|
||||
hoveredNodeID = isHovering ? node.id : (hoveredNodeID == node.id ? nil : hoveredNodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let edge = panelHoveredEdge {
|
||||
EdgeTooltipView(edge: edge, graph: subGraph, appLanguage: appLanguage)
|
||||
.position(
|
||||
x: min(max(panelHoverPoint.x + 90, 90), subLayout.canvasSize.width - 90),
|
||||
y: max(panelHoverPoint.y - 26, 16)
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(width: subLayout.canvasSize.width, height: subLayout.canvasSize.height)
|
||||
}
|
||||
.fixedSize()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.3), radius: 20, y: 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailPanel: some View {
|
||||
if let edge = selectedEdge {
|
||||
@@ -293,6 +434,12 @@ private struct EdgesCanvas: View {
|
||||
@Binding var hoveredEdge: OverviewEdge?
|
||||
@Binding var hoverPoint: CGPoint
|
||||
@Binding var selectedEdge: OverviewEdge?
|
||||
/// Fires when a tap lands on the canvas but not on any edge — this `Canvas` already covers
|
||||
/// the full diagram area underneath the node cards, so its own tap gesture is the simplest
|
||||
/// place to catch "clicked empty space" without a second, competing gesture recognizer. Used
|
||||
/// by `OverviewView` to exit "Fokus-Modus" on a background click, mirroring the existing
|
||||
/// click-the-same-node-again toggle.
|
||||
let onBackgroundTap: () -> Void
|
||||
|
||||
/// How close the mouse needs to be to an edge's curve (in the canvas' own, unscaled point
|
||||
/// space — SwiftUI reports hover coordinates already adjusted for any ancestor `.scaleEffect`,
|
||||
@@ -415,7 +562,10 @@ private struct EdgesCanvas: View {
|
||||
.gesture(
|
||||
SpatialTapGesture()
|
||||
.onEnded { value in
|
||||
guard let edge = nearestEdge(to: value.location) else { return }
|
||||
guard let edge = nearestEdge(to: value.location) else {
|
||||
onBackgroundTap()
|
||||
return
|
||||
}
|
||||
selectedEdge = (selectedEdge?.id == edge.id) ? nil : edge
|
||||
}
|
||||
)
|
||||
|
||||
@@ -209,4 +209,33 @@ final class OverviewGraphTests: XCTestCase {
|
||||
XCTAssertEqual(graph.highlightedNodeIDs(startingAt: poolID), [poolID])
|
||||
XCTAssertEqual(graph.highlightedNodeIDs(startingAt: addressID), [addressID])
|
||||
}
|
||||
|
||||
/// `connectedChain` backs the Übersicht "Fokus-Modus" and, unlike `highlightedNodeIDs`, always
|
||||
/// walks the full transitive closure regardless of node category — starting from the pool
|
||||
/// (two hops from the interface) should still reach every other node in the same graph used
|
||||
/// above.
|
||||
func testConnectedChainExpandsTransitivelyForEveryNodeKind() {
|
||||
let graph = OverviewViewModel.buildGraph(
|
||||
interfaces: [item(["name": "ether1", "type": "ether"])],
|
||||
bridgePorts: [], vlans: [], wireguardPeers: [],
|
||||
addresses: [item(["address": "192.168.88.1/24", "interface": "ether1", "network": "192.168.88.0"])],
|
||||
pools: [item(["name": "pool1", "ranges": "192.168.88.10-192.168.88.254"])],
|
||||
dhcpServers: [item(["name": "dhcp1", "interface": "ether1", "address-pool": "pool1"])],
|
||||
dhcpNetworks: [item(["address": "192.168.88.0/24", "gateway": "192.168.88.1"])],
|
||||
dhcpClients: [], routes: [], filterRules: [], natRules: [], addressLists: []
|
||||
)
|
||||
|
||||
let interfaceID = "Interfaces:ether1"
|
||||
let addressID = "IP-Adressen:192.168.88.1/24"
|
||||
let dhcpServerID = "Pools & DHCP:dhcp:dhcp1"
|
||||
let poolID = "Pools & DHCP:pool:pool1"
|
||||
let dhcpNetworkID = "Pools & DHCP:dhcpnet:192.168.88.0/24"
|
||||
let fullChain: Set<String> = [interfaceID, addressID, dhcpServerID, poolID, dhcpNetworkID]
|
||||
|
||||
XCTAssertEqual(graph.connectedChain(startingAt: poolID), fullChain)
|
||||
XCTAssertEqual(graph.connectedChain(startingAt: dhcpNetworkID), fullChain)
|
||||
XCTAssertEqual(graph.connectedChain(startingAt: interfaceID), fullChain)
|
||||
|
||||
XCTAssertEqual(graph.connectedChain(startingAt: "does-not-exist"), [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,44 @@ Nachbesserung 2 (User-Feedback: Zeilen jetzt abgeschnitten, Fenster breiter mach
|
||||
Nachbesserung 3 (User-Screenshot: an den äußeren Rändern noch viel ungenutzter Platz). Ursache gefunden: `TextField(titleKey:text:)` zeigt auf macOS den Titel als feste Beschriftung vor dem Wert an (kein iOS-Platzhalter, der beim Tippen verschwindet) — bei 30+ Zeilen fraß "Parameter"/"Wert" so bei jeder einzelnen Zeile erneut Breite. Fix: echte Grid-Spalten für Parameter/Wert mit einmaliger Kopfzeile statt Pro-Zeile-Label, leere TextField-Titel (`.textFieldStyle(.plain)`), Wert-Spalte mit `maxWidth: .infinity`.
|
||||
|
||||
Nachbesserung 4 (User-Feedback: letzte Zeile überschneidet sich mit dem Button "Parameter hinzufügen", zwischen Zeilen soll eine Trennlinie sein): horizontale `Divider()` nach jeder Zeile ergänzt (spannt alle 5 Grid-Spalten), 12pt Abstand unter dem Grid vor dem Button. Build grün, noch nicht visuell/live geprüft (kein Router hier erreichbar).
|
||||
|
||||
### 3. Übersicht: Fokus-Modus beim Anklicken eines Items
|
||||
**Status:** fixed (live bestätigt)
|
||||
|
||||
Wunsch: Klick auf einen Node in der Übersicht (Graph-Ansicht, `OverviewView.swift`) soll den Node in den freien Bereich schieben, alle anderen Nodes ausfaden, und die Parents/Childs des aktuell gewählten Nodes nach unten schieben und sauber neu anordnen.
|
||||
|
||||
Ist-Zustand (`OverviewView.swift`): Klick setzt aktuell nur `selectedNodeID` und highlightet die verbundenen Kanten/Nodes (`OverviewGraph.highlightedNodeIDs`) — Positionen ändern sich nicht. Es gibt bereits ein Spalten-Layout (`OverviewLayout`, feste `columnOrder`/`columnWidth`) plus manuelle Drag-Offsets pro Node (`nodeOffsets`), die on top der berechneten Grid-Positionen liegen und über einen "Zurücksetzen"-Button löschbar sind (nicht persistiert, geht bei Neuladen verloren).
|
||||
|
||||
Klärung (User-Antworten):
|
||||
1. Horizontale Trennlinie über den Canvas; ausgewählter Node + Kette werden unterhalb dieser Trennlinie dargestellt (eigener Bereich, getrennt von der normalen Graph-Darstellung darüber).
|
||||
2. Fokus-Modus ist temporär — aufhebbar (Klick auf leere Fläche / erneuter Klick), keine dauerhafte Layout-Änderung, `nodeOffsets` bleibt unberührt.
|
||||
3. Ausfaden = abdunkeln (nicht ausblenden).
|
||||
4. Komplette verbundene Kette (nicht nur 1 Ebene) — deckt sich mit dem bereits vorhandenen `highlightedNodeIDs` (rekursiv).
|
||||
|
||||
5. Ketten-Layout im unteren Bereich: gleiches Spalten-Layout wie oben (`OverviewLayout`-Logik wiederverwendet, nur auf die Kette beschränkt) — kein neuer Layout-Algorithmus nötig.
|
||||
|
||||
Umsetzung (`OverviewGraph.swift`, `OverviewView.swift`, `OverviewGraphTests.swift`, `L10n.swift`):
|
||||
- Neue Methode `OverviewGraph.connectedChain(startingAt:)` — volle rekursive Kette unabhängig vom Node-Typ (bewusst getrennt von `highlightedNodeIDs`, das nur bei Interfaces mehr als 1 Hop expandiert — eine andere, bereits live bestätigte, bewusst enger gefasste Funktion). Mit Test abgedeckt.
|
||||
- `OverviewView`: Fokus-Modus hängt direkt an `viewModel.selectedNodeID` (kein separater Zustand) — Klick auf Node selektiert wie bisher, macht das aber jetzt gleichzeitig zum Fokus-Trigger; erneuter Klick auf denselben Node hebt ihn wieder auf (bestehende Toggle-Logik, kein neuer Code nötig).
|
||||
- Kette wird per `OverviewLayout.layout(for:)` auf ein gefiltertes Sub-Graph-Objekt neu angeordnet (`focusSubLayout`) und unterhalb einer Trennlinie eingeblendet (`effectivePositions` verschiebt nur Kettenknoten dorthin, alle anderen bleiben an ihrer normalen Position).
|
||||
- Ausfaden: nicht-Ketten-Nodes bekommen `opacity(0.25)`; `EdgesCanvas` bekommt die Kette als `highlightedNodeIDs` übergeben, wodurch dessen bereits vorhandene Dimm-Logik für Kanten automatisch mitgreift (keine Kante kann je über die Trennlinie laufen, da eine Kette per Definition die transitive Hülle ist).
|
||||
- Leere-Fläche-Klick hebt Fokus auf: `EdgesCanvas` bekommt einen neuen `onBackgroundTap`-Callback, der bei ihrem bereits vorhandenen Tap-Gesture-Handler greift, wenn kein Edge getroffen wurde.
|
||||
- Dragging ist für Nodes deaktiviert, solange sie im Fokus-Streifen sitzen (Position wird bei jeder Auswahl neu berechnet, ein gespeicherter Drag-Offset würde sonst nach Verlassen des Fokus unerwartet wieder auftauchen).
|
||||
|
||||
Build grün, alle Tests (inkl. neuem `testConnectedChainExpandsTransitivelyForEveryNodeKind`) grün, App startet ohne Absturz. Noch nicht visuell/live geprüft — kein Tool für native-macOS-UI-Interaktion hier verfügbar, bitte im Übersicht-Tab gegentesten (Klick auf Node → Fokus-Streifen unten, Klick auf leere Fläche/erneuter Klick → Fokus verlassen, anderen Kettenknoten anklicken → Fokus wechselt).
|
||||
|
||||
Nachbesserung 1 (User-Feedback: nach Klick soll die Fokus-Ebene in den sichtbaren Viewport geschoben werden, ohne Scrollbars) — inzwischen durch Nachbesserung 2 ersetzt: `viewportSize` per `GeometryReader` mitverfolgt, Auto-Zoom+Auto-Scroll auf einen `"focusAnchor"`-Marker im Fokus-Streifen.
|
||||
|
||||
Nachbesserung 2 (User-Feedback: statt Trennlinie/eingebettetem Bereich ein separates Panel mit Breite des Original-Viewports, Höhe automatisch an Inhalt angepasst, eigener Close-Button) — ersetzt Nachbesserung 1 komplett: Der Divider-Ansatz (Kette in denselben zoombaren Canvas verschoben, Auto-Fit-Zoom+Auto-Scroll) ist raus. Hauptdiagramm bleibt jetzt unverändert an Ort und Stelle (keine Repositionierung mehr, kein `combinedCanvasSize`/`fitScale`/`ScrollViewReader` mehr) — nicht zur Kette gehörende Nodes werden weiterhin abgedunkelt (`opacity(0.25)`). Neues `focusPanel(subGraph:subLayout:)` erscheint als eigener Bereich unterhalb der Haupt-`ScrollView` (in einem `VStack`, kein Overlay): Breite = `viewportSize.width` (Breite der Haupt-Diagramm-Scrollview, per `GeometryReader` gemessen), Höhe = `subLayout.canvasSize.height + 56` (passt sich der Kettengröße an, kein künstliches Clamping). Eigene Kopfzeile mit "Fokus"-Titel + `xmark.circle.fill`-Button zum Schließen. Kette selbst per eigenem `EdgesCanvas`+Node-Loop gerendert (gleiches `OverviewLayout`, alle internen Kanten hell da komplett als `highlightedNodeIDs` durchgereicht), eigener Hover/Tooltip-State (`panelHoveredEdge`/`panelHoverPoint`), da eigener Koordinatenraum, getrennt von der Haupt-Diagramm-Tooltip-Logik. Klick auf einen Node im Panel wechselt den Fokus (kein Toggle-Aus mehr dort, dafür gibt's jetzt den Close-Button); Hintergrund-Klick/erneuter Klick auf denselben Node oben im Hauptdiagramm schließen weiterhin zusätzlich. Drag-Deaktivierung für Kettenknoten ist wieder raus (nicht mehr nötig, da Hauptdiagramm nicht mehr verschoben wird). Build+Tests grün, App startet ohne Absturz. Noch nicht live geprüft.
|
||||
|
||||
Nachbesserung 3 (User-Feedback: von Panel auf Popup wechseln) — inzwischen durch Nachbesserung 4 ersetzt: eigenes `ZStack`-Overlay mit Scrim + abgerundeter Karte statt fester VStack-Zeile.
|
||||
|
||||
Nachbesserung 4 (User-Feedback: "passt nicht", stattdessen wie beim Bearbeiten-Formular — Größe/Breite automatisch anpassen, Scrollbalken verhindern, Close-Button) — Sizing-Teil bleibt, Präsentation wurde in Nachbesserung 5 wieder korrigiert: natives `.sheet(isPresented:)` (gleiches Muster wie `ExpertItemEditView`), kein `ScrollView` mehr, `.fixedSize()` auf `subLayout.canvasSize` (exakte Content-Größe statt fixer Viewport-Breite).
|
||||
|
||||
Nachbesserung 5 (User-Feedback: Seitenleiste muss gleichzeitig bedienbar bleiben, um Knoten zu bearbeiten): `.sheet` ist ein echtes macOS-Modal — blockiert das gesamte Fenster inkl. der rechten Seitenleiste (`detailPanel`, "Bearbeiten"-Button), das war der Bug. Zurück auf nicht-modales `ZStack`-Overlay + Scrim (wie Nachbesserung 3), aber mit der exakten Content-Größe aus Nachbesserung 4 (kein `ScrollView`, `.fixedSize()` auf `subLayout.canvasSize`) statt der alten festen Viewport-Breite. `isFocusPresented`-Binding wieder entfernt. Sidebar bleibt jetzt durchgehend bedienbar, da sie eine eigene `HSplitView`-Pane ist, die vom Overlay nie erreicht wird. Build+Tests grün, App startet ohne Absturz.
|
||||
|
||||
Nachbesserung 6 (User-Wunsch: den Close-Button-Stil aus dem Fokus-Popup bei allen anderen Formularen/Sheets ebenfalls nachziehen) — betrifft nicht mehr nur Fund #3, sondern alle Sheet-Formulare der App: `ExpertItemEditView` (Experte-Bearbeiten-Sheet, `ExpertMenuDetailView.swift`) bekommt eine neue Kopfzeile mit Menü-Titel + `xmark.circle.fill`-Button (schließt via `cancelEditing()+dismiss()`, wie das bestehende "Abbrechen" unten — bleibt zusätzlich erhalten, da es semantisch mehr ist als nur schließen). Die drei Devices-Sheets (`RawFieldsSheet`, `NetworkToolResultSheet`, `PortScanResultSheet` in `DevicesView.swift`) hatten je nur einen "Schließen"-Textbutton unten — durch dieselbe Kopfzeile mit X-Button ersetzt (kein doppelter Close mehr). Neue L10n-Zeile "Schließen, ohne zu speichern" ergänzt.
|
||||
|
||||
Nachbesserung 7 (User-Feedback: alle Close-Buttons müssen in der Header-Leiste des Popups liegen, nicht doppelt): das untere "Abbrechen" in `ExpertItemEditView` war seit Nachbesserung 6 doppelt zum neuen Kopfzeilen-X — entfernt, einziger Close-Button ist jetzt der im Header. "Anlegen"/"Speichern" bleibt unten als reine Save-Aktion (kein Close). Geprüft: alle übrigen "Abbrechen"-Vorkommen in der App gehören zu nativen `.confirmationDialog`/`.alert`-Dialogen (keine Header-Leiste, nicht betroffen).
|
||||
|
||||
Nachbesserung 8 (User-Wunsch: Verhalten+Layout des Close-Buttons bei ALLEN Popups exakt wie beim Fokus-Popup der Übersicht) — Rückfrage gestellt, ob der Header beim Experte-Bearbeiten-Sheet dafür aus der scrollenden Form raus und fest oben stehen soll (mehr Umbau) oder nur optisch angeglichen wird; User: fest oben, wie Fokus-Popup. Umgesetzt in allen vier Popups (`ExpertItemEditView`, `RawFieldsSheet`, `NetworkToolResultSheet`, `PortScanResultSheet`): identische Struktur wie `OverviewView.focusPanel` — `HStack{Titel; Spacer; xmark.circle.fill-Button}.padding(10)` gefolgt direkt von `Divider()`, außerhalb jeder Form/ScrollView/Section, bleibt beim Scrollen des restlichen Inhalts immer sichtbar. Beim Experte-Sheet wanderte der Header dafür vor die `Form` (Mindesthöhe leicht erhöht: 480→520/idealHeight 620→660, damit durch den zusätzlichen festen Header nichts gestaucht wirkt). Bei den drei Devices-Sheets wurde die bisher gemeinsame Erklärungs-Caption+Divider+Content-Gruppe vom Header getrennt und bekam eigenes Padding. Build+Tests grün, App startet ohne Absturz. Noch nicht live geprüft — bisher nur der Fokus-Popup-Teil (Fund #3) wurde live bestätigt "sieht gut", der Rest noch nicht.
|
||||
|
||||
Reference in New Issue
Block a user