From 2d6c532ec0c6de72861abf3af9fc6779a4c90f8a Mon Sep 17 00:00:00 2001 From: XlNTARO Date: Sun, 26 Jul 2026 12:36:27 +0300 Subject: [PATCH] add calendar as a desktop widget --- assets/translations/en.json | 3 + assets/translations/uk-UA.json | 3 + meson.build | 1 + src/app/application_ui.cpp | 1 + src/calendar/calendar_service.cpp | 19 +- src/calendar/calendar_service.h | 7 +- .../control_center/tabs/calendar_tab.cpp | 2 +- src/shell/desktop/desktop_widget_factory.cpp | 20 +- src/shell/desktop/desktop_widget_factory.h | 2 + src/shell/desktop/desktop_widget_services.h | 2 + .../desktop_widget_settings_registry.cpp | 7 +- .../widgets/desktop_calendar_widget.cpp | 745 ++++++++++++++++++ .../desktop/widgets/desktop_calendar_widget.h | 69 ++ tests/config_validate/generated-config | 10 + 14 files changed, 880 insertions(+), 11 deletions(-) create mode 100644 src/shell/desktop/widgets/desktop_calendar_widget.cpp create mode 100644 src/shell/desktop/widgets/desktop_calendar_widget.h diff --git a/assets/translations/en.json b/assets/translations/en.json index 1f5d1a122e..0967dbd66c 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -512,6 +512,7 @@ "shadow": "Shadow", "show-caps-lock": "Caps Lock Warning", "show-device": "Show Device", + "show-events": "Show Events", "show-forecast": "Show Forecast", "show-keyboard-layout": "Keyboard Layout", "show-label": "Show Label", @@ -522,6 +523,7 @@ "show-vpn-label": "Show VPN Label", "show-weather": "Weather", "show-when-idle": "Show When Idle", + "show-week-numbers": "Show Week Numbers", "stat": "Stat", "stat-cpu-temp": "CPU Temp", "stat-cpu-usage": "CPU Usage", @@ -561,6 +563,7 @@ "types": { "audio-visualizer": "Audio Visualizer", "button": "Button", + "calendar": "Calendar", "clock": "Clock", "fancy-audio-visualizer": "Fancy Audio Visualizer", "label": "Label", diff --git a/assets/translations/uk-UA.json b/assets/translations/uk-UA.json index 11faff2dd5..5ca29b982d 100644 --- a/assets/translations/uk-UA.json +++ b/assets/translations/uk-UA.json @@ -497,11 +497,13 @@ "secondary-color": "Другорядний колір", "sensitivity": "Чутливість", "shadow": "Тінь", + "show-events": "Показувати події", "show-forecast": "Показати прогноз погоди", "show-keyboard-layout": "Розкладка клавіатури", "show-label": "Показати мітку", "show-login-button": "Кнопка підтвердження входу", "show-when-idle": "Показувати, коли неактивний", + "show-week-numbers": "Показувати номери тижнів", "stat": "Стан", "stat-cpu-temp": "Температура CPU", "stat-cpu-usage": "Використання CPU", @@ -536,6 +538,7 @@ "types": { "audio-visualizer": "Аудіовізуалізатор", "button": "Кнопка", + "calendar": "Календар", "clock": "Годинник", "fancy-audio-visualizer": "Ефектний аудіовізуалізатор", "label": "Мітка", diff --git a/meson.build b/meson.build index 7045e8bbea..09e9341aa2 100644 --- a/meson.build +++ b/meson.build @@ -593,6 +593,7 @@ _noctalia_sources = files( 'src/shell/lockscreen/lockscreen_widgets_host.cpp', 'src/shell/desktop/widgets/desktop_audio_visualizer_widget.cpp', 'src/shell/desktop/widgets/desktop_button_widget.cpp', + 'src/shell/desktop/widgets/desktop_calendar_widget.cpp', 'src/shell/desktop/widgets/desktop_clock_widget.cpp', 'src/shell/desktop/widgets/desktop_fancy_audio_visualizer_widget.cpp', 'src/shell/desktop/widgets/desktop_label_widget.cpp', diff --git a/src/app/application_ui.cpp b/src/app/application_ui.cpp index 7e53ef9210..223c08fbb9 100644 --- a/src/app/application_ui.cpp +++ b/src/app/application_ui.cpp @@ -928,6 +928,7 @@ void Application::initWidgetControllersAndCallbacks() { .configService = &m_configService, }; const DesktopWidgetRuntimeServices desktopWidgetRuntime{ + .calendar = &m_calendarService, .pipewire = m_pipewireService.get(), .pipewireSpectrum = m_pipewireSpectrum.get(), .weather = &m_weatherService, diff --git a/src/calendar/calendar_service.cpp b/src/calendar/calendar_service.cpp index efda38d2fb..3b9a829463 100644 --- a/src/calendar/calendar_service.cpp +++ b/src/calendar/calendar_service.cpp @@ -230,14 +230,25 @@ void CalendarService::retryCredentialMigration() { }); } -void CalendarService::addChangeCallback(ChangeCallback callback) { - if (callback) { - m_callbacks.push_back(std::move(callback)); +CalendarService::ChangeCallbackId CalendarService::addChangeCallback(ChangeCallback callback) { + if (!callback) { + return 0; } + const ChangeCallbackId id = m_nextCallbackId++; + m_callbacks.emplace_back(id, std::move(callback)); + return id; +} + +void CalendarService::removeChangeCallback(ChangeCallbackId callbackId) { + if (callbackId == 0) { + return; + } + std::erase_if(m_callbacks, [callbackId](const auto& entry) { return entry.first == callbackId; }); } void CalendarService::notifyChanged() { - for (auto& callback : m_callbacks) { + for (auto& [id, callback] : m_callbacks) { + (void)id; if (callback) { callback(); } diff --git a/src/calendar/calendar_service.h b/src/calendar/calendar_service.h index 5d1431140d..1a1e3df67a 100644 --- a/src/calendar/calendar_service.h +++ b/src/calendar/calendar_service.h @@ -32,6 +32,7 @@ namespace security { class CalendarService { public: using ChangeCallback = std::function; + using ChangeCallbackId = std::uint64_t; enum class ConnectState : std::uint8_t { Idle, Pending, Success, Failed }; enum class CachePersistenceState : std::uint8_t { Opening, @@ -63,7 +64,8 @@ class CalendarService { ); void initialize(); - void addChangeCallback(ChangeCallback callback); + [[nodiscard]] ChangeCallbackId addChangeCallback(ChangeCallback callback); + void removeChangeCallback(ChangeCallbackId callbackId); void setCredentialChangeCallback(ChangeCallback callback) { m_credentialChangeCallback = std::move(callback); } [[nodiscard]] int pollTimeoutMs() const; @@ -141,7 +143,8 @@ class CalendarService { HttpClient& m_httpClient; NotificationManager* m_notifications = nullptr; CalendarConfig m_activeConfig; - std::vector m_callbacks; + std::vector> m_callbacks; + ChangeCallbackId m_nextCallbackId = 1; ChangeCallback m_credentialChangeCallback; calendar::GoogleOAuthBroker m_oauth; diff --git a/src/shell/control_center/tabs/calendar_tab.cpp b/src/shell/control_center/tabs/calendar_tab.cpp index fe9e5d0589..b0eea23d60 100644 --- a/src/shell/control_center/tabs/calendar_tab.cpp +++ b/src/shell/control_center/tabs/calendar_tab.cpp @@ -146,7 +146,7 @@ std::unique_ptr CalendarTab::create() { if (m_calendar != nullptr && !m_changeCallbackRegistered) { m_changeCallbackRegistered = true; - m_calendar->addChangeCallback([this]() { + (void)m_calendar->addChangeCallback([this]() { m_eventsDirty = true; PanelManager::instance().refresh(); }); diff --git a/src/shell/desktop/desktop_widget_factory.cpp b/src/shell/desktop/desktop_widget_factory.cpp index 25792c41ce..89c86ed265 100644 --- a/src/shell/desktop/desktop_widget_factory.cpp +++ b/src/shell/desktop/desktop_widget_factory.cpp @@ -6,6 +6,7 @@ #include "scripting/plugin_runtime_context.h" #include "shell/desktop/widgets/desktop_audio_visualizer_widget.h" #include "shell/desktop/widgets/desktop_button_widget.h" +#include "shell/desktop/widgets/desktop_calendar_widget.h" #include "shell/desktop/widgets/desktop_clock_widget.h" #include "shell/desktop/widgets/desktop_fancy_audio_visualizer_widget.h" #include "shell/desktop/widgets/desktop_label_widget.h" @@ -182,13 +183,26 @@ namespace { } // namespace DesktopWidgetFactory::DesktopWidgetFactory(DesktopWidgetRuntimeServices services) - : m_pipewire(services.pipewire), m_pipewireSpectrum(services.pipewireSpectrum), m_weather(services.weather), - m_mpris(services.mpris), m_httpClient(services.httpClient), m_sysmon(services.sysmon), - m_scriptDeps(services.scriptDeps) {} + : m_calendar(services.calendar), m_pipewire(services.pipewire), m_pipewireSpectrum(services.pipewireSpectrum), + m_weather(services.weather), m_mpris(services.mpris), m_httpClient(services.httpClient), + m_sysmon(services.sysmon), m_scriptDeps(services.scriptDeps) {} std::unique_ptr DesktopWidgetFactory::create( const std::string& type, const std::unordered_map& settings, float contentScale ) const { + if (type == "calendar") { + auto widget = std::make_unique( + m_scriptDeps.configService, m_calendar, + DesktopCalendarWidget::Options{ + .showEvents = getBoolSetting(settings, "show_events", true), + .showWeekNumbers = getBoolSetting(settings, "show_week_numbers", false), + } + ); + applyCommonSettings(*widget, settings); + widget->setContentScale(contentScale); + return widget; + } + if (type == "clock") { const std::string styleSetting = getStringSetting(settings, "clock_style", "digital"); const DesktopClockWidget::Style style = diff --git a/src/shell/desktop/desktop_widget_factory.h b/src/shell/desktop/desktop_widget_factory.h index b5816e2403..bbc4c877b8 100644 --- a/src/shell/desktop/desktop_widget_factory.h +++ b/src/shell/desktop/desktop_widget_factory.h @@ -8,6 +8,7 @@ #include class HttpClient; +class CalendarService; class MprisService; class SystemMonitorService; class PipeWireService; @@ -24,6 +25,7 @@ class DesktopWidgetFactory { ) const; private: + CalendarService* m_calendar = nullptr; PipeWireService* m_pipewire = nullptr; PipeWireSpectrum* m_pipewireSpectrum = nullptr; const WeatherService* m_weather = nullptr; diff --git a/src/shell/desktop/desktop_widget_services.h b/src/shell/desktop/desktop_widget_services.h index 526e42dafd..c5715976da 100644 --- a/src/shell/desktop/desktop_widget_services.h +++ b/src/shell/desktop/desktop_widget_services.h @@ -1,6 +1,7 @@ #pragma once class ClipboardService; +class CalendarService; class ConfigService; class FileWatcher; class HttpClient; @@ -27,6 +28,7 @@ struct DesktopWidgetScriptDeps { }; struct DesktopWidgetRuntimeServices { + CalendarService* calendar = nullptr; PipeWireService* pipewire = nullptr; PipeWireSpectrum* pipewireSpectrum = nullptr; const WeatherService* weather = nullptr; diff --git a/src/shell/desktop/desktop_widget_settings_registry.cpp b/src/shell/desktop/desktop_widget_settings_registry.cpp index 325d766568..d24855f4e2 100644 --- a/src/shell/desktop/desktop_widget_settings_registry.cpp +++ b/src/shell/desktop/desktop_widget_settings_registry.cpp @@ -22,6 +22,7 @@ namespace desktop_settings { const std::vector kDesktopWidgetTypeSpecs = { {.type = "audio_visualizer", .labelKey = "desktop-widgets.editor.types.audio-visualizer"}, {.type = "button", .labelKey = "desktop-widgets.editor.types.button"}, + {.type = "calendar", .labelKey = "desktop-widgets.editor.types.calendar"}, {.type = "clock", .labelKey = "desktop-widgets.editor.types.clock"}, {.type = "fancy_audio_visualizer", .labelKey = "desktop-widgets.editor.types.fancy-audio-visualizer"}, {.type = "label", .labelKey = "desktop-widgets.editor.types.label"}, @@ -240,7 +241,11 @@ namespace desktop_settings { std::vector specs; auto add = [&](WidgetSettingSpec spec) { specs.push_back(std::move(spec)); }; - if (type == "clock") { + if (type == "calendar") { + add(boolSpec("show_events", true)); + add(boolSpec("show_week_numbers", false)); + add(fontFamilySpec()); + } else if (type == "clock") { const WidgetSettingVisibility digitalOnly{{"clock_style", {"digital"}}}; const WidgetSettingVisibility analogOnly{{"clock_style", {"analog"}}}; add(segmentedSpec( diff --git a/src/shell/desktop/widgets/desktop_calendar_widget.cpp b/src/shell/desktop/widgets/desktop_calendar_widget.cpp new file mode 100644 index 0000000000..b464f1cb51 --- /dev/null +++ b/src/shell/desktop/widgets/desktop_calendar_widget.cpp @@ -0,0 +1,745 @@ +#include "shell/desktop/widgets/desktop_calendar_widget.h" + +#include "calendar/calendar_service.h" +#include "config/config_service.h" +#include "core/ui_phase.h" +#include "i18n/i18n.h" +#include "render/core/color.h" +#include "render/core/renderer.h" +#include "render/scene/input_area.h" +#include "render/scene/node.h" +#include "time/time_format.h" +#include "ui/builders.h" +#include "ui/controls/button.h" +#include "ui/controls/flex.h" +#include "ui/controls/grid_tile.h" +#include "ui/controls/grid_view.h" +#include "ui/controls/label.h" +#include "ui/controls/scroll_view.h" +#include "ui/style.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + + constexpr float kCalendarWidth = 340.0f; + constexpr float kEventsWidth = 240.0f; + constexpr float kWidgetHeight = 390.0f; + constexpr float kSectionGap = Style::spaceLg; + constexpr float kGridGap = Style::spaceXs; + constexpr float kHeaderHeight = Style::controlHeight; + constexpr float kWeekdayHeight = 20.0f; + constexpr float kDayCellHeight = 43.0f; + constexpr float kDayButtonSize = 34.0f; + constexpr float kDotDiameter = 4.0f; + constexpr float kWeekColumnWidth = 24.0f; + + struct CalendarState { + int currentYear = 0; + int currentMonth = 0; + int today = 0; + int displayYear = 0; + int displayMonth = 0; + int displayWeekday = 0; + bool isCurrentMonth = false; + }; + + CalendarState calendarState(int monthOffset) { + const std::time_t now = std::time(nullptr); + std::tm local{}; + localtime_r(&now, &local); + + CalendarState state; + state.currentYear = local.tm_year + 1900; + state.currentMonth = local.tm_mon; + state.today = local.tm_mday; + + const auto currentMonth = + std::chrono::year{state.currentYear} / std::chrono::month{static_cast(state.currentMonth + 1)}; + const auto displayDate = + std::chrono::year_month_day((currentMonth + std::chrono::months{monthOffset}) / std::chrono::day{1}); + state.displayYear = static_cast(static_cast(displayDate.year())); + state.displayMonth = static_cast(static_cast(displayDate.month())) - 1; + state.displayWeekday = static_cast(std::chrono::weekday(std::chrono::sys_days{displayDate}).c_encoding()); + state.isCurrentMonth = state.displayYear == state.currentYear && state.displayMonth == state.currentMonth; + return state; + } + + int daysInMonth(int year, int month0) { + const auto last = + std::chrono::year{year} / std::chrono::month{static_cast(month0 + 1)} / std::chrono::last; + return static_cast(static_cast(last.day())); + } + + std::string monthName(int month0) { + if (month0 < 0 || month0 > 11) { + return {}; + } + std::tm tm{}; + tm.tm_mon = month0; + tm.tm_mday = 1; + return formatStrftime("%B", tm); + } + + int dateKey(int year, int month0, int day) { return year * 10000 + (month0 + 1) * 100 + day; } + + int localDateKey(std::chrono::system_clock::time_point time) { + const std::time_t raw = std::chrono::system_clock::to_time_t(time); + std::tm tm{}; + localtime_r(&raw, &tm); + return dateKey(tm.tm_year + 1900, tm.tm_mon, tm.tm_mday); + } + + std::pair eventDayRange(const CalendarEvent& event) { + const int start = localDateKey(event.start); + auto endTime = event.end; + if (event.allDay && event.end > event.start) { + endTime -= std::chrono::hours{24}; + } + return {start, std::max(start, localDateKey(endTime))}; + } + + ColorSpec eventColor(const CalendarEvent& event) { + Color color; + if (!event.colorHex.empty() && tryParseHexColor(event.colorHex, color)) { + return fixedColorSpec(color); + } + if (const auto role = colorRoleFromToken(event.colorHex); role.has_value()) { + return colorSpecFromRole(*role); + } + return colorSpecFromRole(ColorRole::Primary); + } + + void applyFontFamily(Node* node, const std::string& family) { + if (node == nullptr) { + return; + } + if (auto* label = dynamic_cast(node); label != nullptr) { + label->setFontFamily(family); + } + for (const auto& child : node->children()) { + applyFontFamily(child.get(), family); + } + } + +} // namespace + +DesktopCalendarWidget::DesktopCalendarWidget(ConfigService* config, CalendarService* calendar, Options options) + : m_config(config), m_calendar(calendar), m_showEvents(options.showEvents), + m_showWeekNumbers(options.showWeekNumbers) {} + +DesktopCalendarWidget::~DesktopCalendarWidget() { + if (m_calendar != nullptr && m_calendarCallbackId != 0) { + m_calendar->removeChangeCallback(m_calendarCallbackId); + } +} + +void DesktopCalendarWidget::create() { + auto root = ui::row({ + .out = &m_rootLayout, + .align = FlexAlign::Stretch, + .gap = kSectionGap * contentScale(), + }); + + auto calendarArea = std::make_unique(); + m_calendarArea = calendarArea.get(); + calendarArea->setOnAxis([this](const InputArea::PointerData& data) { + if (data.axis != WL_POINTER_AXIS_VERTICAL_SCROLL) { + return; + } + const float steps = data.scrollSteps(); + if (steps != 0.0f) { + changeMonthBy(steps > 0.0f ? 1 : -1); + } + }); + + auto calendarColumn = ui::column({ + .out = &m_calendarColumn, + .align = FlexAlign::Stretch, + .gap = Style::spaceSm * contentScale(), + }); + calendarColumn->addChild( + ui::label({ + .out = &m_todayLabel, + .text = formatLocalTime(m_config != nullptr ? m_config->config().shell.dateFormat.c_str() : "%A, %x"), + .fontSize = Style::fontSizeTitle * contentScale(), + .fontWeight = FontWeight::Medium, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::Secondary), + .maxLines = 1, + .configure = [this](Label& label) { + label.setHitTestVisible(true); + label.setOnClick([this](const InputArea::PointerData&) { focusToday(); }); + }, + }) + ); + + auto header = ui::row({ + .out = &m_header, + .align = FlexAlign::Center, + .justify = FlexJustify::SpaceBetween, + .gap = Style::spaceSm * contentScale(), + }); + header->addChild( + ui::button({ + .out = &m_previousButton, + .glyph = "chevron-left", + .variant = ButtonVariant::Ghost, + .onClick = [this]() { changeMonthBy(-1); }, + }) + ); + header->addChild( + ui::label({ + .out = &m_monthLabel, + .fontSize = (Style::fontSizeTitle + Style::spaceXs) * contentScale(), + .fontWeight = FontWeight::Bold, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::OnSurface), + .maxLines = 1, + .textAlign = TextAlign::Center, + .flexGrow = 1.0f, + .configure = [this](Label& label) { + label.setHitTestVisible(true); + label.setOnClick([this](const InputArea::PointerData&) { focusToday(); }); + }, + }) + ); + header->addChild( + ui::button({ + .out = &m_nextButton, + .glyph = "chevron-right", + .variant = ButtonVariant::Ghost, + .onClick = [this]() { changeMonthBy(1); }, + }) + ); + calendarColumn->addChild(std::move(header)); + + auto grid = ui::column({.out = &m_grid, .align = FlexAlign::Stretch, .gap = kGridGap * contentScale()}); + calendarColumn->addChild(std::move(grid)); + calendarArea->addChild(std::move(calendarColumn)); + root->addChild(std::move(calendarArea)); + + auto eventsColumn = ui::column({ + .out = &m_eventsColumn, + .align = FlexAlign::Stretch, + .gap = Style::spaceSm * contentScale(), + .visible = m_showEvents, + }); + eventsColumn->addChild( + ui::label({ + .out = &m_eventsTitle, + .text = i18n::tr("control-center.calendar.events"), + .fontSize = Style::fontSizeTitle * contentScale(), + .fontWeight = FontWeight::Bold, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::OnSurface), + .maxLines = 2, + }) + ); + eventsColumn->addChild( + ui::scrollView({ + .out = &m_eventsScroll, + .fillWidth = true, + .fillHeight = true, + .flexGrow = 1.0f, + }) + ); + root->addChild(std::move(eventsColumn)); + + setRoot(std::move(root)); + focusToday(); + + if (m_calendar != nullptr) { + m_calendarCallbackId = m_calendar->addChangeCallback([this]() { markDirty(); }); + } +} + +bool DesktopCalendarWidget::applySetting( + const std::string& key, const WidgetSettingValue& value, + const std::unordered_map& allSettings, Renderer& renderer +) { + if (key == "show_events") { + if (const auto* enabled = std::get_if(&value)) { + m_showEvents = *enabled; + if (m_eventsColumn != nullptr) { + m_eventsColumn->setVisible(m_showEvents); + } + m_dirty = true; + layout(renderer); + return true; + } + return false; + } + if (key == "show_week_numbers") { + if (const auto* enabled = std::get_if(&value)) { + m_showWeekNumbers = *enabled; + m_dirty = true; + layout(renderer); + return true; + } + return false; + } + return DesktopWidget::applySetting(key, value, allSettings, renderer); +} + +void DesktopCalendarWidget::doLayout(Renderer& renderer) { + if (m_rootLayout == nullptr || m_calendarArea == nullptr || m_calendarColumn == nullptr || m_grid == nullptr) { + return; + } + + const float scale = contentScale(); + const float calendarWidth = (kCalendarWidth + (m_showWeekNumbers ? kWeekColumnWidth + kGridGap : 0.0f)) * scale; + const float eventsWidth = kEventsWidth * scale; + const float height = kWidgetHeight * scale; + const float totalWidth = calendarWidth + (m_showEvents ? (kSectionGap * scale + eventsWidth) : 0.0f); + + m_rootLayout->setGap(kSectionGap * scale); + m_rootLayout->setSize(totalWidth, height); + m_calendarArea->setSize(calendarWidth, height); + m_calendarColumn->setGap(Style::spaceSm * scale); + m_calendarColumn->setSize(calendarWidth, height); + if (m_header != nullptr) { + m_header->setGap(Style::spaceSm * scale); + m_header->setSize(calendarWidth, kHeaderHeight * scale); + } + for (Button* button : {m_previousButton, m_nextButton}) { + if (button == nullptr) { + continue; + } + button->setMinWidth(kHeaderHeight * scale); + button->setMinHeight(kHeaderHeight * scale); + button->setGlyphSize(Style::fontSizeBody * scale); + button->setPadding(Style::spaceXs * scale, Style::spaceXs * scale); + button->setRadius(Style::scaledRadiusMd(scale)); + } + if (m_todayLabel != nullptr) { + m_todayLabel->setFontSize(Style::fontSizeTitle * scale); + m_todayLabel->setMaxWidth(calendarWidth); + } + if (m_monthLabel != nullptr) { + m_monthLabel->setFontSize((Style::fontSizeTitle + Style::spaceXs) * scale); + m_monthLabel->setMaxWidth(std::max(1.0f, calendarWidth - 2.0f * kHeaderHeight * scale)); + } + if (m_eventsColumn != nullptr) { + m_eventsColumn->setVisible(m_showEvents); + m_eventsColumn->setGap(Style::spaceSm * scale); + m_eventsColumn->setSize(eventsWidth, height); + } + if (m_eventsTitle != nullptr) { + m_eventsTitle->setFontSize(Style::fontSizeTitle * scale); + m_eventsTitle->setMaxWidth(eventsWidth); + } + if (m_eventsScroll != nullptr) { + m_eventsScroll->setSize(eventsWidth, std::max(1.0f, height - 40.0f * scale)); + m_eventsScroll->setViewportPaddingH(Style::spaceXs * scale); + m_eventsScroll->setViewportPaddingV(Style::spaceXs * scale); + } + + rebuildCalendar(); + if (m_showEvents) { + rebuildEventList(); + } + applyFontFamily(root(), m_fontFamily); + m_rootLayout->layout(renderer); + m_dirty = false; +} + +void DesktopCalendarWidget::doUpdate(Renderer& /*renderer*/) { + const CalendarState state = calendarState(m_monthOffset); + const int todayKey = dateKey(state.currentYear, state.currentMonth, state.today); + if (todayKey != m_lastTodayKey) { + m_lastTodayKey = todayKey; + m_dirty = true; + } + if (m_todayLabel != nullptr) { + m_todayLabel->setText( + formatLocalTime(m_config != nullptr ? m_config->config().shell.dateFormat.c_str() : "%A, %x") + ); + } + if (m_dirty && !isLayingOut()) { + requestLayout(); + } +} + +void DesktopCalendarWidget::onFontFamilyChanged(const std::string& family, Renderer& /*renderer*/) { + applyFontFamily(root(), family); +} + +void DesktopCalendarWidget::changeMonthBy(int delta) { + if (delta == 0) { + return; + } + m_monthOffset += delta; + m_dirty = true; + requestLayout(); +} + +void DesktopCalendarWidget::focusToday() { + const CalendarState state = calendarState(0); + m_monthOffset = 0; + m_selectedYear = state.currentYear; + m_selectedMonth = state.currentMonth; + m_selectedDay = state.today; + m_lastTodayKey = dateKey(state.currentYear, state.currentMonth, state.today); + m_dirty = true; + requestLayout(); +} + +void DesktopCalendarWidget::markDirty() { + m_dirty = true; + requestLayout(); +} + +void DesktopCalendarWidget::rebuildCalendar() { + uiAssertNotRendering("DesktopCalendarWidget::rebuildCalendar"); + if (m_grid == nullptr || m_monthLabel == nullptr) { + return; + } + while (!m_grid->children().empty()) { + m_grid->removeChild(m_grid->children().front().get()); + } + + const float scale = contentScale(); + const float gap = kGridGap * scale; + const float calendarWidth = (kCalendarWidth + (m_showWeekNumbers ? kWeekColumnWidth + kGridGap : 0.0f)) * scale; + const float weekWidth = m_showWeekNumbers ? kWeekColumnWidth * scale : 0.0f; + const float dayGridWidth = calendarWidth - (m_showWeekNumbers ? weekWidth + gap : 0.0f); + const float dayColumnWidth = std::max(1.0f, (dayGridWidth - 6.0f * gap) / 7.0f); + const float buttonSize = std::min(kDayButtonSize * scale, dayColumnWidth); + const float cellHeight = kDayCellHeight * scale; + const float weekdayHeight = kWeekdayHeight * scale; + const float dotDiameter = kDotDiameter * scale; + + const CalendarState state = calendarState(m_monthOffset); + const int year = state.displayYear; + const int month = state.displayMonth; + m_monthLabel->setText(monthName(month) + " " + std::to_string(year)); + const bool focusedOnToday = m_monthOffset == 0 + && m_selectedYear == state.currentYear + && m_selectedMonth == state.currentMonth + && m_selectedDay == state.today; + if (focusedOnToday) { + m_monthLabel->clearTooltip(); + } else { + m_monthLabel->setTooltip(i18n::tr("control-center.calendar.today")); + } + + const int firstDayOfWeek = localeFirstDayOfWeek(); + std::array weekdays; + for (int i = 0; i < 7; ++i) { + std::tm tm{}; + tm.tm_wday = (firstDayOfWeek + i) % 7; + tm.tm_mday = 1; + weekdays[static_cast(i)] = formatStrftime("%a", tm); + } + + auto weekdayRow = std::make_unique(); + weekdayRow->setColumns(7); + weekdayRow->setColumnGap(gap); + weekdayRow->setStretchItems(true); + weekdayRow->setSize(dayGridWidth, weekdayHeight); + weekdayRow->setMinCellHeight(weekdayHeight); + for (std::size_t i = 0; i < weekdays.size(); ++i) { + auto tile = std::make_unique(); + tile->setDirection(FlexDirection::Vertical); + tile->setAlign(FlexAlign::Center); + tile->setJustify(FlexJustify::Center); + const int weekday = (firstDayOfWeek + static_cast(i)) % 7; + tile->addChild( + ui::label({ + .text = weekdays[i], + .fontSize = Style::fontSizeCaption * scale, + .fontWeight = FontWeight::Medium, + .fontFamily = m_fontFamily, + .color = + colorSpecFromRole(weekday == 0 || weekday == 6 ? ColorRole::Secondary : ColorRole::OnSurfaceVariant), + .maxLines = 1, + }) + ); + weekdayRow->addChild(std::move(tile)); + } + + const int firstWeekdayOffset = (state.displayWeekday - firstDayOfWeek + 7) % 7; + const int previousMonth = month == 0 ? 11 : month - 1; + const int previousYear = month == 0 ? year - 1 : year; + const int previousDays = daysInMonth(previousYear, previousMonth); + const int monthDays = daysInMonth(year, month); + const int nextMonth = month == 11 ? 0 : month + 1; + const int nextYear = month == 11 ? year + 1 : year; + + std::array, 32> eventDots; + if (m_calendar != nullptr) { + const int firstKey = dateKey(year, month, 1); + const int lastKey = dateKey(year, month, monthDays); + for (const CalendarEvent& event : m_calendar->snapshot().events) { + const auto [eventStart, eventEnd] = eventDayRange(event); + if (eventEnd < firstKey || eventStart > lastKey) { + continue; + } + for (int day = 1; day <= monthDays; ++day) { + const int key = dateKey(year, month, day); + auto& dots = eventDots[static_cast(day)]; + if (key >= eventStart && key <= eventEnd && dots.size() < 3) { + dots.push_back(eventColor(event)); + } + } + } + } + + auto dayGrid = std::make_unique(); + dayGrid->setColumns(7); + dayGrid->setColumnGap(gap); + dayGrid->setStretchItems(true); + dayGrid->setSize(dayGridWidth, 6.0f * cellHeight + 5.0f * gap); + dayGrid->setMinCellHeight(cellHeight); + + int inMonthDay = 1; + int trailingDay = 1; + for (int index = 0; index < 42; ++index) { + int cellYear = year; + int cellMonth = month; + int cellDay = 0; + int monthShift = 0; + bool inMonth = false; + + if (index < firstWeekdayOffset) { + cellDay = previousDays - firstWeekdayOffset + index + 1; + cellYear = previousYear; + cellMonth = previousMonth; + monthShift = -1; + } else if (inMonthDay > monthDays) { + cellDay = trailingDay++; + cellYear = nextYear; + cellMonth = nextMonth; + monthShift = 1; + } else { + cellDay = inMonthDay++; + inMonth = true; + } + + auto tile = std::make_unique(); + tile->setDirection(FlexDirection::Vertical); + tile->setAlign(FlexAlign::Center); + tile->setJustify(FlexJustify::Center); + tile->setGap(1.0f * scale); + + auto button = ui::button({ + .text = std::to_string(cellDay), + .fontSize = Style::fontSizeBody * scale, + .contentAlign = ButtonContentAlign::Center, + .variant = ButtonVariant::Ghost, + .minWidth = buttonSize, + .minHeight = buttonSize, + .padding = 0.0f, + .radius = Style::scaledRadiusMd(scale), + .width = buttonSize, + .height = buttonSize, + }); + if (button->label() != nullptr) { + button->label()->setFontFamily(m_fontFamily); + } + + if (!inMonth) { + Button::ButtonPalette muted = Button::defaultPalette(ButtonVariant::Ghost); + muted.normal.label = colorSpecFromRole(ColorRole::OnSurfaceVariant, 0.75f); + button->setCustomPalette(muted); + } else { + const bool selected = m_selectedYear == year && m_selectedMonth == month && m_selectedDay == cellDay; + if (selected) { + button->setVariant(ButtonVariant::Primary); + } else { + if (state.isCurrentMonth && cellDay == state.today) { + Button::ButtonPalette today = Button::defaultPalette(ButtonVariant::Ghost); + today.normal.label = colorSpecFromRole(ColorRole::Primary); + button->setCustomPalette(today); + } + if (button->label() != nullptr) { + button->label()->setFontWeight(FontWeight::Bold); + } + } + } + + auto selectDay = [this, cellYear, cellMonth, cellDay, monthShift]() { + m_selectedYear = cellYear; + m_selectedMonth = cellMonth; + m_selectedDay = cellDay; + m_monthOffset += monthShift; + m_dirty = true; + requestLayout(); + }; + button->setOnClick(selectDay); + tile->addChild(std::move(button)); + + auto dots = ui::row({.align = FlexAlign::Center, .justify = FlexJustify::Center, .gap = 2.0f * scale}); + dots->setSize(buttonSize, dotDiameter); + if (inMonth) { + for (const ColorSpec& color : eventDots[static_cast(cellDay)]) { + dots->addChild( + ui::box({ + .fill = color, + .radius = dotDiameter * 0.5f, + .width = dotDiameter, + .height = dotDiameter, + }) + ); + } + } + auto dotArea = std::make_unique(); + dotArea->setSize(buttonSize, dotDiameter); + dotArea->setOnClick([selectDay](const InputArea::PointerData&) { selectDay(); }); + dotArea->addChild(std::move(dots)); + tile->addChild(std::move(dotArea)); + dayGrid->addChild(std::move(tile)); + } + + const float gridHeight = weekdayHeight + gap + 6.0f * cellHeight + 5.0f * gap; + auto days = ui::column({.gap = gap}); + days->setSize(dayGridWidth, gridHeight); + days->addChild(std::move(weekdayRow)); + days->addChild(std::move(dayGrid)); + + if (m_showWeekNumbers) { + auto weekColumn = ui::column({.align = FlexAlign::Center, .gap = gap}); + auto weekdaySpacer = ui::column({}); + weekdaySpacer->setSize(weekWidth, weekdayHeight); + weekColumn->addChild(std::move(weekdaySpacer)); + + const int thursdayColumn = (4 - firstDayOfWeek + 7) % 7; + const auto firstThursday = + std::chrono::sys_days(std::chrono::year{year} / std::chrono::month{static_cast(month + 1)} / 1) + - std::chrono::days{firstWeekdayOffset} + + std::chrono::days{thursdayColumn}; + for (int row = 0; row < 6; ++row) { + auto cell = ui::column({.align = FlexAlign::Center, .justify = FlexJustify::Center}); + cell->setSize(weekWidth, cellHeight); + cell->addChild( + ui::label({ + .text = std::format("{:%V}", firstThursday + std::chrono::days{row * 7}), + .fontSize = Style::fontSizeCaption * scale, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::OnSurfaceVariant, 0.7f), + .maxLines = 1, + }) + ); + weekColumn->addChild(std::move(cell)); + } + weekColumn->setSize(weekWidth, gridHeight); + + auto row = ui::row({.gap = gap}); + row->setSize(calendarWidth, gridHeight); + row->addChild(std::move(weekColumn)); + row->addChild(std::move(days)); + m_grid->addChild(std::move(row)); + } else { + m_grid->addChild(std::move(days)); + } + m_grid->setSize(calendarWidth, gridHeight); +} + +void DesktopCalendarWidget::rebuildEventList() { + if (m_eventsScroll == nullptr || m_eventsScroll->content() == nullptr) { + return; + } + const float scale = contentScale(); + Flex* content = m_eventsScroll->content(); + content->setDirection(FlexDirection::Vertical); + content->setAlign(FlexAlign::Stretch); + content->setGap(Style::spaceSm * scale); + while (!content->children().empty()) { + content->removeChild(content->children().front().get()); + } + + std::tm selected{}; + selected.tm_year = m_selectedYear - 1900; + selected.tm_mon = m_selectedMonth; + selected.tm_mday = m_selectedDay; + selected.tm_isdst = -1; + const std::time_t selectedRaw = std::mktime(&selected); + if (m_eventsTitle != nullptr) { + const char* format = + m_config != nullptr ? m_config->config().controlCenter.calendarTab.eventDateFormat.c_str() : "%A %e %B"; + m_eventsTitle->setText(formatLocalUnixTime(static_cast(selectedRaw), format)); + } + + std::vector events; + const int selectedKey = dateKey(m_selectedYear, m_selectedMonth, m_selectedDay); + if (m_calendar != nullptr) { + for (const CalendarEvent& event : m_calendar->snapshot().events) { + const auto [start, end] = eventDayRange(event); + if (selectedKey >= start && selectedKey <= end) { + events.push_back(&event); + } + } + } + + if (events.empty()) { + content->addChild( + ui::label({ + .text = i18n::tr("control-center.calendar.no-events"), + .fontSize = Style::fontSizeBody * scale, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::OnSurfaceVariant), + .maxLines = 1, + }) + ); + return; + } + + const float dotWidth = Style::spaceXs * scale; + const float rowGap = Style::spaceSm * scale; + const float textWidth = std::max(40.0f, (kEventsWidth - Style::spaceXs * 2.0f) * scale - dotWidth - rowGap); + for (const CalendarEvent* event : events) { + std::string timeText; + if (event->allDay) { + timeText = i18n::tr("control-center.calendar.all-day"); + } else { + const std::time_t raw = std::chrono::system_clock::to_time_t(event->start); + const char* format = + m_config != nullptr ? m_config->config().controlCenter.calendarTab.eventTimeFormat.c_str() : "%H:%M"; + timeText = formatLocalUnixTime(static_cast(raw), format); + } + + auto details = ui::column( + {.align = FlexAlign::Start, .gap = Style::spaceXs * 0.5f * scale, .flexGrow = 1.0f}, + ui::label({ + .text = event->title.empty() ? i18n::tr("control-center.calendar.events") : event->title, + .fontSize = Style::fontSizeBody * scale, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::OnSurface), + .maxWidth = textWidth, + .maxLines = 3, + }), + ui::label({ + .text = timeText, + .fontSize = Style::fontSizeCaption * scale, + .fontFamily = m_fontFamily, + .color = colorSpecFromRole(ColorRole::OnSurfaceVariant), + .maxWidth = textWidth, + .maxLines = 1, + }) + ); + content->addChild( + ui::row( + {.align = FlexAlign::Stretch, .gap = rowGap}, + ui::box({ + .fill = eventColor(*event), + .radius = dotWidth * 0.5f, + .width = dotWidth, + .flexGrow = 0.0f, + }), + std::move(details) + ) + ); + } +} diff --git a/src/shell/desktop/widgets/desktop_calendar_widget.h b/src/shell/desktop/widgets/desktop_calendar_widget.h new file mode 100644 index 0000000000..4a91a21b2b --- /dev/null +++ b/src/shell/desktop/widgets/desktop_calendar_widget.h @@ -0,0 +1,69 @@ +#pragma once + +#include "shell/desktop/desktop_widget.h" + +#include +#include +#include + +class Button; +class CalendarService; +class ConfigService; +class Flex; +class InputArea; +class Label; +class Renderer; +class ScrollView; + +class DesktopCalendarWidget : public DesktopWidget { +public: + struct Options { + bool showEvents = true; + bool showWeekNumbers = false; + }; + + DesktopCalendarWidget(ConfigService* config, CalendarService* calendar, Options options); + ~DesktopCalendarWidget() override; + + void create() override; + bool applySetting( + const std::string& key, const WidgetSettingValue& value, + const std::unordered_map& allSettings, Renderer& renderer + ) override; + +private: + void doLayout(Renderer& renderer) override; + void doUpdate(Renderer& renderer) override; + void onFontFamilyChanged(const std::string& family, Renderer& renderer) override; + void changeMonthBy(int delta); + void focusToday(); + void rebuildCalendar(); + void rebuildEventList(); + void markDirty(); + + ConfigService* m_config = nullptr; + CalendarService* m_calendar = nullptr; + bool m_showEvents = true; + bool m_showWeekNumbers = false; + bool m_dirty = true; + + Flex* m_rootLayout = nullptr; + InputArea* m_calendarArea = nullptr; + Flex* m_calendarColumn = nullptr; + Flex* m_header = nullptr; + Label* m_todayLabel = nullptr; + Label* m_monthLabel = nullptr; + Button* m_previousButton = nullptr; + Button* m_nextButton = nullptr; + Flex* m_grid = nullptr; + Flex* m_eventsColumn = nullptr; + Label* m_eventsTitle = nullptr; + ScrollView* m_eventsScroll = nullptr; + + int m_selectedYear = std::numeric_limits::min(); + int m_selectedMonth = -1; + int m_selectedDay = -1; + int m_monthOffset = 0; + int m_lastTodayKey = -1; + std::uint64_t m_calendarCallbackId = 0; +}; diff --git a/tests/config_validate/generated-config b/tests/config_validate/generated-config index 76cc76743c..a35a1a61de 100644 --- a/tests/config_validate/generated-config +++ b/tests/config_validate/generated-config @@ -11,6 +11,16 @@ enabled = false capsule = true capsule_radius = "auto" +[desktop_widgets] +enabled = false + +[desktop_widgets.widget.calendar_main] +type = "calendar" + +[desktop_widgets.widget.calendar_main.settings] +show_events = true +show_week_numbers = true + [plugins] enabled = ["noctalia/wallhaven"]