From f484253ef79aa3b1efcb7bfc27dc7f6ac9134716 Mon Sep 17 00:00:00 2001 From: Iris Agi Date: Mon, 5 Oct 2026 12:00:18 -0700 Subject: [PATCH] Settings Files pane: no clipping, one grid; pointer cursors on chrome over the page Files pane: the path is the flexible part of a location row (middle truncation, tooltip keeps the full path); nickname, Change... and remove keep their width. The editor keeps one width, so the File associations box shares both edges. The marker, Add Location..., the box and its help share one left edge; the label shares the first row's baseline; rows and gaps are even. The window now grows and shrinks with the pane (a frame set by hand on the hosted pane view pinned the tab view to a stale size), around its centre line. Cursors: the page's text view registers an I-beam under chrome. PointerCursor.swift gives views a shared way to register their own rect. The palette destination chip (hand while it is a menu, arrow as a label), the Overflow toggle, the M-down toggle and the file name now register it. Co-Authored-By: Claude Sonnet 5.5 --- Sources/FloStateNative/App/FileNameBar.swift | 10 +- .../App/LocationsEditorView.swift | 55 ++++- .../FloStateNative/App/OverflowPanel.swift | 10 +- Sources/FloStateNative/App/PaletteView.swift | 13 +- .../FloStateNative/App/PointerCursor.swift | 39 ++++ .../FloStateNative/App/SettingsWindow.swift | 26 ++- .../FloStateNative/App/ViewTogglesBar.swift | 10 +- .../PointerCursorTests.swift | 104 +++++++++ .../SettingsWindowTests.swift | 201 ++++++++++++++++++ 9 files changed, 446 insertions(+), 22 deletions(-) create mode 100644 Sources/FloStateNative/App/PointerCursor.swift create mode 100644 Tests/FloStateNativeTests/PointerCursorTests.swift 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)") + } + } +}