diff --git a/Sources/FloStateNative/App/FileNameBar.swift b/Sources/FloStateNative/App/FileNameBar.swift index f5a59ce..31133b3 100644 --- a/Sources/FloStateNative/App/FileNameBar.swift +++ b/Sources/FloStateNative/App/FileNameBar.swift @@ -17,7 +17,7 @@ import FloKit /// Hooks: ShellRootView (the view, one layout line), ShellWindowController.flush (refresh, window /// title and representedURL). @MainActor -final class FileNameView: FlippedView { +final class FileNameView: FlippedView, PointerCursorProviding { let model: ShellModel private(set) var path: String? private(set) var name = "" @@ -159,6 +159,14 @@ final class FileNameView: FlippedView { } override func mouseEntered(with event: NSEvent) { hovering = true } override func mouseExited(with event: NSEvent) { hovering = false } + + // The pointer: the page's I-beam is under this view (PointerCursor.swift). No name: no menu, nothing to point at. + var pointerRects: [PointerCursor.Rect] { name.isEmpty ? [] : PointerCursor.hand(bounds) } + override func resetCursorRects() { PointerCursor.reset(self) } + override var frame: NSRect { didSet { if frame != oldValue { PointerCursor.invalidate(self) } } } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); PointerCursor.invalidate(self) } + override func viewDidHide() { super.viewDidHide(); PointerCursor.invalidate(self) } + override func viewDidUnhide() { super.viewDidUnhide(); PointerCursor.invalidate(self) } override func hitTest(_ point: NSPoint) -> NSView? { isHidden || name.isEmpty ? nil : super.hitTest(point) } /// Native menus open on mouse down (and the title-bar strip never delivers the mouse-up). override func mouseDown(with event: NSEvent) { showMenu() } diff --git a/Sources/FloStateNative/App/LocationsEditorView.swift b/Sources/FloStateNative/App/LocationsEditorView.swift index 3aeceb3..bf3867b 100644 --- a/Sources/FloStateNative/App/LocationsEditorView.swift +++ b/Sources/FloStateNative/App/LocationsEditorView.swift @@ -82,10 +82,22 @@ struct LocationsState: Equatable { } } -/// One location: default marker, nickname, path, Change… and remove. +/// The default marker: a circle that fills when the location is the default. Its drawn circle sits a +/// little inside the button, so the alignment rect is pulled in: the circle then starts exactly at the +/// left edge of the column (the same edge as Add Location… and the File associations box). +final class DefaultMarkerButton: NSButton { + static let size: CGFloat = 18 + /// How far the symbol's drawn circle sits inside the button's left edge. + static let inkInset: CGFloat = 4 + override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsets(top: 0, left: Self.inkInset, bottom: 0, right: 0) } +} + +/// One location: default marker, nickname, path, Change… and remove. The nickname, Change… and remove +/// keep their width. The path takes what is left of the row and cuts its middle when it is too long +/// (the tooltip has the full path), so a long folder never widens the pane. @MainActor final class LocationRowView: NSStackView { - let marker = NSButton() + let marker = DefaultMarkerButton() let nickname = NSTextField(string: "") let pathLabel = NSTextField(labelWithString: "") let changeButton = NSButton(title: L("Change…"), target: nil, action: nil) @@ -95,27 +107,36 @@ final class LocationRowView: NSStackView { var onRemove: () -> Void = {} static let nicknameWidth: CGFloat = 110 - static let pathWidth: CGFloat = 190 + /// The least the path keeps; the editor gives it more. + static let pathMinWidth: CGFloat = 60 + /// Between the controls of a row. + static let gap: CGFloat = 8 init() { super.init(frame: .zero) orientation = .horizontal - spacing = 8 + spacing = Self.gap alignment = .centerY marker.isBordered = false marker.imagePosition = .imageOnly marker.imageScaling = .scaleProportionallyDown marker.target = self; marker.action = #selector(markerClicked) - marker.widthAnchor.constraint(equalToConstant: 18).isActive = true - marker.heightAnchor.constraint(equalToConstant: 18).isActive = true + marker.widthAnchor.constraint(equalToConstant: DefaultMarkerButton.size).isActive = true + marker.heightAnchor.constraint(equalToConstant: DefaultMarkerButton.size).isActive = true nickname.widthAnchor.constraint(equalToConstant: Self.nicknameWidth).isActive = true nickname.lineBreakMode = .byTruncatingTail nickname.cell?.sendsActionOnEndEditing = true + // The flexible part. Compression resistance and hugging below the fitting priority: the row's + // fitting width does not depend on the text, and the path is the one view that gives way. pathLabel.lineBreakMode = .byTruncatingMiddle - pathLabel.widthAnchor.constraint(equalToConstant: Self.pathWidth).isActive = true + pathLabel.cell?.usesSingleLineMode = true + pathLabel.maximumNumberOfLines = 1 + pathLabel.setContentCompressionResistancePriority(NSLayoutConstraint.Priority(1), for: .horizontal) + pathLabel.setContentHuggingPriority(NSLayoutConstraint.Priority(1), for: .horizontal) + pathLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: Self.pathMinWidth).isActive = true changeButton.bezelStyle = .rounded changeButton.controlSize = .small @@ -128,6 +149,11 @@ final class LocationRowView: NSStackView { removeButton.setAccessibilityLabel(L("Remove")) removeButton.target = self; removeButton.action = #selector(removeClicked) + // these three never give way + for v in [marker, nickname, changeButton, removeButton] { + v.setContentCompressionResistancePriority(.required, for: .horizontal) + v.setContentHuggingPriority(.defaultHigh, for: .horizontal) + } for v in [marker, nickname, pathLabel, changeButton, removeButton] { addArrangedSubview(v) } } required init?(coder: NSCoder) { fatalError() } @@ -172,12 +198,20 @@ final class LocationsEditorView: NSStackView, NSTextFieldDelegate { let addButton = NSButton(title: L("Add Location…"), target: nil, action: nil) private var shown: [String]? + /// The gap between rows, and between the last row and Add Location… (the pane grid's row gap). + static let rowGap: CGFloat = 8 + /// The width of the editor: marker, nickname, a path of about 170 pt, Change… and remove. + static let contentWidth: CGFloat = 410 + init(backend: SettingsBackend) { self.backend = backend super.init(frame: .zero) orientation = .vertical alignment = .leading - spacing = 6 + spacing = Self.rowGap + // The editor keeps one width, empty or not, long paths or not: the File associations box + // below it takes the same width, so their right edges line up. Rows grow it when they must. + widthAnchor.constraint(greaterThanOrEqualToConstant: Self.contentWidth).withPriority(NSLayoutConstraint.Priority(999)).isActive = true emptyLabel.textColor = .secondaryLabelColor addButton.bezelStyle = .rounded addButton.controlSize = .small @@ -212,7 +246,10 @@ final class LocationsEditorView: NSStackView, NSTextFieldDelegate { row.onRemove = { [weak self] in self?.edit { $0.remove(at: i); return true } } return row } - for (i, r) in rows.enumerated() { insertArrangedSubview(r, at: i) } + for (i, r) in rows.enumerated() { + insertArrangedSubview(r, at: i) + r.widthAnchor.constraint(equalTo: widthAnchor).isActive = true // the path takes the rest of the row + } emptyLabel.isHidden = !rows.isEmpty shown = structure if rows.count != hadRows || hadRows == 0 { layoutSubtreeIfNeeded(); onSizeChange() } diff --git a/Sources/FloStateNative/App/OverflowPanel.swift b/Sources/FloStateNative/App/OverflowPanel.swift index 1c496b8..93c5840 100644 --- a/Sources/FloStateNative/App/OverflowPanel.swift +++ b/Sources/FloStateNative/App/OverflowPanel.swift @@ -437,7 +437,7 @@ final class OverflowTextView: NSTextView { // MARK: - toggle button @MainActor -final class OverflowToggleButton: NSButton { +final class OverflowToggleButton: NSButton, PointerCursorProviding { static let size: CGFloat = 28 var idleColor = NSColor.tertiaryLabelColor var hoverColor = NSColor.secondaryLabelColor @@ -470,6 +470,14 @@ final class OverflowToggleButton: NSButton { } override func mouseEntered(with event: NSEvent) { hovering = true } override func mouseExited(with event: NSEvent) { hovering = false } + + // The pointer: the page's I-beam is under this view (PointerCursor.swift). + var pointerRects: [PointerCursor.Rect] { PointerCursor.hand(bounds) } + override func resetCursorRects() { PointerCursor.reset(self) } + override var frame: NSRect { didSet { if frame != oldValue { PointerCursor.invalidate(self) } } } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); PointerCursor.invalidate(self) } + override func viewDidHide() { super.viewDidHide(); PointerCursor.invalidate(self) } + override func viewDidUnhide() { super.viewDidUnhide(); PointerCursor.invalidate(self) } } extension EditorPaneView { diff --git a/Sources/FloStateNative/App/PaletteView.swift b/Sources/FloStateNative/App/PaletteView.swift index 23804b5..27e8387 100644 --- a/Sources/FloStateNative/App/PaletteView.swift +++ b/Sources/FloStateNative/App/PaletteView.swift @@ -35,7 +35,7 @@ final class PaletteInputField: NSTextField { /// The destination button at the right end of the new-note input row: the location's name and a small /// chevron. Quiet: no border until hover. Clicking it drops the menu of locations. -final class PaletteDestinationChip: FlippedView { +final class PaletteDestinationChip: FlippedView, PointerCursorProviding { static let height: CGFloat = 26 static let padding: CGFloat = 8 static let chevronWidth: CGFloat = 7 @@ -43,7 +43,7 @@ final class PaletteDestinationChip: FlippedView { var palette: ShellPalette? var font: NSFont = .systemFont(ofSize: 12) var chip: PaletteDestination.Chip? { - didSet { toolTip = chip?.tooltip; if chip != oldValue { hovering = false; needsDisplay = true } } + didSet { toolTip = chip?.tooltip; if chip != oldValue { hovering = false; needsDisplay = true; PointerCursor.invalidate(self) } } } var hovering = false { didSet { if hovering != oldValue { needsDisplay = true } } } var onClick: (() -> Void)? @@ -86,6 +86,15 @@ final class PaletteDestinationChip: FlippedView { override func mouseExited(with event: NSEvent) { hovering = false } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override var acceptsFirstResponder: Bool { false } + + // The pointer: the page's I-beam is under this view (PointerCursor.swift). A drop-down while there are + // locations to pick from, else a plain label. + var pointerRects: [PointerCursor.Rect] { chip?.hasMenu == true ? PointerCursor.hand(bounds) : PointerCursor.arrow(bounds) } + override func resetCursorRects() { PointerCursor.reset(self) } + override var frame: NSRect { didSet { if frame != oldValue { PointerCursor.invalidate(self) } } } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); PointerCursor.invalidate(self) } + override func viewDidHide() { super.viewDidHide(); PointerCursor.invalidate(self) } + override func viewDidUnhide() { super.viewDidUnhide(); PointerCursor.invalidate(self) } override func mouseDown(with event: NSEvent) { guard chip?.hasMenu == true else { return } hovering = true diff --git a/Sources/FloStateNative/App/PointerCursor.swift b/Sources/FloStateNative/App/PointerCursor.swift new file mode 100644 index 0000000..75aabb9 --- /dev/null +++ b/Sources/FloStateNative/App/PointerCursor.swift @@ -0,0 +1,39 @@ +import AppKit + +/// The cursor of chrome that sits over the page. +/// +/// The page's NSTextView registers an I-beam cursor rect over its whole frame. A view above it that +/// registers no cursor rect of its own does not stop that: the pointer shows the I-beam over a +/// button. A view that wants the arrow or the pointing hand says where in `pointerRects`, and its +/// `resetCursorRects` forwards to `PointerCursor.reset`. Views that can be created, moved, resized +/// or hidden after the window is up also call `PointerCursor.invalidate` on those changes, so the +/// rect follows the view. +@MainActor +protocol PointerCursorProviding: NSView { + /// The cursor rects of this view, in its own coordinates. Empty: the view takes no cursor. + var pointerRects: [PointerCursor.Rect] { get } +} + +enum PointerCursor { + struct Rect: Equatable { + var rect: CGRect + var cursor: NSCursor + } + + /// The whole of `bounds`, as a pointing hand. + static func hand(_ bounds: CGRect) -> [Rect] { [Rect(rect: bounds, cursor: .pointingHand)] } + + /// The whole of `bounds`, as the arrow (a label over the page: the page's I-beam must not show). + static func arrow(_ bounds: CGRect) -> [Rect] { [Rect(rect: bounds, cursor: .arrow)] } + + /// `NSView.resetCursorRects` for a provider. `add` is the sink (tests record into it). + @MainActor + static func reset(_ view: some PointerCursorProviding, using add: ((CGRect, NSCursor) -> Void)? = nil) { + for r in view.pointerRects where !r.rect.isEmpty { + if let add { add(r.rect, r.cursor) } else { view.addCursorRect(r.rect, cursor: r.cursor) } + } + } + + /// The view's rects changed (it moved, resized, was hidden or shown, or its state changed). + static func invalidate(_ view: NSView) { view.window?.invalidateCursorRects(for: view) } +} diff --git a/Sources/FloStateNative/App/SettingsWindow.swift b/Sources/FloStateNative/App/SettingsWindow.swift index 05b27fb..e510278 100644 --- a/Sources/FloStateNative/App/SettingsWindow.swift +++ b/Sources/FloStateNative/App/SettingsWindow.swift @@ -311,7 +311,7 @@ final class SettingControl: NSObject, NSTextFieldDelegate, NSTokenFieldDelegate // wrap onto more lines rather than clipping patterns off the end t.cell?.wraps = true t.cell?.isScrollable = false - t.widthAnchor.constraint(equalToConstant: 300).isActive = true + t.widthAnchor.constraint(equalToConstant: 300).withPriority(.defaultHigh).isActive = true // the pane can set another t.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true tokens = t; built = t } @@ -475,18 +475,20 @@ final class SettingsPaneController: NSViewController { let row = grid.addRow(with: [c.label, c.view]) row.yPlacement = .center if let e = c.locations { - // the label lines up with the first location, not the middle of the list - row.yPlacement = .top - let pad = NSStackView(views: [c.label]) - pad.edgeInsets = NSEdgeInsets(top: 3, left: 0, bottom: 0, right: 0) - row.cell(at: 0).contentView = pad + // the label shares a baseline with the first location (its nickname), not the middle of the list + row.rowAlignment = .firstBaseline e.onSizeChange = { [weak self] in self?.refit() } + locationsEditor = e } if firstInGroup && gi > 0 { row.topPadding = 14 } firstInGroup = false if let h = c.help { grid.addRow(with: [NSGridCell.emptyContentView, h]).topPadding = -3 } } } + // the File associations box is as wide as the locations above it: one left edge, one right edge + if let e = locationsEditor, let t = controls.compactMap(\.tokens).first { + t.widthAnchor.constraint(equalTo: e.widthAnchor).isActive = true + } grid.column(at: 0).xPlacement = .trailing grid.column(at: 1).xPlacement = .leading } @@ -513,6 +515,7 @@ final class SettingsPaneController: NSViewController { } private weak var grid: NSGridView? + private weak var locationsEditor: LocationsEditorView? /// Size the pane to its grid; again after a control (the locations list) changes height. func refit() { @@ -521,7 +524,9 @@ final class SettingsPaneController: NSViewController { let size = NSSize(width: max(520, ceil(fit.width)), height: ceil(fit.height)) guard size != preferredContentSize else { return } preferredContentSize = size - view.frame = NSRect(origin: .zero, size: size) + // Not once the tab view hosts the pane: a frame set by hand turns into fixed constraints + // there (a stale gap below the pane), and the window then never shrinks again. + if view.superview == nil { view.frame = NSRect(origin: .zero, size: size) } if let wc = view.window?.windowController as? SettingsWindowController, wc.selectedPane === self { wc.resizeToPane(animate: false) } @@ -613,7 +618,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { DispatchQueue.main.async { [weak self] in MainActor.assumeIsolated { self?.resizeToPane(animate: false) } } } - /// Fit the window to the selected pane, keeping its top edge. + /// Fit the window to the selected pane: the top edge stays, the window grows or shrinks around its + /// centre line and stays on its screen. func resizeToPane(animate: Bool) { guard let w = window else { return } _ = selectedPane.view @@ -621,7 +627,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { let frame = w.frameRect(forContentRect: NSRect(origin: .zero, size: size)) var f = w.frame f.origin.y += f.height - frame.height + f.origin.x += (f.width - frame.width) / 2 f.size = frame.size + if let screen = w.screen?.visibleFrame, f.width <= screen.width { + f.origin.x = min(max(f.origin.x, screen.minX), screen.maxX - f.width) + } w.setFrame(f, display: true, animate: animate) } diff --git a/Sources/FloStateNative/App/ViewTogglesBar.swift b/Sources/FloStateNative/App/ViewTogglesBar.swift index 5dd4946..f2fa297 100644 --- a/Sources/FloStateNative/App/ViewTogglesBar.swift +++ b/Sources/FloStateNative/App/ViewTogglesBar.swift @@ -108,7 +108,7 @@ final class ViewTogglesView: FlippedView { /// One quiet icon toggle: the count's muted colour while off, a little stronger while on, the /// text colour's secondary strength under the pointer. @MainActor -final class ViewToggleButton: FlippedView { +final class ViewToggleButton: FlippedView, PointerCursorProviding { let icon: Icon /// Width of the drawn icon (strokes included, viewBox units), for optical spacing. let ink: CGFloat @@ -137,6 +137,14 @@ final class ViewToggleButton: FlippedView { } override func mouseEntered(with event: NSEvent) { hovering = true } override func mouseExited(with event: NSEvent) { hovering = false } + + // The pointer: the page's I-beam is under this view (PointerCursor.swift). + var pointerRects: [PointerCursor.Rect] { PointerCursor.hand(bounds) } + override func resetCursorRects() { PointerCursor.reset(self) } + override var frame: NSRect { didSet { if frame != oldValue { PointerCursor.invalidate(self) } } } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow(); PointerCursor.invalidate(self) } + override func viewDidHide() { super.viewDidHide(); PointerCursor.invalidate(self) } + override func viewDidUnhide() { super.viewDidUnhide(); PointerCursor.invalidate(self) } /// Tracked here like IconButton: in the title-bar strip AppKit never delivers the mouse-up. override func mouseDown(with event: NSEvent) { guard let w = window else { return } diff --git a/Tests/FloStateNativeTests/PointerCursorTests.swift b/Tests/FloStateNativeTests/PointerCursorTests.swift new file mode 100644 index 0000000..b600cd6 --- /dev/null +++ b/Tests/FloStateNativeTests/PointerCursorTests.swift @@ -0,0 +1,104 @@ +import AppKit +import XCTest +@testable import FloCore +@testable import FloStateNative + +/// The pointer over chrome that sits on the page. The page's NSTextView registers an I-beam rect over +/// its whole frame, so each button above it must register its own. Real hover (the window server +/// moving the pointer over the view) is not exercised here: these tests assert what each view +/// registers, and that `resetCursorRects` hands those rects to AppKit through PointerCursor.reset. +@MainActor +final class PointerCursorTests: XCTestCase { + /// What the view's `resetCursorRects` adds (the sink replaces addCursorRect). + func registered(_ v: some PointerCursorProviding) -> [(CGRect, NSCursor)] { + var out: [(CGRect, NSCursor)] = [] + PointerCursor.reset(v) { out.append(($0, $1)) } + return out + } + + func assertHand(_ v: some PointerCursorProviding, _ what: String, file: StaticString = #filePath, line: UInt = #line) { + let r = registered(v) + XCTAssertEqual(r.count, 1, what, file: file, line: line) + XCTAssertTrue(r.first?.1 === NSCursor.pointingHand, "\(what): pointing hand", file: file, line: line) + XCTAssertEqual(r.first?.0 ?? .zero, v.bounds, "\(what): over the whole view", file: file, line: line) + XCTAssertFalse(v.bounds.isEmpty, "\(what): has a size", file: file, line: line) + } + + func testTheHelperSkipsEmptyRectsAndForwardsTheRest() { + let v = OverflowToggleButton() + v.frame = .zero + XCTAssertTrue(registered(v).isEmpty, "no size, no rect") + XCTAssertEqual(PointerCursor.hand(CGRect(x: 0, y: 0, width: 4, height: 5)).first?.cursor, NSCursor.pointingHand) + XCTAssertEqual(PointerCursor.arrow(CGRect(x: 0, y: 0, width: 4, height: 5)).first?.cursor, NSCursor.arrow) + } + + func testTheOverflowToggleButtonShowsThePointingHand() { + assertHand(OverflowToggleButton(), "Overflow toggle") + assertHand(OverflowToggleButton(symbol: "sidebar.left", label: "Hide Alternatives", toolTip: "Hide Alternatives"), "Alternatives toggle (the same class)") + } + + func testTheMarkdownToggleShowsThePointingHand() { + let b = ViewToggleButton(icon: ViewToggleButton.markdownMark, ink: 20.5) + b.frame = CGRect(x: 0, y: 0, width: ViewTogglesView.buttonSize, height: ViewTogglesView.buttonSize) + assertHand(b, "M↓ toggle") + } + + func testTheRectFollowsTheViewWhenItResizes() { + let b = OverflowToggleButton() + XCTAssertEqual(registered(b).first?.0, CGRect(x: 0, y: 0, width: 28, height: 28)) + b.frame = CGRect(x: 40, y: 40, width: 36, height: 30) + XCTAssertEqual(registered(b).first?.0, CGRect(x: 0, y: 0, width: 36, height: 30), "bounds, in the view's own coordinates") + } + + func testTheFileNameShowsThePointingHandOnlyWhileItHasAName() async { + let f = ShellFixture(files: ["a.md": "x"]) + await f.open(file: "a.md") + let v = FileNameView(model: f.model) + v.frame = CGRect(x: 0, y: 0, width: 120, height: FileNameView.boxHeight) + XCTAssertTrue(registered(v).isEmpty, "no name yet: no menu, no hand") + v.refresh() + XCTAssertEqual(v.name, "a") + assertHand(v, "file name") + } + + func testThePaletteChipIsAHandWhileItIsAMenuAndAnArrowWhenItIsALabel() async { + let f = ShellFixture(files: ["a.md": "x"]) + let home = TFS.tempDir("home"), work = TFS.tempDir("work") + f.model.setSetting("files.note-locations", .list(["Home|\(home)", "Work|\(work)"])) + f.model.setSetting("files.default-note-location", .string(home)) + await f.open() + let overlay = PaletteOverlayView(model: f.model) + overlay.frame = NSRect(x: 0, y: 0, width: 760, height: 330) + f.model.palette = PaletteState(intent: .createFile, query: "Idea") + overlay.reload(resetField: true); overlay.layoutSubtreeIfNeeded() + XCTAssertEqual(overlay.chip.chip?.hasMenu, true) + assertHand(overlay.chip, "destination chip with locations to pick") + + // no locations: the chip names the folder and is only a label + f.model.setSetting("files.note-locations", .list([])) + f.model.setSetting("files.default-note-location", .string("")) + f.model.palette = PaletteState(intent: .createFile, query: "Idea") + overlay.reload(resetField: true); overlay.layoutSubtreeIfNeeded() + XCTAssertEqual(overlay.chip.chip?.hasMenu, false) + let r = registered(overlay.chip) + XCTAssertEqual(r.count, 1) + XCTAssertTrue(r.first?.1 === NSCursor.arrow, "a label shows the arrow, not the page's I-beam") + XCTAssertEqual(r.first?.0 ?? .zero, overlay.chip.bounds) + } + + func testViewsInAWindowInvalidateTheirRectsWhenTheyMoveOrHide() { + // not hover: only that the hooks run without a window and with one + let w = NSWindow(contentRect: NSRect(x: -10000, y: -10000, width: 200, height: 100), styleMask: [.titled], backing: .buffered, defer: true) + let host = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + w.contentView = host + let b = OverflowToggleButton() + host.addSubview(b) + b.frame = CGRect(x: 10, y: 10, width: 28, height: 28) + b.isHidden = true + b.isHidden = false + b.removeFromSuperview() + XCTAssertNil(b.window) + PointerCursor.invalidate(b) // no window: nothing happens + w.close() + } +} diff --git a/Tests/FloStateNativeTests/SettingsWindowTests.swift b/Tests/FloStateNativeTests/SettingsWindowTests.swift index 7fa9a9d..f37055c 100644 --- a/Tests/FloStateNativeTests/SettingsWindowTests.swift +++ b/Tests/FloStateNativeTests/SettingsWindowTests.swift @@ -319,3 +319,204 @@ final class DefaultLocationSettingTests: XCTestCase { } } + +/// The Files pane layout: nothing clipped at the window's width, one left edge, aligned baselines, +/// even rows. Offscreen: the window is never ordered front. +@MainActor +final class SettingsLayoutTests: XCTestCase { + var data: String! + var backend: SettingsBackend! + var wc: SettingsWindowController! + + override func setUp() async throws { + data = TFS.tempDir("settings") + backend = SettingsBackend(dataDir: AppDataDirectory(baseURL: URL(fileURLWithPath: data))) + wc = SettingsWindowController(backend: backend) + wc.window!.setFrameOrigin(NSPoint(x: -10000, y: -10000)) + wc.select("files") + } + + override func tearDown() async throws { wc.window?.close() } + + var pane: SettingsPaneController { wc.selectedPane } + var content: NSView { wc.window!.contentView! } + var control: SettingControl { pane.control("files.note-locations")! } + var editor: LocationsEditorView { control.locations! } + var tokens: NSView { pane.control("files.associations")!.view } + var help: NSView { pane.control("files.associations")!.help! } + + func set(_ lines: [String], default d: String = "") { + backend.setMany([("files.note-locations", .list(lines)), ("files.default-note-location", .string(d))]) + content.layoutSubtreeIfNeeded() + } + + func longFolder() -> String { + let p = TFS.tempDir("long") + "/some-long-folder-name/writing/drafts" + try! FileManager.default.createDirectory(atPath: p, withIntermediateDirectories: true) + return p + } + + func views(_ v: NSView) -> [NSView] { [v] + v.subviews.flatMap(views) } + + /// Every visible control of the pane that leaves the window's content view (or sits partly outside it). + func clipped() -> [String] { + let box = content.bounds + return views(pane.view).compactMap { v in + guard v is NSControl, !v.isHiddenOrHasHiddenAncestor else { return nil } + let r = v.convert(v.bounds, to: content) + return box.insetBy(dx: -0.5, dy: -0.5).contains(r) ? nil : "\(type(of: v)) \(r)" + } + } + + /// The left edge of a view's alignment rect, in the pane's coordinates. + func left(_ v: NSView) -> CGFloat { + guard let s = v.superview else { return v.frame.minX } + return s.convert(v.alignmentRect(forFrame: v.frame), to: pane.view).minX + } + + func right(_ v: NSView) -> CGFloat { + guard let s = v.superview else { return v.frame.maxX } + return s.convert(v.alignmentRect(forFrame: v.frame), to: pane.view).maxX + } + + /// The first baseline's height from the top of the pane (the pane's view is not flipped). + func baseline(_ v: NSView) -> CGFloat { + pane.view.bounds.height - (v.convert(v.bounds, to: pane.view).maxY - v.firstBaselineOffsetFromTop) + } + + func assertFits(_ what: String, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(clipped(), [], what, file: file, line: line) + XCTAssertEqual(content.frame.width, pane.preferredContentSize.width, accuracy: 1, "\(what): the window is as wide as the pane", file: file, line: line) + XCTAssertEqual(content.frame.height, pane.preferredContentSize.height, accuracy: 1, "\(what): the window is as tall as the pane", file: file, line: line) + } + + func testNothingClipsInAnyCaseLightOrDark() { + let nick = "A very very long nickname that goes on and on" + let long = longFolder() + let cases: [(String, [String], String)] = [ + ("empty", [], ""), + ("long path", ["drafts|\(long)"], long), + ("three", ["Drafts|\(TFS.tempDir("a"))", "Journal|\(TFS.tempDir("b"))", "Work|\(TFS.tempDir("c"))"], ""), + ("long nickname", ["\(nick)|\(long)"], ""), + ("empty again", [], ""), + ] + for name in [NSAppearance.Name.aqua, .darkAqua] { + wc.window!.appearance = NSAppearance(named: name) + for (what, lines, d) in cases { + set(lines, default: d) + assertFits("\(what) (\(name.rawValue))") + XCTAssertFalse(clipped().contains { $0.hasPrefix("NSButton") }, "Change… and the remove button are whole: \(what)") + } + } + XCTAssertGreaterThan(pane.restoreButton.frame.width, 0) + XCTAssertTrue(content.bounds.contains(pane.restoreButton.convert(pane.restoreButton.bounds, to: content)), "Restore Defaults is whole") + } + + func testTheWindowGrowsWhenALocationIsAddedOnTheFilesPane() { + let emptyWidth = content.frame.width + let emptyHeight = content.frame.height + set(["drafts|\(longFolder())"]) + XCTAssertGreaterThan(content.frame.height, emptyHeight - 1) + assertFits("one location added") + set(["a|\(TFS.tempDir("a"))", "b|\(TFS.tempDir("b"))", "c|\(TFS.tempDir("c"))"]) + assertFits("three") + XCTAssertEqual(content.frame.width, emptyWidth, accuracy: 1, "the editor keeps one width: rows do not change the window width") + set([]) + assertFits("back to none") + } + + func testThePathIsTheFlexiblePart() { + let short = TFS.tempDir("s") + set(["a|\(short)"]) + let row = editor.rows[0] + let widthsShort = (row.nickname.frame.width, row.changeButton.frame.width, row.removeButton.frame.width, content.frame.width) + XCTAssertLessThan(row.pathLabel.contentCompressionResistancePriority(for: .horizontal).rawValue, 50, "gives way before anything else, even when fitting") + XCTAssertEqual(row.pathLabel.lineBreakMode, .byTruncatingMiddle) + + let long = longFolder() + set(["a|\(long)"]) + let r = editor.rows[0] + XCTAssertEqual(r.pathLabel.stringValue, NewNoteLocation.abbreviated(long)) + XCTAssertEqual(r.pathLabel.toolTip, NewNoteLocation.abbreviated(long), "the tooltip keeps the full path") + XCTAssertEqual(r.nickname.frame.width, widthsShort.0, accuracy: 0.5, "the nickname field keeps its width") + XCTAssertEqual(r.changeButton.frame.width, widthsShort.1, accuracy: 0.5, "Change… keeps its width") + XCTAssertEqual(r.removeButton.frame.width, widthsShort.2, accuracy: 0.5, "remove keeps its width") + XCTAssertEqual(content.frame.width, widthsShort.3, accuracy: 1, "a long path does not widen the pane") + XCTAssertGreaterThanOrEqual(r.pathLabel.frame.width, LocationRowView.pathMinWidth) + XCTAssertGreaterThan((r.pathLabel.stringValue as NSString).size(withAttributes: [.font: r.pathLabel.font!]).width, r.pathLabel.frame.width, "the long path is cut, not shown whole") + } + + /// The window is not user-resizable, but its frame can still be set (a restored frame, a later change). + /// The pane's constraints give the window a floor: it never gets narrower than the pane needs, so + /// no control is ever cut off, and a wider window keeps the pane at its width. + func testSettingTheWindowSizeByHandNeverClips() { + set(["drafts|\(longFolder())"]) + let fitted = pane.preferredContentSize + let w = wc.window! + for target in [fitted.width - 100, fitted.width + 200, fitted.width] { + w.setContentSize(NSSize(width: target, height: fitted.height)) + content.layoutSubtreeIfNeeded() + XCTAssertEqual(clipped(), [], "nothing clipped after asking for \(target) pt") + XCTAssertGreaterThanOrEqual(content.frame.width, fitted.width - 0.5, "never narrower than the pane needs (asked \(target))") + let r = editor.rows[0] + XCTAssertEqual(r.nickname.frame.width, LocationRowView.nicknameWidth, accuracy: 0.5, "fixed width at \(target)") + XCTAssertGreaterThanOrEqual(r.pathLabel.frame.width, LocationRowView.pathMinWidth - 0.5, "at \(target)") + } + wc.resizeToPane(animate: false) + assertFits("fitted again") + } + + func testOneLeftEdgeEvenRowsAndAlignedBaselines() { + let a = TFS.tempDir("a"), b = TFS.tempDir("b"), c = TFS.tempDir("c") + for lines in [[], ["Drafts|\(a)", "Journal|\(b)", "Work|\(c)"]] { + set(lines, default: b) + let rows = editor.rows + let edge = left(editor.addButton) + XCTAssertEqual(left(tokens), edge, accuracy: 0.5, "the File associations box starts at the Add Location… edge (\(lines.count) rows)") + XCTAssertEqual(left(editor), edge, accuracy: 0.5) + XCTAssertEqual(left(help) + 2, edge, accuracy: 2.5, "the help text starts at that edge too (a label's text has 2 pt of inset)") + for r in rows { XCTAssertEqual(left(r.marker), edge, accuracy: 0.5, "the marker column starts there") } + // the right edges: the last control of a row and the File associations box + for r in rows { XCTAssertEqual(right(r.removeButton), right(tokens), accuracy: 0.5, "rows end where the box ends") } + XCTAssertEqual(right(editor), right(tokens), accuracy: 0.5) + // labels: one column, right-aligned + XCTAssertEqual(right(control.label), right(pane.control("files.associations")!.label), accuracy: 0.5, "the label column is right-aligned") + // the first row's baseline is the label's baseline + let first: NSView = rows.first?.nickname ?? editor.emptyLabel + XCTAssertEqual(baseline(control.label), baseline(first), accuracy: 0.5, "label baseline = first row baseline (\(lines.count) rows)") + // rows: one height, one gap, the same gap above Add Location… + if rows.count > 1 { + XCTAssertEqual(Set(rows.map { $0.frame.height.rounded() }).count, 1, "one row height") + let gaps = zip(rows, rows.dropFirst()).map { $0.frame.minY - $1.frame.maxY } // not flipped: the next row is lower + XCTAssertEqual(Set(gaps.map { $0.rounded() }).count, 1, "one gap between rows \(gaps)") + XCTAssertEqual(rows.last!.frame.minY - editor.addButton.frame.maxY, gaps[0], accuracy: 0.5, "Add Location… sits one gap below the last row") + XCTAssertEqual(gaps[0], LocationsEditorView.rowGap, accuracy: 0.5) + } + } + } + + func testTheWindowGrowsAroundItsCentreAndKeepsItsTop() { + wc.select("general") + let w = wc.window! + let before = w.frame + wc.select("files") + let after = w.frame + XCTAssertNotEqual(before.width, after.width) + XCTAssertEqual(after.midX, before.midX, accuracy: 1, "grows around the centre line") + XCTAssertEqual(after.maxY, before.maxY, accuracy: 1, "the top edge stays") + } + + func testEveryPaneKeepsTheSameGridMargins() { + // every pane lays out one NSGridView: at least 30 pt from the sides (centred when the pane is wider), 22 pt from the top + for p in SettingsPanes.all { + wc.select(p.id) + let v = wc.selectedPane.view + let grid = views(v).compactMap { $0 as? NSGridView }.first! + XCTAssertGreaterThanOrEqual(grid.frame.minX, 29.5, "\(p.id): left margin") + XCTAssertGreaterThanOrEqual(v.bounds.width - grid.frame.maxX, 29.5, "\(p.id): right margin") + XCTAssertEqual(v.bounds.height - grid.frame.maxY, 22, accuracy: 0.5, "\(p.id): top margin") + XCTAssertEqual(grid.rowSpacing, 8, "\(p.id)") + XCTAssertEqual(grid.columnSpacing, 10, "\(p.id)") + } + } +}