Repository navigation
Expand file tree
/
Copy pathComponents.swift
More file actions
1698 lines (1556 loc) · 73.4 KB
/
Copy pathComponents.swift
File metadata and controls
1698 lines (1556 loc) · 73.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The 2.1 controls, straight off design/sayctrl-ui-prototype.html: text tabs,
// pill switchers, list rows, model rows, chips, meters. Every pane is built
// out of these, so the look lives in one place.
import Cocoa
// MARK: - Hover
/// A row that lights up under the cursor.
protocol Hoverable: AnyObject {
func setHovering(_ on: Bool)
}
/// Exactly one row is lit at a time.
///
/// AppKit drops a `mouseExited` whenever rows are rebuilt or shift under a
/// stationary cursor — scroll the Dictionary quickly and half the list used to
/// stay highlighted. Every row reports through here, and the row that lights up
/// puts the previous one out.
final class HoverArbiter {
static let shared = HoverArbiter()
private weak var current: (AnyObject & Hoverable)?
func enter(_ row: AnyObject & Hoverable) {
if let old = current, old !== row { old.setHovering(false) }
current = row
row.setHovering(true)
}
func exit(_ row: AnyObject & Hoverable) {
row.setHovering(false)
if current === row { current = nil }
}
}
/// Holds its content's full size at all times and fades it in and out, so a
/// row's actions can appear on hover without the row changing shape. Hidden,
/// it is also invisible to the mouse.
final class FadeBox: NSView {
var shown = false {
didSet {
guard shown != oldValue else { return }
alphaValue = shown ? 1 : 0
}
}
init(_ content: NSView) {
super.init(frame: .zero)
alphaValue = 0
content.translatesAutoresizingMaskIntoConstraints = false
addSubview(content)
NSLayoutConstraint.activate([
content.leadingAnchor.constraint(equalTo: leadingAnchor),
content.trailingAnchor.constraint(equalTo: trailingAnchor),
content.topAnchor.constraint(equalTo: topAnchor),
content.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) { fatalError() }
override func hitTest(_ point: NSPoint) -> NSView? { shown ? super.hitTest(point) : nil }
}
// MARK: - Text tabs (they replaced every NSSegmentedControl)
/// A row of underlined text labels over a hairline. `.sub` is the smaller
/// second rank, with an ember underline instead of an ink one.
final class TextTabs: NSView {
private var labels: [TabLabel] = []
private let rule = ColorView(color: Theme.hairline)
private let sub: Bool
private(set) var index = 0
var onPick: (Int) -> Void = { _ in }
/// `centered` puts the names in the middle of the rule instead of at its
/// left end; `size` overrides the face the style would pick; `accessory`
/// sits at the right end of the row, level with the names.
///
/// The names ride a FlowRow rather than a stack view because they have to
/// WRAP. Theme.text gives every label tail truncation and a compression
/// resistance of 250, so a row that runs out of width doesn't overflow —
/// it quietly eats itself: at nine providers the ninth rendered as "…"
/// and Local vanished off the end, neither one pickable.
init(_ titles: [String], selected: Int = 0, sub: Bool = false, centered: Bool = false,
size: CGFloat? = nil, accessory: NSView? = nil, onPick: @escaping (Int) -> Void = { _ in }) {
self.sub = sub
self.onPick = onPick
self.index = selected
super.init(frame: .zero)
labels = titles.enumerated().map { i, t in
let l = TabLabel(t, sub: sub, size: size)
l.onClick = { [weak self] in self?.pick(i) }
return l
}
let row = FlowRow(hgap: sub ? 22 : 26, vgap: sub ? 9 : 11, centered: centered)
// A name is wider selected than not, and `fittingSize` would hand the
// packing that difference — so a click could shuffle the row, or push
// the last name onto a second line. Every box is measured selected.
row.measure = { view in
guard let tab = view as? TabLabel else { return view.fittingSize }
return NSSize(width: tab.reservedWidth, height: tab.fittingSize.height)
}
row.set(labels)
row.translatesAutoresizingMaskIntoConstraints = false
rule.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
addSubview(rule)
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: topAnchor),
row.leadingAnchor.constraint(equalTo: leadingAnchor),
rule.topAnchor.constraint(equalTo: row.bottomAnchor),
rule.leadingAnchor.constraint(equalTo: leadingAnchor),
rule.trailingAnchor.constraint(equalTo: trailingAnchor),
rule.heightAnchor.constraint(equalToConstant: 1),
rule.bottomAnchor.constraint(equalTo: bottomAnchor),
])
if let accessory = accessory, let first = labels.first {
accessory.translatesAutoresizingMaskIntoConstraints = false
addSubview(accessory)
NSLayoutConstraint.activate([
row.trailingAnchor.constraint(equalTo: accessory.leadingAnchor, constant: -Theme.m(16)),
accessory.trailingAnchor.constraint(equalTo: trailingAnchor),
accessory.centerYAnchor.constraint(equalTo: first.textCenterY),
accessory.topAnchor.constraint(greaterThanOrEqualTo: topAnchor),
])
} else {
row.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
}
restyle()
}
required init?(coder: NSCoder) { fatalError() }
/// A disabled row keeps its look from whoever dims it (the pattern is a
/// FormRow at 0.45) — this only makes the clicks land nowhere.
var isEnabled = true
func pick(_ i: Int) {
guard isEnabled, i != index, i >= 0, i < labels.count else { return }
index = i
restyle()
onPick(i)
}
/// Move the selection without calling back — for restoring a remembered tab.
func setIndex(_ i: Int) {
guard i >= 0, i < labels.count else { return }
index = i
restyle()
}
private func restyle() {
for (i, l) in labels.enumerated() { l.selected = (i == index) }
rule.color = sub ? Theme.hairsoft : Theme.hairline
}
override func viewDidChangeEffectiveAppearance() { restyle() }
}
/// One label in a TextTabs row, with its own underline.
final class TabLabel: NSView {
private let field: NSTextField
private let bar = ColorView(color: .clear, radius: 1)
private let sub: Bool
private let size: CGFloat
private var hovering = false { didSet { restyle() } }
var selected = false { didSet { restyle() } }
var onClick: () -> Void = {}
init(_ title: String, sub: Bool, size: CGFloat? = nil) {
self.sub = sub
self.size = size ?? (sub ? 13.5 : 14.5)
field = Theme.text(title, self.size, .medium, Theme.muted)
super.init(frame: .zero)
field.translatesAutoresizingMaskIntoConstraints = false
bar.translatesAutoresizingMaskIntoConstraints = false
addSubview(field)
addSubview(bar)
NSLayoutConstraint.activate([
field.topAnchor.constraint(equalTo: topAnchor),
field.leadingAnchor.constraint(equalTo: leadingAnchor),
field.trailingAnchor.constraint(equalTo: trailingAnchor),
bar.topAnchor.constraint(equalTo: field.bottomAnchor, constant: Theme.px(sub ? 9 : 11) - 2),
bar.leadingAnchor.constraint(equalTo: leadingAnchor),
bar.trailingAnchor.constraint(equalTo: trailingAnchor),
bar.heightAnchor.constraint(equalToConstant: 2),
bar.bottomAnchor.constraint(equalTo: bottomAnchor),
])
restyle()
}
required init?(coder: NSCoder) { fatalError() }
/// The width this name reserves in a row — always measured at the SELECTED
/// weight, the wider of the two, so moving the selection can never reflow
/// the row or bump a later name onto another line. An unselected name is a
/// hair narrower than its box and sits at the left of it, which at 22 pt
/// of spacing is invisible.
var reservedWidth: CGFloat {
let font = NSFont.systemFont(ofSize: Theme.px(size), weight: .semibold)
return ceil((field.stringValue as NSString).size(withAttributes: [.font: font]).width)
}
/// The middle of the name, for lining something up with the row.
var textCenterY: NSLayoutYAxisAnchor { field.centerYAnchor }
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp],
owner: self, userInfo: nil))
}
override func mouseEntered(with event: NSEvent) { hovering = true }
override func mouseExited(with event: NSEvent) { hovering = false }
override func mouseDown(with event: NSEvent) { onClick() }
override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) }
override var mouseDownCanMoveWindow: Bool { false }
private func restyle() {
field.textColor = selected ? Theme.ink : (hovering ? Theme.ink2 : Theme.muted)
field.font = .systemFont(ofSize: Theme.px(size), weight: selected ? .semibold : .medium)
// Through `color`, not the layer: a ColorView repaints its own colour
// at the next display, and a bar written to the layer vanished until
// the first click.
bar.color = selected ? (sub ? Theme.ink2 : Theme.ink) : .clear
}
override func viewDidChangeEffectiveAppearance() { restyle() }
}
// MARK: - Pill switcher
/// How big a switcher's pills are: small for a setting in a row, medium for
/// a switch that leads its row (a place's tone), large for a section's main
/// control.
enum PillSize {
case small, medium, large
var pillRadius: CGFloat { Theme.m(self == .large ? 8 : self == .medium ? 7 : 6) }
}
/// The small segmented switcher that survived the redesign — appearance,
/// text size, cleanup level. A sunk trough with one raised pill in it.
final class PillSwitcher: NSView {
private var pills: [PillLabel] = []
private let row: NSStackView
private let fixedWidth: CGFloat?
/// The trough's padding around the pills.
private let inset: CGFloat
private(set) var index = 0
var onPick: (Int) -> Void = { _ in }
/// Which pill the pointer is on, nil when it leaves them — for a
/// switcher whose choices preview on hover (Tones).
var onHover: (Int?) -> Void = { _ in }
/// The raised chip under the picked pill, when it slides (`previews`).
private var thumb: PillThumb?
private var thumbPins: [NSLayoutConstraint] = []
/// `symbols` puts an SF Symbol before each title; `width` fixes the whole
/// trough and shares it equally, so two switchers can line up; `large`
/// is the size for a switcher that is the section's main control rather
/// than a setting in a row. `previews` is for a switcher whose choices
/// preview on hover (Tones): the picked one rides a raised chip that
/// slides to the next pick, the one under the pointer gets a soft fill,
/// and pointing answers with the app in the background too, the way a
/// page in a browser does.
init(_ titles: [String], selected: Int = 0, symbols: [String]? = nil, width: CGFloat? = nil,
size: PillSize = .small, previews: Bool = false, onPick: @escaping (Int) -> Void = { _ in }) {
self.onPick = onPick
self.index = selected
self.fixedWidth = width
inset = size == .medium ? Theme.m(3) : 2
let made = titles.enumerated().map { i, t in PillLabel(t, symbol: symbols?[i], size: size) }
row = NSStackView(views: made)
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = Theme.m(size == .small ? 8 : 10)
layer?.borderWidth = 1
pills = made
for (i, p) in pills.enumerated() {
p.onClick = { [weak self] in self?.pick(i) }
p.onHover = { [weak self] on in self?.onHover(on ? i : nil) }
if previews {
p.raised = false
p.hoverFill = true
p.inBackground = true
}
}
if previews {
let t = PillThumb(radius: size.pillRadius)
t.translatesAutoresizingMaskIntoConstraints = false
addSubview(t)
thumb = t
}
row.orientation = .horizontal
// The medium switch's pills touch, as the mockup's do.
row.spacing = size == .medium ? 0 : 2
// Keep its natural width — a form row would otherwise stretch the
// trough across the whole card — and never give it up to the row's
// words: squeezed, it cut "Always shown" to "Always sh".
row.setHuggingPriority(.required, for: .horizontal)
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
if let width = width {
row.distribution = .fillEqually
widthAnchor.constraint(equalToConstant: width).isActive = true
}
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: topAnchor, constant: inset),
row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -inset),
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: inset),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -inset),
])
restyle()
moveThumb(animated: false)
}
required init?(coder: NSCoder) { fatalError() }
/// Its natural width, so "hug" means something: a plain view has no
/// intrinsic size, and without one a form row hands the trough all of
/// the row's slack and stretches "Light" across the card.
override var intrinsicContentSize: NSSize {
if fixedWidth != nil { return NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric) }
return NSSize(width: row.fittingSize.width + inset * 2, height: NSView.noIntrinsicMetric)
}
func pick(_ i: Int) {
guard i != index, i >= 0, i < pills.count else { return }
index = i
restyle()
moveThumb(animated: true)
onPick(i)
}
func setIndex(_ i: Int) {
guard i >= 0, i < pills.count else { return }
index = i
restyle()
moveThumb(animated: false)
}
private func restyle() { for (i, p) in pills.enumerated() { p.selected = (i == index) } }
/// Pins the chip under the picked pill; on a click it glides there over
/// 300 ms with a touch of overshoot, as design/tones-redesign.html's does.
private func moveThumb(animated: Bool) {
guard let thumb = thumb, index < pills.count else { return }
NSLayoutConstraint.deactivate(thumbPins)
let pill = pills[index]
thumbPins = [
thumb.leadingAnchor.constraint(equalTo: pill.leadingAnchor),
thumb.trailingAnchor.constraint(equalTo: pill.trailingAnchor),
thumb.topAnchor.constraint(equalTo: pill.topAnchor),
thumb.bottomAnchor.constraint(equalTo: pill.bottomAnchor),
]
NSLayoutConstraint.activate(thumbPins)
guard animated, window != nil, !Motion.reduced else { return }
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.3
ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.18, 0.45, 1)
ctx.allowsImplicitAnimation = true
layoutSubtreeIfNeeded()
}
}
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.backgroundColor = Theme.wellFill.cgColor
layer?.borderColor = Theme.hairsoft.cgColor
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true; restyle() }
}
final class PillLabel: NSView {
private let field: NSTextField
private let icon: NSImageView?
private var hovering = false {
didSet {
restyle()
fadeNext = hoverFill && hovering != oldValue
needsDisplay = true
if hovering != oldValue { onHover(hovering) }
}
}
var selected = false { didSet { restyle(); needsDisplay = true } }
var onClick: () -> Void = {}
var onHover: (Bool) -> Void = { _ in }
private let size: CGFloat
/// Paints its own raised chip when picked. Off in a switcher whose chip
/// slides (PillThumb), which paints it instead.
var raised = true
/// A soft fill while pointed at, so the pill you're on reads apart from
/// the picked one.
var hoverFill = false
/// Answers the pointer while the app is in the background too.
var inBackground = false { didSet { updateTrackingAreas() } }
/// The hover fill fades in and out rather than snapping.
private var fadeNext = false
/// The mockup's `.seg b`: 12 px words, 3 px above and below, 11 at the
/// sides; the selected one semibold ink on the raised white chip. Medium
/// is the tone switch in design/tones-preview-options.html: 12.5 px
/// words, 28 tall, 13 at the sides. Large is 14.5 px words with room to
/// be a button: 7 above and below, 18 at the sides.
init(_ title: String, symbol: String? = nil, size pill: PillSize = .small) {
let large = pill == .large
size = pill == .large ? 14.5 : pill == .medium ? 12.5 : 12
field = Theme.text(title, size, .medium, Theme.muted)
icon = symbol.flatMap { Theme.symbol($0, Theme.m(large ? 13 : 11), .medium) }.map { NSImageView(image: $0) }
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = pill.pillRadius
var views: [NSView] = []
if let icon = icon {
icon.translatesAutoresizingMaskIntoConstraints = false
icon.widthAnchor.constraint(equalToConstant: Theme.m(large ? 16 : 13)).isActive = true
views.append(icon)
}
views.append(field)
let row = Theme.hstack(views, spacing: Theme.m(large ? 7 : 5))
row.alignment = .centerY
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
let padX = Theme.m(pill == .large ? 18 : pill == .medium ? 13 : 11)
let padY = Theme.m(large ? 7 : 3)
// As wide as the title is when picked: semibold is wider than
// medium, and a chip that grew on the click clipped its own word.
let bold = (title as NSString).size(withAttributes: [
.font: NSFont.systemFont(ofSize: Theme.m(size), weight: .semibold)]).width
let iconW = icon == nil ? 0 : Theme.m(large ? 16 : 13) + Theme.m(large ? 7 : 5)
widthAnchor.constraint(greaterThanOrEqualToConstant: ceil(bold + iconW + padX * 2) + 1).isActive = true
NSLayoutConstraint.activate([
row.centerXAnchor.constraint(equalTo: centerXAnchor),
row.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: padX),
row.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -padX),
])
if pill == .medium {
// A set height, the words centred in it.
NSLayoutConstraint.activate([
heightAnchor.constraint(equalToConstant: Theme.m(28)),
row.centerYAnchor.constraint(equalTo: centerYAnchor),
])
} else {
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: topAnchor, constant: padY),
row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padY),
])
}
restyle()
}
required init?(coder: NSCoder) { fatalError() }
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
addTrackingArea(NSTrackingArea(rect: .zero, options: [.mouseEnteredAndExited, .inVisibleRect,
inBackground ? .activeAlways : .activeInActiveApp],
owner: self, userInfo: nil))
}
override func mouseEntered(with event: NSEvent) { hovering = true }
override func mouseExited(with event: NSEvent) { hovering = false }
override func mouseDown(with event: NSEvent) { onClick() }
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { inBackground }
override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) }
override var mouseDownCanMoveWindow: Bool { false }
private func restyle() {
field.textColor = selected ? Theme.ink : (hovering ? Theme.ink : Theme.muted)
field.font = .systemFont(ofSize: Theme.m(size), weight: selected ? .semibold : .medium)
icon?.contentTintColor = selected ? Theme.ink : (hovering ? Theme.ink : Theme.muted)
}
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
guard let layer = layer else { return }
let chip = selected && raised
let fill: NSColor = chip ? Theme.segPick : (hoverFill && hovering && !selected ? Theme.navHover : .clear)
if fadeNext, !Motion.reduced {
let fade = CABasicAnimation(keyPath: "backgroundColor")
fade.fromValue = layer.presentation()?.backgroundColor ?? layer.backgroundColor ?? NSColor.clear.cgColor
fade.toValue = fill.cgColor
fade.duration = 0.15
layer.add(fade, forKey: "hover")
}
fadeNext = false
layer.backgroundColor = fill.cgColor
layer.borderWidth = chip ? 0.5 : 0
layer.borderColor = Theme.segEdge.cgColor
layer.shadowColor = NSColor.black.cgColor
layer.shadowOpacity = chip ? (isDark ? 0.35 : 0.10) : 0
layer.shadowRadius = 1.5
layer.shadowOffset = CGSize(width: 0, height: -1)
}
private var isDark: Bool { effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua }
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}
/// The picked pill's raised chip, as its own view so it can slide from one
/// pill to the next instead of jumping (PillSwitcher, `previews`).
private final class PillThumb: NSView {
init(radius: CGFloat) {
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = radius
}
required init?(coder: NSCoder) { fatalError() }
override func hitTest(_ point: NSPoint) -> NSView? { nil }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
layer?.backgroundColor = Theme.segPick.cgColor
layer?.borderWidth = 0.5
layer?.borderColor = Theme.segEdge.cgColor
layer?.shadowColor = NSColor.black.cgColor
layer?.shadowOpacity = dark ? 0.35 : 0.10
layer?.shadowRadius = 1.5
layer?.shadowOffset = CGSize(width: 0, height: -1)
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}
// MARK: - Small pieces
/// The little uppercase tag: LIVE, NO LIVE, CURRENT.
final class Badge: NSView {
enum Look { case ember, grey, moss }
private let look: Look
init(_ text: String, _ look: Look = .ember) {
self.look = look
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = Theme.px(5)
layer?.borderWidth = look == .grey ? 1 : 0
let color: NSColor = look == .ember ? Theme.emberInk : (look == .moss ? Theme.moss : Theme.muted)
let f = Theme.text("", 10.5, .semibold, color)
f.attributedStringValue = NSAttributedString(string: text.uppercased(), attributes: [
.font: NSFont.systemFont(ofSize: Theme.px(10.5), weight: .semibold),
.foregroundColor: color, .kern: Theme.px(10.5) * 0.04,
])
f.translatesAutoresizingMaskIntoConstraints = false
addSubview(f)
NSLayoutConstraint.activate([
f.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Theme.px(7)),
f.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Theme.px(7)),
f.topAnchor.constraint(equalTo: topAnchor, constant: Theme.px(3)),
f.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Theme.px(3)),
])
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
}
required init?(coder: NSCoder) { fatalError() }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
switch look {
case .ember: layer?.backgroundColor = Theme.emberSoft.cgColor
case .grey: layer?.backgroundColor = Theme.wellFill.cgColor
layer?.borderColor = Theme.hairline.cgColor
case .moss: layer?.backgroundColor = Theme.mossSoft.cgColor
}
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}
/// The refresh's `.chip`: a capsule on a soft fill, no outline. The Insights
/// peak chips are the neutral one, sunk, with their figures in ink. A list
/// row's "Learned" is the moss one, which carries a dot as well as a colour,
/// the way every state chip does.
final class Chip: NSView {
enum Look { case neutral, ok }
private let look: Look
/// "busiest day **Mon · 49m**": the lead muted, the figures in ink —
/// tabular digits a step up in weight, not a bold that shouts.
init(_ lead: String, _ bold: [String]) {
look = .neutral
super.init(frame: .zero)
let s = NSMutableAttributedString()
let plain: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: Theme.m(11.5)),
.foregroundColor: Theme.muted,
]
let strong: [NSAttributedString.Key: Any] = [
.font: Theme.digits(11.5, .medium),
.foregroundColor: Theme.ink,
]
if !lead.isEmpty { s.append(NSAttributedString(string: lead + " ", attributes: plain)) }
for (i, b) in bold.enumerated() {
if i > 0 { s.append(NSAttributedString(string: " · ", attributes: plain)) }
s.append(NSAttributedString(string: b, attributes: strong))
}
build(NSTextField(labelWithAttributedString: s), dot: false, height: 22, pad: 10)
}
/// A one-word tag. `compact` is the size that sits inside a list row
/// (the mockup's 19 px row chip) rather than on its own.
init(tag: String, _ look: Look, compact: Bool = false) {
self.look = look
super.init(frame: .zero)
let f = NSTextField(labelWithString: tag)
f.font = .systemFont(ofSize: Theme.m(compact ? 10.5 : 11.5), weight: .medium)
f.textColor = look == .ok ? Theme.moss : Theme.muted
build(f, dot: look == .ok, height: compact ? 19 : 22, pad: compact ? 7 : 9)
}
required init?(coder: NSCoder) { fatalError() }
private func build(_ label: NSTextField, dot: Bool, height: CGFloat, pad: CGFloat) {
wantsLayer = true
var parts: [NSView] = []
if dot {
let side = Theme.m(height < 22 ? 5 : 6)
let d = ColorView(color: Theme.moss, radius: side / 2)
d.translatesAutoresizingMaskIntoConstraints = false
d.widthAnchor.constraint(equalToConstant: side).isActive = true
d.heightAnchor.constraint(equalToConstant: side).isActive = true
parts.append(d)
}
parts.append(label)
let row = Theme.hstack(parts, spacing: Theme.m(5))
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Theme.m(pad)),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Theme.m(pad)),
row.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(equalToConstant: Theme.m(height)),
])
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
}
override func layout() {
super.layout()
layer?.cornerRadius = bounds.height / 2
}
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.backgroundColor = (look == .ok ? Theme.mossSoft : Theme.wellFill).cgColor
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}
/// Big serif number over a small uppercase label. Overview and Insights both
/// open on a row of these.
final class StatTile: NSView {
private let number: NSTextField
private let unit: NSTextField
init(_ value: String, _ label: String, unit unitText: String = "") {
number = Theme.display(value, 30, .semibold, tracking: -0.03)
unit = Theme.text(unitText, 15, .medium, Theme.muted)
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = Theme.m(12)
layer?.borderWidth = 1
unit.isHidden = unitText.isEmpty
let top = Theme.hstack([number, unit], spacing: 2)
top.alignment = .firstBaseline
let column = Theme.vstack([top, Theme.eyebrow(label)], spacing: Theme.px(9))
column.translatesAutoresizingMaskIntoConstraints = false
addSubview(column)
NSLayoutConstraint.activate([
column.topAnchor.constraint(equalTo: topAnchor, constant: Theme.px(18)),
column.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Theme.px(20)),
column.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Theme.px(20)),
column.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Theme.px(18)),
])
}
required init?(coder: NSCoder) { fatalError() }
func set(_ value: String) { Theme.setDisplay(number, value) }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.backgroundColor = Theme.surface.cgColor
layer?.borderColor = Theme.hairline.cgColor
Theme.shadow(layer)
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}
/// A keyboard cap — fn, ⌥, esc.
///
/// It repaints itself when the appearance changes. Baking the colours in at
/// build time left the caps in Settings › Shortcuts white in dark mode, with
/// the label's light ink on top of them and nothing readable in between.
final class KeyCapView: NSView {
/// `small` is the sidebar's cap: 20 tall in the refresh mockup, where the
/// full-size one sits in Settings rows.
init(_ text: String, small: Bool = false) {
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = Theme.m(5)
layer?.borderWidth = 1
let f = Theme.text(text, 12.5, .medium, Theme.ink2)
if small { f.font = .systemFont(ofSize: Theme.m(11), weight: .medium) }
f.alignment = .center
f.translatesAutoresizingMaskIntoConstraints = false
addSubview(f)
let padX = small ? Theme.m(6) : Theme.px(9)
NSLayoutConstraint.activate([
f.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padX),
f.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padX),
f.centerYAnchor.constraint(equalTo: centerYAnchor),
widthAnchor.constraint(greaterThanOrEqualToConstant: small ? Theme.m(20) : Theme.px(30)),
])
if small {
heightAnchor.constraint(equalToConstant: Theme.m(20)).isActive = true
} else {
NSLayoutConstraint.activate([
f.topAnchor.constraint(equalTo: topAnchor, constant: Theme.px(5)),
f.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Theme.px(5)),
])
}
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
}
required init?(coder: NSCoder) { fatalError() }
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.backgroundColor = Theme.keyFill.cgColor
layer?.borderColor = Theme.hairline.cgColor
Theme.lip(layer)
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}
func keyCap(_ text: String) -> NSView { KeyCapView(text) }
// MARK: - Rows
/// Left column, right controls, pinned with explicit constraints rather than
/// a stack spacer — a spacer wins the width fight against a wrapping label and
/// squeezes the text into a narrow column.
func splitRow(_ left: NSView, _ right: NSView, into host: NSView,
gap: CGFloat = 16, vertical: CGFloat = 3) {
left.setContentHuggingPriority(.init(1), for: .horizontal)
left.setContentCompressionResistancePriority(.init(200), for: .horizontal)
right.setContentHuggingPriority(.required, for: .horizontal)
right.setContentCompressionResistancePriority(.required, for: .horizontal)
// A stack view runs its own hugging, and ignores the view-level priority —
// without this the right-hand stack stretches and its buttons drift left.
(right as? NSStackView)?.setHuggingPriority(.required, for: .horizontal)
(left as? NSStackView)?.setHuggingPriority(.init(1), for: .horizontal)
for v in [left, right] {
v.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(v)
}
NSLayoutConstraint.activate([
left.leadingAnchor.constraint(equalTo: host.leadingAnchor),
left.topAnchor.constraint(equalTo: host.topAnchor, constant: Theme.px(vertical)),
left.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -Theme.px(vertical)),
left.trailingAnchor.constraint(equalTo: right.leadingAnchor, constant: -Theme.px(gap)),
right.trailingAnchor.constraint(equalTo: host.trailingAnchor),
right.centerYAnchor.constraint(equalTo: host.centerYAnchor),
right.topAnchor.constraint(greaterThanOrEqualTo: host.topAnchor, constant: Theme.px(vertical)),
])
}
/// The two halves of a grouped row's left side: a 13 px regular label — the
/// weight a Mac settings row uses; medium made every row shout — and a
/// 12 px muted line of help under it, one line by rule. Anything longer
/// belongs in the row's tooltip.
enum RowText {
static func title(_ s: String, _ color: NSColor = Theme.ink) -> NSTextField {
let f = Theme.text(s, 13, .regular, color)
f.font = .systemFont(ofSize: Theme.m(13))
return f
}
static func help(_ s: String) -> NSTextField {
Theme.para(s, 12 * 1.2, .regular, Theme.muted, lineHeight: 1.22)
}
/// New words for a help line, in its own face and leading. A plain
/// `stringValue` drops the paragraph style, and a line that changes
/// (a count, a browser's name) sat tighter than its neighbours.
static func setHelp(_ f: NSTextField, _ s: String) {
let style = NSMutableParagraphStyle()
style.lineHeightMultiple = 1.22
f.attributedStringValue = NSAttributedString(string: s, attributes: [
.font: f.font ?? NSFont.systemFont(ofSize: Theme.m(12)),
.foregroundColor: f.textColor ?? Theme.muted, .paragraphStyle: style,
])
}
/// A row's left side: the picture, if it has one, then the name over its
/// help. The help is pinned to the words' column, not the whole side —
/// pinned wider, it would wrap as if it ran under the tile.
static func lead(_ title: NSTextField, _ help: NSTextField, icon: NSView?, badge: NSView? = nil) -> NSView {
// A badge rides on the title's line: the "Pro" beside a shortcut
// only Pro has.
let top: NSView = badge.map { b -> NSView in
let line = Theme.hstack([title, b], spacing: Theme.m(7))
line.alignment = .centerY
return line
} ?? title
let words = Theme.vstack([top, help], spacing: Theme.m(2))
words.alignment = .leading
help.widthAnchor.constraint(equalTo: words.widthAnchor).isActive = true
guard let icon = icon else { return words }
words.setHuggingPriority(.init(1), for: .horizontal)
let side = Theme.hstack([icon, words], spacing: Theme.m(12))
side.alignment = .centerY
// Fill, not the default gravity areas: the words take the width the
// row gives them. Left at their own size, a line that arrives later
// wrapped to the width of the title above it.
side.distribution = .fill
return side
}
/// The `.srow` layout: words left, control right, both centred on the
/// row — splitRow pins the words to the top, which leaves a one-line
/// label riding high beside a taller control. The row is as tall as the
/// taller side and no taller; the 12 px gap is the mockup's.
static func layout(_ left: NSView, _ right: NSView, into host: NSView) {
left.setContentHuggingPriority(.init(1), for: .horizontal)
left.setContentCompressionResistancePriority(.init(200), for: .horizontal)
right.setContentHuggingPriority(.required, for: .horizontal)
right.setContentCompressionResistancePriority(.required, for: .horizontal)
(right as? NSStackView)?.setHuggingPriority(.required, for: .horizontal)
(left as? NSStackView)?.setHuggingPriority(.init(1), for: .horizontal)
for v in [left, right] {
v.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(v)
}
let snug = host.heightAnchor.constraint(equalToConstant: 0)
snug.priority = .init(200)
NSLayoutConstraint.activate([
left.leadingAnchor.constraint(equalTo: host.leadingAnchor),
left.trailingAnchor.constraint(equalTo: right.leadingAnchor, constant: -Theme.m(12)),
left.centerYAnchor.constraint(equalTo: host.centerYAnchor),
left.topAnchor.constraint(greaterThanOrEqualTo: host.topAnchor),
right.trailingAnchor.constraint(equalTo: host.trailingAnchor),
right.centerYAnchor.constraint(equalTo: host.centerYAnchor),
right.topAnchor.constraint(greaterThanOrEqualTo: host.topAnchor),
snug,
])
}
}
/// Title + explanation on the left, anything you like on the right. `icon`
/// (an IconTile) goes in front for a row that is a thing — a folder, a
/// kind of data — rather than a switch; `tip` carries whatever the one line
/// of help had no room for.
final class FormRow: NSView {
private let detailField: NSTextField
init(_ title: String, _ detail: String = "", controls: [NSView], titleColor: NSColor = Theme.ink,
icon: NSView? = nil, tip: String? = nil, badge: NSView? = nil) {
// Always built, even when empty: a line that arrives later (a count
// still being totted up) has somewhere to land.
detailField = RowText.help(detail)
super.init(frame: .zero)
detailField.isHidden = detail.isEmpty
let left = RowText.lead(RowText.title(title, titleColor), detailField, icon: icon, badge: badge)
let right = Theme.hstack(controls, spacing: Theme.m(8))
// No padding of its own: the group it sits in pads every row alike.
RowText.layout(left, right, into: self)
toolTip = tip
}
required init?(coder: NSCoder) { fatalError() }
var detail: String {
get { detailField.stringValue }
set {
RowText.setHelp(detailField, newValue)
detailField.isHidden = newValue.isEmpty
}
}
}
/// A permission: its picture, what it is, what it is for, and on the right
/// its state — a moss "Allowed" chip once macOS has said yes, or an amber
/// "Needs access" chip with Set Up beside it, which opens the prompt or the
/// pane. Every row the same shape, so the eye reads the column of chips as
/// the state of the Mac. An `optional` grant wears a grey chip instead of
/// the amber one: nothing is wrong while it is off.
final class GrantRow: NSView {
private let right = NSStackView()
private let button: FlatButton
private let optional: Bool
private var chipText = "Allowed"
private var missingText: String?
private let detailField: NSTextField
init(_ title: String, _ detail: String, icon: NSView? = nil, tip: String? = nil,
optional: Bool = false, setUp: @escaping () -> Void) {
button = FlatButton("Set Up", .plain, onClick: setUp)
self.optional = optional
detailField = RowText.help(detail)
super.init(frame: .zero)
let left = RowText.lead(RowText.title(title), detailField, icon: icon)
right.orientation = .horizontal
right.alignment = .centerY
right.spacing = Theme.m(10)
RowText.layout(left, right, into: self)
toolTip = tip
refresh()
}
required init?(coder: NSCoder) { fatalError() }
var granted: Bool = false { didSet { if granted != oldValue { refresh() } } }
/// The line under the name, for a grant whose story changes — which
/// browser is open to be asked, say.
var detail: String {
get { detailField.stringValue }
set { if detailField.stringValue != newValue { RowText.setHelp(detailField, newValue) } }
}
/// The chip's words, for a grant that is a setting rather than a
/// permission ("Ready", and "Not set" while it isn't).
func set(granted: Bool, chip: String, missing: String? = nil) {
chipText = chip
missingText = missing
if self.granted == granted { refresh() } else { self.granted = granted }
}
private func refresh() {
right.arrangedSubviews.forEach { $0.removeFromSuperview() }
if granted {
right.addArrangedSubview(GrantChip(chipText, .ok))
} else {
right.addArrangedSubview(optional ? GrantChip(missingText ?? "Off", .neutral)
: GrantChip(missingText ?? "Needs access", .warn))
right.addArrangedSubview(button)
}
}
}
/// The refresh's state chip: a capsule of soft fill, the word in the
/// state's own colour and a dot of it in front, so the state reads from
/// shape as well as hue. Ember is kept for the one that means live.
final class GrantChip: NSView {
/// `bad` is red: something is ending (a cancelled plan), not broken.
enum Look { case ok, warn, neutral, live, bad }
private let look: Look
private let dot = NSView()
init(_ text: String, _ look: Look = .ok) {
self.look = look
super.init(frame: .zero)
wantsLayer = true
layer?.cornerRadius = Theme.m(11)
// Medium: the dot and the fill already say "state"; semibold on
// top made a column of chips the loudest thing on the page.
let f = Theme.text(text, 11.5, .medium, color)
f.font = .systemFont(ofSize: Theme.m(11.5), weight: .medium)
dot.wantsLayer = true
dot.layer?.cornerRadius = Theme.m(3)
dot.isHidden = look == .neutral
let row = Theme.hstack([dot, f], spacing: Theme.m(6))
// A chip is exactly as wide as its word: without this a form row
// hands it the row's slack and it runs across to the label.
row.setHuggingPriority(.required, for: .horizontal)
f.setContentHuggingPriority(.required, for: .horizontal)
f.setContentCompressionResistancePriority(.required, for: .horizontal)
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
dot.widthAnchor.constraint(equalToConstant: Theme.m(6)),
dot.heightAnchor.constraint(equalToConstant: Theme.m(6)),
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Theme.m(9)),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Theme.m(9)),
row.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(equalToConstant: Theme.m(22)),
])
setContentHuggingPriority(.required, for: .horizontal)
setContentCompressionResistancePriority(.required, for: .horizontal)
}
required init?(coder: NSCoder) { fatalError() }
private var color: NSColor {
switch look {
case .ok: return Theme.moss
case .warn: return Theme.amber
case .neutral: return Theme.muted
case .live: return Theme.emberInk
case .bad: return Theme.danger
}
}
private var fill: NSColor {
switch look {
case .ok: return Theme.mossSoft
case .warn: return Theme.amberSoft
case .neutral: return Theme.wellFill
case .live: return Theme.emberSoft
case .bad: return Theme.dangerSoft
}
}
override var wantsUpdateLayer: Bool { true }
override func updateLayer() {
layer?.backgroundColor = fill.cgColor
dot.layer?.backgroundColor = (look == .live ? Theme.ember : color).cgColor
}
override func viewDidChangeEffectiveAppearance() { needsDisplay = true }
}