30 files changed
+1961
-2
No files matched your search
@@ -167,6 +167,31 @@ an empty compact toolbar, so the traffic lights sit on the same ground colour as
|
||||
line up with the 40 pt page header; the app switches to a regular activation policy while a window
|
||||
is open and back to accessory when all are closed.
|
||||
|
||||
## Calendar
|
||||
|
||||
`CalendarMenuBarController` owns a separate, opt-in status item, independent of the metrics
|
||||
layout and `MetricsHub`. It reuses `StatusPanel`, updates the displayed date every minute and
|
||||
on day/time-zone changes or wake, and stops its timer when disabled. `CalendarPopover` uses a
|
||||
six-week Gregorian grid (1900–2100) and month/year navigation. Clicking a day opens an almanac
|
||||
page in the same panel; returning preserves the selected date and month. `CalendarAlmanac`
|
||||
loads Tyme's daily advice, deities and hour fortunes only for the selected day and caches the
|
||||
value model by civil-date ID. The twelve-hour grid uses early Zi through Hai, excluding the
|
||||
next day's late Zi entry at 23:00; each cell exposes its exact time range in a tooltip. Browsing
|
||||
history preserves the selection across midnight; a selection on today follows the new day.
|
||||
|
||||
`CalendarEngine` is the sole adapter for the pinned MIT-licensed Tyme4Swift 1.5.0 dependency.
|
||||
All Tyme calls run on MainActor because upstream exposes mutable static tables; only value
|
||||
models leave the adapter. Civil dates use the system time zone and calendar day arithmetic,
|
||||
not 24-hour intervals. Tibetan conversion is guarded to the upstream Gregorian coverage
|
||||
1951-01-08 through 2051-02-11. Holiday data currently ends in 2026; unknown years show a notice.
|
||||
Plum-rain dates describe traditional calendar rules, not observed or forecast weather.
|
||||
|
||||
Calendar enablement, visible features and week start persist through `AppSettings` and the
|
||||
optional fields in `SettingsDocument`. Older backups preserve those settings. Additional
|
||||
calendars default off and appear only in selected-day details. There is no event access or
|
||||
network request at runtime. Use `--snapshot <directory> --calendar-only` for deterministic
|
||||
light/dark calendar screenshots without starting unrelated samplers or scans.
|
||||
|
||||
## AI usage statistics
|
||||
|
||||
The active Codex provider reads local JSONL rollouts from CODEX_HOME (default ~/.codex),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 6tail
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -16,6 +16,9 @@ let package = Package(
|
||||
.library(name: "WebDAVSync", targets: ["WebDAVSync"]),
|
||||
.library(name: "XStatsUI", targets: ["XStatsUI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/6tail/tyme4swift.git", exact: "1.5.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Localization", resources: [.process("Resources")]),
|
||||
.target(name: "AIUsage"),
|
||||
@@ -25,7 +28,8 @@ let package = Package(
|
||||
.target(name: "Cleaner", dependencies: ["Localization"]),
|
||||
.target(name: "Updates", dependencies: ["Localization"]),
|
||||
.target(name: "WebDAVSync", dependencies: ["Localization"]),
|
||||
.target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync"],
|
||||
.target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync",
|
||||
.product(name: "Tyme4Swift", package: "tyme4swift")],
|
||||
resources: [.copy("Resources/Flags"), .copy("Resources/Logos")]),
|
||||
.testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]),
|
||||
.testTarget(name: "CleanerTests", dependencies: ["Cleaner"]),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
extension Translations {
|
||||
static let calendarEnglish = #"""
|
||||
日历 Calendar
|
||||
菜单栏日历 Menu Bar Calendar
|
||||
日历设置… Calendar Settings…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 Show a separate date item; click to open the calendar, even when metrics are combined
|
||||
每周开始于 First day of week
|
||||
星期一 Monday
|
||||
星期日 Sunday
|
||||
农历 Lunar calendar
|
||||
星期 Weekdays
|
||||
法定节假日与调休 Holidays and makeup workdays
|
||||
传统与公历节日 Festivals
|
||||
二十四节气 Solar terms
|
||||
干支 Sexagenary cycle
|
||||
三伏天 Dog days
|
||||
梅雨天 Plum-rain days
|
||||
藏历 Tibetan calendar
|
||||
回历 Hijri calendar
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Gregorian dates stay visible. Tibetan and Hijri dates appear in day details. Plum-rain days follow traditional calendar rules, not weather forecasts.
|
||||
该日期暂无藏历数据 Tibetan calendar data is unavailable for this date
|
||||
该年份暂无中国法定假日与调休数据 Chinese holiday and makeup workday data is unavailable for this year
|
||||
按传统历法推算,并非天气预报 Traditional calendar calculation, not a weather forecast
|
||||
年份 Year
|
||||
月份 Month
|
||||
上个月 Previous month
|
||||
下个月 Next month
|
||||
班 W
|
||||
休 R
|
||||
调休上班 Makeup workday
|
||||
放假 Holiday
|
||||
春节 Spring Festival
|
||||
元宵节 Lantern Festival
|
||||
龙头节 Dragon Head-Raising Festival
|
||||
上巳节 Shangsi Festival
|
||||
清明节 Qingming Festival
|
||||
端午节 Dragon Boat Festival
|
||||
七夕节 Qixi Festival
|
||||
中元节 Ghost Festival
|
||||
中秋节 Mid-Autumn Festival
|
||||
重阳节 Double Ninth Festival
|
||||
冬至节 Winter Solstice Festival
|
||||
腊八节 Laba Festival
|
||||
除夕 Lunar New Year's Eve
|
||||
元旦 New Year's Day
|
||||
劳动节 Labour Day
|
||||
国庆节 National Day
|
||||
国庆中秋 National Day / Mid-Autumn
|
||||
抗战胜利日 Victory Day
|
||||
立春 Start of Spring
|
||||
雨水 Rain Water
|
||||
惊蛰 Awakening of Insects
|
||||
春分 Spring Equinox
|
||||
清明 Pure Brightness
|
||||
谷雨 Grain Rain
|
||||
立夏 Start of Summer
|
||||
小满 Grain Buds
|
||||
芒种 Grain in Ear
|
||||
夏至 Summer Solstice
|
||||
小暑 Minor Heat
|
||||
大暑 Major Heat
|
||||
立秋 Start of Autumn
|
||||
处暑 End of Heat
|
||||
白露 White Dew
|
||||
秋分 Autumn Equinox
|
||||
寒露 Cold Dew
|
||||
霜降 Frost's Descent
|
||||
立冬 Start of Winter
|
||||
小雪 Minor Snow
|
||||
大雪 Major Snow
|
||||
冬至 Winter Solstice
|
||||
小寒 Minor Cold
|
||||
大寒 Major Cold
|
||||
返回月历 Back to calendar
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 Show the day cycle in month cells; almanac details always include year, month and day cycles
|
||||
宜 Favorable
|
||||
忌 Avoid
|
||||
纳音 Na Yin
|
||||
冲煞 Clash / Sha
|
||||
冲{} 煞{} Clash: {} · Sha: {}
|
||||
值神 Day deity
|
||||
建除十二神 Twelve officers
|
||||
吉神宜趋 Auspicious deities
|
||||
今日胎神 Fetal deity
|
||||
凶神宜忌 Inauspicious deities
|
||||
彭祖百忌 Peng Zu taboos
|
||||
二十八星宿 Lunar mansions
|
||||
时辰吉凶 Hour fortunes
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi shows 00:00–00:59; late Zi from 23:00 uses the next day's cycle
|
||||
吉 Lucky
|
||||
凶 Unlucky
|
||||
"""#
|
||||
}
|
||||
@@ -1247,3 +1247,92 @@ Token 活动 نشاط الرموز
|
||||
每周 أسبوعي
|
||||
{} 当周:{} Tokens أسبوع {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens الإجمالي حتى أسبوع {}: {} Tokens
|
||||
日历 التقويم
|
||||
菜单栏日历 تقويم شريط القوائم
|
||||
日历设置… إعدادات التقويم…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 عرض التاريخ بشكل مستقل؛ انقر لفتح التقويم حتى عند دمج المؤشرات
|
||||
每周开始于 أول أيام الأسبوع
|
||||
星期一 الاثنين
|
||||
星期日 الأحد
|
||||
农历 التقويم القمري الصيني
|
||||
星期 أيام الأسبوع
|
||||
法定节假日与调休 العطلات وأيام العمل التعويضية
|
||||
传统与公历节日 المناسبات التقليدية والميلادية
|
||||
二十四节气 الفصول الشمسية الأربعة والعشرون
|
||||
干支 الدورة الستينية
|
||||
三伏天 أيام القيظ
|
||||
梅雨天 أيام أمطار البرقوق التقليدية
|
||||
藏历 التقويم التبتي
|
||||
回历 التقويم الهجري
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 تظل التواريخ الميلادية ظاهرة. تظهر التواريخ التبتية والهجرية في تفاصيل اليوم. تُحسب أمطار البرقوق وفق القواعد التقليدية وليست توقعات جوية.
|
||||
该日期暂无藏历数据 لا تتوفر بيانات التقويم التبتي لهذا التاريخ
|
||||
该年份暂无中国法定假日与调休数据 لا تتوفر بيانات العطلات الصينية وأيام العمل التعويضية لهذه السنة
|
||||
按传统历法推算,并非天气预报 حساب وفق التقويم التقليدي، وليس توقعًا جويًا
|
||||
年份 السنة
|
||||
月份 الشهر
|
||||
上个月 الشهر السابق
|
||||
下个月 الشهر التالي
|
||||
班 ع
|
||||
休 ر
|
||||
调休上班 يوم عمل تعويضي
|
||||
放假 عطلة
|
||||
春节 عيد الربيع
|
||||
元宵节 عيد الفوانيس
|
||||
龙头节 مهرجان رأس التنين
|
||||
上巳节 مهرجان شانغسي
|
||||
清明节 عيد تشينغمينغ
|
||||
端午节 عيد قوارب التنين
|
||||
七夕节 عيد تشيشي
|
||||
中元节 عيد الأشباح
|
||||
中秋节 عيد منتصف الخريف
|
||||
重阳节 عيد التسعة المزدوجة
|
||||
冬至节 عيد الانقلاب الشتوي
|
||||
腊八节 عيد لابا
|
||||
除夕 ليلة رأس السنة القمرية
|
||||
元旦 رأس السنة
|
||||
劳动节 عيد العمال
|
||||
国庆节 العيد الوطني
|
||||
国庆中秋 العيد الوطني / منتصف الخريف
|
||||
抗战胜利日 يوم النصر
|
||||
立春 بداية الربيع
|
||||
雨水 ماء المطر
|
||||
惊蛰 استيقاظ الحشرات
|
||||
春分 الاعتدال الربيعي
|
||||
清明 الصفاء النقي
|
||||
谷雨 مطر الحبوب
|
||||
立夏 بداية الصيف
|
||||
小满 الامتلاء الصغير
|
||||
芒种 الحبوب في السنابل
|
||||
夏至 الانقلاب الصيفي
|
||||
小暑 الحر الصغير
|
||||
大暑 الحر الكبير
|
||||
立秋 بداية الخريف
|
||||
处暑 نهاية الحر
|
||||
白露 الندى الأبيض
|
||||
秋分 الاعتدال الخريفي
|
||||
寒露 الندى البارد
|
||||
霜降 نزول الصقيع
|
||||
立冬 بداية الشتاء
|
||||
小雪 الثلج الصغير
|
||||
大雪 الثلج الكبير
|
||||
冬至 الانقلاب الشتوي
|
||||
小寒 البرد الصغير
|
||||
大寒 البرد الكبير
|
||||
返回月历 العودة إلى التقويم
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 عرض دورة اليوم في خلايا الشهر؛ تتضمن تفاصيل التقويم التقليدي دائمًا دورات السنة والشهر واليوم
|
||||
宜 مستحب
|
||||
忌 يُتجنّب
|
||||
纳音 نا يين
|
||||
冲煞 التعارض / شا
|
||||
冲{} 煞{} التعارض: {} · شا: {}
|
||||
值神 حارس اليوم
|
||||
建除十二神 الحراس الاثنا عشر
|
||||
吉神宜趋 حراس الحظ الحسن
|
||||
今日胎神 حارس الجنين
|
||||
凶神宜忌 حراس النحس
|
||||
彭祖百忌 محظورات بنغ تسو
|
||||
二十八星宿 منازل القمر
|
||||
时辰吉凶 حظ الساعات
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 تُعرض فترة زي من 00:00 إلى 00:59؛ ومن 23:00 تُستخدم دورة اليوم التالي
|
||||
吉 حسن
|
||||
凶 سيئ
|
||||
|
@@ -1247,3 +1247,92 @@ Token 活动 Token-Aktivität
|
||||
每周 Wöchentlich
|
||||
{} 当周:{} Tokens Woche vom {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Bis zur Woche vom {}: {} Tokens kumuliert
|
||||
日历 Kalender
|
||||
菜单栏日历 Menüleistenkalender
|
||||
日历设置… Kalendereinstellungen…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 Separates Datum; ein Klick öffnet den Kalender, auch bei kombinierten Messwerten
|
||||
每周开始于 Wochenbeginn
|
||||
星期一 Montag
|
||||
星期日 Sonntag
|
||||
农历 Chinesischer Mondkalender
|
||||
星期 Wochentage
|
||||
法定节假日与调休 Feiertage und Ausgleichsarbeitstage
|
||||
传统与公历节日 Traditionelle und gregorianische Feste
|
||||
二十四节气 24 Sonnenperioden
|
||||
干支 Sechzigjahreszyklus
|
||||
三伏天 Hundstage
|
||||
梅雨天 Traditionelle Pflaumenregenzeit
|
||||
藏历 Tibetischer Kalender
|
||||
回历 Hidschri-Kalender
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Gregorianische Daten bleiben sichtbar. Tibetische und Hidschri-Daten stehen in den Tagesdetails. Die Pflaumenregenzeit folgt traditionellen Kalenderregeln, nicht Wettervorhersagen.
|
||||
该日期暂无藏历数据 Für dieses Datum sind keine tibetischen Kalenderdaten verfügbar
|
||||
该年份暂无中国法定假日与调休数据 Für dieses Jahr fehlen chinesische Feiertags- und Ausgleichsarbeitstagsdaten
|
||||
按传统历法推算,并非天气预报 Traditionelle Kalenderberechnung, keine Wettervorhersage
|
||||
年份 Jahr
|
||||
月份 Monat
|
||||
上个月 Vorheriger Monat
|
||||
下个月 Nächster Monat
|
||||
班 A
|
||||
休 F
|
||||
调休上班 Ausgleichsarbeitstag
|
||||
放假 Feiertag
|
||||
春节 Frühlingsfest
|
||||
元宵节 Laternenfest
|
||||
龙头节 Longtaitou
|
||||
上巳节 Shangsi-Fest
|
||||
清明节 Qingming-Fest
|
||||
端午节 Drachenbootfest
|
||||
七夕节 Qixi-Fest
|
||||
中元节 Geisterfest
|
||||
中秋节 Mittherbstfest
|
||||
重阳节 Doppelneunfest
|
||||
冬至节 Wintersonnenwendfest
|
||||
腊八节 Laba-Fest
|
||||
除夕 Mondneujahrsabend
|
||||
元旦 Neujahr
|
||||
劳动节 Tag der Arbeit
|
||||
国庆节 Nationalfeiertag
|
||||
国庆中秋 Nationalfeiertag / Mittherbstfest
|
||||
抗战胜利日 Tag des Sieges
|
||||
立春 Frühlingsbeginn
|
||||
雨水 Regenwasser
|
||||
惊蛰 Erwachen der Insekten
|
||||
春分 Frühlings-Tagundnachtgleiche
|
||||
清明 Helle Klarheit
|
||||
谷雨 Getreideregen
|
||||
立夏 Sommerbeginn
|
||||
小满 Kleine Fülle
|
||||
芒种 Ährensaat
|
||||
夏至 Sommersonnenwende
|
||||
小暑 Kleine Hitze
|
||||
大暑 Große Hitze
|
||||
立秋 Herbstbeginn
|
||||
处暑 Ende der Hitze
|
||||
白露 Weißer Tau
|
||||
秋分 Herbst-Tagundnachtgleiche
|
||||
寒露 Kalter Tau
|
||||
霜降 Reifbeginn
|
||||
立冬 Winterbeginn
|
||||
小雪 Kleiner Schnee
|
||||
大雪 Großer Schnee
|
||||
冬至 Wintersonnenwende
|
||||
小寒 Kleine Kälte
|
||||
大寒 Große Kälte
|
||||
返回月历 Zurück zum Kalender
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 Tageszyklus in Monatszellen anzeigen; Almanachdetails enthalten immer Jahres-, Monats- und Tageszyklen
|
||||
宜 Günstig
|
||||
忌 Meiden
|
||||
纳音 Na Yin
|
||||
冲煞 Konflikt / Sha
|
||||
冲{} 煞{} Konflikt: {} · Sha: {}
|
||||
值神 Tagesgottheit
|
||||
建除十二神 Zwölf Tagesämter
|
||||
吉神宜趋 Günstige Gottheiten
|
||||
今日胎神 Fötusgottheit
|
||||
凶神宜忌 Ungünstige Gottheiten
|
||||
彭祖百忌 Peng-Zu-Tabus
|
||||
二十八星宿 Mondhäuser
|
||||
时辰吉凶 Stundenglück
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi zeigt 00:00–00:59; ab 23:00 gilt der Zyklus des Folgetags
|
||||
吉 Glück
|
||||
凶 Unglück
|
||||
|
@@ -1247,3 +1247,92 @@ Token 活动 Actividad de tokens
|
||||
每周 Semanal
|
||||
{} 当周:{} Tokens Semana del {}: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Acumulado hasta la semana del {}: {} Tokens
|
||||
日历 Calendario
|
||||
菜单栏日历 Calendario de la barra de menús
|
||||
日历设置… Ajustes del calendario…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 Muestra la fecha por separado; haz clic para abrir el calendario, incluso con métricas combinadas
|
||||
每周开始于 Primer día de la semana
|
||||
星期一 Lunes
|
||||
星期日 Domingo
|
||||
农历 Calendario lunar chino
|
||||
星期 Días de la semana
|
||||
法定节假日与调休 Festivos y días laborales compensatorios
|
||||
传统与公历节日 Fiestas tradicionales y gregorianas
|
||||
二十四节气 24 términos solares
|
||||
干支 Ciclo sexagenario
|
||||
三伏天 Días caniculares
|
||||
梅雨天 Lluvias de ciruela tradicionales
|
||||
藏历 Calendario tibetano
|
||||
回历 Calendario hiyri
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Las fechas gregorianas siempre se muestran. Los calendarios tibetano e hiyri aparecen en los detalles del día. Las lluvias de ciruela se calculan según reglas tradicionales, no son pronósticos.
|
||||
该日期暂无藏历数据 No hay datos del calendario tibetano para esta fecha
|
||||
该年份暂无中国法定假日与调休数据 No hay datos de festivos chinos ni días laborales compensatorios para este año
|
||||
按传统历法推算,并非天气预报 Cálculo del calendario tradicional, no un pronóstico
|
||||
年份 Año
|
||||
月份 Mes
|
||||
上个月 Mes anterior
|
||||
下个月 Mes siguiente
|
||||
班 T
|
||||
休 D
|
||||
调休上班 Día laboral compensatorio
|
||||
放假 Festivo
|
||||
春节 Fiesta de la Primavera
|
||||
元宵节 Fiesta de los Faroles
|
||||
龙头节 Longtaitou
|
||||
上巳节 Fiesta de Shangsi
|
||||
清明节 Fiesta de Qingming
|
||||
端午节 Fiesta del Bote del Dragón
|
||||
七夕节 Fiesta de Qixi
|
||||
中元节 Fiesta de los Fantasmas
|
||||
中秋节 Fiesta del Medio Otoño
|
||||
重阳节 Fiesta del Doble Nueve
|
||||
冬至节 Fiesta del Solsticio de Invierno
|
||||
腊八节 Fiesta de Laba
|
||||
除夕 Víspera del Año Nuevo Lunar
|
||||
元旦 Año Nuevo
|
||||
劳动节 Día del Trabajo
|
||||
国庆节 Fiesta Nacional
|
||||
国庆中秋 Fiesta Nacional / Medio Otoño
|
||||
抗战胜利日 Día de la Victoria
|
||||
立春 Inicio de la primavera
|
||||
雨水 Agua de lluvia
|
||||
惊蛰 Despertar de los insectos
|
||||
春分 Equinoccio de primavera
|
||||
清明 Claridad pura
|
||||
谷雨 Lluvia para el grano
|
||||
立夏 Inicio del verano
|
||||
小满 Pequeña abundancia
|
||||
芒种 Grano en espiga
|
||||
夏至 Solsticio de verano
|
||||
小暑 Calor menor
|
||||
大暑 Calor mayor
|
||||
立秋 Inicio del otoño
|
||||
处暑 Fin del calor
|
||||
白露 Rocío blanco
|
||||
秋分 Equinoccio de otoño
|
||||
寒露 Rocío frío
|
||||
霜降 Descenso de la escarcha
|
||||
立冬 Inicio del invierno
|
||||
小雪 Nieve menor
|
||||
大雪 Nieve mayor
|
||||
冬至 Solsticio de invierno
|
||||
小寒 Frío menor
|
||||
大寒 Frío mayor
|
||||
返回月历 Volver al calendario
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 Muestra el ciclo diario en las celdas; el almanaque siempre incluye los ciclos del año, mes y día
|
||||
宜 Favorable
|
||||
忌 Evitar
|
||||
纳音 Na Yin
|
||||
冲煞 Conflicto / Sha
|
||||
冲{} 煞{} Conflicto: {} · Sha: {}
|
||||
值神 Deidad del día
|
||||
建除十二神 Doce oficiales
|
||||
吉神宜趋 Deidades favorables
|
||||
今日胎神 Deidad fetal
|
||||
凶神宜忌 Deidades desfavorables
|
||||
彭祖百忌 Tabúes de Peng Zu
|
||||
二十八星宿 Mansiones lunares
|
||||
时辰吉凶 Fortuna horaria
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi muestra 00:00–00:59; desde las 23:00 usa el ciclo del día siguiente
|
||||
吉 Bueno
|
||||
凶 Malo
|
||||
|
@@ -1247,3 +1247,92 @@ Token 活动 Activité des tokens
|
||||
每周 Par semaine
|
||||
{} 当周:{} Tokens Semaine du {} : {} Tokens
|
||||
截至 {} 当周累计:{} Tokens Cumul jusqu’à la semaine du {} : {} Tokens
|
||||
日历 Calendrier
|
||||
菜单栏日历 Calendrier de la barre des menus
|
||||
日历设置… Réglages du calendrier…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 Affiche la date séparément ; cliquez pour ouvrir le calendrier, même avec les indicateurs regroupés
|
||||
每周开始于 Premier jour de la semaine
|
||||
星期一 Lundi
|
||||
星期日 Dimanche
|
||||
农历 Calendrier lunaire chinois
|
||||
星期 Jours de la semaine
|
||||
法定节假日与调休 Jours fériés et jours de travail compensatoires
|
||||
传统与公历节日 Fêtes traditionnelles et grégoriennes
|
||||
二十四节气 24 périodes solaires
|
||||
干支 Cycle sexagésimal
|
||||
三伏天 Jours caniculaires
|
||||
梅雨天 Pluies des prunes traditionnelles
|
||||
藏历 Calendrier tibétain
|
||||
回历 Calendrier hégirien
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 Les dates grégoriennes restent visibles. Les dates tibétaines et hégiriennes figurent dans les détails du jour. Les pluies des prunes suivent les règles traditionnelles, sans prévision météo.
|
||||
该日期暂无藏历数据 Aucune donnée du calendrier tibétain pour cette date
|
||||
该年份暂无中国法定假日与调休数据 Aucune donnée sur les jours fériés chinois et les jours de travail compensatoires pour cette année
|
||||
按传统历法推算,并非天气预报 Calcul du calendrier traditionnel, pas une prévision météo
|
||||
年份 Année
|
||||
月份 Mois
|
||||
上个月 Mois précédent
|
||||
下个月 Mois suivant
|
||||
班 T
|
||||
休 R
|
||||
调休上班 Jour de travail compensatoire
|
||||
放假 Jour férié
|
||||
春节 Fête du Printemps
|
||||
元宵节 Fête des Lanternes
|
||||
龙头节 Longtaitou
|
||||
上巳节 Fête de Shangsi
|
||||
清明节 Fête de Qingming
|
||||
端午节 Fête des Bateaux-Dragons
|
||||
七夕节 Fête de Qixi
|
||||
中元节 Fête des Fantômes
|
||||
中秋节 Fête de la Mi-Automne
|
||||
重阳节 Fête du Double Neuf
|
||||
冬至节 Fête du Solstice d’Hiver
|
||||
腊八节 Fête de Laba
|
||||
除夕 Veille du Nouvel An lunaire
|
||||
元旦 Jour de l’An
|
||||
劳动节 Fête du Travail
|
||||
国庆节 Fête nationale
|
||||
国庆中秋 Fête nationale / Mi-Automne
|
||||
抗战胜利日 Jour de la Victoire
|
||||
立春 Début du printemps
|
||||
雨水 Eau de pluie
|
||||
惊蛰 Réveil des insectes
|
||||
春分 Équinoxe de printemps
|
||||
清明 Clarté pure
|
||||
谷雨 Pluie des céréales
|
||||
立夏 Début de l’été
|
||||
小满 Petite plénitude
|
||||
芒种 Grain en épi
|
||||
夏至 Solstice d’été
|
||||
小暑 Petite chaleur
|
||||
大暑 Grande chaleur
|
||||
立秋 Début de l’automne
|
||||
处暑 Fin de la chaleur
|
||||
白露 Rosée blanche
|
||||
秋分 Équinoxe d’automne
|
||||
寒露 Rosée froide
|
||||
霜降 Arrivée du givre
|
||||
立冬 Début de l’hiver
|
||||
小雪 Petite neige
|
||||
大雪 Grande neige
|
||||
冬至 Solstice d’hiver
|
||||
小寒 Petit froid
|
||||
大寒 Grand froid
|
||||
返回月历 Retour au calendrier
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 Affiche le cycle du jour dans les cases ; l’almanach inclut toujours les cycles de l’année, du mois et du jour
|
||||
宜 Favorable
|
||||
忌 À éviter
|
||||
纳音 Na Yin
|
||||
冲煞 Opposition / Sha
|
||||
冲{} 煞{} Opposition : {} · Sha : {}
|
||||
值神 Divinité du jour
|
||||
建除十二神 Douze officiers
|
||||
吉神宜趋 Divinités favorables
|
||||
今日胎神 Divinité fœtale
|
||||
凶神宜忌 Divinités défavorables
|
||||
彭祖百忌 Tabous de Peng Zu
|
||||
二十八星宿 Demeures lunaires
|
||||
时辰吉凶 Fortune des heures
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 Zi affiche 00 h–00 h 59 ; dès 23 h, le cycle du lendemain s’applique
|
||||
吉 Favorable
|
||||
凶 Défavorable
|
||||
|
@@ -1247,3 +1247,92 @@ Token 活动 トークン活動
|
||||
每周 週別
|
||||
{} 当周:{} Tokens {} の週:{} Tokens
|
||||
截至 {} 当周累计:{} Tokens {} の週までの累計:{} Tokens
|
||||
日历 カレンダー
|
||||
菜单栏日历 メニューバーカレンダー
|
||||
日历设置… カレンダー設定…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 日付を独立表示し、クリックで月表示を開きます。指標の結合設定には影響されません
|
||||
每周开始于 週の開始曜日
|
||||
星期一 月曜日
|
||||
星期日 日曜日
|
||||
农历 旧暦
|
||||
星期 曜日
|
||||
法定节假日与调休 祝日と振替出勤日
|
||||
传统与公历节日 伝統と西暦の祝祭日
|
||||
二十四节气 二十四節気
|
||||
干支 干支
|
||||
三伏天 三伏
|
||||
梅雨天 入梅・出梅
|
||||
藏历 チベット暦
|
||||
回历 ヒジュラ暦
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 西暦は常に表示します。チベット暦とヒジュラ暦は日付の詳細に表示します。入梅・出梅は伝統的な暦の計算で、天気予報ではありません。
|
||||
该日期暂无藏历数据 この日付のチベット暦データはありません
|
||||
该年份暂无中国法定假日与调休数据 この年の中国の祝日・振替出勤日データはありません
|
||||
按传统历法推算,并非天气预报 伝統的な暦の計算であり、天気予報ではありません
|
||||
年份 年
|
||||
月份 月
|
||||
上个月 前の月
|
||||
下个月 次の月
|
||||
班 勤
|
||||
休 休
|
||||
调休上班 振替出勤
|
||||
放假 休日
|
||||
春节 春節
|
||||
元宵节 元宵節
|
||||
龙头节 龍頭節
|
||||
上巳节 上巳節
|
||||
清明节 清明節
|
||||
端午节 端午節
|
||||
七夕节 七夕節
|
||||
中元节 中元節
|
||||
中秋节 中秋節
|
||||
重阳节 重陽節
|
||||
冬至节 冬至節
|
||||
腊八节 臘八節
|
||||
除夕 旧暦の大晦日
|
||||
元旦 元日
|
||||
劳动节 労働節
|
||||
国庆节 国慶節
|
||||
国庆中秋 国慶節・中秋節
|
||||
抗战胜利日 抗日戦争勝利記念日
|
||||
立春 立春
|
||||
雨水 雨水
|
||||
惊蛰 啓蟄
|
||||
春分 春分
|
||||
清明 清明
|
||||
谷雨 穀雨
|
||||
立夏 立夏
|
||||
小满 小満
|
||||
芒种 芒種
|
||||
夏至 夏至
|
||||
小暑 小暑
|
||||
大暑 大暑
|
||||
立秋 立秋
|
||||
处暑 処暑
|
||||
白露 白露
|
||||
秋分 秋分
|
||||
寒露 寒露
|
||||
霜降 霜降
|
||||
立冬 立冬
|
||||
小雪 小雪
|
||||
大雪 大雪
|
||||
冬至 冬至
|
||||
小寒 小寒
|
||||
大寒 大寒
|
||||
返回月历 月表示に戻る
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 月表示の日干支を設定します。暦の詳細には年・月・日の干支を常に表示します
|
||||
宜 宜
|
||||
忌 忌
|
||||
纳音 納音
|
||||
冲煞 沖煞
|
||||
冲{} 煞{} 沖:{} 煞:{}
|
||||
值神 値神
|
||||
建除十二神 建除十二直
|
||||
吉神宜趋 吉神
|
||||
今日胎神 今日の胎神
|
||||
凶神宜忌 凶神
|
||||
彭祖百忌 彭祖百忌
|
||||
二十八星宿 二十八宿
|
||||
时辰吉凶 時辰の吉凶
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 子の刻は当日の0時台を表示します。23時以降は翌日の干支で計算します
|
||||
吉 吉
|
||||
凶 凶
|
||||
|
@@ -1247,3 +1247,92 @@ Token 活动 토큰 활동
|
||||
每周 주별
|
||||
{} 当周:{} Tokens {} 주: {} Tokens
|
||||
截至 {} 当周累计:{} Tokens {} 주까지 누적: {} Tokens
|
||||
日历 달력
|
||||
菜单栏日历 메뉴 막대 달력
|
||||
日历设置… 달력 설정…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 날짜를 독립적으로 표시합니다. 클릭하면 월 달력이 열리며 지표 통합 설정과 무관합니다
|
||||
每周开始于 한 주의 시작
|
||||
星期一 월요일
|
||||
星期日 일요일
|
||||
农历 음력
|
||||
星期 요일
|
||||
法定节假日与调休 공휴일 및 대체 근무일
|
||||
传统与公历节日 전통 및 양력 기념일
|
||||
二十四节气 24절기
|
||||
干支 간지
|
||||
三伏天 삼복
|
||||
梅雨天 입매·출매
|
||||
藏历 티베트력
|
||||
回历 이슬람력
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 양력은 항상 표시됩니다. 티베트력과 이슬람력은 날짜 상세 정보에 표시됩니다. 입매·출매는 전통 달력 계산이며 일기 예보가 아닙니다.
|
||||
该日期暂无藏历数据 이 날짜의 티베트력 데이터가 없습니다
|
||||
该年份暂无中国法定假日与调休数据 이 연도의 중국 공휴일 및 대체 근무일 데이터가 없습니다
|
||||
按传统历法推算,并非天气预报 전통 달력 계산이며 일기 예보가 아닙니다
|
||||
年份 연도
|
||||
月份 월
|
||||
上个月 이전 달
|
||||
下个月 다음 달
|
||||
班 근
|
||||
休 휴
|
||||
调休上班 대체 근무
|
||||
放假 휴일
|
||||
春节 춘절
|
||||
元宵节 원소절
|
||||
龙头节 용두절
|
||||
上巳节 상사절
|
||||
清明节 청명절
|
||||
端午节 단오절
|
||||
七夕节 칠석
|
||||
中元节 중원절
|
||||
中秋节 중추절
|
||||
重阳节 중양절
|
||||
冬至节 동지절
|
||||
腊八节 납팔절
|
||||
除夕 음력 섣달그믐
|
||||
元旦 새해 첫날
|
||||
劳动节 노동절
|
||||
国庆节 국경절
|
||||
国庆中秋 국경절·중추절
|
||||
抗战胜利日 항일 전쟁 승리 기념일
|
||||
立春 입춘
|
||||
雨水 우수
|
||||
惊蛰 경칩
|
||||
春分 춘분
|
||||
清明 청명
|
||||
谷雨 곡우
|
||||
立夏 입하
|
||||
小满 소만
|
||||
芒种 망종
|
||||
夏至 하지
|
||||
小暑 소서
|
||||
大暑 대서
|
||||
立秋 입추
|
||||
处暑 처서
|
||||
白露 백로
|
||||
秋分 추분
|
||||
寒露 한로
|
||||
霜降 상강
|
||||
立冬 입동
|
||||
小雪 소설
|
||||
大雪 대설
|
||||
冬至 동지
|
||||
小寒 소한
|
||||
大寒 대한
|
||||
返回月历 월 달력으로 돌아가기
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 월 달력 칸의 일진 표시를 설정합니다. 황력 상세에는 연·월·일 간지가 항상 표시됩니다
|
||||
宜 길
|
||||
忌 기
|
||||
纳音 납음
|
||||
冲煞 충살
|
||||
冲{} 煞{} 충: {} · 살: {}
|
||||
值神 당일 신
|
||||
建除十二神 건제십이신
|
||||
吉神宜趋 길신
|
||||
今日胎神 오늘의 태신
|
||||
凶神宜忌 흉신
|
||||
彭祖百忌 팽조백기
|
||||
二十八星宿 이십팔수
|
||||
时辰吉凶 시진 길흉
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 자시는 당일 00시를 표시합니다. 23시 이후의 야자시는 다음 날 간지로 계산합니다
|
||||
吉 길
|
||||
凶 흉
|
||||
|
@@ -1248,3 +1248,92 @@ Token 活动 Token 活動
|
||||
每周 每週
|
||||
{} 当周:{} Tokens {} 當週:{} Tokens
|
||||
截至 {} 当周累计:{} Tokens 截至 {} 當週累計:{} Tokens
|
||||
日历 日曆
|
||||
菜单栏日历 選單列日曆
|
||||
日历设置… 日曆設定…
|
||||
独立显示日期,点击打开月历;不受指标合并布局影响 獨立顯示日期,點擊開啟月曆;不受指標合併佈局影響
|
||||
每周开始于 每週開始於
|
||||
星期一 星期一
|
||||
星期日 星期日
|
||||
农历 農曆
|
||||
星期 星期
|
||||
法定节假日与调休 法定節假日與調休
|
||||
传统与公历节日 傳統與公曆節日
|
||||
二十四节气 二十四節氣
|
||||
干支 干支
|
||||
三伏天 三伏天
|
||||
梅雨天 梅雨天
|
||||
藏历 藏曆
|
||||
回历 回曆
|
||||
公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。 公曆始終顯示。藏曆與回曆顯示在日期詳情中;梅雨天按傳統曆法推算,並非天氣預報。
|
||||
该日期暂无藏历数据 該日期暫無藏曆資料
|
||||
该年份暂无中国法定假日与调休数据 該年份暫無中國法定假日與調休資料
|
||||
按传统历法推算,并非天气预报 按傳統曆法推算,並非天氣預報
|
||||
年份 年份
|
||||
月份 月份
|
||||
上个月 上個月
|
||||
下个月 下個月
|
||||
班 班
|
||||
休 休
|
||||
调休上班 調休上班
|
||||
放假 放假
|
||||
春节 春節
|
||||
元宵节 元宵節
|
||||
龙头节 龍頭節
|
||||
上巳节 上巳節
|
||||
清明节 清明節
|
||||
端午节 端午節
|
||||
七夕节 七夕節
|
||||
中元节 中元節
|
||||
中秋节 中秋節
|
||||
重阳节 重陽節
|
||||
冬至节 冬至節
|
||||
腊八节 臘八節
|
||||
除夕 除夕
|
||||
元旦 元旦
|
||||
劳动节 勞動節
|
||||
国庆节 國慶節
|
||||
国庆中秋 國慶中秋
|
||||
抗战胜利日 抗戰勝利日
|
||||
立春 立春
|
||||
雨水 雨水
|
||||
惊蛰 驚蟄
|
||||
春分 春分
|
||||
清明 清明
|
||||
谷雨 穀雨
|
||||
立夏 立夏
|
||||
小满 小滿
|
||||
芒种 芒種
|
||||
夏至 夏至
|
||||
小暑 小暑
|
||||
大暑 大暑
|
||||
立秋 立秋
|
||||
处暑 處暑
|
||||
白露 白露
|
||||
秋分 秋分
|
||||
寒露 寒露
|
||||
霜降 霜降
|
||||
立冬 立冬
|
||||
小雪 小雪
|
||||
大雪 大雪
|
||||
冬至 冬至
|
||||
小寒 小寒
|
||||
大寒 大寒
|
||||
返回月历 返回月曆
|
||||
控制月历格中的日干支;黄历详情始终显示年月日干支 控制月曆格中的日干支;黃曆詳情始終顯示年月日干支
|
||||
宜 宜
|
||||
忌 忌
|
||||
纳音 納音
|
||||
冲煞 沖煞
|
||||
冲{} 煞{} 沖{} 煞{}
|
||||
值神 值神
|
||||
建除十二神 建除十二神
|
||||
吉神宜趋 吉神宜趨
|
||||
今日胎神 今日胎神
|
||||
凶神宜忌 凶神宜忌
|
||||
彭祖百忌 彭祖百忌
|
||||
二十八星宿 二十八星宿
|
||||
时辰吉凶 時辰吉凶
|
||||
子时显示当日早子时;23 点起的晚子时按次日干支计算 子時顯示當日早子時;23 點起的晚子時按次日干支計算
|
||||
吉 吉
|
||||
凶 凶
|
||||
|
@@ -8,7 +8,7 @@
|
||||
|
||||
extension Translations {
|
||||
static var tables: [String] {
|
||||
[english, webDAVEnglish, languagePickerEnglish]
|
||||
[english, webDAVEnglish, languagePickerEnglish, calendarEnglish]
|
||||
}
|
||||
|
||||
static let english = #"""
|
||||
|
||||
@@ -12,6 +12,7 @@ import SwiftUI
|
||||
public final class AppController: NSObject, NSApplicationDelegate {
|
||||
private let model = AppModel()
|
||||
private var menuBar: MenuBarController!
|
||||
private var calendarMenuBar: CalendarMenuBarController!
|
||||
private var mainWindow: MainWindowController!
|
||||
private var updateWindow: UpdateWindowController!
|
||||
private var speedTestWindow: SpeedTestWindowController!
|
||||
@@ -31,6 +32,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
|
||||
updateAction: #selector(checkForUpdatesFromMenu))
|
||||
menuBar = MenuBarController(model: model)
|
||||
calendarMenuBar = CalendarMenuBarController(model: model)
|
||||
mainWindow = MainWindowController(model: model)
|
||||
mainWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
||||
updateWindow = UpdateWindowController(model: model)
|
||||
@@ -39,22 +41,27 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
egressWindow = EgressWindowController(model: model)
|
||||
egressWindow.onVisibilityChange = { [weak self] _ in self?.updateActivationPolicy() }
|
||||
menuBar.update()
|
||||
calendarMenuBar.start()
|
||||
|
||||
// 设置已并入主窗口:所有“设置…”入口都打开主窗口的通用设置
|
||||
model.openSettings = { [weak self] in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.calendarMenuBar.dismiss()
|
||||
self?.mainWindow.show(tab: .settingsGeneral)
|
||||
}
|
||||
model.openMainWindow = { [weak self] tab in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.calendarMenuBar.dismiss()
|
||||
self?.mainWindow.show(tab: tab)
|
||||
}
|
||||
model.openEgressWindow = { [weak self] in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.calendarMenuBar.dismiss()
|
||||
self?.egressWindow.show()
|
||||
}
|
||||
model.openSpeedTestWindow = { [weak self] in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.calendarMenuBar.dismiss()
|
||||
self?.speedTestWindow.show()
|
||||
}
|
||||
model.quit = { NSApp.terminate(nil) }
|
||||
@@ -79,6 +86,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
startUpdateChecks()
|
||||
model.alerts.openTab = { [weak self] tab in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.calendarMenuBar.dismiss()
|
||||
self?.mainWindow.show(tab: tab)
|
||||
}
|
||||
model.alerts.start()
|
||||
@@ -101,6 +109,13 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
if arguments.contains("--show-calendar") {
|
||||
model.settings.calendarEnabled = true
|
||||
calendarMenuBar.update()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.calendarMenuBar.show()
|
||||
}
|
||||
}
|
||||
if arguments.contains("--show-window") {
|
||||
mainWindow.show(tab: nil)
|
||||
}
|
||||
@@ -139,6 +154,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
public func applicationWillTerminate(_ notification: Notification) {
|
||||
calendarMenuBar.stop()
|
||||
model.aiUsage.stop()
|
||||
model.keepAwake.releaseForTermination()
|
||||
if model.fans.mode != .automatic || model.keepAwake.lidClosedActive {
|
||||
@@ -188,6 +204,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
private func startUpdateChecks() {
|
||||
model.updates.onPrompt = { [weak self] in
|
||||
self?.menuBar.dismissPopovers()
|
||||
self?.calendarMenuBar.dismiss()
|
||||
self?.updateWindow.show()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import Tyme4Swift
|
||||
|
||||
/// 选中日期才计算的黄历值模型,不在生成 42 格月历时重复查询宜忌表。
|
||||
struct CalendarAlmanac: Identifiable {
|
||||
let id: String
|
||||
struct Hour: Identifiable {
|
||||
let id: Int
|
||||
let stem: String
|
||||
let branch: String
|
||||
let isLucky: Bool
|
||||
let timeRange: String
|
||||
}
|
||||
let ganzhiSummary: String
|
||||
let recommends: [String]
|
||||
let avoids: [String]
|
||||
let sound: String
|
||||
let opposingZodiac: String
|
||||
let ominousDirection: String
|
||||
let twelveStar: String
|
||||
let twelveStarIsLucky: Bool
|
||||
let hours: [Hour]
|
||||
let duty: String
|
||||
let luckyGods: [String]
|
||||
let unluckyGods: [String]
|
||||
let fetus: String
|
||||
let pengZu: [String]
|
||||
let star: String
|
||||
let starIsLucky: Bool
|
||||
}
|
||||
|
||||
extension CalendarEngine {
|
||||
static func almanac(for day: CalendarDay) -> CalendarAlmanac? {
|
||||
guard let solar = try? SolarDay.fromYmd(day.year, day.month, day.day) else { return nil }
|
||||
let lunar = solar.getLunarDay()
|
||||
let cycle = solar.getSixtyCycleDay()
|
||||
let gods = cycle.gods
|
||||
let star = cycle.twentyEightStar
|
||||
let pengZu = cycle.sixtyCycle.pengZu
|
||||
// Tyme 返回早子时、丑至亥、晚子时共 13 项。末项 23 点已用次日日柱,
|
||||
// 黄历的十二格取前 12 项,避免重复子时;保留农历闰月信息,直接使用原对象。
|
||||
let hours = lunar.hours.prefix(12).enumerated().map { index, hour in
|
||||
CalendarAlmanac.Hour(id: index, stem: hour.sixtyCycle.heavenStem.getName(),
|
||||
branch: hour.sixtyCycle.earthBranch.getName(),
|
||||
isLucky: hour.twelveStar.ecliptic.luck.index == 0,
|
||||
timeRange: index == 0 ? "00:00–00:59" : String(format: "%02d:00–%02d:59", index * 2 - 1, index * 2))
|
||||
}
|
||||
return CalendarAlmanac(
|
||||
id: day.id,
|
||||
ganzhiSummary: "\(cycle.year.getName())(\(cycle.year.earthBranch.getZodiac().getName()))年 \(cycle.month.getName())月 \(cycle.sixtyCycle.getName())日",
|
||||
recommends: cycle.recommends.map { $0.getName() }, avoids: cycle.avoids.map { $0.getName() },
|
||||
sound: cycle.sixtyCycle.sound.getName(), opposingZodiac: cycle.sixtyCycle.earthBranch.getOpposite().getZodiac().getName(),
|
||||
ominousDirection: cycle.sixtyCycle.earthBranch.getOminous().getName(),
|
||||
twelveStar: cycle.twelveStar.getName(), twelveStarIsLucky: cycle.twelveStar.ecliptic.luck.index == 0,
|
||||
hours: hours, duty: cycle.duty.getName(),
|
||||
luckyGods: gods.filter { $0.luck.index == 0 }.map { $0.getName() },
|
||||
unluckyGods: gods.filter { $0.luck.index != 0 }.map { $0.getName() },
|
||||
fetus: lunar.fetusDay.getName(),
|
||||
pengZu: [pengZu.pengZuHeavenStem.getName(), pengZu.pengZuEarthBranch.getName()],
|
||||
star: star.getName() + star.sevenStar.getName() + star.getAnimal().getName(), starIsLucky: star.luck.index == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
/// 传统黄历详情:以分格表呈现同一日期的数据,正文保留 Tyme 的传统名称。
|
||||
struct CalendarAlmanacView: View {
|
||||
let day: CalendarDay
|
||||
let almanac: CalendarAlmanac
|
||||
let features: Set<CalendarFeature>
|
||||
private let heading = Color.dynamic(light: 0xAE743F, dark: 0xDBAC79)
|
||||
private let lucky = Color.dynamic(light: 0x288B3B, dark: 0x63CC76)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 8) {
|
||||
Text(dateTitle).font(.system(size: 13)).foregroundStyle(.secondary)
|
||||
if features.contains(.lunar) {
|
||||
Text(day.lunarSummary).font(.system(size: 21)).foregroundStyle(.red)
|
||||
}
|
||||
Text(almanac.ganzhiSummary).font(.system(size: 13)).foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
advice(tr("宜"), entries: almanac.recommends, color: lucky)
|
||||
advice(tr("忌"), entries: almanac.avoids, color: .red)
|
||||
}
|
||||
almanacTable
|
||||
supplementaryDetails
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private var dateTitle: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = L10n.locale
|
||||
formatter.calendar = CalendarEngine.gregorian()
|
||||
formatter.setLocalizedDateFormatFromTemplate(features.contains(.weekdays) ? "yMMMMdEEEE" : "yMMMMd")
|
||||
return formatter.string(from: day.date)
|
||||
}
|
||||
|
||||
private func advice(_ label: String, entries: [String], color: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text(label).font(.system(size: 12, weight: .medium)).foregroundStyle(.white)
|
||||
.padding(4).background(color, in: Capsule())
|
||||
Text(entries.isEmpty ? tr("无") : entries.joined(separator: " "))
|
||||
.font(.system(size: 12)).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
|
||||
private var almanacTable: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
tile(tr("纳音")) { Text(almanac.sound) }
|
||||
verticalLine
|
||||
tile(tr("冲煞")) { Text(tr("冲\(almanac.opposingZodiac) 煞\(almanac.ominousDirection)")) }
|
||||
verticalLine
|
||||
tile(tr("值神")) {
|
||||
HStack(spacing: 4) {
|
||||
Text(almanac.twelveStar)
|
||||
luckLabel(almanac.twelveStarIsLucky)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Divider()
|
||||
hourTable
|
||||
Divider()
|
||||
HStack(spacing: 0) {
|
||||
sideTile(tr("建除十二神"), value: almanac.duty)
|
||||
verticalLine
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
tile(tr("吉神宜趋")) { Text(almanac.luckyGods.joined(separator: " ")) }
|
||||
verticalLine
|
||||
tile(tr("今日胎神")) { Text(almanac.fetus) }
|
||||
verticalLine
|
||||
tile(tr("凶神宜忌")) { Text(almanac.unluckyGods.joined(separator: " ")) }
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Divider()
|
||||
tile(tr("彭祖百忌")) { Text(almanac.pengZu.joined(separator: "\n")) }
|
||||
}
|
||||
verticalLine
|
||||
sideTile(tr("二十八星宿"), value: almanac.star, isLucky: almanac.starIsLucky)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.overlay { Rectangle().strokeBorder(DS.Palette.border, lineWidth: 1) }
|
||||
}
|
||||
|
||||
private func sideTile(_ title: String, value: String, isLucky: Bool? = nil) -> some View {
|
||||
Group {
|
||||
if L10n.language.isChinese {
|
||||
HStack(spacing: 6) {
|
||||
Text(title.map(String.init).joined(separator: "\n"))
|
||||
.font(.system(size: 16)).foregroundStyle(heading)
|
||||
VStack(spacing: 5) {
|
||||
Text(value.map(String.init).joined(separator: "\n")).foregroundStyle(.secondary)
|
||||
if let isLucky { luckLabel(isLucky) }
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(title + " " + value + (isLucky.map { " " + tr($0 ? "吉" : "凶") } ?? ""))
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
Text(title).font(.system(size: 13)).foregroundStyle(heading)
|
||||
Text(value).font(.system(size: 12)).foregroundStyle(.secondary)
|
||||
if let isLucky { luckLabel(isLucky) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: L10n.language.isChinese ? 52 : 72)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var hourTable: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(tr("时辰吉凶"))
|
||||
.font(.system(size: 15)).foregroundStyle(heading)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: L10n.language.isChinese ? 42 : 60).padding(.horizontal, 6)
|
||||
.help(tr("子时显示当日早子时;23 点起的晚子时按次日干支计算"))
|
||||
verticalLine
|
||||
ForEach(almanac.hours) { hour in
|
||||
VStack(spacing: 2) {
|
||||
Text(hour.stem)
|
||||
Text(hour.branch)
|
||||
luckLabel(hour.isLucky, compact: true)
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.help(hour.timeRange)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(hour.stem)\(hour.branch) \(hour.timeRange) \(tr(hour.isLucky ? "吉" : "凶"))")
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
private var verticalLine: some View {
|
||||
Rectangle().fill(DS.Palette.border).frame(width: 1)
|
||||
}
|
||||
|
||||
private func tile<Content: View>(_ title: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(spacing: 5) {
|
||||
Text(title).font(.system(size: 16)).foregroundStyle(heading)
|
||||
content().font(.system(size: 12)).foregroundStyle(.secondary)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 10)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
@ViewBuilder private func luckLabel(_ isLucky: Bool, compact: Bool = false) -> some View {
|
||||
if compact && !L10n.language.isChinese {
|
||||
Image(systemName: isLucky ? "checkmark" : "xmark")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(isLucky ? lucky : .red)
|
||||
.accessibilityLabel(tr(isLucky ? "吉" : "凶"))
|
||||
.help(tr(isLucky ? "吉" : "凶"))
|
||||
} else {
|
||||
Text(tr(isLucky ? "吉" : "凶")).foregroundStyle(isLucky ? lucky : .red)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var supplementaryDetails: some View {
|
||||
let rows = additionalDetails
|
||||
if !rows.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(rows) { row in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(row.feature.title).foregroundStyle(.secondary)
|
||||
Text(row.value).frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
if features.contains(.plumRain), day.plumRain != nil {
|
||||
Text(tr("按传统历法推算,并非天气预报")).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.padding(12)
|
||||
.background(DS.Palette.textSecondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalDetails: [CalendarDetail] {
|
||||
var rows: [CalendarDetail] = []
|
||||
if features.contains(.holidays), let holiday = day.holiday {
|
||||
rows.append(.init(feature: .holidays, value: tr(holiday.name) + " · " + tr(holiday.isWork ? "调休上班" : "放假")))
|
||||
}
|
||||
if features.contains(.festivals), !day.festivals.isEmpty {
|
||||
rows.append(.init(feature: .festivals, value: day.festivals.map { tr($0) }.joined(separator: " · ")))
|
||||
}
|
||||
if features.contains(.solarTerms), let value = day.solarTerm { rows.append(.init(feature: .solarTerms, value: tr(value))) }
|
||||
if features.contains(.dogDays), let value = day.dogDays { rows.append(.init(feature: .dogDays, value: value)) }
|
||||
if features.contains(.plumRain), let value = day.plumRain { rows.append(.init(feature: .plumRain, value: value)) }
|
||||
return rows + CalendarEngine.additionalCalendars(for: day, features: features)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
// Tyme 1.5 使用 Swift 5;并发兼容仅限此适配层,所有访问由 MainActor 串行化。
|
||||
@preconcurrency import Tyme4Swift
|
||||
|
||||
/// 公历是月历的坐标;其余信息可独立隐藏,设置以稳定 rawValue 保存和同步。
|
||||
public enum CalendarFeature: String, CaseIterable, Identifiable, Sendable {
|
||||
case lunar, weekdays, holidays, festivals, solarTerms, ganzhi, dogDays, plumRain, tibetan, hijri
|
||||
public var id: String { rawValue }
|
||||
public static let defaults: Set<Self> = [.lunar, .weekdays, .holidays, .festivals, .solarTerms]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .lunar: tr("农历")
|
||||
case .weekdays: tr("星期")
|
||||
case .holidays: tr("法定节假日与调休")
|
||||
case .festivals: tr("传统与公历节日")
|
||||
case .solarTerms: tr("二十四节气")
|
||||
case .ganzhi: tr("干支")
|
||||
case .dogDays: tr("三伏天")
|
||||
case .plumRain: tr("梅雨天")
|
||||
case .tibetan: tr("藏历")
|
||||
case .hijri: tr("回历")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarMonth: Equatable, Hashable {
|
||||
static let years = 1900...2100
|
||||
var year: Int
|
||||
var month: Int
|
||||
|
||||
func shifted(by delta: Int) -> Self? {
|
||||
let index = year * 12 + month - 1 + delta
|
||||
let result = Self(year: index / 12, month: index % 12 + 1)
|
||||
return Self.years.contains(result.year) ? result : nil
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarDay: Identifiable, Equatable {
|
||||
struct Holiday: Equatable {
|
||||
let name: String
|
||||
let isWork: Bool
|
||||
}
|
||||
let date: Date
|
||||
let year: Int
|
||||
let month: Int
|
||||
let day: Int
|
||||
/// Foundation 的星期编号:1 为周日。
|
||||
let weekday: Int
|
||||
let lunarLabel: String
|
||||
let lunarSummary: String
|
||||
let ganzhiDay: String
|
||||
let ganzhiSummary: String
|
||||
let festivals: [String]
|
||||
let solarTerm: String?
|
||||
let holiday: Holiday?
|
||||
let dogDays: String?
|
||||
let plumRain: String?
|
||||
var id: String { String(format: "%04d-%02d-%02d", year, month, day) }
|
||||
var isWeekend: Bool { weekday == 1 || weekday == 7 }
|
||||
|
||||
/// 每格仅显示一条主注释,所有启用的信息在选中日期详情中保留。
|
||||
func subtitle(features: Set<CalendarFeature>) -> String {
|
||||
if features.contains(.festivals), let festival = festivals.first { return festival }
|
||||
if features.contains(.solarTerms), let solarTerm { return solarTerm }
|
||||
if features.contains(.dogDays), let dogDays { return dogDays }
|
||||
if features.contains(.plumRain), let plumRain { return plumRain }
|
||||
return features.contains(.lunar) ? lunarLabel : ""
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarDetail: Identifiable {
|
||||
let feature: CalendarFeature
|
||||
let value: String
|
||||
var isUnavailable = false
|
||||
var id: CalendarFeature { feature }
|
||||
}
|
||||
|
||||
/// Tyme 使用可变的静态表;统一在主线程读取,不把其引用类型暴露给界面或跨线程共享。
|
||||
@MainActor
|
||||
enum CalendarEngine {
|
||||
static func gregorian(timeZone: TimeZone = .autoupdatingCurrent) -> Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = timeZone
|
||||
return calendar
|
||||
}
|
||||
|
||||
static func today(at date: Date = Date(), timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? {
|
||||
let parts = gregorian(timeZone: timeZone).dateComponents([.year, .month, .day], from: date)
|
||||
guard let year = parts.year, let month = parts.month, let day = parts.day else { return nil }
|
||||
return self.day(year: year, month: month, day: day, timeZone: timeZone)
|
||||
}
|
||||
|
||||
/// 保留同月的公历选择,并用当前时区重建 Date;不能复用旧时区的绝对时间。
|
||||
static func selection(_ previous: CalendarDay?, in month: CalendarMonth,
|
||||
timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? {
|
||||
let day = previous?.year == month.year && previous?.month == month.month ? (previous?.day ?? 1) : 1
|
||||
return self.day(year: month.year, month: month.month, day: day, timeZone: timeZone)
|
||||
}
|
||||
|
||||
static func month(year: Int, month: Int, firstWeekday: Int,
|
||||
timeZone: TimeZone = .autoupdatingCurrent) -> [CalendarDay] {
|
||||
guard CalendarMonth.years.contains(year), (1...12).contains(month),
|
||||
let first = day(year: year, month: month, day: 1, timeZone: timeZone) else { return [] }
|
||||
let calendar = gregorian(timeZone: timeZone)
|
||||
let weekStart = firstWeekday == 1 ? 1 : 2
|
||||
let offset = (first.weekday - weekStart + 7) % 7
|
||||
// 用日历加天而非 86400 秒;夏令时切换不能导致重复日期或漏掉日期。
|
||||
return (0..<42).compactMap { index in
|
||||
guard let date = calendar.date(byAdding: .day, value: index - offset, to: first.date) else { return nil }
|
||||
return today(at: date, timeZone: timeZone)
|
||||
}
|
||||
}
|
||||
|
||||
static func day(year: Int, month: Int, day: Int,
|
||||
timeZone: TimeZone = .autoupdatingCurrent) -> CalendarDay? {
|
||||
// 包含边缘月份的相邻日期;界面浏览限制在 1900–2100 年,避开历史历法切换歧义。
|
||||
guard (1899...2101).contains(year), (1...12).contains(month),
|
||||
let solar = try? SolarDay.fromYmd(year, month, day) else { return nil }
|
||||
let calendar = gregorian(timeZone: timeZone)
|
||||
guard let date = calendar.date(from: DateComponents(year: year, month: month, day: day, hour: 12)) else { return nil }
|
||||
let lunar = solar.getLunarDay()
|
||||
let cycle = solar.getSixtyCycleDay()
|
||||
let term = solar.termDay
|
||||
let festivals = [lunar.festival?.getName(), solar.festival?.getName()].compactMap { $0 }
|
||||
return CalendarDay(date: date, year: year, month: month, day: day,
|
||||
weekday: calendar.component(.weekday, from: date),
|
||||
lunarLabel: lunar.day == 1 ? lunar.lunarMonth.getName() : lunar.getName(),
|
||||
lunarSummary: lunar.description, ganzhiDay: cycle.sixtyCycle.getName(),
|
||||
ganzhiSummary: "\(cycle.year.getName())年 \(cycle.month.getName())月 \(cycle.sixtyCycle.getName())日",
|
||||
festivals: festivals, solarTerm: term.dayIndex == 0 ? term.solarTerm.getName() : nil,
|
||||
holiday: solar.legalHoliday.map { .init(name: $0.name, isWork: $0.isWork) },
|
||||
dogDays: solar.dogDay?.description, plumRain: solar.plumRainDay?.description)
|
||||
}
|
||||
|
||||
/// 年份覆盖来自固定版本的数据本身;未收录年份不能把 nil 解读为“无需调休”。
|
||||
static func hasHolidayData(year: Int) -> Bool { holidayYears.contains(year) }
|
||||
private static let holidayYears: Set<Int> = {
|
||||
let data = Array(LegalHoliday.DATA.utf8)
|
||||
return Set(stride(from: 0, to: data.count, by: 13).compactMap { index in
|
||||
guard index + 4 <= data.count else { return nil }
|
||||
return Int(String(decoding: data[index..<(index + 4)], as: UTF8.self))
|
||||
})
|
||||
}()
|
||||
|
||||
static func additionalCalendars(for day: CalendarDay, features: Set<CalendarFeature>) -> [CalendarDetail] {
|
||||
guard let solar = try? SolarDay.fromYmd(day.year, day.month, day.day) else { return [] }
|
||||
var details: [CalendarDetail] = []
|
||||
if features.contains(.tibetan) {
|
||||
// 上游从固定起点向前扫描,范围外部分路径使用 try!;先检查完整的公历覆盖范围。
|
||||
let supported = day.id >= "1951-01-08" && day.id <= "2051-02-11"
|
||||
let value = supported ? (try? solar.getRabByungDay().description) : nil
|
||||
details.append(.init(feature: .tibetan, value: value ?? tr("该日期暂无藏历数据"), isUnavailable: value == nil))
|
||||
}
|
||||
if features.contains(.hijri) {
|
||||
details.append(.init(feature: .hijri, value: solar.getHijriDay().description))
|
||||
}
|
||||
return details
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AppKit
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
/// 独立日历入口:只按分钟刷新日期文本,关闭后移除定时器,不参与 MetricsHub 的秒级采样。
|
||||
@MainActor
|
||||
final class CalendarMenuBarController: NSObject {
|
||||
private let model: AppModel
|
||||
private var item: NSStatusItem?
|
||||
private var panel: StatusPanel?
|
||||
private var timer: Timer?
|
||||
private var observers: [(NotificationCenter, NSObjectProtocol)] = []
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start() {
|
||||
update()
|
||||
observeSettings()
|
||||
for (center, name) in [
|
||||
(NotificationCenter.default, Notification.Name.NSCalendarDayChanged),
|
||||
(NotificationCenter.default, Notification.Name.NSSystemTimeZoneDidChange),
|
||||
(NSWorkspace.shared.notificationCenter, NSWorkspace.didWakeNotification),
|
||||
] {
|
||||
let token = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.refreshTitle() }
|
||||
}
|
||||
observers.append((center, token))
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
for (center, token) in observers { center.removeObserver(token) }
|
||||
observers.removeAll()
|
||||
dismiss()
|
||||
if let item { NSStatusBar.system.removeStatusItem(item) }
|
||||
item = nil
|
||||
}
|
||||
|
||||
private func observeSettings() {
|
||||
withObservationTracking {
|
||||
_ = model.settings.calendarEnabled
|
||||
_ = model.settings.calendarFeatures
|
||||
_ = model.settings.calendarFirstWeekday
|
||||
_ = model.settings.language
|
||||
_ = model.settings.appearance
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.update()
|
||||
self?.observeSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
guard model.settings.calendarEnabled else {
|
||||
dismiss()
|
||||
if let item { NSStatusBar.system.removeStatusItem(item) }
|
||||
item = nil
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
item.autosaveName = "XStats.calendar"
|
||||
item.button?.target = self
|
||||
item.button?.action = #selector(clicked(_:))
|
||||
item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
item.button?.image = NSImage(systemSymbolName: "calendar", accessibilityDescription: tr("日历"))
|
||||
item.button?.imagePosition = .imageLeading
|
||||
item.button?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular)
|
||||
self.item = item
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.refreshTitle() }
|
||||
}
|
||||
timer?.tolerance = 2
|
||||
}
|
||||
refreshTitle()
|
||||
panel?.refreshHeight()
|
||||
}
|
||||
|
||||
private func refreshTitle() {
|
||||
guard let button = item?.button else { return }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = L10n.locale
|
||||
formatter.calendar = CalendarEngine.gregorian()
|
||||
formatter.timeZone = .autoupdatingCurrent
|
||||
formatter.setLocalizedDateFormatFromTemplate(model.settings.calendarFeatures.contains(.weekdays) ? "MdEEE" : "Md")
|
||||
button.title = " " + formatter.string(from: Date())
|
||||
formatter.dateStyle = .full
|
||||
button.toolTip = formatter.string(from: Date())
|
||||
}
|
||||
|
||||
@objc private func clicked(_ sender: NSStatusBarButton) {
|
||||
if NSApp.currentEvent?.type == .rightMouseUp, let item {
|
||||
let menu = NSMenu()
|
||||
menu.addItem(withTitle: tr("日历设置…"), action: #selector(openSettings), keyEquivalent: "").target = self
|
||||
menu.addItem(withTitle: tr("退出 XStats"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
|
||||
item.menu = menu
|
||||
sender.performClick(nil)
|
||||
item.menu = nil
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard let button = item?.button, let window = button.window else { return }
|
||||
if panel == nil {
|
||||
let model = self.model
|
||||
let panel = StatusPanel(width: CalendarPopover.width, minHeight: 380,
|
||||
content: { CalendarPopover().environment(model) },
|
||||
measuring: { CalendarPopover().environment(model).environment(\.isSnapshot, true) })
|
||||
panel.onVisibilityChange = { [weak self] visible in self?.item?.button?.highlight(visible) }
|
||||
self.panel = panel
|
||||
}
|
||||
refreshTitle()
|
||||
let anchor = window.convertToScreen(button.convert(button.bounds, to: nil))
|
||||
panel?.toggle(below: anchor, on: window.screen)
|
||||
}
|
||||
|
||||
func dismiss() { panel?.dismiss() }
|
||||
|
||||
@objc private func openSettings() {
|
||||
dismiss()
|
||||
model.openMainWindow(.settingsMenuBar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
/// 月历与黄历详情在同一个面板内切换;返回时保留浏览月份和选中的日期。
|
||||
struct CalendarPopover: View {
|
||||
static let width: CGFloat = 560
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.isSnapshot) private var isSnapshot
|
||||
@State private var month: CalendarMonth
|
||||
@State private var selected: CalendarDay?
|
||||
@State private var days: [CalendarDay]
|
||||
@State private var lastTodayID: String?
|
||||
@State private var showsDayDetails: Bool
|
||||
@State private var almanac: CalendarAlmanac?
|
||||
private let referenceDate: Date?
|
||||
|
||||
init(referenceDate: Date? = nil, showsDayDetails: Bool = false) {
|
||||
self.referenceDate = referenceDate
|
||||
let today = CalendarEngine.today(at: referenceDate ?? Date())
|
||||
let month = CalendarMonth(year: today?.year ?? 2026, month: today?.month ?? 1)
|
||||
_month = State(initialValue: month)
|
||||
_selected = State(initialValue: today)
|
||||
_showsDayDetails = State(initialValue: showsDayDetails)
|
||||
_almanac = State(initialValue: showsDayDetails ? today.flatMap { CalendarEngine.almanac(for: $0) } : nil)
|
||||
_lastTodayID = State(initialValue: today?.id)
|
||||
_days = State(initialValue: CalendarEngine.month(year: month.year, month: month.month, firstWeekday: 2))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 60)) { context in
|
||||
let today = CalendarEngine.today(at: referenceDate ?? context.date)
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
if showsDayDetails { detailHeader(today: today) }
|
||||
else { header(today: today) }
|
||||
}
|
||||
.padding(18)
|
||||
PageScroll {
|
||||
VStack(spacing: 16) {
|
||||
if showsDayDetails, let selected, let almanac {
|
||||
CalendarAlmanacView(day: selected, almanac: almanac, features: model.settings.calendarFeatures)
|
||||
} else {
|
||||
calendarGrid(today: today)
|
||||
}
|
||||
if model.settings.calendarFeatures.contains(.holidays), !CalendarEngine.hasHolidayData(year: month.year) {
|
||||
Text(tr("该年份暂无中国法定假日与调休数据"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.environment(\.isPopover, true)
|
||||
}
|
||||
.onChange(of: today?.id) { _, newID in
|
||||
// 正在看今天时跨日自动跟进;浏览历史日期时保留用户选择。
|
||||
if selected?.id == lastTodayID, let today {
|
||||
selected = today
|
||||
month = CalendarMonth(year: today.year, month: today.month)
|
||||
}
|
||||
lastTodayID = newID
|
||||
}
|
||||
}
|
||||
.frame(width: Self.width)
|
||||
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
.fixedSize(horizontal: false, vertical: isSnapshot)
|
||||
.background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl))
|
||||
.appLanguageEnvironment()
|
||||
.overlay { RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border) }
|
||||
.onAppear { reload() }
|
||||
.onChange(of: month) { _, _ in reload() }
|
||||
.onChange(of: selected?.id) { _, _ in refreshAlmanac() }
|
||||
.onChange(of: model.settings.calendarFirstWeekday) { _, _ in reload() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in reload() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
|
||||
guard referenceDate == nil, let today = CalendarEngine.today() else { return }
|
||||
if selected?.id == lastTodayID { goToToday(today) }
|
||||
lastTodayID = today.id
|
||||
}
|
||||
}
|
||||
|
||||
private func header(today: CalendarDay?) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "calendar").foregroundStyle(Color.accentColor)
|
||||
Picker(tr("年份"), selection: $month.year) {
|
||||
ForEach(CalendarMonth.years, id: \.self) { Text(String($0)).tag($0) }
|
||||
}
|
||||
.labelsHidden().fixedSize()
|
||||
Picker(tr("月份"), selection: $month.month) {
|
||||
ForEach(1...12, id: \.self) { value in Text(monthName(value)).tag(value) }
|
||||
}
|
||||
.labelsHidden().fixedSize()
|
||||
Spacer(minLength: 0)
|
||||
Button { shift(-1) } label: { Image(systemName: "chevron.left") }
|
||||
.help(tr("上个月")).accessibilityLabel(tr("上个月"))
|
||||
.disabled(month.shifted(by: -1) == nil)
|
||||
Button(tr("今天")) { if let today { goToToday(today) } }
|
||||
Button { shift(1) } label: { Image(systemName: "chevron.right") }
|
||||
.help(tr("下个月")).accessibilityLabel(tr("下个月"))
|
||||
.disabled(month.shifted(by: 1) == nil)
|
||||
Button { model.openMainWindow(.settingsMenuBar) } label: { Image(systemName: "gearshape") }
|
||||
.help(tr("日历设置…")).accessibilityLabel(tr("日历设置…"))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
private func calendarGrid(today: CalendarDay?) -> some View {
|
||||
let features = model.settings.calendarFeatures
|
||||
let firstWeekday = model.settings.calendarFirstWeekday
|
||||
return VStack(spacing: 8) {
|
||||
if features.contains(.weekdays) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<7) { offset in
|
||||
let weekday = (firstWeekday - 1 + offset) % 7 + 1
|
||||
Text(weekdayName(weekday))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(weekday == 1 || weekday == 7 ? Color.red : .secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 5) {
|
||||
ForEach(days) { day in
|
||||
CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month,
|
||||
isToday: day.id == today?.id, isSelected: day.id == selected?.id) {
|
||||
selected = day
|
||||
if day.month != month.month, CalendarMonth.years.contains(day.year) {
|
||||
month = CalendarMonth(year: day.year, month: day.month)
|
||||
}
|
||||
showsDayDetails = true
|
||||
refreshAlmanac()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailHeader(today: CalendarDay?) -> some View {
|
||||
HStack {
|
||||
Button {
|
||||
showsDayDetails = false
|
||||
} label: {
|
||||
Label(tr("返回月历"), systemImage: "chevron.backward")
|
||||
}
|
||||
Spacer()
|
||||
Button(tr("今天")) {
|
||||
if let today { goToToday(today) }
|
||||
}
|
||||
Button { model.openMainWindow(.settingsMenuBar) } label: { Image(systemName: "gearshape") }
|
||||
.help(tr("日历设置…")).accessibilityLabel(tr("日历设置…"))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
private func refreshAlmanac() {
|
||||
guard showsDayDetails, let selected, almanac?.id != selected.id else { return }
|
||||
almanac = CalendarEngine.almanac(for: selected)
|
||||
}
|
||||
|
||||
private func reload() {
|
||||
selected = CalendarEngine.selection(selected, in: month)
|
||||
days = CalendarEngine.month(year: month.year, month: month.month, firstWeekday: model.settings.calendarFirstWeekday)
|
||||
}
|
||||
|
||||
private func shift(_ delta: Int) {
|
||||
guard let next = month.shifted(by: delta) else { return }
|
||||
month = next
|
||||
selected = CalendarEngine.day(year: next.year, month: next.month, day: 1)
|
||||
}
|
||||
|
||||
private func goToToday(_ today: CalendarDay) {
|
||||
month = CalendarMonth(year: today.year, month: today.month)
|
||||
selected = today
|
||||
}
|
||||
|
||||
private func monthName(_ month: Int) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = L10n.locale
|
||||
formatter.calendar = CalendarEngine.gregorian()
|
||||
return formatter.monthSymbols[month - 1]
|
||||
}
|
||||
|
||||
private func weekdayName(_ weekday: Int) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = L10n.locale
|
||||
formatter.calendar = CalendarEngine.gregorian()
|
||||
return formatter.shortWeekdaySymbols[weekday - 1]
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalendarDayCell: View {
|
||||
let day: CalendarDay
|
||||
let features: Set<CalendarFeature>
|
||||
let isCurrentMonth: Bool
|
||||
let isToday: Bool
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
private var holiday: CalendarDay.Holiday? { features.contains(.holidays) ? day.holiday : nil }
|
||||
private var isRest: Bool { holiday.map { !$0.isWork } ?? day.isWeekend }
|
||||
private var foreground: Color { isSelected ? .white : isRest ? .red : DS.Palette.textPrimary }
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 3) {
|
||||
Text(String(day.day)).font(.system(size: 21, weight: isToday ? .semibold : .regular, design: .rounded))
|
||||
Text(tr(day.subtitle(features: features)))
|
||||
.font(.system(size: 10))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
if features.contains(.ganzhi) {
|
||||
Text(day.ganzhiDay).font(.system(size: 9)).opacity(0.75)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(foreground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 66)
|
||||
.background(isSelected ? Color.accentColor : holiday != nil ? foreground.opacity(0.05) : .clear,
|
||||
in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay {
|
||||
if isToday && !isSelected { RoundedRectangle(cornerRadius: 10).strokeBorder(Color.accentColor, lineWidth: 1.5) }
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if let holiday {
|
||||
Text(tr(holiday.isWork ? "班" : "休"))
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(2)
|
||||
.background(holiday.isWork ? Color.secondary : .red, in: RoundedRectangle(cornerRadius: 3))
|
||||
.padding(2)
|
||||
} else if isToday {
|
||||
Circle().fill(isSelected ? .white : Color.accentColor).frame(width: 5, height: 5).padding(5)
|
||||
}
|
||||
}
|
||||
.opacity(isCurrentMonth ? 1 : 0.35)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel([day.id, features.contains(.lunar) ? day.lunarSummary : "", tr(day.subtitle(features: features)),
|
||||
holiday.map { tr($0.isWork ? "调休上班" : "放假") } ?? "", isToday ? tr("今天") : ""]
|
||||
.filter { !$0.isEmpty }.joined(separator: ", "))
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
struct CalendarSettings: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = model.settings
|
||||
SettingsGroup(caption: tr("日历")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: tr("菜单栏日历"), subtitle: tr("独立显示日期,点击打开月历;不受指标合并布局影响"), icon: "calendar") {
|
||||
DSToggle(isOn: $settings.calendarEnabled, label: tr("菜单栏日历"))
|
||||
}
|
||||
}
|
||||
if settings.calendarEnabled {
|
||||
GroupRow {
|
||||
SettingRow(title: tr("每周开始于")) {
|
||||
Picker(tr("每周开始于"), selection: $settings.calendarFirstWeekday) {
|
||||
Text(tr("星期一")).tag(2)
|
||||
Text(tr("星期日")).tag(1)
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
ForEach(CalendarFeature.allCases) { feature in
|
||||
GroupRow {
|
||||
SettingRow(title: feature.title, subtitle: feature == .ganzhi ? tr("控制月历格中的日干支;黄历详情始终显示年月日干支") : nil) {
|
||||
DSToggle(isOn: Binding(get: { settings.calendarFeatures.contains(feature) }, set: { enabled in
|
||||
if enabled { settings.calendarFeatures.insert(feature) }
|
||||
else { settings.calendarFeatures.remove(feature) }
|
||||
}), label: feature.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
GroupRow {
|
||||
Text(tr("公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。"))
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +285,7 @@ struct MenuBarSettings: View {
|
||||
@Bindable var settings = model.settings
|
||||
|
||||
MenuBarPreview()
|
||||
CalendarSettings()
|
||||
|
||||
SettingsGroup(caption: tr("布局")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
|
||||
@@ -17,6 +17,12 @@ enum SnapshotRenderer {
|
||||
static func run(outputDirectory: URL) async {
|
||||
try? FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// 日历快照只渲染固定日期,不启动系统采样、AI 扫描或磁盘扫描。
|
||||
if CommandLine.arguments.contains("--calendar-only") {
|
||||
renderCalendar(outputDirectory: outputDirectory)
|
||||
return
|
||||
}
|
||||
|
||||
let defaults = UserDefaults(suiteName: "XStats.snapshot") ?? .standard
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
settings.language = L10n.language
|
||||
@@ -116,6 +122,29 @@ enum SnapshotRenderer {
|
||||
print(tr("截图已输出到 \(outputDirectory.path)"))
|
||||
}
|
||||
|
||||
private static func renderCalendar(outputDirectory: URL) {
|
||||
let name = "XStats.calendarSnapshot.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: name) else { return }
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
settings.calendarEnabled = true
|
||||
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [])
|
||||
guard let september = CalendarEngine.day(year: 2026, month: 9, day: 23)?.date,
|
||||
let summer = CalendarEngine.day(year: 2024, month: 6, day: 11)?.date else { return }
|
||||
for (name, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
|
||||
guard let appearance = NSAppearance(named: name) else { continue }
|
||||
NSApp.appearance = appearance
|
||||
settings.calendarFeatures = CalendarFeature.defaults
|
||||
write(CalendarPopover(referenceDate: september), model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("calendar-\(suffix).png"))
|
||||
write(CalendarPopover(referenceDate: september, showsDayDetails: true), model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("calendar-almanac-\(suffix).png"))
|
||||
settings.calendarFeatures = Set(CalendarFeature.allCases)
|
||||
write(CalendarPopover(referenceDate: summer, showsDayDetails: true), model: model, appearance: appearance,
|
||||
to: outputDirectory.appendingPathComponent("calendar-details-\(suffix).png"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 用真实的 NSHostingView 放进离屏窗口截图,渲染路径与 App 内一致
|
||||
private static func write<V: View>(_ view: V, model: AppModel, appearance: NSAppearance, to url: URL) {
|
||||
let hosting = NSHostingView(rootView: view
|
||||
|
||||
@@ -393,6 +393,17 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
|
||||
public final class AppSettings {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
|
||||
/// 日历独立于性能指标,即使指标合并也保留单独入口;旧用户默认关闭。
|
||||
public var calendarEnabled: Bool {
|
||||
didSet { defaults.set(calendarEnabled, forKey: Keys.calendarEnabled) }
|
||||
}
|
||||
public var calendarFeatures: Set<CalendarFeature> {
|
||||
didSet { defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) }
|
||||
}
|
||||
public var calendarFirstWeekday: Int {
|
||||
didSet { defaults.set(calendarFirstWeekday, forKey: Keys.calendarFirstWeekday) }
|
||||
}
|
||||
|
||||
public var menuBarItems: Set<MenuBarItem> {
|
||||
didSet { defaults.set(menuBarItems.map(\.rawValue).sorted(), forKey: Keys.menuBarItems) }
|
||||
}
|
||||
@@ -545,6 +556,10 @@ public final class AppSettings {
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled)
|
||||
calendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures)
|
||||
.map { Set($0.compactMap(CalendarFeature.init(rawValue:))) } ?? CalendarFeature.defaults
|
||||
calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2
|
||||
let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:))
|
||||
menuBarItems = Set(items ?? [.cpu, .memory, .network])
|
||||
menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked
|
||||
@@ -638,6 +653,9 @@ public final class AppSettings {
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let calendarEnabled = "calendarEnabled"
|
||||
static let calendarFeatures = "calendarFeatures"
|
||||
static let calendarFirstWeekday = "calendarFirstWeekday"
|
||||
static let menuBarItems = "menuBarItems"
|
||||
static let menuBarStyle = "menuBarStyle"
|
||||
static let networkStyle = "networkStyle"
|
||||
|
||||
@@ -15,6 +15,9 @@ import WebDAVSync
|
||||
/// 应用时逐项校验,不认识的值跳过
|
||||
public struct SettingsDocument: Codable, Equatable, Sendable {
|
||||
public var schema: Int? = 1
|
||||
public var calendarEnabled: Bool?
|
||||
public var calendarFeatures: [String]?
|
||||
public var calendarFirstWeekday: Int?
|
||||
public var menuBarItems: [String]?
|
||||
public var menuBarStyle: String?
|
||||
public var networkStyle: String?
|
||||
@@ -49,6 +52,9 @@ extension AppSettings {
|
||||
/// 显式列出可同步的偏好;WebDAV 地址、用户名和密码不会进入备份。
|
||||
public func exportDocument() -> SettingsDocument {
|
||||
var doc = SettingsDocument()
|
||||
doc.calendarEnabled = calendarEnabled
|
||||
doc.calendarFeatures = calendarFeatures.map(\.rawValue).sorted()
|
||||
doc.calendarFirstWeekday = calendarFirstWeekday
|
||||
doc.menuBarItems = menuBarItems.map(\.rawValue).sorted()
|
||||
doc.menuBarStyle = menuBarStyle.rawValue
|
||||
doc.networkStyle = networkStyle.rawValue
|
||||
@@ -88,6 +94,9 @@ extension AppSettings {
|
||||
value.flatMap { options.contains($0) ? $0 : nil }
|
||||
}
|
||||
|
||||
assign(\.calendarEnabled, doc.calendarEnabled)
|
||||
assign(\.calendarFeatures, doc.calendarFeatures.map { Set($0.compactMap(CalendarFeature.init(rawValue:))) })
|
||||
assign(\.calendarFirstWeekday, option(doc.calendarFirstWeekday, in: [1, 2]))
|
||||
assign(\.menuBarItems, doc.menuBarItems.map { Set($0.compactMap(MenuBarItem.init(rawValue:))) })
|
||||
assign(\.menuBarStyle, doc.menuBarStyle.flatMap(MenuBarStyle.init(rawValue:)))
|
||||
assign(\.networkStyle, doc.networkStyle.flatMap(NetworkMenuStyle.init(rawValue:)))
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import XStatsUI
|
||||
|
||||
@MainActor
|
||||
struct CalendarAlmanacTests {
|
||||
@Test func referenceDateMatchesAlmanacFields() throws {
|
||||
let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23))
|
||||
let detail = try #require(CalendarEngine.almanac(for: day))
|
||||
#expect(detail.sound == "壁上土")
|
||||
#expect(detail.opposingZodiac == "马")
|
||||
#expect(detail.ominousDirection == "南")
|
||||
#expect(detail.twelveStar == "司命")
|
||||
#expect(detail.twelveStarIsLucky)
|
||||
#expect(detail.duty == "平")
|
||||
#expect(detail.fetus == "占碓磨 房内南")
|
||||
#expect(detail.pengZu == ["庚不经络织机虚张", "子不问卜自惹祸殃"])
|
||||
#expect(detail.star == "箕水豹")
|
||||
#expect(detail.starIsLucky)
|
||||
#expect(detail.ganzhiSummary == "丙午(马)年 丁酉月 庚子日")
|
||||
}
|
||||
|
||||
@Test func recommendationsAndAvoidancesComeFromTyme() throws {
|
||||
let day = try #require(CalendarEngine.day(year: 2024, month: 6, day: 26))
|
||||
let detail = try #require(CalendarEngine.almanac(for: day))
|
||||
#expect(detail.recommends == ["嫁娶", "祭祀", "理发", "作灶", "修饰垣墙", "平治道涂", "整手足甲", "沐浴", "冠笄"])
|
||||
#expect(detail.avoids == ["破土", "出行", "栽种"])
|
||||
#expect(Set(detail.luckyGods).isDisjoint(with: Set(detail.unluckyGods)))
|
||||
}
|
||||
|
||||
@Test func twelveHoursDoNotIncludeNextDaysLateZi() throws {
|
||||
let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23))
|
||||
let detail = try #require(CalendarEngine.almanac(for: day))
|
||||
#expect(detail.hours.count == 12)
|
||||
#expect(detail.hours.map(\.branch) == ["子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥"])
|
||||
#expect(detail.hours.map(\.stem) == ["丙", "丁", "戊", "己", "庚", "辛", "壬", "癸", "甲", "乙", "丙", "丁"])
|
||||
#expect(detail.hours.map(\.isLucky) == [true, true, false, true, false, false, true, false, true, true, false, false])
|
||||
#expect(Set(detail.hours.map(\.id)).count == 12)
|
||||
}
|
||||
|
||||
@Test(arguments: [(1900, 1, 1), (2023, 3, 22), (2100, 12, 31)])
|
||||
func supportedBoundariesAndLunarLeapMonth(_ date: (Int, Int, Int)) throws {
|
||||
let day = try #require(CalendarEngine.day(year: date.0, month: date.1, day: date.2))
|
||||
let detail = try #require(CalendarEngine.almanac(for: day))
|
||||
#expect(detail.hours.count == 12)
|
||||
#expect(detail.sound.isEmpty == false)
|
||||
#expect(detail.pengZu.count == 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import XStatsUI
|
||||
|
||||
@MainActor
|
||||
struct CalendarTests {
|
||||
let zone = TimeZone(identifier: "Asia/Shanghai")!
|
||||
|
||||
@Test func monthGridIncludesLeapDayAndAdjacentMonths() throws {
|
||||
let days = CalendarEngine.month(year: 2024, month: 2, firstWeekday: 2, timeZone: zone)
|
||||
#expect(days.count == 42)
|
||||
#expect(days.first?.id == "2024-01-29")
|
||||
#expect(days.last?.id == "2024-03-10")
|
||||
#expect(days.filter { $0.month == 2 }.count == 29)
|
||||
let sunday = CalendarEngine.month(year: 2024, month: 2, firstWeekday: 1, timeZone: zone)
|
||||
#expect(sunday.first?.id == "2024-01-28")
|
||||
}
|
||||
|
||||
@Test func lunarLeapMonthAndFestivalPriority() throws {
|
||||
let leap = try #require(CalendarEngine.day(year: 2023, month: 3, day: 22, timeZone: zone))
|
||||
#expect(leap.lunarLabel == "闰二月")
|
||||
let newYear = try #require(CalendarEngine.day(year: 2026, month: 2, day: 17, timeZone: zone))
|
||||
#expect(newYear.festivals.contains("春节"))
|
||||
#expect(newYear.subtitle(features: [.lunar, .festivals]) == "春节")
|
||||
#expect(newYear.subtitle(features: [.lunar]) == "正月")
|
||||
#expect(newYear.subtitle(features: []) == "")
|
||||
}
|
||||
|
||||
@Test func restAndMakeupWorkAreDistinct() throws {
|
||||
let rest = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25, timeZone: zone))
|
||||
let work = try #require(CalendarEngine.day(year: 2026, month: 9, day: 20, timeZone: zone))
|
||||
#expect(rest.holiday?.isWork == false)
|
||||
#expect(work.holiday?.isWork == true)
|
||||
#expect(CalendarEngine.hasHolidayData(year: 2026))
|
||||
#expect(CalendarEngine.hasHolidayData(year: 2099) == false)
|
||||
}
|
||||
|
||||
@Test func termsAndSeasonalBoundaries() throws {
|
||||
#expect(CalendarEngine.day(year: 2026, month: 9, day: 23, timeZone: zone)?.solarTerm == "秋分")
|
||||
#expect(CalendarEngine.day(year: 2026, month: 9, day: 24, timeZone: zone)?.solarTerm == nil)
|
||||
#expect(CalendarEngine.day(year: 2024, month: 6, day: 10, timeZone: zone)?.plumRain == nil)
|
||||
#expect(CalendarEngine.day(year: 2024, month: 6, day: 11, timeZone: zone)?.plumRain == "入梅第1天")
|
||||
#expect(CalendarEngine.day(year: 2024, month: 7, day: 6, timeZone: zone)?.plumRain == "出梅")
|
||||
#expect(CalendarEngine.day(year: 2024, month: 7, day: 7, timeZone: zone)?.plumRain == nil)
|
||||
#expect(CalendarEngine.day(year: 2012, month: 8, day: 8, timeZone: zone)?.dogDays == "末伏第2天")
|
||||
}
|
||||
|
||||
@Test func optionalCalendarsHandleUnsupportedDates() throws {
|
||||
let date = try #require(CalendarEngine.day(year: 2026, month: 5, day: 13, timeZone: zone))
|
||||
let details = CalendarEngine.additionalCalendars(for: date, features: [.hijri, .tibetan])
|
||||
#expect(details.first { $0.feature == .hijri }?.value == "1447年都尔喀尔德月26日")
|
||||
#expect(details.first { $0.feature == .tibetan }?.value != nil)
|
||||
let early = try #require(CalendarEngine.day(year: 1900, month: 1, day: 1, timeZone: zone))
|
||||
#expect(CalendarEngine.additionalCalendars(for: early, features: [.tibetan]).first?.isUnavailable == true)
|
||||
let late = try #require(CalendarEngine.day(year: 2100, month: 1, day: 1, timeZone: zone))
|
||||
#expect(CalendarEngine.additionalCalendars(for: late, features: [.tibetan]).first?.isUnavailable == true)
|
||||
#expect(CalendarEngine.additionalCalendars(for: date, features: []).isEmpty)
|
||||
}
|
||||
|
||||
@Test(arguments: [(1951, 1, 7, true), (1951, 1, 8, false), (2051, 2, 11, false), (2051, 2, 12, true)])
|
||||
func tibetanCoverageBoundaries(_ sample: (Int, Int, Int, Bool)) throws {
|
||||
let day = try #require(CalendarEngine.day(year: sample.0, month: sample.1, day: sample.2, timeZone: zone))
|
||||
let detail = try #require(CalendarEngine.additionalCalendars(for: day, features: [.tibetan]).first)
|
||||
#expect(detail.isUnavailable == sample.3)
|
||||
}
|
||||
|
||||
@Test func invalidDatesAndNavigationLimits() {
|
||||
#expect(CalendarEngine.day(year: 2026, month: 2, day: 30, timeZone: zone) == nil)
|
||||
#expect(CalendarEngine.month(year: 2026, month: 13, firstWeekday: 2, timeZone: zone).isEmpty)
|
||||
#expect(CalendarMonth(year: 2026, month: 12).shifted(by: 1) == CalendarMonth(year: 2027, month: 1))
|
||||
#expect(CalendarMonth(year: 1900, month: 1).shifted(by: -1) == nil)
|
||||
#expect(CalendarMonth(year: 2100, month: 12).shifted(by: 1) == nil)
|
||||
}
|
||||
|
||||
@Test func localDayChangesAcrossMidnightAndTimeZones() throws {
|
||||
let instant = try #require(ISO8601DateFormatter().date(from: "2026-09-23T16:00:00Z"))
|
||||
#expect(CalendarEngine.today(at: instant, timeZone: zone)?.id == "2026-09-24")
|
||||
#expect(CalendarEngine.today(at: instant, timeZone: TimeZone(secondsFromGMT: 0)!)?.id == "2026-09-23")
|
||||
let losAngeles = TimeZone(identifier: "America/Los_Angeles")!
|
||||
let days = CalendarEngine.month(year: 2026, month: 3, firstWeekday: 2, timeZone: losAngeles)
|
||||
#expect(Set(days.map(\.id)).count == 42)
|
||||
}
|
||||
|
||||
@Test func historicalSelectionKeepsCivilDateAfterTimeZoneChange() throws {
|
||||
let old = try #require(CalendarEngine.day(year: 2024, month: 3, day: 22, timeZone: zone))
|
||||
let newZone = try #require(TimeZone(identifier: "America/Los_Angeles"))
|
||||
let month = CalendarMonth(year: 2024, month: 3)
|
||||
let selected = try #require(CalendarEngine.selection(old, in: month, timeZone: newZone))
|
||||
#expect(selected.id == old.id)
|
||||
#expect(selected.lunarSummary == old.lunarSummary)
|
||||
#expect(selected.date != old.date)
|
||||
let parts = CalendarEngine.gregorian(timeZone: newZone).dateComponents([.day, .hour], from: selected.date)
|
||||
#expect(parts.day == 22)
|
||||
#expect(parts.hour == 12)
|
||||
#expect(CalendarEngine.selection(old, in: CalendarMonth(year: 2024, month: 4), timeZone: newZone)?.id == "2024-04-01")
|
||||
}
|
||||
|
||||
@Test func preferencesPersistAndRoundTripWhileOldBackupsPreserveThem() throws {
|
||||
let name = "CalendarTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
#expect(settings.calendarEnabled == false)
|
||||
#expect(settings.calendarFeatures == CalendarFeature.defaults)
|
||||
settings.calendarEnabled = true
|
||||
settings.calendarFeatures = [.hijri, .ganzhi]
|
||||
settings.calendarFirstWeekday = 1
|
||||
let restored = AppSettings(defaults: defaults)
|
||||
#expect(restored.calendarEnabled)
|
||||
#expect(restored.calendarFeatures == [.hijri, .ganzhi])
|
||||
#expect(restored.calendarFirstWeekday == 1)
|
||||
let doc = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(settings.exportDocument()))
|
||||
restored.calendarFeatures = []
|
||||
restored.apply(doc)
|
||||
#expect(restored.calendarFeatures == [.hijri, .ganzhi])
|
||||
restored.apply(SettingsDocument())
|
||||
#expect(restored.calendarEnabled)
|
||||
#expect(restored.calendarFeatures == [.hijri, .ganzhi])
|
||||
var invalid = SettingsDocument()
|
||||
invalid.calendarFeatures = ["hijri", "future-feature"]
|
||||
invalid.calendarFirstWeekday = 99
|
||||
restored.apply(invalid)
|
||||
#expect(restored.calendarFeatures == [.hijri])
|
||||
#expect(restored.calendarFirstWeekday == 1)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,21 @@ All system-monitoring metrics are read on your own Mac and are never uploaded. N
|
||||
|
||||
</div>
|
||||
|
||||
## Calendar
|
||||
Enable the independent date item in **Settings → Menu Bar → Calendar**. Browse months and years,
|
||||
return to today, and select a day for details. Gregorian dates stay visible; lunar dates, weekdays,
|
||||
festivals, solar terms, Chinese holidays and makeup workdays, sexagenary cycles, dog days and
|
||||
traditional plum-rain days each have a display switch. Tibetan and Hijri details are opt-in.
|
||||
The item stays separate when metrics are combined; calendar preferences are included in WebDAV backups.
|
||||
|
||||
Calculations run locally using [Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift), licensed under MIT
|
||||
(see [third-party notices](ThirdPartyNotices.md)). Browse 1900–2100; Tibetan data covers Gregorian
|
||||
1951-01-08 through 2051-02-11. Chinese holiday data currently ends in 2026; unsupported dates show a notice.
|
||||
Plum-rain days follow traditional calendar rules and are not weather forecasts.
|
||||
|
||||
Click a day for its traditional almanac: favorable and avoided activities, Na Yin, clashes, day deities, twelve-hour fortunes, twelve officers, fetal deity, Peng Zu taboos and lunar mansions. Returning to the month preserves the selection.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
@@ -25,6 +25,19 @@ XStats は、Mac の各 CPU コア、GPU、メモリ負荷、通信速度、デ
|
||||
|
||||
</div>
|
||||
|
||||
## カレンダー
|
||||
「設定 → メニューバー → カレンダー」で独立した日付表示を有効にできます。月・年の切り替え、
|
||||
今日への移動、日付の詳細表示に対応します。西暦を常に表示し、旧暦、曜日、祝祭日、中国の振替出勤日、
|
||||
二十四節気、干支、三伏、入梅・出梅は個別に切り替えられます。チベット暦とヒジュラ暦は初期設定では無効です。
|
||||
設定は WebDAV バックアップに含まれます。
|
||||
|
||||
[Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift)(MIT、[第三者ライセンス](ThirdPartyNotices.md))
|
||||
でローカル計算します。閲覧範囲は 1900〜2100 年、チベット暦のデータ範囲は西暦 1951-01-08〜2051-02-11 です。
|
||||
中国の法定休日データは現在 2026 年までで、範囲外はその旨を表示します。入梅・出梅は伝統的な暦の計算で、天気予報ではありません。
|
||||
|
||||
日付をクリックすると、宜忌、納音、沖煞、値神、時辰の吉凶、建除十二直、吉神・凶神、胎神、彭祖百忌、二十八宿の詳細を表示します。月表示に戻っても選択日は保持されます。
|
||||
|
||||
|
||||
## インストール
|
||||
|
||||
[GitHub Releases](https://github.com/ysicing/xstats/releases) から Apple Silicon 版をダウンロードしてください。
|
||||
|
||||
@@ -25,6 +25,19 @@ XStats는 CPU 코어별 사용량, GPU, 메모리 압력, 네트워크 속도,
|
||||
|
||||
</div>
|
||||
|
||||
## 달력
|
||||
「설정 → 메뉴 막대 → 달력」에서 독립 날짜 항목을 켤 수 있습니다. 월·연도 이동, 오늘로 돌아가기,
|
||||
날짜별 상세 정보를 지원합니다. 양력은 항상 표시하며 음력, 요일, 명절, 중국 공휴일과 대체 근무일,
|
||||
절기, 간지, 삼복, 전통 입매·출매 날짜는 개별적으로 켜고 끌 수 있습니다. 티베트력과 이슬람력은 기본적으로 꺼져 있습니다.
|
||||
달력 설정은 WebDAV 백업에 포함됩니다.
|
||||
|
||||
[Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift)(MIT, [타사 라이선스](ThirdPartyNotices.md))로
|
||||
기기에서 계산합니다. 탐색 범위는 1900–2100년이며 티베트력 데이터는 양력 1951-01-08부터 2051-02-11까지입니다.
|
||||
중국 공휴일 데이터는 현재 2026년까지 제공하며 범위 밖에서는 안내를 표시합니다. 전통 입매·출매 날짜는 일기 예보가 아닙니다.
|
||||
|
||||
날짜를 클릭하면 길한 활동과 피할 활동, 납음, 충살, 당일 신, 열두 시진의 길흉, 건제십이신, 길신·흉신, 태신, 팽조백기, 이십팔수를 표시합니다. 월 달력으로 돌아가도 선택한 날짜는 유지됩니다.
|
||||
|
||||
|
||||
## 설치
|
||||
|
||||
[GitHub Releases](https://github.com/ysicing/xstats/releases)에서 Apple Silicon 버전을 다운로드하세요.
|
||||
|
||||
@@ -27,6 +27,18 @@ XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各
|
||||
|
||||
</div>
|
||||
|
||||
## 日历
|
||||
在「设置 → 菜单栏 → 日历」开启独立日期入口,点击查看月历。支持切换月份、年份、
|
||||
返回今天和选择日期详情;公历始终显示,农历、星期、节日、节气、调休、干支、三伏天和梅雨天可分别开关。
|
||||
藏历和回历默认关闭,开启后显示在日期详情中。日历不受指标合并布局影响,设置可随 WebDAV 备份。
|
||||
|
||||
历法由 [Tyme4Swift 1.5.0](https://github.com/6tail/tyme4swift) 在本机计算,采用 MIT 许可,
|
||||
见[第三方声明](ThirdPartyNotices.md)。月历可浏览 1900–2100 年;藏历数据覆盖公历 1951-01-08 至 2051-02-11,
|
||||
中国法定假日与调休数据当前收录到 2026 年,超出覆盖范围会提示。梅雨天是传统历法推算,并非天气预报。
|
||||
|
||||
点击日期可进入黄历详情,查看宜忌、纳音、冲煞、值神、十二时辰吉凶、建除十二神、吉凶神煞、胎神、彭祖百忌和二十八星宿;返回月历后保留原日期选择。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -87,3 +87,10 @@ XStats 未引入这些项目的可执行程序、Sparkle 更新模块或浏览
|
||||
|
||||
完整许可证文本见 [LICENSES/AI-Usage-and-OpenUsage-MIT.txt](LICENSES/AI-Usage-and-OpenUsage-MIT.txt)
|
||||
与 [LICENSES/usage-bar-BSD-2-Clause.txt](LICENSES/usage-bar-BSD-2-Clause.txt)。
|
||||
|
||||
## Tyme4Swift
|
||||
|
||||
日历功能通过 Swift Package Manager 使用 [6tail/tyme4swift](https://github.com/6tail/tyme4swift)
|
||||
1.5.0(MIT License,Copyright (c) 2026 6tail),提供公历、农历、藏历、回历、干支、
|
||||
节气、节日、调休、三伏与传统梅雨日期计算。未修改上游源码。
|
||||
完整许可见 [LICENSES/Tyme4Swift-MIT.txt](LICENSES/Tyme4Swift-MIT.txt)。
|
||||
Reference in new issue
Block a user