diff --git a/Modules/Bar/Calendar/CalendarPanel.qml b/Modules/Bar/Calendar/CalendarPanel.qml index ef84a7be..2c2fba29 100644 --- a/Modules/Bar/Calendar/CalendarPanel.qml +++ b/Modules/Bar/Calendar/CalendarPanel.qml @@ -9,595 +9,719 @@ import qs.Services import qs.Widgets NPanel { - id: root + id: root - property ShellScreen screen - readonly property var now: Time.date + property ShellScreen screen + readonly property var now: Time.date - preferredWidth: (Settings.data.location.showWeekNumberInCalendar ? 400 : 380) * Style.uiScaleRatio - preferredHeight: (Settings.data.location.weatherEnabled && Settings.data.location.showCalendarWeather ? 590 : 380) * Style.uiScaleRatio - panelKeyboardFocus: true + panelKeyboardFocus: true - // Helper function to calculate ISO week number - function getISOWeekNumber(date) { - const target = new Date(date.valueOf()) - const dayNr = (date.getDay() + 6) % 7 - target.setDate(target.getDate() - dayNr + 3) - const firstThursday = new Date(target.getFullYear(), 0, 4) - const diff = target - firstThursday - const oneWeek = 1000 * 60 * 60 * 24 * 7 - const weekNumber = 1 + Math.round(diff / oneWeek) - return weekNumber - } + // Helper function to calculate ISO week number + function getISOWeekNumber(date) { + const target = new Date(date.valueOf()) + const dayNr = (date.getDay() + 6) % 7 + target.setDate(target.getDate() - dayNr + 3) + const firstThursday = new Date(target.getFullYear(), 0, 4) + const diff = target - firstThursday + const oneWeek = 1000 * 60 * 60 * 24 * 7 + const weekNumber = 1 + Math.round(diff / oneWeek) + return weekNumber + } - panelContent: Item { - anchors.fill: parent + panelContent: Item { + anchors.fill: parent - ColumnLayout { - id: content - anchors.fill: parent - anchors.margins: Style.marginL - width: root.preferredWidth - Style.marginL * 2 - spacing: Style.marginM + // Dynamic sizing properties that NPanel will bind to + property real contentPreferredWidth: (Settings.data.location.showWeekNumberInCalendar ? 400 : 380) * Style.uiScaleRatio + property real contentPreferredHeight: { + // Calculate number of weeks in the calendar grid + const numWeeks = grid.daysModel ? Math.ceil( + grid.daysModel.length / 7) : 5 - readonly property int firstDayOfWeek: Settings.data.location.firstDayOfWeek === -1 ? I18n.locale.firstDayOfWeek : Settings.data.location.firstDayOfWeek - property bool isCurrentMonth: true - readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null) + // Base heights + const bannerHeight = 90 * Style.uiScaleRatio + const navigationHeight = 50 * Style.uiScaleRatio + const dayNamesHeight = 35 * Style.uiScaleRatio + const margins = Style.marginL // * 2 - function checkIsCurrentMonth() { - return (Time.date.getMonth() === grid.month) && (Time.date.getFullYear() === grid.year) - } + // Calendar grid height (dynamic based on number of weeks) + const rowHeight = Style.baseWidgetSize * 0.9 + Style.marginXXS + const calendarGridHeight = numWeeks * rowHeight - Component.onCompleted: { - isCurrentMonth = checkIsCurrentMonth() - } + // Weather card height + const weatherHeight = (Settings.data.location.weatherEnabled + && Settings.data.location.showCalendarWeather ? 210 : 0) + * Style.uiScaleRatio - Shortcut { - sequence: "Escape" - onActivated: { - if (timerActive) { - cancelTimer() - } else { - cancelTimer() - root.close() - } + return bannerHeight + navigationHeight + dayNamesHeight + + calendarGridHeight + weatherHeight + margins } - context: Qt.WidgetShortcut - enabled: root.opened - } - - Connections { - target: Time - function onDateChanged() { - content.isCurrentMonth = content.checkIsCurrentMonth() - } - } - - Connections { - target: I18n - function onLanguageChanged() { - // Force update of day names when language changes - grid.month = grid.month - } - } - - // Banner with date/time/clock - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: capsuleColumn.implicitHeight + Style.marginS * 2 - Layout.bottomMargin: Style.marginM - radius: Style.radiusL - color: Color.mPrimary ColumnLayout { - id: capsuleColumn - anchors.top: parent.top - anchors.left: parent.left - anchors.bottom: parent.bottom + id: content + anchors.fill: parent + anchors.margins: Style.marginL + width: parent.contentPreferredWidth - Style.marginL * 2 + spacing: Style.marginM - anchors.topMargin: Style.marginS - anchors.bottomMargin: Style.marginS - anchors.rightMargin: clockLoader.width + (Style.marginL * 2) - anchors.leftMargin: Style.marginL + readonly property int firstDayOfWeek: Settings.data.location.firstDayOfWeek === -1 ? I18n.locale.firstDayOfWeek : Settings.data.location.firstDayOfWeek + property bool isCurrentMonth: true + readonly property bool weatherReady: Settings.data.location.weatherEnabled + && (LocationService.data.weather !== null) - spacing: 0 - - // Combined layout for weather icon, date, and weather text - RowLayout { - Layout.fillWidth: true - height: 60 * Style.uiScaleRatio - clip: true - spacing: Style.marginS - - // Today day number - with simple, stable animation - NText { - opacity: content.isCurrentMonth ? 1.0 : 0.0 - Layout.preferredWidth: content.isCurrentMonth ? implicitWidth : 0 - elide: Text.ElideNone - clip: true - - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - text: Time.date.getDate() - pointSize: Style.fontSizeXXXL * 1.5 - font.weight: Style.fontWeightBold - color: Color.mOnPrimary - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - } - } - Behavior on Layout.preferredWidth { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.InOutQuad - } - } + function checkIsCurrentMonth() { + return (Time.date.getMonth() === grid.month) + && (Time.date.getFullYear() === grid.year) } - // Month, year, location - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - Layout.bottomMargin: Style.marginXXS - Layout.topMargin: -Style.marginXXS - spacing: -Style.marginXS + Component.onCompleted: { + isCurrentMonth = checkIsCurrentMonth() + } - RowLayout { + Shortcut { + sequence: "Escape" + onActivated: { + if (timerActive) { + cancelTimer() + } else { + cancelTimer() + root.close() + } + } + context: Qt.WidgetShortcut + enabled: root.opened + } + + Connections { + target: Time + function onDateChanged() { + content.isCurrentMonth = content.checkIsCurrentMonth() + } + } + + Connections { + target: I18n + function onLanguageChanged() { + // Force update of day names when language changes + grid.month = grid.month + } + } + + // Banner with date/time/clock + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: capsuleColumn.implicitHeight + Style.marginS * 2 + Layout.bottomMargin: Style.marginM + radius: Style.radiusL + color: Color.mPrimary + + ColumnLayout { + id: capsuleColumn + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + + anchors.topMargin: Style.marginS + anchors.bottomMargin: Style.marginS + anchors.rightMargin: clockLoader.width + (Style.marginL * 2) + anchors.leftMargin: Style.marginL + + spacing: 0 + + // Combined layout for weather icon, date, and weather text + RowLayout { + Layout.fillWidth: true + height: 60 * Style.uiScaleRatio + clip: true + spacing: Style.marginS + + // Today day number - with simple, stable animation + NText { + opacity: content.isCurrentMonth ? 1.0 : 0.0 + Layout.preferredWidth: content.isCurrentMonth ? implicitWidth : 0 + elide: Text.ElideNone + clip: true + + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + text: Time.date.getDate() + pointSize: Style.fontSizeXXXL * 1.5 + font.weight: Style.fontWeightBold + color: Color.mOnPrimary + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + Behavior on Layout.preferredWidth { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.InOutQuad + } + } + } + + // Month, year, location + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Layout.bottomMargin: Style.marginXXS + Layout.topMargin: -Style.marginXXS + spacing: -Style.marginXS + + RowLayout { + spacing: Style.marginS + + NText { + text: I18n.locale.monthName( + grid.month, + Locale.LongFormat).toUpperCase() + pointSize: Style.fontSizeXL * 1.1 + font.weight: Style.fontWeightBold + color: Color.mOnPrimary + Layout.alignment: Qt.AlignBaseline + elide: Text.ElideRight + } + + NText { + text: `${grid.year}` + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Qt.alpha(Color.mOnPrimary, 0.7) + Layout.alignment: Qt.AlignBaseline + } + } + + RowLayout { + spacing: 0 + + NText { + text: { + if (!Settings.data.location.weatherEnabled) + return "" + if (!content.weatherReady) + return I18n.tr( + "calendar.weather.loading") + const chunks = Settings.data.location.name.split( + ",") + return chunks[0] + } + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Color.mOnPrimary + Layout.maximumWidth: 150 + elide: Text.ElideRight + } + + NText { + text: content.weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : "" + pointSize: Style.fontSizeXS + font.weight: Style.fontWeightMedium + color: Qt.alpha(Color.mOnPrimary, 0.7) + } + } + } + + // Spacer to push content left + Item { + Layout.fillWidth: true + } + } + } + + // Analog clock + ClockLoader { + id: clockLoader + anchors.right: parent.right + anchors.rightMargin: Style.marginM + anchors.verticalCenter: parent.verticalCenter + progressColor: Color.mOnPrimary + Layout.alignment: Qt.AlignVCenter + now: root.now + } + } + + RowLayout { + Layout.fillWidth: true spacing: Style.marginS - NText { - text: I18n.locale.monthName(grid.month, Locale.LongFormat).toUpperCase() - pointSize: Style.fontSizeXL * 1.1 - font.weight: Style.fontWeightBold - color: Color.mOnPrimary - Layout.alignment: Qt.AlignBaseline - elide: Text.ElideRight + NDivider { + Layout.fillWidth: true } - NText { - text: `${grid.year}` - pointSize: Style.fontSizeM - font.weight: Style.fontWeightBold - color: Qt.alpha(Color.mOnPrimary, 0.7) - Layout.alignment: Qt.AlignBaseline - } - } + NIconButton { + icon: "chevron-left" + onClicked: { + let newDate = new Date(grid.year, grid.month - 1, 1) + grid.year = newDate.getFullYear() + grid.month = newDate.getMonth() + content.isCurrentMonth = content.checkIsCurrentMonth() + const now = new Date() + const monthStart = new Date(grid.year, grid.month, 1) + const monthEnd = new Date(grid.year, grid.month + 1, 0) - RowLayout { + const daysBehind = Math.max( + 0, Math.ceil( + (now - monthStart) / (24 * 60 * 60 * 1000))) + const daysAhead = Math.max( + 0, Math.ceil( + (monthEnd - now) / (24 * 60 * 60 * 1000))) + + CalendarService.loadEvents(daysAhead + 30, + daysBehind + 30) + } + } + + NIconButton { + icon: "calendar" + onClicked: { + grid.month = Time.date.getMonth() + grid.year = Time.date.getFullYear() + content.isCurrentMonth = true + CalendarService.loadEvents() + } + } + + NIconButton { + icon: "chevron-right" + onClicked: { + let newDate = new Date(grid.year, grid.month + 1, 1) + grid.year = newDate.getFullYear() + grid.month = newDate.getMonth() + content.isCurrentMonth = content.checkIsCurrentMonth() + const now = new Date() + const monthStart = new Date(grid.year, grid.month, 1) + const monthEnd = new Date(grid.year, grid.month + 1, 0) + + const daysBehind = Math.max( + 0, Math.ceil( + (now - monthStart) / (24 * 60 * 60 * 1000))) + const daysAhead = Math.max( + 0, Math.ceil( + (monthEnd - now) / (24 * 60 * 60 * 1000))) + + CalendarService.loadEvents(daysAhead + 30, + daysBehind + 30) + } + } + } + + RowLayout { + Layout.fillWidth: true spacing: 0 - NText { - text: { - if (!Settings.data.location.weatherEnabled) - return "" - if (!content.weatherReady) - return I18n.tr("calendar.weather.loading") - const chunks = Settings.data.location.name.split(",") - return chunks[0] - } - pointSize: Style.fontSizeM - font.weight: Style.fontWeightMedium - color: Color.mOnPrimary - Layout.maximumWidth: 150 - elide: Text.ElideRight + Item { + visible: Settings.data.location.showWeekNumberInCalendar + Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 } - NText { - text: content.weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : "" - pointSize: Style.fontSizeXS - font.weight: Style.fontWeightMedium - color: Qt.alpha(Color.mOnPrimary, 0.7) - } - } - } - - // Spacer to push content left - Item { - Layout.fillWidth: true - } - } - } - - // Analog clock - ClockLoader { - id: clockLoader - anchors.right: parent.right - anchors.rightMargin: Style.marginM - anchors.verticalCenter: parent.verticalCenter - progressColor: Color.mOnPrimary - Layout.alignment: Qt.AlignVCenter - now: root.now - } - } - - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - NDivider { - Layout.fillWidth: true - } - - NIconButton { - icon: "chevron-left" - onClicked: { - let newDate = new Date(grid.year, grid.month - 1, 1) - grid.year = newDate.getFullYear() - grid.month = newDate.getMonth() - content.isCurrentMonth = content.checkIsCurrentMonth() - const now = new Date() - const monthStart = new Date(grid.year, grid.month, 1) - const monthEnd = new Date(grid.year, grid.month + 1, 0) - - const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))) - const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))) - - CalendarService.loadEvents(daysAhead + 30, daysBehind + 30) - } - } - - NIconButton { - icon: "calendar" - onClicked: { - grid.month = Time.date.getMonth() - grid.year = Time.date.getFullYear() - content.isCurrentMonth = true - CalendarService.loadEvents() - } - } - - NIconButton { - icon: "chevron-right" - onClicked: { - let newDate = new Date(grid.year, grid.month + 1, 1) - grid.year = newDate.getFullYear() - grid.month = newDate.getMonth() - content.isCurrentMonth = content.checkIsCurrentMonth() - const now = new Date() - const monthStart = new Date(grid.year, grid.month, 1) - const monthEnd = new Date(grid.year, grid.month + 1, 0) - - const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))) - const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))) - - CalendarService.loadEvents(daysAhead + 30, daysBehind + 30) - } - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - - Item { - visible: Settings.data.location.showWeekNumberInCalendar - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 - } - - GridLayout { - Layout.fillWidth: true - columns: 7 - rows: 1 - columnSpacing: 0 - rowSpacing: 0 - Repeater { - model: 7 - Item { - Layout.fillWidth: true - Layout.preferredHeight: Style.baseWidgetSize * 0.6 - NText { - anchors.centerIn: parent - text: { - let dayIndex = (content.firstDayOfWeek + index) % 7 - const dayName = I18n.locale.dayName(dayIndex, Locale.ShortFormat) - return dayName.substring(0, 2).toUpperCase() - } - color: Color.mPrimary - pointSize: Style.fontSizeS - font.weight: Style.fontWeightBold - horizontalAlignment: Text.AlignHCenter - } - } - } - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - - // Helper function to check if a date has events - function hasEventsOnDate(year, month, day) { - if (!CalendarService.available || CalendarService.events.length === 0) - return false - - const targetDate = new Date(year, month, day) - const targetStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000 - const targetEnd = targetStart + 86400 // +24 hours - - return CalendarService.events.some(event => { - // Check if event starts or overlaps with this day - return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd) - }) - } - - // Helper function to get events for a specific date - function getEventsForDate(year, month, day) { - if (!CalendarService.available || CalendarService.events.length === 0) - return [] - - const targetDate = new Date(year, month, day) - const targetStart = Math.floor(new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000) - const targetEnd = targetStart + 86400 // +24 hours - - return CalendarService.events.filter(event => { - return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd) - }) - } - - // Helper function to check if an event is all-day - function isAllDayEvent(event) { - const duration = event.end - event.start - const startDate = new Date(event.start * 1000) - const isAtMidnight = startDate.getHours() === 0 && startDate.getMinutes() === 0 - return duration === 86400 && isAtMidnight - } - - // Helper function to check if an event is multi-day - function isMultiDayEvent(event) { - if (isAllDayEvent(event)) { - return false - } - - const startDate = new Date(event.start * 1000) - const endDate = new Date(event.end * 1000) - - const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()) - const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()) - - return startDateOnly.getTime() !== endDateOnly.getTime() - } - - // Helper function to get color for a specific event - function getEventColor(event, isToday) { - if (isMultiDayEvent(event)) { - return isToday ? Color.mOnSecondary : Color.mTertiary - } else if (isAllDayEvent(event)) { - return isToday ? Color.mOnSecondary : Color.mSecondary - } else { - return isToday ? Color.mOnSecondary : Color.mPrimary - } - } - - // Column of week numbers - ColumnLayout { - visible: Settings.data.location.showWeekNumberInCalendar - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 - spacing: Style.marginXXS - - property var weekNumbers: { - if (!grid.daysModel || grid.daysModel.length === 0) - return [] - - const weeks = [] - const numWeeks = Math.ceil(grid.daysModel.length / 7) - - for (var i = 0; i < numWeeks; i++) { - const dayIndex = i * 7 - if (dayIndex < grid.daysModel.length) { - const weekDay = grid.daysModel[dayIndex] - const date = new Date(weekDay.year, weekDay.month, weekDay.day) - - // Get Thursday of this week for ISO week calculation - const firstDayOfWeek = content.firstDayOfWeek - let thursday = new Date(date) - if (firstDayOfWeek === 0) { - thursday.setDate(date.getDate() + 4) - } else if (firstDayOfWeek === 1) { - thursday.setDate(date.getDate() + 3) - } else { - let daysToThursday = (4 - firstDayOfWeek + 7) % 7 - thursday.setDate(date.getDate() + daysToThursday) - } - - weeks.push(root.getISOWeekNumber(thursday)) - } - } - return weeks - } - - Repeater { - model: parent.weekNumbers - Item { - Layout.preferredWidth: Style.baseWidgetSize * 0.7 - Layout.preferredHeight: Style.baseWidgetSize * 0.9 - NText { - anchors.centerIn: parent - color: Color.mOutline - pointSize: Style.fontSizeXXS - font.weight: Style.fontWeightMedium - text: modelData - } - } - } - } - - GridLayout { - id: grid - Layout.fillWidth: true - columns: 7 - columnSpacing: Style.marginXXS - rowSpacing: Style.marginXXS - - property int month: Time.date.getMonth() - property int year: Time.date.getFullYear() - - // Calculate days to display - property var daysModel: { - const firstOfMonth = new Date(year, month, 1) - const lastOfMonth = new Date(year, month + 1, 0) - const daysInMonth = lastOfMonth.getDate() - - // Get first day of week (0 = Sunday, 1 = Monday, etc.) - const firstDayOfWeek = content.firstDayOfWeek - const firstOfMonthDayOfWeek = firstOfMonth.getDay() - - // Calculate days before first of month - let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7 - - // Calculate days after last of month to complete the week - const lastOfMonthDayOfWeek = lastOfMonth.getDay() - const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7 - - // Build array of day objects - const days = [] - const today = new Date() - - // Previous month days - const prevMonth = new Date(year, month, 0) - const prevMonthDays = prevMonth.getDate() - for (var i = daysBefore - 1; i >= 0; i--) { - const day = prevMonthDays - i - const date = new Date(year, month - 1, day) - days.push({ - "day": day, - "month": month - 1, - "year": month === 0 ? year - 1 : year, - "today": false, - "currentMonth": false - }) - } - - // Current month days - for (var day = 1; day <= daysInMonth; day++) { - const date = new Date(year, month, day) - const isToday = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate() - days.push({ - "day": day, - "month": month, - "year": year, - "today": isToday, - "currentMonth": true - }) - } - - // Next month days (only if needed to complete the week) - for (var i = 1; i <= daysAfter; i++) { - days.push({ - "day": i, - "month": month + 1, - "year": month === 11 ? year + 1 : year, - "today": false, - "currentMonth": false - }) - } - - return days - } - - Repeater { - model: grid.daysModel - - Item { - Layout.fillWidth: true - Layout.preferredHeight: Style.baseWidgetSize * 0.9 - - Rectangle { - width: Style.baseWidgetSize * 0.9 - height: Style.baseWidgetSize * 0.9 - anchors.centerIn: parent - radius: Style.radiusM - color: modelData.today ? Color.mSecondary : Color.transparent - - NText { - anchors.centerIn: parent - text: modelData.day - color: { - if (modelData.today) - return Color.mOnSecondary - if (modelData.currentMonth) - return Color.mOnSurface - return Color.mOnSurfaceVariant - } - opacity: modelData.currentMonth ? 1.0 : 0.4 - pointSize: Style.fontSizeM - font.weight: modelData.today ? Style.fontWeightBold : Style.fontWeightMedium - } - - // Event indicator dots - Row { - visible: Settings.data.location.showCalendarEvents && parent.parent.parent.parent.hasEventsOnDate(modelData.year, modelData.month, modelData.day) - spacing: 2 - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: Style.marginXS - - Repeater { - model: parent.parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day) - - Rectangle { - width: 4 - height: width - radius: width / 2 - color: parent.parent.parent.parent.parent.getEventColor(modelData, modelData.today) + GridLayout { + Layout.fillWidth: true + columns: 7 + rows: 1 + columnSpacing: 0 + rowSpacing: 0 + Repeater { + model: 7 + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.baseWidgetSize * 0.6 + NText { + anchors.centerIn: parent + text: { + let dayIndex = (content.firstDayOfWeek + index) % 7 + const dayName = I18n.locale.dayName( + dayIndex, + Locale.ShortFormat) + return dayName.substring(0, 2).toUpperCase() + } + color: Color.mPrimary + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + horizontalAlignment: Text.AlignHCenter + } + } } - } } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - enabled: Settings.data.location.showCalendarEvents - - onEntered: { - const events = parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day) - if (events.length > 0) { - const summaries = events.map(e => e.summary).join('\n') - TooltipService.show(Screen, parent, summaries) - TooltipService.updateText(summaries) - } - } - - onClicked: { - const dateWithSlashes = `${(modelData.month + 1).toString().padStart(2, '0')}/${modelData.day.toString().padStart(2, '0')}/${modelData.year.toString().substring(2)}` - Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]) - PanelService.getPanel("calendarPanel").toggle(null) - } - - onExited: { - TooltipService.hide() - } - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - } - } } - } + + RowLayout { + Layout.fillWidth: true + spacing: 0 + + // Helper function to check if a date has events + function hasEventsOnDate(year, month, day) { + if (!CalendarService.available + || CalendarService.events.length === 0) + return false + + const targetDate = new Date(year, month, day) + const targetStart = new Date(targetDate.getFullYear(), + targetDate.getMonth(), + targetDate.getDate( + )).getTime() / 1000 + const targetEnd = targetStart + 86400 // +24 hours + + return CalendarService.events.some(event => { + // Check if event starts or overlaps with this day + return (event.start >= targetStart + && event.start < targetEnd) + || (event.end > targetStart + && event.end <= targetEnd) + || (event.start < targetStart + && event.end > targetEnd) + }) + } + + // Helper function to get events for a specific date + function getEventsForDate(year, month, day) { + if (!CalendarService.available + || CalendarService.events.length === 0) + return [] + + const targetDate = new Date(year, month, day) + const targetStart = Math.floor( + new Date(targetDate.getFullYear(), + targetDate.getMonth(), + targetDate.getDate( + )).getTime() / 1000) + const targetEnd = targetStart + 86400 // +24 hours + + return CalendarService.events.filter(event => { + return (event.start >= targetStart + && event.start < targetEnd) + || (event.end > targetStart + && event.end <= targetEnd) + || (event.start < targetStart + && event.end > targetEnd) + }) + } + + // Helper function to check if an event is all-day + function isAllDayEvent(event) { + const duration = event.end - event.start + const startDate = new Date(event.start * 1000) + const isAtMidnight = startDate.getHours() === 0 + && startDate.getMinutes() === 0 + return duration === 86400 && isAtMidnight + } + + // Helper function to check if an event is multi-day + function isMultiDayEvent(event) { + if (isAllDayEvent(event)) { + return false + } + + const startDate = new Date(event.start * 1000) + const endDate = new Date(event.end * 1000) + + const startDateOnly = new Date(startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate()) + const endDateOnly = new Date(endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate()) + + return startDateOnly.getTime() !== endDateOnly.getTime() + } + + // Helper function to get color for a specific event + function getEventColor(event, isToday) { + if (isMultiDayEvent(event)) { + return isToday ? Color.mOnSecondary : Color.mTertiary + } else if (isAllDayEvent(event)) { + return isToday ? Color.mOnSecondary : Color.mSecondary + } else { + return isToday ? Color.mOnSecondary : Color.mPrimary + } + } + + // Column of week numbers + ColumnLayout { + visible: Settings.data.location.showWeekNumberInCalendar + Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 + Layout.preferredHeight: { + const numWeeks = weekNumbers ? weekNumbers.length : 5 + const rowHeight = Style.baseWidgetSize * 0.9 + Style.marginXXS + return numWeeks * rowHeight + } + spacing: Style.marginXXS + + Behavior on Layout.preferredHeight { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } + + property var weekNumbers: { + if (!grid.daysModel || grid.daysModel.length === 0) + return [] + + const weeks = [] + const numWeeks = Math.ceil(grid.daysModel.length / 7) + + for (var i = 0; i < numWeeks; i++) { + const dayIndex = i * 7 + if (dayIndex < grid.daysModel.length) { + const weekDay = grid.daysModel[dayIndex] + const date = new Date(weekDay.year, + weekDay.month, + weekDay.day) + + // Get Thursday of this week for ISO week calculation + const firstDayOfWeek = content.firstDayOfWeek + let thursday = new Date(date) + if (firstDayOfWeek === 0) { + thursday.setDate(date.getDate() + 4) + } else if (firstDayOfWeek === 1) { + thursday.setDate(date.getDate() + 3) + } else { + let daysToThursday = (4 - firstDayOfWeek + 7) % 7 + thursday.setDate(date.getDate( + ) + daysToThursday) + } + + weeks.push(root.getISOWeekNumber(thursday)) + } + } + return weeks + } + + Repeater { + model: parent.weekNumbers + Item { + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.preferredHeight: Style.baseWidgetSize * 0.9 + NText { + anchors.centerIn: parent + color: Color.mOutline + pointSize: Style.fontSizeXXS + font.weight: Style.fontWeightMedium + text: modelData + } + } + } + } + + GridLayout { + id: grid + Layout.fillWidth: true + Layout.preferredHeight: { + const numWeeks = daysModel ? Math.ceil( + daysModel.length / 7) : 5 + const rowHeight = Style.baseWidgetSize * 0.9 + Style.marginXXS + return numWeeks * rowHeight + } + columns: 7 + columnSpacing: Style.marginXXS + rowSpacing: Style.marginXXS + + property int month: Time.date.getMonth() + property int year: Time.date.getFullYear() + + Behavior on Layout.preferredHeight { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } + + // Calculate days to display + property var daysModel: { + const firstOfMonth = new Date(year, month, 1) + const lastOfMonth = new Date(year, month + 1, 0) + const daysInMonth = lastOfMonth.getDate() + + // Get first day of week (0 = Sunday, 1 = Monday, etc.) + const firstDayOfWeek = content.firstDayOfWeek + const firstOfMonthDayOfWeek = firstOfMonth.getDay() + + // Calculate days before first of month + let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7 + + // Calculate days after last of month to complete the week + const lastOfMonthDayOfWeek = lastOfMonth.getDay() + const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7 + + // Build array of day objects + const days = [] + const today = new Date() + + // Previous month days + const prevMonth = new Date(year, month, 0) + const prevMonthDays = prevMonth.getDate() + for (var i = daysBefore - 1; i >= 0; i--) { + const day = prevMonthDays - i + const date = new Date(year, month - 1, day) + days.push({ + "day": day, + "month": month - 1, + "year": month === 0 ? year - 1 : year, + "today": false, + "currentMonth": false + }) + } + + // Current month days + for (var day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day) + const isToday = date.getFullYear( + ) === today.getFullYear() + && date.getMonth() === today.getMonth( + ) && date.getDate( + ) === today.getDate() + days.push({ + "day": day, + "month": month, + "year": year, + "today": isToday, + "currentMonth": true + }) + } + + // Next month days (only if needed to complete the week) + for (var i = 1; i <= daysAfter; i++) { + days.push({ + "day": i, + "month": month + 1, + "year": month === 11 ? year + 1 : year, + "today": false, + "currentMonth": false + }) + } + + return days + } + + Repeater { + model: grid.daysModel + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.baseWidgetSize * 0.9 + + Rectangle { + width: Style.baseWidgetSize * 0.9 + height: Style.baseWidgetSize * 0.9 + anchors.centerIn: parent + radius: Style.radiusM + color: modelData.today ? Color.mSecondary : Color.transparent + + NText { + anchors.centerIn: parent + text: modelData.day + color: { + if (modelData.today) + return Color.mOnSecondary + if (modelData.currentMonth) + return Color.mOnSurface + return Color.mOnSurfaceVariant + } + opacity: modelData.currentMonth ? 1.0 : 0.4 + pointSize: Style.fontSizeM + font.weight: modelData.today ? Style.fontWeightBold : Style.fontWeightMedium + } + + // Event indicator dots + Row { + visible: Settings.data.location.showCalendarEvents + && parent.parent.parent.parent.hasEventsOnDate( + modelData.year, + modelData.month, modelData.day) + spacing: 2 + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.marginXS + + Repeater { + model: parent.parent.parent.parent.parent.getEventsForDate( + modelData.year, + modelData.month, + modelData.day) + + Rectangle { + width: 4 + height: width + radius: width / 2 + color: parent.parent.parent.parent.parent.getEventColor( + modelData, + modelData.today) + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + enabled: Settings.data.location.showCalendarEvents + + onEntered: { + const events = parent.parent.parent.parent.getEventsForDate( + modelData.year, + modelData.month, + modelData.day) + if (events.length > 0) { + const summaries = events.map( + e => e.summary).join( + '\n') + TooltipService.show(Screen, parent, + summaries) + TooltipService.updateText(summaries) + } + } + + onClicked: { + const dateWithSlashes = `${(modelData.month + 1).toString( + ).padStart( + 2, + '0')}/${modelData.day.toString( + ).padStart( + 2, + '0')}/${modelData.year.toString( + ).substring( + 2)}` + Quickshell.execDetached( + ["gnome-calendar", "--date", dateWithSlashes]) + PanelService.getPanel( + "calendarPanel").toggle( + null) + } + + onExited: { + TooltipService.hide() + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + } + } + } + } + + // Spacer to push weather card to bottom when calendar has fewer weeks + Item { + Layout.fillHeight: true + Layout.minimumHeight: 0 + } + + Loader { + id: weatherLoader + active: Settings.data.location.weatherEnabled + && Settings.data.location.showCalendarWeather + Layout.fillWidth: true + + sourceComponent: WeatherCard { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + forecastDays: 6 + } + } } - } - - // Spacer to push weather card to bottom when calendar has fewer weeks - Item { - Layout.fillHeight: true - Layout.minimumHeight: 0 - } - - Loader { - id: weatherLoader - active: Settings.data.location.weatherEnabled && Settings.data.location.showCalendarWeather - Layout.fillWidth: true - - sourceComponent: WeatherCard { - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - forecastDays: 6 - } - } } - } } diff --git a/Widgets/NPanel.qml b/Widgets/NPanel.qml index ee7c4b35..def4d0d8 100644 --- a/Widgets/NPanel.qml +++ b/Widgets/NPanel.qml @@ -5,576 +5,673 @@ import qs.Commons import qs.Services Loader { - id: root + id: root - property ShellScreen screen + property ShellScreen screen - property bool attachedToBar: Settings.data.ui.panelsAttachedToBar - property bool useOverlay: Settings.data.ui.panelsOverlayLayer + property bool attachedToBar: Settings.data.ui.panelsAttachedToBar + property bool useOverlay: Settings.data.ui.panelsOverlayLayer - property Component panelContent: null - property real preferredWidth: 700 - property real preferredHeight: 900 - property real preferredWidthRatio - property real preferredHeightRatio - property color panelBackgroundColor: Color.mSurface - property color panelBorderColor: Color.mOutline - property bool draggable: false - property var buttonItem: null - property string buttonName: "" + property Component panelContent: null - property bool panelAnchorHorizontalCenter: false - property bool panelAnchorVerticalCenter: false - property bool panelAnchorTop: false - property bool panelAnchorBottom: false - property bool panelAnchorLeft: false - property bool panelAnchorRight: false + // Panel size properties. Can be set directly on NPanel, or dynamically by the content. + // For dynamic sizing, the content should expose contentPreferredWidth, contentPreferredHeight, + // contentPreferredWidthRatio, or contentPreferredHeightRatio properties. + // Changes to these properties will be animated smoothly (except during panel dragging). + property real preferredWidth: 700 + property real preferredHeight: 900 + property real preferredWidthRatio + property real preferredHeightRatio + property color panelBackgroundColor: Color.mSurface + property color panelBorderColor: Color.mOutline + property bool draggable: false + property var buttonItem: null + property string buttonName: "" - // Properties to support positioning relative to the opener (button) - property bool useButtonPosition: false - property point buttonPosition: Qt.point(0, 0) - property int buttonWidth: 0 - property int buttonHeight: 0 + property bool panelAnchorHorizontalCenter: false + property bool panelAnchorVerticalCenter: false + property bool panelAnchorTop: false + property bool panelAnchorBottom: false + property bool panelAnchorLeft: false + property bool panelAnchorRight: false - property bool panelKeyboardFocus: false - property bool backgroundClickEnabled: true + // Properties to support positioning relative to the opener (button) + property bool useButtonPosition: false + property point buttonPosition: Qt.point(0, 0) + property int buttonWidth: 0 + property int buttonHeight: 0 - // Animation properties - property real panelBackgroundOpacity: 0 - property real panelContentOpacity: 0 - property real dimmingOpacity: 0 + property bool panelKeyboardFocus: false + property bool backgroundClickEnabled: true - readonly property string barPosition: Settings.data.bar.position - readonly property bool barIsVertical: barPosition === "left" || barPosition === "right" - readonly property real verticalBarWidth: Style.barHeight + // Animation properties + property real panelBackgroundOpacity: 0 + property real panelContentOpacity: 0 + property real dimmingOpacity: 0 - // Effective anchor properties - combines explicit anchors with implicit anchoring from useButtonPosition - readonly property bool effectivePanelAnchorTop: panelAnchorTop || (useButtonPosition && barPosition === "top") - readonly property bool effectivePanelAnchorBottom: panelAnchorBottom || (useButtonPosition && barPosition === "bottom") - readonly property bool effectivePanelAnchorLeft: panelAnchorLeft || (useButtonPosition && barPosition === "left") - readonly property bool effectivePanelAnchorRight: panelAnchorRight || (useButtonPosition && barPosition === "right") + readonly property string barPosition: Settings.data.bar.position + readonly property bool barIsVertical: barPosition === "left" + || barPosition === "right" + readonly property real verticalBarWidth: Style.barHeight - signal opened - signal closed + // Effective anchor properties - combines explicit anchors with implicit anchoring from useButtonPosition + readonly property bool effectivePanelAnchorTop: panelAnchorTop + || (useButtonPosition + && barPosition === "top") + readonly property bool effectivePanelAnchorBottom: panelAnchorBottom + || (useButtonPosition + && barPosition === "bottom") + readonly property bool effectivePanelAnchorLeft: panelAnchorLeft + || (useButtonPosition + && barPosition === "left") + readonly property bool effectivePanelAnchorRight: panelAnchorRight + || (useButtonPosition + && barPosition === "right") - active: false - asynchronous: true + signal opened + signal closed - Component.onCompleted: { - PanelService.registerPanel(root) - } + active: false + asynchronous: true - // ----------------------------------------- - // Functions to control background click behavior - function disableBackgroundClick() { - backgroundClickEnabled = false - } - - function enableBackgroundClick() { - // Add a small delay to prevent immediate close after drag release - enableBackgroundClickTimer.restart() - } - - Timer { - id: enableBackgroundClickTimer - interval: 100 - repeat: false - onTriggered: backgroundClickEnabled = true - } - - // ----------------------------------------- - function toggle(buttonItem, buttonName) { - if (!active) { - open(buttonItem, buttonName) - } else { - close() - } - } - - // ----------------------------------------- - function open(buttonItem, buttonName) { - root.buttonItem = buttonItem - root.buttonName = buttonName || "" - - setPosition() - - PanelService.willOpenPanel(root) - - backgroundClickEnabled = true - active = true - root.opened() - } - - // ----------------------------------------- - function close() { - dimmingOpacity = 0 - panelBackgroundOpacity = 0 - panelContentOpacity = 0 - root.closed() - active = false - useButtonPosition = false - backgroundClickEnabled = true - PanelService.closedPanel(root) - } - - // ----------------------------------------- - function setPosition() { - // If we have a button name, we are landing here from an IPC call. - // IPC calls have no idead on which screen they panel will spawn. - // Resolve the button name to a proper button item now that we have a screen. - if (buttonName !== "" && root.screen !== null) { - buttonItem = BarService.lookupWidget(buttonName, root.screen.name) + Component.onCompleted: { + PanelService.registerPanel(root) } - // Get the button position if provided - if (buttonItem !== undefined && buttonItem !== null) { - useButtonPosition = true - var itemPos = buttonItem.mapToItem(null, 0, 0) - buttonPosition = Qt.point(itemPos.x, itemPos.y) - buttonWidth = buttonItem.width - buttonHeight = buttonItem.height - } else { - useButtonPosition = false + // ----------------------------------------- + // Functions to control background click behavior + function disableBackgroundClick() { + backgroundClickEnabled = false } - } - // ----------------------------------------- - sourceComponent: Component { - // PanelWindow has its own screen property inherited of QsWindow - PanelWindow { - id: panelWindow - - readonly property bool barIsVisible: (screen !== null) && (Settings.data.bar.monitors.includes(screen.name) || (Settings.data.bar.monitors.length === 0)) - - Component.onCompleted: { - Logger.d("NPanel", "Opened", root.objectName, "on", screen.name) - dimmingOpacity = Style.opacityHeavy - } - - Connections { - target: panelWindow - function onScreenChanged() { - root.screen = screen - - // If called from IPC always reposition if screen is updated - if (buttonName) { - setPosition() - } - Logger.d("NPanel", "OnScreenChanged", root.screen.name) - } - } - - visible: true - color: Settings.data.general.dimDesktop ? Qt.alpha(Color.mShadow, dimmingOpacity) : Color.transparent - - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "noctalia-panel" - WlrLayershell.layer: useOverlay ? WlrLayer.Overlay : WlrLayer.Top - WlrLayershell.keyboardFocus: root.panelKeyboardFocus ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - Region { - id: maskRegion - } - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - } - } - - anchors.top: true - anchors.left: true - anchors.right: true - anchors.bottom: true - - // Close any panel with Esc without requiring focus - Shortcut { - sequences: ["Escape"] - enabled: root.active - onActivated: root.close() - context: Qt.WindowShortcut - } - - // Clicking outside of the rectangle to close - MouseArea { - anchors.fill: parent - enabled: root.backgroundClickEnabled - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onClicked: root.close() - } - - // The actual panel's content - NRectangleCurved { - id: panelBackground - - backgroundColor: panelBackgroundColor - - topLeftRadius: Style.radiusL - topRightRadius: Style.radiusL - bottomLeftRadius: Style.radiusL - bottomRightRadius: Style.radiusL - - // Set inverted corners based on panel anchors and bar position - - // Top-left corner - topLeftInverted: { - if (!attachedToBar) - return false - - // Inverted if panel is anchored to top edge (bar is at top) - if (effectivePanelAnchorTop) - return true - // Or if panel is anchored to left edge (bar is at left) - if (effectivePanelAnchorLeft) - return true - return false - } - topLeftInvertedDirection: effectivePanelAnchorTop ? "horizontal" : "vertical" - - // Top-right corner - topRightInverted: { - if (!attachedToBar) - return false - - // Inverted if panel is anchored to top edge (bar is at top) - if (effectivePanelAnchorTop) - return true - // Or if panel is anchored to right edge (bar is at right) - if (effectivePanelAnchorRight) - return true - return false - } - topRightInvertedDirection: effectivePanelAnchorTop ? "horizontal" : "vertical" - - // Bottom-left corner - bottomLeftInverted: { - if (!attachedToBar) - return false - - // Inverted if panel is anchored to bottom edge (bar is at bottom) - if (effectivePanelAnchorBottom) - return true - // Or if panel is anchored to left edge (bar is at left) - if (effectivePanelAnchorLeft) - return true - return false - } - bottomLeftInvertedDirection: effectivePanelAnchorBottom ? "horizontal" : "vertical" - - // Bottom-right corner - bottomRightInverted: { - if (!attachedToBar) - return false - - // Inverted if panel is anchored to bottom edge (bar is at bottom) - if (effectivePanelAnchorBottom) - return true - // Or if panel is anchored to right edge (bar is at right) - if (effectivePanelAnchorRight) - return true - return false - } - bottomRightInvertedDirection: effectivePanelAnchorBottom ? "horizontal" : "vertical" - - // Dragging support - property bool draggable: root.draggable - property bool isDragged: false - property real manualX: 0 - property real manualY: 0 - width: { - var w - if (preferredWidthRatio !== undefined) { - w = Math.round(Math.max(screen?.width * preferredWidthRatio, preferredWidth)) - } else { - w = preferredWidth - } - // Clamp width so it is never bigger than the screen - return Math.min(w, screen?.width - Style.marginL * 2) - } - height: { - var h - if (preferredHeightRatio !== undefined) { - h = Math.round(Math.max(screen?.height * preferredHeightRatio, preferredHeight)) - } else { - h = preferredHeight - } - - // Clamp width so it is never bigger than the screen - return Math.min(h, screen?.height - Style.barHeight - Style.marginL * 2) - } - - opacity: root.panelBackgroundOpacity - x: isDragged ? manualX : calculatedX - y: isDragged ? manualY : calculatedY - - // --------------------------------------------- - // Does not account for corners are they are negligible and helps keep the code clean. - // --------------------------------------------- - property real marginTop: { - if (!barIsVisible) { - return 0 - } - - switch (barPosition) { - case "top": - return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL : 0) - default: - return attachedToBar ? 0 : Style.marginS - } - } - - property real marginBottom: { - if (!barIsVisible) { - return 0 - } - switch (barPosition) { - case "bottom": - return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL : 0) - default: - return attachedToBar ? 0 : Style.marginS - } - } - - property real marginLeft: { - if (!barIsVisible) { - return 0 - } - switch (barPosition) { - case "left": - return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0) - default: - return attachedToBar ? 0 : Style.marginS - } - } - - property real marginRight: { - if (!barIsVisible) { - return 0 - } - switch (barPosition) { - case "right": - return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0) - default: - return attachedToBar ? 0 : Style.marginS - } - } - - // --------------------------------------------- - property int calculatedX: { - // Priority to fixed anchoring - if (panelAnchorHorizontalCenter) { - // Center horizontally but respect bar margins - var centerX = Math.round((panelWindow.width - panelBackground.width) / 2) - var minX = marginLeft - var maxX = panelWindow.width - panelBackground.width - marginRight - return Math.round(Math.max(minX, Math.min(centerX, maxX))) - } else if (panelAnchorLeft) { - return marginLeft - } else if (panelAnchorRight) { - return Math.round(panelWindow.width - panelBackground.width - marginRight) - } - - // No fixed anchoring - if (barIsVertical) { - // Vertical bar - if (barPosition === "right") { - // To the left of the right bar - return Math.round(panelWindow.width - panelBackground.width - marginRight) - } else { - // To the right of the left bar - return marginLeft - } - } else { - // Horizontal bar - if (root.useButtonPosition) { - // Position panel relative to button - var targetX = buttonPosition.x + (buttonWidth / 2) - (panelBackground.width / 2) - // Keep panel within screen bounds - var maxX = panelWindow.width - panelBackground.width - marginRight - var minX = marginLeft - return Math.round(Math.max(minX, Math.min(targetX, maxX))) - } else { - // Fallback to center horizontally - return Math.round((panelWindow.width - panelBackground.width) / 2) - } - } - } - - // --------------------------------------------- - property int calculatedY: { - // Priority to fixed anchoring - if (panelAnchorVerticalCenter) { - // Center vertically but respect bar margins - var centerY = Math.round((panelWindow.height - panelBackground.height) / 2) - var minY = marginTop - var maxY = panelWindow.height - panelBackground.height - marginBottom - return Math.round(Math.max(minY, Math.min(centerY, maxY))) - } else if (panelAnchorTop) { - return marginTop - } else if (panelAnchorBottom) { - return Math.round(panelWindow.height - panelBackground.height - marginBottom) - } - - // No fixed anchoring - if (barIsVertical) { - // Vertical bar - if (useButtonPosition) { - // Position panel relative to button - var targetY = buttonPosition.y + (buttonHeight / 2) - (panelBackground.height / 2) - // Keep panel within screen bounds - var maxY = panelWindow.height - panelBackground.height - marginBottom - var minY = marginTop - return Math.round(Math.max(minY, Math.min(targetY, maxY))) - } else { - // Fallback to center vertically - return Math.round((panelWindow.height - panelBackground.height) / 2) - } - } else { - // Horizontal bar - if (barPosition === "bottom") { - // Above the bottom bar - return Math.round(panelWindow.height - panelBackground.height - marginBottom) - } else { - // Below the top bar - return marginTop - } - } - } - - // Animate in when component is completed - Component.onCompleted: { - // Start invisible - // Use a timer to delay the animation start, allowing QML to properly set up initial state - fadeInTimer.start() - } - - Timer { - id: fadeInTimer - interval: 1 - repeat: false - onTriggered: { - // Fade in background - root.panelBackgroundOpacity = 1.0 - } - } - - // Timer to fade in content after slide animation completes - Timer { - id: contentFadeInTimer - interval: Style.animationFast - repeat: false - running: true - onTriggered: root.panelContentOpacity = 1.0 - } - - // Reset drag position when panel closes - Connections { - target: root - function onClosed() { - panelBackground.isDragged = false - } - } - - // Prevent closing when clicking in the panel bg - MouseArea { - anchors.fill: parent - } - - // Animation behavior - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutQuad - } - } - - Loader { - id: panelContentLoader - anchors.fill: parent - sourceComponent: root.panelContent - opacity: root.panelContentOpacity - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - easing.type: Easing.OutQuad - } - } - } - - // Handle drag move on the whole panel area - DragHandler { - id: dragHandler - target: null - enabled: panelBackground.draggable - property real dragStartX: 0 - property real dragStartY: 0 - onActiveChanged: { - if (active) { - // Capture current position into manual coordinates BEFORE toggling isDragged - panelBackground.manualX = panelBackground.x - panelBackground.manualY = panelBackground.y - dragStartX = panelBackground.x - dragStartY = panelBackground.y - panelBackground.isDragged = true - if (root.enableBackgroundClick) - root.disableBackgroundClick() - } else { - // Keep isDragged true so we continue using the manual x/y after release - if (root.enableBackgroundClick) - root.enableBackgroundClick() - } - } - onTranslationChanged: { - // Proposed new coordinates from fixed drag origin - var nx = dragStartX + translation.x - var ny = dragStartY + translation.y - - // Calculate gaps so we never overlap the bar on any side - var baseGap = Style.marginS - var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * 2 * Style.marginXL : 0 - var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * 2 * Style.marginXL : 0 - - var insetLeft = baseGap + ((barIsVisible && barPosition === "left") ? (Style.barHeight + floatExtraH) : 0) - var insetRight = baseGap + ((barIsVisible && barPosition === "right") ? (Style.barHeight + floatExtraH) : 0) - var insetTop = baseGap + ((barIsVisible && barPosition === "top") ? (Style.barHeight + floatExtraV) : 0) - var insetBottom = baseGap + ((barIsVisible && barPosition === "bottom") ? (Style.barHeight + floatExtraV) : 0) - - // Clamp within screen bounds accounting for insets - var maxX = panelWindow.width - panelBackground.width - insetRight - var minX = insetLeft - var maxY = panelWindow.height - panelBackground.height - insetBottom - var minY = insetTop - - panelBackground.manualX = Math.round(Math.max(minX, Math.min(nx, maxX))) - panelBackground.manualY = Math.round(Math.max(minY, Math.min(ny, maxY))) - } - } - - // Drag indicator border - Rectangle { - anchors.fill: parent - anchors.margins: 0 - color: Color.transparent - border.color: Color.mPrimary - border.width: Style.borderM - radius: Style.radiusL - visible: panelBackground.isDragged && dragHandler.active - opacity: 0.8 - z: 3000 - - // Subtle glow effect - Rectangle { - anchors.fill: parent - anchors.margins: 0 - color: Color.transparent - border.color: Color.mPrimary - border.width: Style.borderS - radius: Style.radiusL - opacity: 0.3 - } - } - } + function enableBackgroundClick() { + // Add a small delay to prevent immediate close after drag release + enableBackgroundClickTimer.restart() + } + + Timer { + id: enableBackgroundClickTimer + interval: 100 + repeat: false + onTriggered: backgroundClickEnabled = true + } + + // ----------------------------------------- + function toggle(buttonItem, buttonName) { + if (!active) { + open(buttonItem, buttonName) + } else { + close() + } + } + + // ----------------------------------------- + function open(buttonItem, buttonName) { + root.buttonItem = buttonItem + root.buttonName = buttonName || "" + + setPosition() + + PanelService.willOpenPanel(root) + + backgroundClickEnabled = true + active = true + root.opened() + } + + // ----------------------------------------- + function close() { + dimmingOpacity = 0 + panelBackgroundOpacity = 0 + panelContentOpacity = 0 + root.closed() + active = false + useButtonPosition = false + backgroundClickEnabled = true + PanelService.closedPanel(root) + } + + // ----------------------------------------- + function setPosition() { + // If we have a button name, we are landing here from an IPC call. + // IPC calls have no idead on which screen they panel will spawn. + // Resolve the button name to a proper button item now that we have a screen. + if (buttonName !== "" && root.screen !== null) { + buttonItem = BarService.lookupWidget(buttonName, root.screen.name) + } + + // Get the button position if provided + if (buttonItem !== undefined && buttonItem !== null) { + useButtonPosition = true + var itemPos = buttonItem.mapToItem(null, 0, 0) + buttonPosition = Qt.point(itemPos.x, itemPos.y) + buttonWidth = buttonItem.width + buttonHeight = buttonItem.height + } else { + useButtonPosition = false + } + } + + // ----------------------------------------- + sourceComponent: Component { + // PanelWindow has its own screen property inherited of QsWindow + PanelWindow { + id: panelWindow + + readonly property bool barIsVisible: (screen !== null) + && (Settings.data.bar.monitors.includes( + screen.name) + || (Settings.data.bar.monitors.length === 0)) + + Component.onCompleted: { + Logger.d("NPanel", "Opened", root.objectName, "on", screen.name) + dimmingOpacity = Style.opacityHeavy + } + + Connections { + target: panelWindow + function onScreenChanged() { + root.screen = screen + + // If called from IPC always reposition if screen is updated + if (buttonName) { + setPosition() + } + Logger.d("NPanel", "OnScreenChanged", root.screen.name) + } + } + + visible: true + color: Settings.data.general.dimDesktop ? Qt.alpha( + Color.mShadow, + dimmingOpacity) : Color.transparent + + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "noctalia-panel" + WlrLayershell.layer: useOverlay ? WlrLayer.Overlay : WlrLayer.Top + WlrLayershell.keyboardFocus: root.panelKeyboardFocus ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + + Region { + id: maskRegion + } + + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + } + } + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + + // Close any panel with Esc without requiring focus + Shortcut { + sequences: ["Escape"] + enabled: root.active + onActivated: root.close() + context: Qt.WindowShortcut + } + + // Clicking outside of the rectangle to close + MouseArea { + anchors.fill: parent + enabled: root.backgroundClickEnabled + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onClicked: root.close() + } + + // The actual panel's content + NRectangleCurved { + id: panelBackground + + backgroundColor: panelBackgroundColor + + topLeftRadius: Style.radiusL + topRightRadius: Style.radiusL + bottomLeftRadius: Style.radiusL + bottomRightRadius: Style.radiusL + + // Set inverted corners based on panel anchors and bar position + + // Top-left corner + topLeftInverted: { + if (!attachedToBar) + return false + + // Inverted if panel is anchored to top edge (bar is at top) + if (effectivePanelAnchorTop) + return true + // Or if panel is anchored to left edge (bar is at left) + if (effectivePanelAnchorLeft) + return true + return false + } + topLeftInvertedDirection: effectivePanelAnchorTop ? "horizontal" : "vertical" + + // Top-right corner + topRightInverted: { + if (!attachedToBar) + return false + + // Inverted if panel is anchored to top edge (bar is at top) + if (effectivePanelAnchorTop) + return true + // Or if panel is anchored to right edge (bar is at right) + if (effectivePanelAnchorRight) + return true + return false + } + topRightInvertedDirection: effectivePanelAnchorTop ? "horizontal" : "vertical" + + // Bottom-left corner + bottomLeftInverted: { + if (!attachedToBar) + return false + + // Inverted if panel is anchored to bottom edge (bar is at bottom) + if (effectivePanelAnchorBottom) + return true + // Or if panel is anchored to left edge (bar is at left) + if (effectivePanelAnchorLeft) + return true + return false + } + bottomLeftInvertedDirection: effectivePanelAnchorBottom ? "horizontal" : "vertical" + + // Bottom-right corner + bottomRightInverted: { + if (!attachedToBar) + return false + + // Inverted if panel is anchored to bottom edge (bar is at bottom) + if (effectivePanelAnchorBottom) + return true + // Or if panel is anchored to right edge (bar is at right) + if (effectivePanelAnchorRight) + return true + return false + } + bottomRightInvertedDirection: effectivePanelAnchorBottom ? "horizontal" : "vertical" + + // Dragging support + property bool draggable: root.draggable + property bool isDragged: false + property real manualX: 0 + property real manualY: 0 + width: { + var w + if (root.preferredWidthRatio !== undefined) { + w = Math.round( + Math.max( + screen?.width * root.preferredWidthRatio, + root.preferredWidth)) + } else { + w = root.preferredWidth + } + // Clamp width so it is never bigger than the screen + return Math.min(w, screen?.width - Style.marginL * 2) + } + height: { + var h + if (root.preferredHeightRatio !== undefined) { + h = Math.round( + Math.max( + screen?.height * root.preferredHeightRatio, + root.preferredHeight)) + } else { + h = root.preferredHeight + } + + // Clamp height so it is never bigger than the screen + return Math.min( + h, + screen?.height - Style.barHeight - Style.marginL * 2) + } + + opacity: root.panelBackgroundOpacity + x: isDragged ? manualX : calculatedX + y: isDragged ? manualY : calculatedY + + // Animate width and height changes smoothly + Behavior on width { + enabled: !panelBackground.isDragged + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } + + Behavior on height { + enabled: !panelBackground.isDragged + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + } + + // --------------------------------------------- + // Does not account for corners are they are negligible and helps keep the code clean. + // --------------------------------------------- + property real marginTop: { + if (!barIsVisible) { + return 0 + } + + switch (barPosition) { + case "top": + return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + + (Settings.data.bar.floating ? Settings.data.bar.marginVertical + * Style.marginXL : 0) + default: + return attachedToBar ? 0 : Style.marginS + } + } + + property real marginBottom: { + if (!barIsVisible) { + return 0 + } + switch (barPosition) { + case "bottom": + return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + + (Settings.data.bar.floating ? Settings.data.bar.marginVertical + * Style.marginXL : 0) + default: + return attachedToBar ? 0 : Style.marginS + } + } + + property real marginLeft: { + if (!barIsVisible) { + return 0 + } + switch (barPosition) { + case "left": + return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal + * Style.marginXL : 0) + default: + return attachedToBar ? 0 : Style.marginS + } + } + + property real marginRight: { + if (!barIsVisible) { + return 0 + } + switch (barPosition) { + case "right": + return (Style.barHeight + (attachedToBar ? 0 : Style.marginS)) + + (Settings.data.bar.floating ? Settings.data.bar.marginHorizontal + * Style.marginXL : 0) + default: + return attachedToBar ? 0 : Style.marginS + } + } + + // --------------------------------------------- + property int calculatedX: { + // Priority to fixed anchoring + if (panelAnchorHorizontalCenter) { + // Center horizontally but respect bar margins + var centerX = Math.round( + (panelWindow.width - panelBackground.width) / 2) + var minX = marginLeft + var maxX = panelWindow.width - panelBackground.width - marginRight + return Math.round(Math.max(minX, + Math.min(centerX, maxX))) + } else if (panelAnchorLeft) { + return marginLeft + } else if (panelAnchorRight) { + return Math.round( + panelWindow.width - panelBackground.width - marginRight) + } + + // No fixed anchoring + if (barIsVertical) { + // Vertical bar + if (barPosition === "right") { + // To the left of the right bar + return Math.round( + panelWindow.width - panelBackground.width - marginRight) + } else { + // To the right of the left bar + return marginLeft + } + } else { + // Horizontal bar + if (root.useButtonPosition) { + // Position panel relative to button + var targetX = buttonPosition.x + (buttonWidth / 2) + - (panelBackground.width / 2) + // Keep panel within screen bounds + var maxX = panelWindow.width - panelBackground.width - marginRight + var minX = marginLeft + return Math.round(Math.max(minX, + Math.min(targetX, maxX))) + } else { + // Fallback to center horizontally + return Math.round( + (panelWindow.width - panelBackground.width) / 2) + } + } + } + + // --------------------------------------------- + property int calculatedY: { + // Priority to fixed anchoring + if (panelAnchorVerticalCenter) { + // Center vertically but respect bar margins + var centerY = Math.round( + (panelWindow.height - panelBackground.height) / 2) + var minY = marginTop + var maxY = panelWindow.height - panelBackground.height - marginBottom + return Math.round(Math.max(minY, + Math.min(centerY, maxY))) + } else if (panelAnchorTop) { + return marginTop + } else if (panelAnchorBottom) { + return Math.round( + panelWindow.height - panelBackground.height - marginBottom) + } + + // No fixed anchoring + if (barIsVertical) { + // Vertical bar + if (useButtonPosition) { + // Position panel relative to button + var targetY = buttonPosition.y + (buttonHeight / 2) + - (panelBackground.height / 2) + // Keep panel within screen bounds + var maxY = panelWindow.height - panelBackground.height - marginBottom + var minY = marginTop + return Math.round(Math.max(minY, + Math.min(targetY, maxY))) + } else { + // Fallback to center vertically + return Math.round( + (panelWindow.height - panelBackground.height) / 2) + } + } else { + // Horizontal bar + if (barPosition === "bottom") { + // Above the bottom bar + return Math.round( + panelWindow.height - panelBackground.height - marginBottom) + } else { + // Below the top bar + return marginTop + } + } + } + + // Animate in when component is completed + Component.onCompleted: { + // Start invisible + // Use a timer to delay the animation start, allowing QML to properly set up initial state + fadeInTimer.start() + } + + Timer { + id: fadeInTimer + interval: 1 + repeat: false + onTriggered: { + // Fade in background + root.panelBackgroundOpacity = 1.0 + } + } + + // Timer to fade in content after slide animation completes + Timer { + id: contentFadeInTimer + interval: Style.animationFast + repeat: false + running: true + onTriggered: root.panelContentOpacity = 1.0 + } + + // Reset drag position when panel closes + Connections { + target: root + function onClosed() { + panelBackground.isDragged = false + } + } + + // Prevent closing when clicking in the panel bg + MouseArea { + anchors.fill: parent + } + + // Animation behavior + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + + Loader { + id: panelContentLoader + anchors.fill: parent + sourceComponent: root.panelContent + opacity: root.panelContentOpacity + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + + // Allow content to dynamically resize the panel + onItemChanged: { + if (item) { + // Bind to content's preferredWidth/Height if they exist + if (item.hasOwnProperty('contentPreferredWidth')) { + root.preferredWidth = Qt.binding( + () => item.contentPreferredWidth) + } + if (item.hasOwnProperty('contentPreferredHeight')) { + root.preferredHeight = Qt.binding( + () => item.contentPreferredHeight) + } + if (item.hasOwnProperty( + 'contentPreferredWidthRatio')) { + root.preferredWidthRatio = Qt.binding( + () => item.contentPreferredWidthRatio) + } + if (item.hasOwnProperty( + 'contentPreferredHeightRatio')) { + root.preferredHeightRatio = Qt.binding( + () => item.contentPreferredHeightRatio) + } + } + } + } + + // Handle drag move on the whole panel area + DragHandler { + id: dragHandler + target: null + enabled: panelBackground.draggable + property real dragStartX: 0 + property real dragStartY: 0 + onActiveChanged: { + if (active) { + // Capture current position into manual coordinates BEFORE toggling isDragged + panelBackground.manualX = panelBackground.x + panelBackground.manualY = panelBackground.y + dragStartX = panelBackground.x + dragStartY = panelBackground.y + panelBackground.isDragged = true + if (root.enableBackgroundClick) + root.disableBackgroundClick() + } else { + // Keep isDragged true so we continue using the manual x/y after release + if (root.enableBackgroundClick) + root.enableBackgroundClick() + } + } + onTranslationChanged: { + // Proposed new coordinates from fixed drag origin + var nx = dragStartX + translation.x + var ny = dragStartY + translation.y + + // Calculate gaps so we never overlap the bar on any side + var baseGap = Style.marginS + var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * 2 * Style.marginXL : 0 + var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * 2 * Style.marginXL : 0 + + var insetLeft = baseGap + ((barIsVisible + && barPosition === "left") ? (Style.barHeight + floatExtraH) : 0) + var insetRight = baseGap + ((barIsVisible + && barPosition === "right") ? (Style.barHeight + floatExtraH) : 0) + var insetTop = baseGap + ((barIsVisible && barPosition + === "top") ? (Style.barHeight + floatExtraV) : 0) + var insetBottom = baseGap + ((barIsVisible + && barPosition === "bottom") ? (Style.barHeight + floatExtraV) : 0) + + // Clamp within screen bounds accounting for insets + var maxX = panelWindow.width - panelBackground.width - insetRight + var minX = insetLeft + var maxY = panelWindow.height - panelBackground.height - insetBottom + var minY = insetTop + + panelBackground.manualX = Math.round( + Math.max(minX, Math.min(nx, maxX))) + panelBackground.manualY = Math.round( + Math.max(minY, Math.min(ny, maxY))) + } + } + + // Drag indicator border + Rectangle { + anchors.fill: parent + anchors.margins: 0 + color: Color.transparent + border.color: Color.mPrimary + border.width: Style.borderM + radius: Style.radiusL + visible: panelBackground.isDragged && dragHandler.active + opacity: 0.8 + z: 3000 + + // Subtle glow effect + Rectangle { + anchors.fill: parent + anchors.margins: 0 + color: Color.transparent + border.color: Color.mPrimary + border.width: Style.borderS + radius: Style.radiusL + opacity: 0.3 + } + } + } + } } - } }