Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Sources/FloStateNative/App/FileNameBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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() }
Expand Down
55 changes: 46 additions & 9 deletions Sources/FloStateNative/App/LocationsEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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() }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() }
Expand Down
10 changes: 9 additions & 1 deletion Sources/FloStateNative/App/OverflowPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 11 additions & 2 deletions Sources/FloStateNative/App/PaletteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ 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
static let gap: CGFloat = 5
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)?
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions Sources/FloStateNative/App/PointerCursor.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
26 changes: 18 additions & 8 deletions Sources/FloStateNative/App/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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() {
Expand All @@ -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)
}
Expand Down Expand Up @@ -613,15 +618,20 @@ 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
let size = selectedPane.preferredContentSize
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)
}

Expand Down
10 changes: 9 additions & 1 deletion Sources/FloStateNative/App/ViewTogglesBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading