Repository navigation
Expand file tree
/
Copy pathCountdownWidget.swift
More file actions
2712 lines (2566 loc) · 133 KB
/
Copy pathCountdownWidget.swift
File metadata and controls
2712 lines (2566 loc) · 133 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
// CountdownWidget — macOS 悬浮纪念日倒计时小组件(多实例版)
// 数据源:官方 Anytype 客户端本地 API(经 anniversary.py)
// 每个进程 = 一个悬浮窗 = 绑定一个「纪念日」类型对象。
// 同步由官方客户端通过 any-sync 网络完成;标题/日期/颜色可动态刷新。
import Cocoa
import Foundation
let PYTHON = "/usr/bin/python3"
// anniversary.py 查找链(开源可移植,不写死个人绝对路径):
// 1) 环境变量 WNN_ANNIVERSARY_PY 显式指定
// 2) App 包 Resources 资源(打包时把脚本放进 Resources)
// 3) 可执行文件同目录(仓库根直接编译产物 CountdownWidget-bin)
// 4) 可执行文件上溯三级(.app 放在仓库根时,Contents/MacOS → 仓库根)
// 5) .app 包根目录(脚本随包分发)
// 6) PATH 中的 anniversary(如 ~/bin/anniversary 软链)
// 7) 兜底:当前工作目录下的 anniversary.py
func resolveCLIPath() -> String {
let fm = FileManager.default
let execURL = Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0])
let execDir = execURL.deletingLastPathComponent().path
var candidates: [String] = []
if let ov = ProcessInfo.processInfo.environment["WNN_ANNIVERSARY_PY"] { candidates.append(ov) }
if let r = Bundle.main.path(forResource: "anniversary", ofType: "py") { candidates.append(r) }
candidates.append(execDir + "/anniversary.py")
candidates.append(execDir + "/../../../anniversary.py")
candidates.append(Bundle.main.bundlePath + "/anniversary.py")
for c in candidates where fm.fileExists(atPath: c) { return c }
// PATH 查找 anniversary(软链/包装脚本均可,交给 python3 执行)
let sh = Process()
sh.executableURL = URL(fileURLWithPath: "/bin/sh")
sh.arguments = ["-c", "command -v anniversary"]
let pipe = Pipe(); sh.standardOutput = pipe; sh.standardError = Pipe()
do {
try sh.run()
let d = pipe.fileHandleForReading.readDataToEndOfFile()
sh.waitUntilExit()
let p = String(data: d, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !p.isEmpty && fm.fileExists(atPath: p) { return p }
} catch {}
return "anniversary.py"
}
let CLI_PATH = resolveCLIPath()
let CONFIG_DIR = (NSHomeDirectory() as NSString).appendingPathComponent(".whynownote/widgets")
let FETCH_INTERVAL: TimeInterval = 60 // 每 60s 拉一次对象(标题/日期/颜色)
let RECOMPUTE_INTERVAL: TimeInterval = 30 // 每 30s 本地重算数值
let MINI_HEIGHT: CGFloat = 42
let ANNIV_TYPE_KEY = "ji_nian_ri" // 迷你模式:高度贴合单行文字
let PANEL_TYPE_KEY = "ju_he_mian_ban" // 聚合面板对象类型
let ASSET_TYPE_KEY = "zi_chan" // 资产对象类型
// 资产字段 key(与 anniversary.py 保持一致,以类型定义实际 key 为准)
let A_GOU_MAI = "gou_mai_iso" // 购买日期(ISO)
let A_BAO_XIU = "bao_xiu_iso" // 保修到期日(ISO)
let A_PRICE = "price" // 购买价格(元)
let A_USES = "uses" // 使用次数
let A_PERIOD = "period" // 计数周期:日/月/年
let A_ASSET_MODE = "asset_mode" // 使用次数计费 / 使用时长计费
// 聚合面板字段 key(与 anniversary.py 保持一致)
let P_MO_SHI = "mo_shi"
let P_SHAI_XUAN = "shai_xuan_mo_shi"
let P_FEN_LEI = "fen_lei"
let P_BIAO_QIAN = "biao_qian"
let P_XUAN_ZHONG = "xian_shi_de_dui_xiang"
let P_PAI_XU = "pai_xu_mo_shi"
let P_XIAN_ZHI = "xian_zhi_xian_shi_shu_liang"
let DIM_TYPE = "对象类型"
let DIM_MODE = "日期计数默认模式"
let DIM_FEN_LEI = "分类"
let DIM_BIAO_QIAN = "标签"
let SORT_NAME_ASC = "名称升序"
// 迷你行/面板行排版:center=居中;compact=左对齐(按内容);justify=两端对齐(标题小字左、数值右,标题截断)
let LAYOUT_CENTER = "center"
let LAYOUT_COMPACT = "compact"
let LAYOUT_JUSTIFY = "justify"
// 排序中文选项(Anytype 选择值)↔ 内部归一化 key(共 4 项)
let SORT_KEY_BY_NAME: [String: String] = [
"名称升序": "name_asc", "名称降序": "name_desc",
"计数值升序": "count_asc", "计数值降序": "count_desc"
]
enum Mode: String {
case sinceDay = "since_day"
case untilDay = "until_day"
case sinceHour = "since_hour"
case untilHour = "until_hour"
case sinceAnniv = "since_anniv" // 周年计数(已经多少周年)
case untilRepeat = "until_repeat"
case birthday = "birthday" // 最近重复日(生日提醒:还有多少天)
var title: String {
switch self { case .sinceDay: return "正计日"; case .untilDay: return "倒计日"
case .sinceHour: return "正计时"; case .untilHour: return "倒计时"
case .sinceAnniv: return "周年计数"; case .untilRepeat: return "最近重复日"
case .birthday: return "生日" }
}
var unit: String {
switch self { case .sinceDay, .untilDay, .untilRepeat, .birthday: return "日"
case .sinceHour, .untilHour: return "小时"
case .sinceAnniv: return "周年" }
}
var isSince: Bool {
switch self { case .sinceDay, .sinceHour, .sinceAnniv: return true
case .untilDay, .untilHour, .untilRepeat, .birthday: return false }
}
static var all: [Mode] = [.sinceDay, .untilDay, .sinceHour, .untilHour, .sinceAnniv, .untilRepeat, .birthday]
// 客户端字段里用户可能用的别名(如 "距离最近的重复日")
var aliases: [String] {
switch self { case .untilRepeat: return ["距离最近的重复日"]; default: return [] }
}
// 匹配 Anytype 里配置的默认模式文本:先精确匹配标题/别名,再模糊包含兜底
static func fromText(_ s: String) -> Mode? {
if let m = all.first(where: { $0.title == s || $0.aliases.contains(s) }) { return m }
return all.first { s.contains($0.title) }
}
}
struct Inst: Codable {
var object_id: String?
var mode: String
var mode_manual: Bool? // [本地]单窗口手动指定过模式;false/nil=跟随对象 mo_shi
var x: Int
var y: Int
var w: Int?
var h: Int?
var locked: Bool?
var pinned: Bool?
var mini: Bool?
var agg: Bool? // 聚合面板
var agg_type: String? // 聚合面板的对象类型 key
var agg_title: String? // 聚合面板自定义标题(空则默认「{模式}看板」)
var agg_sel: [String]? // [旧]聚合面板手动选择,已改为存 Anytype 对象,仅解码兼容
var agg_sort: String? // [旧]聚合面板排序,已改为存 Anytype 对象,仅解码兼容
var mode_override: [String: String]? // [本地]面板内某对象的临时显示模式 {对象id: mode raw},不回写
var asset_mode: String? // [本地]资产单窗口计费方式覆盖(使用时长计费/使用次数计费),不回写
var asset_mode_override: [String: String]? // [本地]面板内资产成员的计费方式覆盖 {对象id: 计费方式},不回写
var font_scale: Double? // [本地]字号缩放(滚轮/双指缩放),默认 1.0
var panel_layout: String? // [本地]面板行排版 compact/justify,默认 compact
}
// 子进程运行 CLI,返回 stdout
func runCLI(_ args: [String]) -> String {
let p = Process()
p.executableURL = URL(fileURLWithPath: PYTHON)
p.arguments = [CLI_PATH] + args
// 脚本位于网盘同步目录:禁止 Python 写 __pycache__——为不同小版本新建 .pyc 会触发
// 同步扩展的文件创建流程,在同步客户端异常时 open() 永久挂起并卡死整个 GUI
var env = ProcessInfo.processInfo.environment
env["PYTHONDONTWRITEBYTECODE"] = "1"
p.environment = env
let out = Pipe(); p.standardOutput = out; p.standardError = Pipe()
do { try p.run()
let d = out.fileHandleForReading.readDataToEndOfFile(); p.waitUntilExit()
return String(data: d, encoding: .utf8) ?? "" } catch { return "" }
}
func cliJSON(_ args: [String]) -> Any? {
let s = runCLI(args)
guard let d = s.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: d)
}
func newInstanceId() -> String {
return "w\(Int(Date().timeIntervalSince1970 * 1000))" // 毫秒精度,避免同秒复制撞 id
}
func colorFromHex(_ hex: String) -> NSColor {
var h = hex.trimmingCharacters(in: .whitespaces).uppercased()
if h.hasPrefix("#") { h.removeFirst() }
var r: UInt64 = 0; Scanner(string: h).scanHexInt64(&r)
if h.count == 6 {
return NSColor(red: CGFloat((r>>16)&0xff)/255, green: CGFloat((r>>8)&0xff)/255,
blue: CGFloat(r&0xff)/255, alpha: 1)
}
return .white
}
class DragPanel: NSPanel {
var dragStart: NSPoint?; var originStart: NSPoint?
var onDragEnd: (() -> Void)?
var onRightClick: ((NSEvent) -> Void)?
var onResize: ((CGSize) -> Void)?
var resizing = false
var lastResize: NSPoint?
let corner: CGFloat = 24 // 右上角可缩放区域大小
// 无边框面板默认不能成为 key;滚轮缩放要求窗口处于聚焦状态,故放开
override var canBecomeKey: Bool { true }
override func mouseDown(with event: NSEvent) {
makeKey() // 点击即聚焦(不抢占其他应用焦点,nonactivatingPanel)
if event.clickCount >= 2 { return } // 双击交给热区手势(次数+1),不启动拖拽
let p = event.locationInWindow
let f = frame
if p.x >= f.width - corner && p.y >= f.height - corner { // 右上角 => 缩放
resizing = true; lastResize = p; dragStart = nil; originStart = nil
} else { // 其余区域 => 拖动
dragStart = p; originStart = f.origin; resizing = false
}
}
// 长按热区触发时,终止可能已经开始的窗口拖拽(长按期间弹窗会吞掉 mouseUp)
func cancelDrag() {
resizing = false; lastResize = nil
dragStart = nil; originStart = nil
}
override func mouseDragged(with event: NSEvent) {
if resizing, let lp = lastResize {
let p = event.locationInWindow
onResize?(CGSize(width: p.x - lp.x, height: lp.y - p.y)) // 往下拖=>高度增
lastResize = p
} else if let s = dragStart, let o = originStart {
let c = event.locationInWindow
setFrameOrigin(NSPoint(x: o.x + c.x - s.x, y: o.y + c.y - s.y))
}
}
override func mouseUp(with event: NSEvent) {
resizing = false; lastResize = nil
dragStart = nil; originStart = nil
onDragEnd?()
}
override func rightMouseDown(with event: NSEvent) { onRightClick?(event) }
}
// 右上角抓手(纯装饰,不拦截事件,交给面板角落缩放)
class GripView: NSView {
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
NSColor.white.withAlphaComponent(0.55).setStroke()
ctx.setLineWidth(1.4)
let w = bounds.width, h = bounds.height
for i in 0..<3 {
// 三条斜线由右上角指向窗口中心(左下)
let x0 = w - 5 - CGFloat(i) * 5
let y0 = h - 5 - CGFloat(i) * 5
ctx.move(to: CGPoint(x: x0, y: y0))
ctx.addLine(to: CGPoint(x: x0 - 5, y: y0 - 5))
ctx.strokePath()
}
}
override func hitTest(_ point: NSPoint) -> NSView? { return nil } // 不拦截,让事件落到面板
}
// 右下资产图标容器:图片图层在每次布局时铺满(随滚轮缩放自动跟随);
// 兼作「次数」热区——单击 +1、长按弹窗 -1(仅次数计费可交互)
class AssetIconBoxView: NSView {
weak var fillLayer: CALayer?
var onTap: (() -> Void)?
var onLongPress: (() -> Void)?
// 次数计费=true:手型光标/悬停增亮/点击生效;时长计费=false:纯展示,事件交回面板
var interactive = false {
didSet {
guard oldValue != interactive else { return }
hovering = false
applyHoverAlpha()
window?.invalidateCursorRects(for: self)
}
}
private var hovering = false
private var hoverArea: NSTrackingArea?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
let press = NSPressGestureRecognizer(target: self, action: #selector(iconLongPress(_:)))
press.minimumPressDuration = 0.6
press.allowableMovement = 10
addGestureRecognizer(press)
}
required init?(coder: NSCoder) { fatalError() }
override func layout() {
super.layout()
if let l = fillLayer { l.frame = bounds }
}
// 可交互时吞掉 mouseDown:阻止面板把图标上的按下当成窗口拖拽起点
override func mouseDown(with event: NSEvent) {
if !interactive { super.mouseDown(with: event) }
}
// 单击 +1(长按弹窗时 suppressIncClick 会吞掉抬手时的本次回调)
override func mouseUp(with event: NSEvent) {
guard interactive else { return }
let p = convert(event.locationInWindow, from: nil)
if bounds.insetBy(dx: -4, dy: -4).contains(p) { onTap?() }
}
@objc private func iconLongPress(_ g: NSPressGestureRecognizer) {
guard g.state == .began, interactive else { return }
onLongPress?()
}
private func applyHoverAlpha() {
layer?.opacity = (interactive && hovering) ? 1.0 : (interactive ? 0.9 : 1.0)
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let a = hoverArea { removeTrackingArea(a) }
let a = NSTrackingArea(rect: bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self, userInfo: nil)
addTrackingArea(a); hoverArea = a
}
override func mouseEntered(with event: NSEvent) {
guard interactive else { return }
hovering = true; applyHoverAlpha()
}
override func mouseExited(with event: NSEvent) {
hovering = false; applyHoverAlpha()
}
override func resetCursorRects() {
if interactive { addCursorRect(bounds, cursor: .pointingHand) }
}
}
// 根容器:窗口聚焦(key)时,滚轮/双指上下驱动整体字号缩放
class WheelZoomView: NSView {
var onZoom: ((CGFloat) -> Void)?
override func scrollWheel(with event: NSEvent) {
guard window?.isKeyWindow == true, event.scrollingDeltaY != 0 else {
super.scrollWheel(with: event); return
}
onZoom?(event.scrollingDeltaY)
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var window: DragPanel!
var content: NSView!
var titleLabel: NSTextField!
var hintLabel: NSTextField!
var numberLabel: NSTextField!
var unitLabel: NSTextField!
var gripView: GripView!
var glass: NSVisualEffectView!
var bgLayer: CALayer!
var emojiBgLabel: NSTextField!
var iconFileCache: String?
var sizeLocked = false
var objectMenu: NSMenu?
var instanceId = ""
var objectId: String?
var mode: Mode = .sinceDay
var modeManual = false // true=用户手动指定过模式,不再跟随对象默认;false=跟随 mo_shi/mo_ren_mo_shi
var target: Date?
var color: NSColor = .white
var lastFetch = Date.distantPast
var timer: Timer?
var pinned = true
var miniMode = false
var normalW: CGFloat = 0
var normalH: CGFloat = 0
var stack: NSStackView!
var titleWidthCon: NSLayoutConstraint!
// 资产迷你行:左组(标题/已经/数值/周期)之外的弹性间隔与右组均价标签
var miniSpacer: NSView!
var miniAvgLabel: NSTextField!
// stack 横向定位:纪念日迷你/普通=居中;资产迷你 compact=靠左;justify=靠左且撑满
var stackCenterXCon: NSLayoutConstraint!
var stackLeadingCon: NSLayoutConstraint!
var stackFillWCon: NSLayoutConstraint!
var lastIcon: Any?
var currentType: String?
var prefAppliedObject: String?
var objectName = ""
// 聚合面板状态(配置存在 Anytype 面板对象上;本地只存窗口属性与模式覆盖)
var aggMode = false
var aggStack: NSStackView!
var aggHeader: NSTextField?
var aggItems: [[String: Any]] = [] // 当前行集合(手选优先;排序/limit 之前)
var aggCandidates: [[String: Any]] = [] // 自动筛选后的候选(勾选菜单用)
var aggDisplay: [[String: Any]] = [] // 排序+limit 后实际显示
// (标题, 提示小字, 数值, 周期/单位, 右组均价仅资产行)
var aggRows: [(NSTextField, NSTextField, NSTextField, NSTextField, NSTextField?)] = []
// 面板资产行视图 → 对象 id(双击+1 / 长按减一热区)
var aggRowOidMap: [NSView: String] = [:]
var panelSort = "name_asc" // 归一化排序:name_asc/name_desc/count_asc/count_desc
var panelLimit = 0
var panelNameTemplate = "" // 面板对象名(支持 {} 变量)
var panelModeFilterText: String?
var panelDims: [String] = []
var panelFenLei: [String] = []
var panelBiaoQian: [String] = []
var panelTypeKeys: [String] = []
var panelProps: [String: Any] = [:]
var panelFields: [[String: Any]] = []
var modeOverride: [String: Mode] = [:] // 本地临时显示模式,不回写
var fontScale: CGFloat = 1.0 // 字号缩放(聚焦时滚轮/双指调节),本地持久化
var panelLayout = LAYOUT_COMPACT // 面板行排版,本地持久化
var panelLayoutLoaded = false // 排版是否来自显式选择/配置(否则迷你按类型给默认)
var dateFmtCache: [String: DateFormatter] = [:]
// 迷你行生效排版:显式选择优先;无配置时资产默认左对齐、纪念日默认居中
var effectiveMiniLayout: String {
if panelLayoutLoaded { return panelLayout }
return isAsset ? LAYOUT_COMPACT : LAYOUT_CENTER
}
// 资产(zi_chan)单对象视图
var isAsset = false
var assetContainer: NSView!
var assetTitleLabel: NSTextField!
var assetBigNum: NSTextField!
var assetBigUnit: NSTextField!
var assetSmallNum: NSTextField!
var assetSmallUnit: NSTextField!
var assetPriceLabel: NSTextField!
var assetAvgLabel: NSTextField!
var assetIconBox: NSView! // 等边矩形容器
var assetIconLayer: CALayer! // file 图标:短边填充(aspect-fill)
var assetEmojiLabel: NSTextField! // emoji 图标
// 非迷你资产卡片的栈式布局引用(设计稿 js_21UtLMTGZaI),供滚轮缩放时改间距/尺寸
var assetOuterStack: NSStackView!
var assetNumRow: NSStackView!
var assetWarrantyRow: NSStackView!
var assetPriceCol: NSStackView!
var assetPadCons: [NSLayoutConstraint] = []
var assetIconWCon: NSLayoutConstraint!
var assetIconHCon: NSLayoutConstraint!
var assetTitleMaxWCon: NSLayoutConstraint!
var assetUserWidth = false // 用户手动拖宽过卡片(jsdesign:宽可调);之后宽度只扩不收
var suppressIncClick = false // 长按弹窗后吞掉随之而来的 click(避免同时 +1)
var assetData: [String: Any] = [:]
var assetModePref: String? = nil // 单对象窗口的资产计费方式覆盖(使用时长计费/使用次数计费)
var assetModeOverride: [String: String] = [:] // 面板内资产成员的计费方式覆盖 {oid: 计费方式}
var assetIconFileCache: String? // 已下载图标的 file id
var assetIconImage: NSImage? // 普通模式方形图标内存缓存
var configURL: URL { URL(fileURLWithPath: "\(CONFIG_DIR)/\(instanceId).json") }
func applicationDidFinishLaunching(_ n: Notification) {
NSApp.setActivationPolicy(.accessory)
// 解析启动参数
let args = CommandLine.arguments
var initialObject: String?
var i = 0
while i < args.count {
if args[i] == "--instance" && i+1 < args.count { instanceId = args[i+1]; i += 2 }
else if args[i] == "--object" && i+1 < args.count { initialObject = args[i+1]; i += 2 }
else if args[i] == "--agg" { aggMode = true; i += 1 }
else if args[i] == "--agg-type" && i+1 < args.count { aggMode = true; i += 2 } // [旧参数]兼容:面板对象改由 --object 绑定
else { i += 1 }
}
// 默认模式
mode = .sinceDay
// 加载该实例配置
if !instanceId.isEmpty, let d = try? Data(contentsOf: configURL),
let inst = try? JSONDecoder().decode(Inst.self, from: d) {
mode = Mode(rawValue: inst.mode) ?? .sinceDay
modeManual = inst.mode_manual ?? false
if inst.object_id != nil { objectId = inst.object_id }
if let l = inst.locked { sizeLocked = l }
if let p = inst.pinned { pinned = p }
if let mi = inst.mini { miniMode = mi }
if let a = inst.agg { aggMode = a }
// agg_type/agg_title/agg_sel/agg_sort 为旧字段,配置已迁移到 Anytype 面板对象,不再读取
if let ov = inst.mode_override {
for (k, v) in ov { if let m = Mode(rawValue: v) { modeOverride[k] = m } }
}
if let aov = inst.asset_mode_override { assetModeOverride = aov }
if let am = inst.asset_mode, !am.isEmpty { assetModePref = am }
if let fs = inst.font_scale, fs > 0 { fontScale = CGFloat(fs) }
if let pl = inst.panel_layout { panelLayout = pl; panelLayoutLoaded = true }
if let x = inst.x as Int?, let y = inst.y as Int?, x != 0 {
windowPosX = CGFloat(x); windowPosY = CGFloat(y)
}
if let w = inst.w, w > 0 { windowSizeW = CGFloat(w); assetUserWidth = true }
if let h = inst.h, h > 0 { windowSizeH = CGFloat(h) }
}
if let o = initialObject { objectId = o }
if instanceId.isEmpty { instanceId = newInstanceId() }
saveConfig() // 持久化启动参数(如新面板窗口的 --object 绑定)
buildWindow()
if aggMode {
if objectId == nil { pickDefaultPanel() } else { fetchAggregate() }
} else if objectId == nil { pickDefaultObject() } else { fetchAndRender() }
timer = Timer.scheduledTimer(withTimeInterval: RECOMPUTE_INTERVAL, repeats: true) { _ in
self.onTick()
}
}
// App 已在运行时再次双击图标 / Finder「打开」:后台组件没有 Dock 窗口可激活,
// 直接新建一个默认小组件窗口(多窗口=多进程,与「复制窗口」同一启动方式)
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
let newId = newInstanceId()
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/open")
p.arguments = ["-n", Bundle.main.bundlePath, "--args", "--instance", newId]
try? p.run()
return true
}
var windowPosX: CGFloat = 0
var windowPosY: CGFloat = 0
var windowSizeW: CGFloat = 0
var windowSizeH: CGFloat = 0
func buildWindow() {
let panel = DragPanel(contentRect: NSRect(x: 0, y: 0, width: 240, height: 164),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
panel.level = pinned ? .floating : .normal
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false // 不用窗口矩形阴影,改用圆角容器自绘圆角阴影
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
// 圆角容器:提供圆角阴影,承载圆角毛玻璃与文字;聚焦时滚轮缩放字号
let container = WheelZoomView(frame: NSRect(x: 0, y: 0, width: 240, height: 164))
container.wantsLayer = true
container.layer?.cornerRadius = 20
container.layer?.masksToBounds = false
container.layer?.shadowColor = NSColor.black.cgColor
container.layer?.shadowRadius = 16
container.layer?.shadowOpacity = 0.35
container.layer?.shadowOffset = .zero
container.layer?.borderWidth = 0
// 图标背景模式的深色底(毛玻璃时被玻璃盖住,无感)
container.layer?.backgroundColor = NSColor(calibratedWhite: 0.1, alpha: 0.9).cgColor
// 图标背景层(有 file 图标时显示,70% 透明度,圆角裁剪;按短边等比填充 aspect-fill)
bgLayer = CALayer()
bgLayer.frame = container.bounds
bgLayer.cornerRadius = 20
bgLayer.masksToBounds = true
bgLayer.contentsGravity = .resizeAspectFill
bgLayer.opacity = 0.7
bgLayer.isHidden = true
bgLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
container.layer?.insertSublayer(bgLayer, at: 0)
// emoji 背景层(有 emoji 图标时显示,70% 透明度,居中)
emojiBgLabel = NSTextField(labelWithString: "")
emojiBgLabel.font = NSFont.systemFont(ofSize: 92)
emojiBgLabel.textColor = NSColor.white.withAlphaComponent(0.9)
emojiBgLabel.alignment = .center
emojiBgLabel.alphaValue = 0.7
emojiBgLabel.isHidden = true
emojiBgLabel.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(emojiBgLabel)
// 圆角毛玻璃(模糊桌面背景,裁剪成圆角;随窗口拉伸)
glass = NSVisualEffectView(frame: container.bounds)
glass.material = .hudWindow
glass.blendingMode = .behindWindow
glass.state = .active
glass.wantsLayer = true
glass.layer?.cornerRadius = 20
glass.layer?.masksToBounds = true
glass.autoresizingMask = [.width, .height]
container.addSubview(glass)
titleLabel = NSTextField(labelWithString: "")
titleLabel.font = NSFont.systemFont(ofSize: 30, weight: .bold)
titleLabel.textColor = NSColor.white.withAlphaComponent(0.95)
titleLabel.alignment = .center
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
hintLabel = NSTextField(labelWithString: "")
hintLabel.font = NSFont.systemFont(ofSize: 12)
hintLabel.textColor = NSColor.white.withAlphaComponent(0.6)
hintLabel.alignment = .center
hintLabel.setContentHuggingPriority(.required, for: .horizontal)
numberLabel = NSTextField(labelWithString: "--")
numberLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 56, weight: .bold)
numberLabel.alignment = .center
numberLabel.setContentHuggingPriority(.required, for: .horizontal)
unitLabel = NSTextField(labelWithString: "日")
unitLabel.font = NSFont.systemFont(ofSize: 15, weight: .bold)
unitLabel.textColor = NSColor.white.withAlphaComponent(0.7)
unitLabel.alignment = .center
unitLabel.setContentHuggingPriority(.required, for: .horizontal)
let stack = NSStackView()
stack.orientation = .vertical
stack.alignment = .centerX
stack.spacing = 6
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(hintLabel)
stack.addArrangedSubview(numberLabel)
stack.addArrangedSubview(unitLabel)
// 资产迷你行专用:弹性间隔 + 右组均价;hidden 时不参与 stack 布局
miniSpacer = NSView()
miniSpacer.isHidden = true
miniAvgLabel = NSTextField(labelWithString: "")
miniAvgLabel.isHidden = true
miniAvgLabel.setContentHuggingPriority(.required, for: .horizontal)
miniAvgLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
stack.addArrangedSubview(miniSpacer)
stack.addArrangedSubview(miniAvgLabel)
stack.translatesAutoresizingMaskIntoConstraints = false
self.stack = stack
// 资产迷你行:整行热区(双击使用次数+1 / 长按弹窗减一);非资产迷你时 action 内自行忽略
installUsesGestures(on: stack, double: #selector(miniRowDoubleTap), long: #selector(miniRowLongPress))
// 聚合面板行容器(多行列表;单对象模式下隐藏)
aggStack = NSStackView()
aggStack.orientation = .vertical
aggStack.alignment = .leading
aggStack.spacing = 4
aggStack.translatesAutoresizingMaskIntoConstraints = false
aggStack.isHidden = true
container.addSubview(aggStack)
NSLayoutConstraint.activate([
aggStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 14),
aggStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
// 等宽约束:「两端对齐」排版时行可撑满整行;紧凑排版的行按内容宽度,不受影响
aggStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18)
])
gripView = GripView(frame: NSRect(x: container.bounds.width - 22,
y: container.bounds.height - 24,
width: 20, height: 20))
gripView.isHidden = sizeLocked
gripView.autoresizingMask = [.minXMargin, .minYMargin] // 钉住右上角(避开右下图标热区)
gripView.wantsLayer = true
buildAssetChrome(container)
container.addSubview(stack)
container.addSubview(gripView)
stackCenterXCon = stack.centerXAnchor.constraint(equalTo: container.centerXAnchor)
stackLeadingCon = stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12)
stackFillWCon = stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12)
NSLayoutConstraint.activate([
stackCenterXCon,
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor),
stack.widthAnchor.constraint(lessThanOrEqualTo: container.widthAnchor, constant: -24),
stack.heightAnchor.constraint(lessThanOrEqualTo: container.heightAnchor, constant: -24),
emojiBgLabel.centerXAnchor.constraint(equalTo: container.centerXAnchor),
emojiBgLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor)
])
titleWidthCon = titleLabel.widthAnchor.constraint(lessThanOrEqualTo: container.widthAnchor, constant: -24)
titleWidthCon.isActive = true
content = container
panel.contentView = container
container.onZoom = { [weak self] dy in self?.onWheelZoom(dy) }
applyFontScale() // 应用持久化的字号缩放(面板行在 fetch 后重建时同样读 fontScale)
stack.isHidden = aggMode
aggStack.isHidden = !aggMode
panel.onDragEnd = { [weak self] in self?.saveConfig(); self?.writeSizeBack() }
panel.onResize = { [weak self] delta in self?.applyResize(delta) }
panel.onRightClick = { [weak self] event in
guard let self = self, let cv = self.content else { return }
let menu = NSMenu()
self.buildMenu(menu)
NSMenu.popUpContextMenu(menu, with: event, for: cv)
}
window = panel
// 定位(含持久化大小)
let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
var w = windowSizeW > 0 ? windowSizeW : panel.frame.width
var h = windowSizeH > 0 ? windowSizeH : panel.frame.height
if aggMode {
w = min(max(w, 200), 800); h = 60 // 初始小高度,fetch 后 resizeAggWindow 贴合内容
} else if miniMode {
w = min(max(w, 160), 800); h = MINI_HEIGHT
} else {
w = min(max(w, 150), 800); h = min(max(h, 150), 600)
}
var x = windowPosX != 0 ? windowPosX : screen.maxX - w - 24
var y = windowPosY != 0 ? windowPosY : screen.maxY - h - 24
// 屏幕外兜底:持久化位置可能来自已拔掉的外接显示器;若与任一显示器可见区域
// 的交集小到看不见(或完全不相交),回退到主屏右上角默认位,并写回以便持久化
let desired = NSRect(x: x, y: y, width: w, height: h)
let visibleEnough = NSScreen.screens.contains {
let inter = $0.visibleFrame.intersection(desired)
return inter.width > 120 && inter.height >= 60
}
if !visibleEnough {
x = screen.maxX - w - 24
y = screen.maxY - h - 24
windowPosX = x; windowPosY = y
}
panel.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true)
window.orderFrontRegardless()
}
// 右键菜单
func buildMenu(_ m: NSMenu) {
if aggMode { buildPanelMenu(m) } else { buildSingleMenu(m) }
// 模式(仅单对象窗口):纪念日→计数模式(默认=跟随对象);资产→计费方式(默认=跟随对象默认模式)
if !aggMode {
let modeItem = NSMenuItem(title: isAsset ? "计费方式" : "模式", action: nil, keyEquivalent: "")
m.addItem(modeItem)
let ms = NSMenu()
// 第一项始终是「默认(跟随对象)」
let defTitle = "默认(跟随对象)"
let def = NSMenuItem(title: defTitle, action: #selector(selectModeFollow(_:)), keyEquivalent: "")
def.representedObject = ""; def.target = self
if !modeManual { def.state = .on }
ms.addItem(def)
ms.addItem(.separator())
if isAsset {
// 资产:使用时长计费 / 使用次数计费
for txt in ["使用时长计费", "使用次数计费"] {
let it = NSMenuItem(title: txt, action: #selector(selectAssetMode(_:)), keyEquivalent: "")
it.representedObject = txt; it.target = self
if modeManual, assetModePref == txt { it.state = .on }
ms.addItem(it)
}
} else {
for mm in Mode.all {
let it = NSMenuItem(title: mm.title, action: #selector(selectMode(_:)), keyEquivalent: "")
it.representedObject = mm.rawValue; it.target = self
if modeManual, mm == mode { it.state = .on }
ms.addItem(it)
}
}
m.setSubmenu(ms, for: modeItem)
}
m.addItem(NSMenuItem(title: "复制窗口", action: #selector(launchNew(_:)), keyEquivalent: ""))
m.addItem(NSMenuItem(title: "新建聚合面板", action: #selector(launchNewPanel(_:)), keyEquivalent: ""))
m.addItem(NSMenuItem(title: "马上刷新最新数据", action: #selector(refreshNow(_:)), keyEquivalent: "r"))
let lockItem = NSMenuItem(title: "锁定大小", action: #selector(toggleLock(_:)), keyEquivalent: "")
lockItem.state = sizeLocked ? .on : .off
m.addItem(lockItem)
let pinItem = NSMenuItem(title: "置顶", action: #selector(togglePin(_:)), keyEquivalent: "")
pinItem.state = pinned ? .on : .off
m.addItem(pinItem)
if !aggMode {
let miniItem = NSMenuItem(title: "迷你模式", action: #selector(toggleMini(_:)), keyEquivalent: "")
miniItem.state = miniMode ? .on : .off
m.addItem(miniItem)
}
// 排版切换:面板行(紧凑/两端);迷你行(居中/左对齐/两端对齐,纪念日与资产一致)
if aggMode || miniMode {
let layoutItem = NSMenuItem(title: "排版", action: nil, keyEquivalent: "")
m.addItem(layoutItem)
let lm = NSMenu()
let current = aggMode ? panelLayout : effectiveMiniLayout
let options: [(String, String)] = aggMode
? [(LAYOUT_COMPACT, "自然紧凑(当前)"),
(LAYOUT_JUSTIFY, "两端对齐(标题小字在左、数值单位在右,超长截断…)")]
: [(LAYOUT_CENTER, "居中对齐"),
(LAYOUT_COMPACT, "左对齐(自然紧凑)"),
(LAYOUT_JUSTIFY, isAsset
? "两端对齐(左组在左、均价在右)"
: "两端对齐(标题提示在左、数值单位在右)")]
for (lk, ll) in options {
let it = NSMenuItem(title: ll, action: #selector(panelSetLayout(_:)), keyEquivalent: "")
it.representedObject = lk; it.target = self
if current == lk { it.state = .on }
lm.addItem(it)
}
m.setSubmenu(lm, for: layoutItem)
}
m.addItem(.separator())
m.addItem(NSMenuItem(title: "关闭此窗口", action: #selector(quit(_:)), keyEquivalent: "q"))
for it in m.items { if it.target == nil && it.action != nil { it.target = self } }
}
// 单对象窗口菜单:选择对象 + 对象类型 + 修改时间
func buildSingleMenu(_ m: NSMenu) {
let objItem = NSMenuItem(title: "选择对象", action: nil, keyEquivalent: "")
m.addItem(objItem)
let sub = NSMenu()
let listType = isAsset ? ASSET_TYPE_KEY : nil
let objs = cliJSON(["objects"] + (listType.map { [$0] } ?? [])) as? [[String: Any]] ?? []
if objs.isEmpty {
sub.addItem(NSMenuItem(title: isAsset ? "(无「资产」对象)" : "(无「纪念日」对象)",
action: nil, keyEquivalent: ""))
}
for o in objs {
let name = o["name"] as? String ?? "?"
let oid = o["id"] as? String ?? ""
let it = NSMenuItem(title: name, action: #selector(selectObject(_:)), keyEquivalent: "")
it.representedObject = oid; it.target = self
sub.addItem(it)
}
m.setSubmenu(sub, for: objItem)
let typeItem = NSMenuItem(title: "对象类型", action: nil, keyEquivalent: "")
m.addItem(typeItem)
let tms = NSMenu()
let types = cliJSON(["types"]) as? [[String: Any]] ?? []
if types.isEmpty { tms.addItem(NSMenuItem(title: "(无类型)", action: nil, keyEquivalent: "")) }
for t in types {
let key = t["key"] as? String ?? ""
let name = t["name"] as? String ?? key
tms.addItem(typeMenuItem(name: name, key: key,
checked: key == currentType,
action: #selector(selectType(_:))))
}
m.setSubmenu(tms, for: typeItem)
if !isAsset {
m.addItem(NSMenuItem(title: "修改时间…", action: #selector(editTime(_:)), keyEquivalent: ""))
}
}
// 聚合面板窗口菜单(配置全部回写到 Anytype 面板对象;显示模式覆盖仅本地)
func buildPanelMenu(_ m: NSMenu) {
// 1. 选择对象:切换绑定的面板 + 勾选本面板显示的成员
let objItem = NSMenuItem(title: "选择对象", action: nil, keyEquivalent: "")
m.addItem(objItem)
let sub = NSMenu()
let panels = cliJSON(["objects", PANEL_TYPE_KEY]) as? [[String: Any]] ?? []
if panels.isEmpty { sub.addItem(NSMenuItem(title: "(无聚合面板对象)", action: nil, keyEquivalent: "")) }
for p in panels {
let it = NSMenuItem(title: p["name"] as? String ?? "?",
action: #selector(selectPanelObject(_:)), keyEquivalent: "")
it.representedObject = p["id"]; it.target = self
if p["id"] as? String == objectId { it.state = .on }
sub.addItem(it)
}
sub.addItem(.separator())
// 勾选改为弹窗复选列表,避免菜单点一次就消失、无法连续多选
sub.addItem(NSMenuItem(title: "勾选显示对象…", action: #selector(panelPickMembers(_:)), keyEquivalent: ""))
m.setSubmenu(sub, for: objItem)
// 2. 成员类型筛选(值)
let typeItem = NSMenuItem(title: "成员类型", action: nil, keyEquivalent: "")
m.addItem(typeItem)
let tms = NSMenu()
for t in cliJSON(["types"]) as? [[String: Any]] ?? [] {
guard let key = t["key"] as? String else { continue }
tms.addItem(typeMenuItem(name: t["name"] as? String ?? key, key: key,
checked: panelTypeKeys.contains(key),
action: #selector(togglePanelType(_:))))
}
m.setSubmenu(tms, for: typeItem)
// 3. 筛选意愿与值
let filterItem = NSMenuItem(title: "筛选", action: nil, keyEquivalent: "")
m.addItem(filterItem)
let fm = NSMenu()
let typeDim = NSMenuItem(title: "按对象类型筛选", action: #selector(togglePanelDim(_:)), keyEquivalent: "")
typeDim.representedObject = DIM_TYPE; typeDim.target = self
typeDim.state = panelDims.contains(DIM_TYPE) ? .on : .off
fm.addItem(typeDim)
fm.addItem(configuredSubmenu(title: "分类", dim: DIM_FEN_LEI,
values: unionValues(P_FEN_LEI), selected: panelFenLei,
toggle: #selector(togglePanelFenLei(_:))))
fm.addItem(configuredSubmenu(title: "标签", dim: DIM_BIAO_QIAN,
values: unionValues(P_BIAO_QIAN), selected: panelBiaoQian,
toggle: #selector(togglePanelBiaoQian(_:))))
// 日期计数模式:二级菜单选模式值(=启用该维度)
let modeDimItem = NSMenuItem(title: "按日期计数模式筛选", action: nil, keyEquivalent: "")
fm.addItem(modeDimItem)
let mdm = NSMenu()
let off = NSMenuItem(title: "不按模式筛选", action: #selector(panelSetModeFilter(_:)), keyEquivalent: "")
off.representedObject = ""; off.target = self
off.state = panelDims.contains(DIM_MODE) ? .off : .on
mdm.addItem(off)
let curMode = panelModeFilterText.flatMap { Mode.fromText($0) }
for mm in Mode.all {
let it = NSMenuItem(title: mm.title, action: #selector(panelSetModeFilter(_:)), keyEquivalent: "")
it.representedObject = mm.rawValue; it.target = self
if panelDims.contains(DIM_MODE), curMode == mm { it.state = .on }
mdm.addItem(it)
}
fm.setSubmenu(mdm, for: modeDimItem)
m.setSubmenu(fm, for: filterItem)
// 4. 对象显示模式(本地覆盖,不回写 Anytype)——按成员类型区分选项:
// 纪念日→计数模式;资产→计费方式;其它类型→无可用选项
let ovItem = NSMenuItem(title: "对象显示模式(仅本窗口)", action: nil, keyEquivalent: "")
m.addItem(ovItem)
let ovm = NSMenu()
if aggDisplay.isEmpty { ovm.addItem(NSMenuItem(title: "(无显示中的对象)", action: nil, keyEquivalent: "")) }
for row in aggDisplay {
guard let oid = row["id"] as? String else { continue }
let nm = row["name"] as? String ?? "?"
let per = NSMenuItem(title: nm, action: nil, keyEquivalent: "")
ovm.addItem(per)
let pm = NSMenu()
if isAssetMember(row) {
// 资产成员:计费方式
let def = NSMenuItem(title: "默认(跟随对象)", action: #selector(setLocalAssetOverride(_:)), keyEquivalent: "")
def.representedObject = ["id": oid, "mode": ""]; def.target = self
if assetModeOverride[oid] == nil { def.state = .on }
pm.addItem(def)
for txt in ["使用时长计费", "使用次数计费"] {
let it = NSMenuItem(title: txt, action: #selector(setLocalAssetOverride(_:)), keyEquivalent: "")
it.representedObject = ["id": oid, "mode": txt]; it.target = self
if assetModeOverride[oid] == txt { it.state = .on }
pm.addItem(it)
}
} else {
// 纪念日成员:计数模式
let def = NSMenuItem(title: "默认(跟随对象)", action: #selector(setLocalModeOverride(_:)), keyEquivalent: "")
def.representedObject = ["id": oid, "mode": ""]; def.target = self
if modeOverride[oid] == nil { def.state = .on }
pm.addItem(def)
for mm in Mode.all {
let it = NSMenuItem(title: mm.title, action: #selector(setLocalModeOverride(_:)), keyEquivalent: "")
it.representedObject = ["id": oid, "mode": mm.rawValue]; it.target = self
if modeOverride[oid] == mm { it.state = .on }
pm.addItem(it)
}
}
ovm.setSubmenu(pm, for: per)
}
m.setSubmenu(ovm, for: ovItem)
// 5. 排序(回写对象)
let sortItem = NSMenuItem(title: "排序", action: nil, keyEquivalent: "")
m.addItem(sortItem)
let sm = NSMenu()
for (sk, sl) in [("name_asc", "名称升序"), ("name_desc", "名称降序"),
("count_asc", "计数值升序"), ("count_desc", "计数值降序")] {
let it = NSMenuItem(title: sl, action: #selector(panelSetSort(_:)), keyEquivalent: "")
it.representedObject = sl; it.target = self
if panelSort == sk { it.state = .on }
sm.addItem(it)
}
m.setSubmenu(sm, for: sortItem)
// 排版(自然紧凑/两端对齐)入口在 buildMenu 通用部分,面板与资产迷你共用
m.addItem(NSMenuItem(title: "显示数量…", action: #selector(panelEditLimit(_:)), keyEquivalent: ""))
m.addItem(NSMenuItem(title: "重命名…", action: #selector(panelRename(_:)), keyEquivalent: ""))
}
// 类型菜单项:纪念日/资产/聚合面板可选;其他类型禁选、灰显并备注「(暂不支持)」
func typeMenuItem(name: String, key: String, checked: Bool, action: Selector) -> NSMenuItem {
let supported = key == ANNIV_TYPE_KEY || key == PANEL_TYPE_KEY || key == ASSET_TYPE_KEY
let it = NSMenuItem()
it.representedObject = key
if supported {
it.title = name
it.action = action; it.target = self
if checked { it.state = .on }
} else {
it.attributedTitle = NSAttributedString(
string: "\(name)(暂不支持)",
attributes: [.foregroundColor: NSColor.disabledControlTextColor])
it.isEnabled = false
}
return it
}
// 「分类/标签」筛选子菜单:启用开关 + 候选值勾选
func configuredSubmenu(title: String, dim: String, values: [String],
selected: [String], toggle: Selector) -> NSMenuItem {
let top = NSMenuItem(title: title, action: nil, keyEquivalent: "")
let menu = NSMenu()
let en = NSMenuItem(title: "启用\(title)筛选", action: #selector(togglePanelDim(_:)), keyEquivalent: "")
en.representedObject = dim; en.target = self
en.state = panelDims.contains(dim) ? .on : .off
menu.addItem(en)
menu.addItem(.separator())
if values.isEmpty { menu.addItem(NSMenuItem(title: "(候选对象无\(title))", action: nil, keyEquivalent: "")) }
for v in values {
let it = NSMenuItem(title: v, action: toggle, keyEquivalent: "")
it.representedObject = v; it.target = self
it.state = selected.contains(v) ? .on : .off
menu.addItem(it)
}
top.submenu = menu
return top
}
func unionValues(_ key: String) -> [String] {
var set = Set<String>()
for c in aggCandidates {
for v in c[key] as? [String] ?? [] { set.insert(v) }
}
return set.sorted()
}
func panelSelectedIds() -> [String] { panelProps[P_XUAN_ZHONG] as? [String] ?? [] }
// 回写面板对象属性并立即重拉
@discardableResult func panelSet(_ updates: [String: Any]) -> Bool {
guard let oid = objectId,
let data = try? JSONSerialization.data(withJSONObject: updates),
let json = String(data: data, encoding: .utf8) else { return false }
let out = runCLI(["panel-set", oid, json])
fetchAggregate()
return out.contains("\"ok\"")
}
func toggleDim(_ dim: String) -> [String] {
var d = panelDims
if let i = d.firstIndex(of: dim) { d.remove(at: i) } else { d.append(dim) }
return d
}
@objc func selectObject(_ s: NSMenuItem) {
guard let oid = s.representedObject as? String else { return }
objectId = oid
// 切换对象后恢复跟随对象默认
modeManual = false; assetModePref = nil
saveConfig()
fetchAndRender()
}
@objc func selectType(_ s: NSMenuItem) {
guard let key = s.representedObject as? String else { return }
if key == PANEL_TYPE_KEY {
// 切到聚合面板形态
aggMode = true; objectId = nil; prefAppliedObject = nil
isAsset = false; modeManual = false; assetModePref = nil
modeOverride = [:]; saveConfig()
switchPanelChrome(true)