From c24001173d407217fd2ea21c530f816a81450f33 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:14:33 +0900 Subject: [PATCH 1/8] feat(core): add RectRelation to classify rect pairs Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/RectRelation.swift | 45 ++++++++++++ .../ViewMonitorTests/RectRelationTests.swift | 72 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 Sources/ViewMonitor/Core/RectRelation.swift create mode 100644 Tests/ViewMonitorTests/RectRelationTests.swift diff --git a/Sources/ViewMonitor/Core/RectRelation.swift b/Sources/ViewMonitor/Core/RectRelation.swift new file mode 100644 index 0000000..671f8bf --- /dev/null +++ b/Sources/ViewMonitor/Core/RectRelation.swift @@ -0,0 +1,45 @@ +// +// RectRelation.swift +// ViewMonitor +// + +import UIKit + +/// 2つの矩形の位置関係。ビュー階層に依存しない純粋な判定。 +enum RectRelation: Equatable { + + /// 少なくとも一方の軸で離れている。投影が重なっている軸は nil。 + /// 0 は「ぴったり接している」を意味する有効値。 + case separated(gapX: CGFloat?, gapY: CGFloat?) + + /// 両軸で投影が重なっているが、内包ではない。値は重なり幅(正)。 + case overlapping(overlapX: CGFloat, overlapY: CGFloat) + + /// 一方が他方を内包する。インセットは常に「内側の矩形の、外側の + /// 矩形の各辺からの距離」で、どちらを先に渡しても同じ値になる。 + case contained(insets: UIEdgeInsets) + + /// `a` と `b` の位置関係を判定する。 + /// サイズ 0 の矩形は `contains` が偽になり gap 計算に落ちる(許容)。 + static func between(_ a: CGRect, _ b: CGRect) -> RectRelation { + if a.contains(b) || b.contains(a) { + let outer = a.contains(b) ? a : b + let inner = a.contains(b) ? b : a + return .contained(insets: UIEdgeInsets( + top: inner.minY - outer.minY, + left: inner.minX - outer.minX, + bottom: outer.maxY - inner.maxY, + right: outer.maxX - inner.maxX + )) + } + let gapX = max(a.minX, b.minX) - min(a.maxX, b.maxX) + let gapY = max(a.minY, b.minY) - min(a.maxY, b.maxY) + if gapX < 0 && gapY < 0 { + return .overlapping(overlapX: -gapX, overlapY: -gapY) + } + return .separated( + gapX: gapX >= 0 ? gapX : nil, + gapY: gapY >= 0 ? gapY : nil + ) + } +} diff --git a/Tests/ViewMonitorTests/RectRelationTests.swift b/Tests/ViewMonitorTests/RectRelationTests.swift new file mode 100644 index 0000000..5f93dc8 --- /dev/null +++ b/Tests/ViewMonitorTests/RectRelationTests.swift @@ -0,0 +1,72 @@ +import Testing +import UIKit +@testable import ViewMonitor + +@Suite("RectRelation") +struct RectRelationTests { + + @Test("縦に離れている(X投影は重なる)") + func verticallySeparated() { + let a = CGRect(x: 0, y: 0, width: 100, height: 20) + let b = CGRect(x: 0, y: 44, width: 100, height: 20) + + #expect(RectRelation.between(a, b) == .separated(gapX: nil, gapY: 24)) + } + + @Test("横に離れている(Y投影は重なる)") + func horizontallySeparated() { + let a = CGRect(x: 0, y: 0, width: 50, height: 20) + let b = CGRect(x: 58, y: 0, width: 50, height: 20) + + #expect(RectRelation.between(a, b) == .separated(gapX: 8, gapY: nil)) + } + + @Test("斜めに離れている") + func diagonallySeparated() { + let a = CGRect(x: 0, y: 0, width: 50, height: 20) + let b = CGRect(x: 58, y: 44, width: 50, height: 20) + + #expect(RectRelation.between(a, b) == .separated(gapX: 8, gapY: 24)) + } + + @Test("接している辺は gap 0") + func touchingEdges() { + let a = CGRect(x: 0, y: 0, width: 100, height: 20) + let b = CGRect(x: 0, y: 20, width: 100, height: 20) + + #expect(RectRelation.between(a, b) == .separated(gapX: nil, gapY: 0)) + } + + @Test("部分的に重なっている") + func partiallyOverlapping() { + let a = CGRect(x: 0, y: 0, width: 100, height: 100) + let b = CGRect(x: 50, y: 70, width: 100, height: 100) + + #expect(RectRelation.between(a, b) == .overlapping(overlapX: 50, overlapY: 30)) + } + + @Test("内包はインセット4値を返す") + func containedInsets() { + let outer = CGRect(x: 0, y: 0, width: 100, height: 100) + let inner = CGRect(x: 16, y: 12, width: 60, height: 40) + + #expect(RectRelation.between(outer, inner) == .contained( + insets: UIEdgeInsets(top: 12, left: 16, bottom: 48, right: 24) + )) + } + + @Test("内包の判定は引数の順序に依存しない") + func containmentIsDirectionAgnostic() { + let outer = CGRect(x: 0, y: 0, width: 100, height: 100) + let inner = CGRect(x: 16, y: 12, width: 60, height: 40) + + #expect(RectRelation.between(outer, inner) == RectRelation.between(inner, outer)) + } + + @Test("同一矩形はインセット0の内包") + func identicalRects() { + let rect = CGRect(x: 10, y: 10, width: 50, height: 50) + + #expect(RectRelation.between(rect, rect) == .contained(insets: .zero)) + } +} From 7079ce4bbcfb263d6b21b9c4be6258b1c4effe8f Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:24:32 +0900 Subject: [PATCH 2/8] refactor(core): build common rows from a single title list Eliminates nil guard branching by using optional chaining and map operations. Consolidates title list definition to a single location, improving maintainability. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/InfoRowBuilder.swift | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Sources/ViewMonitor/Core/InfoRowBuilder.swift b/Sources/ViewMonitor/Core/InfoRowBuilder.swift index 6a31c5e..cde7c28 100644 --- a/Sources/ViewMonitor/Core/InfoRowBuilder.swift +++ b/Sources/ViewMonitor/Core/InfoRowBuilder.swift @@ -20,21 +20,17 @@ enum InfoRowBuilder { /// nil のときは共通8行をすべて `None` で返す。呼び出し側が計測対象を /// 取得できなかった場合の防御的な契約で、前の計測値を出し続けない。 static func rows(from inspection: ViewInspection?) -> [InfoRow] { - guard let inspection else { - return ["class", "x", "y", "width", "height", "background", "alpha", "cornerRadius"] - .map { InfoRow(title: $0, value: "None") } - } var rows = [ - InfoRow(title: "class", value: inspection.className), - InfoRow(title: "x", value: format(inspection.frameInWindow.origin.x)), - InfoRow(title: "y", value: format(inspection.frameInWindow.origin.y)), - InfoRow(title: "width", value: format(inspection.size.width)), - InfoRow(title: "height", value: format(inspection.size.height)), - InfoRow(title: "background", value: hex(inspection.backgroundColorHex)), - InfoRow(title: "alpha", value: format(inspection.alpha)), - InfoRow(title: "cornerRadius", value: format(inspection.cornerRadius)) + InfoRow(title: "class", value: inspection?.className ?? "None"), + InfoRow(title: "x", value: inspection.map { format($0.frameInWindow.origin.x) } ?? "None"), + InfoRow(title: "y", value: inspection.map { format($0.frameInWindow.origin.y) } ?? "None"), + InfoRow(title: "width", value: inspection.map { format($0.size.width) } ?? "None"), + InfoRow(title: "height", value: inspection.map { format($0.size.height) } ?? "None"), + InfoRow(title: "background", value: inspection.map { hex($0.backgroundColorHex) } ?? "None"), + InfoRow(title: "alpha", value: inspection.map { format($0.alpha) } ?? "None"), + InfoRow(title: "cornerRadius", value: inspection.map { format($0.cornerRadius) } ?? "None") ] - if let font = inspection.font { + if let font = inspection?.font { rows.append(InfoRow(title: "font", value: font.familyName)) rows.append(InfoRow(title: "fontSize", value: format(font.pointSize))) rows.append(InfoRow(title: "fontColor", value: hex(font.colorHex))) From d56b4827389b19dcf457c87db14df648fc476f71 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:25:38 +0900 Subject: [PATCH 3/8] feat(core): append distance rows when a reference inspection is given Adds comparedTo parameter to rows(from:comparedTo:) with default nil for backward compatibility. Distance section includes vs row plus relational rows (gapX/gapY for separated, overlapX/overlapY for overlapping, or top/ left/bottom/right for contained). Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/InfoRowBuilder.swift | 43 +++++++++++-- .../InfoRowBuilderTests.swift | 62 +++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/Sources/ViewMonitor/Core/InfoRowBuilder.swift b/Sources/ViewMonitor/Core/InfoRowBuilder.swift index cde7c28..bd96ce6 100644 --- a/Sources/ViewMonitor/Core/InfoRowBuilder.swift +++ b/Sources/ViewMonitor/Core/InfoRowBuilder.swift @@ -12,14 +12,19 @@ struct InfoRow: Equatable { } /// ViewInspection から表示行を組み立てる。 -/// 条件行(フォント系)と数値・色の整形をすべてここに集約し、 +/// 条件行(フォント系・距離)と数値・色の整形をすべてここに集約し、 /// UIKit のビュー階層に依存しない純粋関数だけで構成する。 enum InfoRowBuilder { /// 表示行を組み立てる。 - /// nil のときは共通8行をすべて `None` で返す。呼び出し側が計測対象を - /// 取得できなかった場合の防御的な契約で、前の計測値を出し続けない。 - static func rows(from inspection: ViewInspection?) -> [InfoRow] { + /// `inspection` が nil のときは共通8行をすべて `None` で返す。呼び出し側が + /// 計測対象を取得できなかった場合の防御的な契約で、前の計測値を出し続けない。 + /// `reference` は直前に選択していたビューの計測結果。`inspection` とともに + /// 非 nil のとき、距離セクション(vs 行+関係に応じた行)を末尾に加える。 + static func rows( + from inspection: ViewInspection?, + comparedTo reference: ViewInspection? = nil + ) -> [InfoRow] { var rows = [ InfoRow(title: "class", value: inspection?.className ?? "None"), InfoRow(title: "x", value: inspection.map { format($0.frameInWindow.origin.x) } ?? "None"), @@ -35,6 +40,36 @@ enum InfoRowBuilder { rows.append(InfoRow(title: "fontSize", value: format(font.pointSize))) rows.append(InfoRow(title: "fontColor", value: hex(font.colorHex))) } + if let inspection, let reference { + rows.append(contentsOf: distanceRows(from: inspection, to: reference)) + } + return rows + } + + /// vs 行と、矩形関係に応じた距離行。 + /// 分離時に投影が重なっている軸(nil)は行を出さない。 + private static func distanceRows( + from inspection: ViewInspection, + to reference: ViewInspection + ) -> [InfoRow] { + var rows = [InfoRow(title: "vs", value: reference.className)] + switch RectRelation.between(inspection.frameInWindow, reference.frameInWindow) { + case .separated(let gapX, let gapY): + if let gapX { + rows.append(InfoRow(title: "gapX", value: format(gapX))) + } + if let gapY { + rows.append(InfoRow(title: "gapY", value: format(gapY))) + } + case .overlapping(let overlapX, let overlapY): + rows.append(InfoRow(title: "overlapX", value: format(overlapX))) + rows.append(InfoRow(title: "overlapY", value: format(overlapY))) + case .contained(let insets): + rows.append(InfoRow(title: "top", value: format(insets.top))) + rows.append(InfoRow(title: "left", value: format(insets.left))) + rows.append(InfoRow(title: "bottom", value: format(insets.bottom))) + rows.append(InfoRow(title: "right", value: format(insets.right))) + } return rows } diff --git a/Tests/ViewMonitorTests/InfoRowBuilderTests.swift b/Tests/ViewMonitorTests/InfoRowBuilderTests.swift index 151cc4f..0fc1782 100644 --- a/Tests/ViewMonitorTests/InfoRowBuilderTests.swift +++ b/Tests/ViewMonitorTests/InfoRowBuilderTests.swift @@ -86,4 +86,66 @@ struct InfoRowBuilderTests { #expect(rows[1] == InfoRow(title: "x", value: pair.1)) } + + @Test("分離した参照を渡すと vs 行と gap 行が末尾に並ぶ") + func appendsDistanceRowsForSeparatedReference() { + // 現在: y120 h20 → minY 120 / 参照: y76 h20 → maxY 96 → gapY 24。X は同一投影で nil + let reference = makeInspection(className: "UILabel", y: 76, height: 20) + + let rows = InfoRowBuilder.rows(from: makeInspection(), comparedTo: reference) + + #expect(Array(rows.suffix(2)) == [ + InfoRow(title: "vs", value: "UILabel"), + InfoRow(title: "gapY", value: "24") + ]) + } + + @Test("重なっている参照は overlap 行になる") + func appendsOverlapRows() { + // 現在 x16..359 y120..140 / 参照 x200..543 y130..150 → overlapX 159, overlapY 10 + let reference = makeInspection(className: "UIView", x: 200, y: 130) + + let rows = InfoRowBuilder.rows(from: makeInspection(), comparedTo: reference) + + #expect(Array(rows.suffix(3)) == [ + InfoRow(title: "vs", value: "UIView"), + InfoRow(title: "overlapX", value: "159"), + InfoRow(title: "overlapY", value: "10") + ]) + } + + @Test("内包する参照はインセット4行になる") + func appendsInsetRows() { + // 現在 x16..359 y120..140 は参照 x0..375 y100..160 に内包 + let reference = makeInspection(className: "UIStackView", x: 0, y: 100, width: 375, height: 60) + + let rows = InfoRowBuilder.rows(from: makeInspection(), comparedTo: reference) + + #expect(Array(rows.suffix(5)) == [ + InfoRow(title: "vs", value: "UIStackView"), + InfoRow(title: "top", value: "20"), + InfoRow(title: "left", value: "16"), + InfoRow(title: "bottom", value: "20"), + InfoRow(title: "right", value: "16") + ]) + } + + @Test("inspection が nil なら参照があっても距離セクションを出さない") + func omitsDistanceSectionForNilInspection() { + let rows = InfoRowBuilder.rows(from: nil, comparedTo: makeInspection()) + + #expect(rows.map(\.title) == ["class", "x", "y", "width", "height", "background", "alpha", "cornerRadius"]) + } + + @Test("フォント行の後に距離セクションが来る") + func distanceSectionFollowsFontRows() { + let font = ViewInspection.FontInfo(familyName: "Helvetica", pointSize: 17, colorHex: nil) + let reference = makeInspection(className: "UILabel", y: 76, height: 20) + + let rows = InfoRowBuilder.rows(from: makeInspection(font: font), comparedTo: reference) + + #expect(rows.count == 13) + #expect(rows[10].title == "fontColor") + #expect(rows[11] == InfoRow(title: "vs", value: "UILabel")) + } } From d1598a6bbce03ea9996d607e216c4a7da81973d2 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:31:09 +0900 Subject: [PATCH 4/8] feat(ui): show distance to the previously selected view Track the previously selected MonitorButton (lastSelectedButton, weak) in MonitorOverlay and pass its inspection as the comparedTo reference to InfoRowBuilder.rows(from:comparedTo:), so selecting a second view appends the vs/gap/overlap/inset rows. select(sender:) is loosened from private to internal as a test seam for the new MonitorOverlayTests. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/UI/MonitorOverlay.swift | 18 ++- .../MonitorOverlayTests.swift | 112 ++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 Tests/ViewMonitorTests/MonitorOverlayTests.swift diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index fbac05b..033f0a6 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -20,6 +20,10 @@ final class MonitorOverlay: NSObject { /// 計測のために userInteractionEnabled を一時的に有効化したビュー。 /// hide() で元に戻す。 private var forcedInteractionViews: [UIView] = [] + /// 直前に選択したボタン。距離計測の参照元。 + /// weak なので hide() / 画面遷移の reload でボタンが破棄されれば + /// 自動的に nil に戻り、古い画面のビューとペアになることがない。 + private weak var lastSelectedButton: MonitorButton? init(configuration: MonitorConfiguration = .default) { self.configuration = configuration @@ -80,8 +84,10 @@ final class MonitorOverlay: NSObject { view.addSubview(button) } + /// 選択状態の切り替え。実行時はボタンの target-action からのみ呼ばれる。 + /// MonitorOverlayTests から呼べるよう internal にしている。 @objc - private func select(sender: MonitorButton) { + func select(sender: MonitorButton) { sender.isSelected.toggle() guard let infoView else { return @@ -94,9 +100,17 @@ final class MonitorOverlay: NSObject { // シーンがずれたときに誤ったウィンドウで変換してしまう。 let window = rootView?.window ?? (rootView as? UIWindow) let inspection = sender.targetView.map { ViewInspector.inspect($0, in: window) } - infoView.update(rows: InfoRowBuilder.rows(from: inspection)) + let reference: ViewInspection? = { + guard let last = lastSelectedButton, last !== sender, + let referenceView = last.targetView, referenceView.window != nil else { + return nil + } + return ViewInspector.inspect(referenceView, in: window) + }() + infoView.update(rows: InfoRowBuilder.rows(from: inspection, comparedTo: reference)) sender.layer.borderWidth = 2.0 sender.layer.borderColor = UIColor.red.cgColor + lastSelectedButton = sender } for other in buttons where other !== sender { other.layer.borderWidth = 0.0 diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift new file mode 100644 index 0000000..8028a69 --- /dev/null +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -0,0 +1,112 @@ +import Testing +import UIKit +@testable import ViewMonitor + +@Suite("MonitorOverlay") +@MainActor +struct MonitorOverlayTests { + + private func makeWindow() -> UIWindow { + // 素の UIView をルートにすると frameInWindow が bounds にフォールバックして + // 全ビューが原点で重なるため、実座標が出る UIWindow をルートにする。 + UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + } + + private func monitorButton(on view: UIView) -> MonitorButton? { + view.subviews.compactMap { $0 as? MonitorButton }.first + } + + private func infoView(in root: UIView) -> InfoView? { + root.subviews.compactMap { $0 as? InfoView }.first + } + + private func rowTexts(in root: UIView) -> [String] { + infoView(in: root)?.rowLabels.compactMap(\.text) ?? [] + } + + @Test("1つ目の選択では距離セクションが出ない") + func firstSelectionHasNoDistanceSection() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + window.addSubview(label) + overlay.show(on: window) + + overlay.select(sender: try #require(monitorButton(on: label))) + + #expect(!rowTexts(in: window).contains { $0.hasPrefix("vs:") }) + } + + @Test("2つ目の選択で vs 行と距離行が出る") + func secondSelectionShowsDistanceSection() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + let image = UIImageView(frame: CGRect(x: 16, y: 144, width: 100, height: 40)) + window.addSubview(label) + window.addSubview(image) + overlay.show(on: window) + + overlay.select(sender: try #require(monitorButton(on: label))) + overlay.select(sender: try #require(monitorButton(on: image))) + + let texts = rowTexts(in: window) + #expect(texts.contains("vs: UILabel")) + #expect(texts.contains("gapY: 24")) + #expect(!texts.contains { $0.hasPrefix("gapX:") }) + } + + @Test("同じボタンの再選択では距離セクションが出ない") + func reselectingSameButtonHasNoDistanceSection() throws { + // ON → OFF → ON で参照が自分自身にならないこと。 + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + window.addSubview(label) + overlay.show(on: window) + let button = try #require(monitorButton(on: label)) + + overlay.select(sender: button) + overlay.select(sender: button) + overlay.select(sender: button) + + #expect(!rowTexts(in: window).contains { $0.hasPrefix("vs:") }) + } + + @Test("参照ビューが window から外れていたら距離セクションを出さない") + func removedReferenceOmitsDistanceSection() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + let image = UIImageView(frame: CGRect(x: 16, y: 144, width: 100, height: 40)) + window.addSubview(label) + window.addSubview(image) + overlay.show(on: window) + let labelButton = try #require(monitorButton(on: label)) + let imageButton = try #require(monitorButton(on: image)) + + overlay.select(sender: labelButton) + label.removeFromSuperview() + overlay.select(sender: imageButton) + + #expect(!rowTexts(in: window).contains { $0.hasPrefix("vs:") }) + } + + @Test("targetView が nil のボタンを選択すると全項目 None になる") + func nilTargetShowsNoneRows() throws { + // フェーズAで未検証だった防御的経路の回収。 + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + window.addSubview(label) + overlay.show(on: window) + let button = try #require(monitorButton(on: label)) + button.targetView = nil + + overlay.select(sender: button) + + let texts = rowTexts(in: window) + #expect(texts.count == 8) + #expect(texts.allSatisfy { $0.hasSuffix(": None") }) + } +} From a66c396b032a7ca577239b7ebd0f16b392da6e4b Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:38:10 +0900 Subject: [PATCH 5/8] docs(changelog): add unreleased entry for distance measurement Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ea605..9ca4c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- 2つのビュー間の距離計測。ビューを選択したあと別のビューを選択すると、直前の選択との関係が InfoView に表示される。離れていればエッジ間ギャップ(`gapX` / `gapY`)、重なっていれば重なり幅(`overlapX` / `overlapY`)、一方が他方を内包していれば内側ビューのインセット(`top` / `left` / `bottom` / `right`) + ## [2.1.0] - 2026-08-05 ### Added From ee6d0228ac0f46129820d95d26f37769c7669015 Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:47:45 +0900 Subject: [PATCH 6/8] docs(core): correct zero-size rectangle containment behavior documentation Update RectRelation.between doc comment to accurately reflect that CGRect.contains returns true when a zero-size rectangle is inside the outer bounds, making them treated as contained. Previously documented behavior was inverted. Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/Core/RectRelation.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/ViewMonitor/Core/RectRelation.swift b/Sources/ViewMonitor/Core/RectRelation.swift index 671f8bf..320eda7 100644 --- a/Sources/ViewMonitor/Core/RectRelation.swift +++ b/Sources/ViewMonitor/Core/RectRelation.swift @@ -20,7 +20,8 @@ enum RectRelation: Equatable { case contained(insets: UIEdgeInsets) /// `a` と `b` の位置関係を判定する。 - /// サイズ 0 の矩形は `contains` が偽になり gap 計算に落ちる(許容)。 + /// サイズ 0 の矩形も内側にあれば `contains` が真になり内包として扱われる。 + /// 外側にあれば gap 計算に落ちる(いずれも許容)。 static func between(_ a: CGRect, _ b: CGRect) -> RectRelation { if a.contains(b) || b.contains(a) { let outer = a.contains(b) ? a : b From 44626826fdcdc57fbd27c0efd70870552f7d96fb Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Wed, 5 Aug 2026 23:47:55 +0900 Subject: [PATCH 7/8] fix(ui): harden reference view window identity check and explicit reference reset Improve reference tracking robustness in MonitorOverlay: - Enhance window reference guard to require reference and measurement targets to be in the same window instance (referenceWindow === window), preventing mixed coordinate system measurements when views move between windows - Explicitly reset lastSelectedButton in hide() for lifetime-independent cleanup instead of relying on dealloc Co-Authored-By: Claude Fable 5 --- Sources/ViewMonitor/UI/MonitorOverlay.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index 033f0a6..5bfe17f 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -49,6 +49,7 @@ final class MonitorOverlay: NSObject { infoView = nil forcedInteractionViews.forEach { $0.isUserInteractionEnabled = false } forcedInteractionViews.removeAll() + lastSelectedButton = nil rootView = nil } @@ -102,7 +103,8 @@ final class MonitorOverlay: NSObject { let inspection = sender.targetView.map { ViewInspector.inspect($0, in: window) } let reference: ViewInspection? = { guard let last = lastSelectedButton, last !== sender, - let referenceView = last.targetView, referenceView.window != nil else { + let referenceView = last.targetView, + let referenceWindow = referenceView.window, referenceWindow === window else { return nil } return ViewInspector.inspect(referenceView, in: window) From f74e57560fdb0b1fef390ad2faff8ab77e12b48b Mon Sep 17 00:00:00 2001 From: Daisuke Yamashita Date: Thu, 6 Aug 2026 00:18:19 +0900 Subject: [PATCH 8/8] feat(ui): mark the reference view with a blue border Real-device feedback: users couldn't tell which view the distance section was comparing against, since the reference (previous selection) only showed up as a class name in the "vs" row. Keep a 2pt systemBlue border on the reference button for as long as the distance section is shown, alongside the existing 2pt red border on the current selection. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- Sources/ViewMonitor/UI/MonitorOverlay.swift | 10 ++- .../MonitorOverlayTests.swift | 65 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ca4c17..6c9e6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 2つのビュー間の距離計測。ビューを選択したあと別のビューを選択すると、直前の選択との関係が InfoView に表示される。離れていればエッジ間ギャップ(`gapX` / `gapY`)、重なっていれば重なり幅(`overlapX` / `overlapY`)、一方が他方を内包していれば内側ビューのインセット(`top` / `left` / `bottom` / `right`) +- 2つのビュー間の距離計測。ビューを選択したあと別のビューを選択すると、直前の選択との関係が InfoView に表示される。離れていればエッジ間ギャップ(`gapX` / `gapY`)、重なっていれば重なり幅(`overlapX` / `overlapY`)、一方が他方を内包していれば内側ビューのインセット(`top` / `left` / `bottom` / `right`)。比較相手には青枠が付き、どのビューとの距離かが画面上で分かる ## [2.1.0] - 2026-08-05 diff --git a/Sources/ViewMonitor/UI/MonitorOverlay.swift b/Sources/ViewMonitor/UI/MonitorOverlay.swift index 5bfe17f..cdd187d 100644 --- a/Sources/ViewMonitor/UI/MonitorOverlay.swift +++ b/Sources/ViewMonitor/UI/MonitorOverlay.swift @@ -93,6 +93,8 @@ final class MonitorOverlay: NSObject { guard let infoView else { return } + // 距離セクションを表示しているときだけ非 nil。青枠の維持対象。 + var referenceButton: MonitorButton? if sender.isSelected { infoView.isHidden = false // 座標変換は show(on:) で受け取った rootView を基準にする。 @@ -112,9 +114,15 @@ final class MonitorOverlay: NSObject { infoView.update(rows: InfoRowBuilder.rows(from: inspection, comparedTo: reference)) sender.layer.borderWidth = 2.0 sender.layer.borderColor = UIColor.red.cgColor + if reference != nil { + referenceButton = lastSelectedButton + referenceButton?.isSelected = false + referenceButton?.layer.borderWidth = 2.0 + referenceButton?.layer.borderColor = UIColor.systemBlue.cgColor + } lastSelectedButton = sender } - for other in buttons where other !== sender { + for other in buttons where other !== sender && other !== referenceButton { other.layer.borderWidth = 0.0 other.isSelected = false } diff --git a/Tests/ViewMonitorTests/MonitorOverlayTests.swift b/Tests/ViewMonitorTests/MonitorOverlayTests.swift index 8028a69..4879f12 100644 --- a/Tests/ViewMonitorTests/MonitorOverlayTests.swift +++ b/Tests/ViewMonitorTests/MonitorOverlayTests.swift @@ -109,4 +109,69 @@ struct MonitorOverlayTests { #expect(texts.count == 8) #expect(texts.allSatisfy { $0.hasSuffix(": None") }) } + + @Test("2つ目の選択で参照ビューに青枠が残る") + func secondSelectionMarksReferenceWithBlueBorder() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + let image = UIImageView(frame: CGRect(x: 16, y: 144, width: 100, height: 40)) + window.addSubview(label) + window.addSubview(image) + overlay.show(on: window) + let labelButton = try #require(monitorButton(on: label)) + let imageButton = try #require(monitorButton(on: image)) + + overlay.select(sender: labelButton) + overlay.select(sender: imageButton) + + #expect(imageButton.layer.borderWidth == 2.0) + #expect(imageButton.layer.borderColor == UIColor.red.cgColor) + #expect(labelButton.layer.borderWidth == 2.0) + #expect(labelButton.layer.borderColor == UIColor.systemBlue.cgColor) + #expect(labelButton.isSelected == false) + } + + @Test("3つ目の選択で青枠は直前の選択に移る") + func thirdSelectionMovesBlueBorder() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + let first = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + let second = UIImageView(frame: CGRect(x: 16, y: 144, width: 100, height: 40)) + let third = UILabel(frame: CGRect(x: 16, y: 208, width: 100, height: 20)) + window.addSubview(first) + window.addSubview(second) + window.addSubview(third) + overlay.show(on: window) + let firstButton = try #require(monitorButton(on: first)) + let secondButton = try #require(monitorButton(on: second)) + let thirdButton = try #require(monitorButton(on: third)) + + overlay.select(sender: firstButton) + overlay.select(sender: secondButton) + overlay.select(sender: thirdButton) + + #expect(thirdButton.layer.borderColor == UIColor.red.cgColor) + #expect(secondButton.layer.borderColor == UIColor.systemBlue.cgColor) + #expect(firstButton.layer.borderWidth == 0.0) + } + + @Test("参照が無効なら青枠を出さない") + func invalidReferenceGetsNoBlueBorder() throws { + let window = makeWindow() + let overlay = MonitorOverlay() + let label = UILabel(frame: CGRect(x: 16, y: 100, width: 100, height: 20)) + let image = UIImageView(frame: CGRect(x: 16, y: 144, width: 100, height: 40)) + window.addSubview(label) + window.addSubview(image) + overlay.show(on: window) + let labelButton = try #require(monitorButton(on: label)) + let imageButton = try #require(monitorButton(on: image)) + + overlay.select(sender: labelButton) + label.removeFromSuperview() + overlay.select(sender: imageButton) + + #expect(labelButton.layer.borderWidth == 0.0) + } }