diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index b925cccb..34e6db35 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -2711,6 +2711,13 @@ Basic.Settings.Video.FPSCommon="Common FPS Values" Basic.Settings.Video.FPSInteger="Integer FPS Value" Basic.Settings.Video.FPSFraction="Fractional FPS Value" +# Calendar widget +AdvSceneSwitcher.calendar.today="Today" +AdvSceneSwitcher.calendar.month="Month" +AdvSceneSwitcher.calendar.week="Week" +AdvSceneSwitcher.calendar.day="Day" +AdvSceneSwitcher.calendar.moreEvents="+%1 more" + # Legacy tabs below - please don't waste your time adding translations for these :) # Transition Tab AdvSceneSwitcher.transitionTab.title="Transition" diff --git a/plugins/schedule/calendar/calendar-day-view.cpp b/plugins/schedule/calendar/calendar-day-view.cpp new file mode 100644 index 00000000..db3860c1 --- /dev/null +++ b/plugins/schedule/calendar/calendar-day-view.cpp @@ -0,0 +1,493 @@ +#include "calendar-day-view.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace advss { + +// --------------------------------------------------------------------------- +// CalendarDayHeader – fixed date strip above the scroll area +// --------------------------------------------------------------------------- + +class CalendarDayHeader : public QWidget { +public: + explicit CalendarDayHeader(QWidget *parent = nullptr); + void SetDate(const QDate &date); + +protected: + void paintEvent(QPaintEvent *) override; + +private: + QDate _date; +}; + +CalendarDayHeader::CalendarDayHeader(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(CalendarDayView::DAY_HEADER_HEIGHT); +} + +void CalendarDayHeader::SetDate(const QDate &date) +{ + _date = date; + update(); +} + +void CalendarDayHeader::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const QDate today = QDate::currentDate(); + const bool isToday = (_date == today); + QLocale locale; + + // Time-axis placeholder + p.fillRect(0, 0, CalendarDayView::TIME_AXIS_WIDTH, height(), + palette().button()); + + // Day column + const int colX = CalendarDayView::TIME_AXIS_WIDTH; + const int colW = width() - colX; + + QColor bg = palette().button().color(); + if (isToday) { + bg = palette().highlight().color().lighter(165); + } + p.fillRect(colX, 0, colW, height(), bg); + + p.setPen(palette().mid().color()); + p.drawLine(colX, 0, colX, height()); + + QFont f = p.font(); + f.setPixelSize(13); + f.setBold(isToday); + p.setFont(f); + p.setPen(isToday ? palette().highlight().color() + : palette().buttonText().color()); + + const QString label = + locale.dayName(_date.dayOfWeek(), QLocale::LongFormat) + " " + + QString::number(_date.day()) + " " + + locale.monthName(_date.month(), QLocale::ShortFormat) + " " + + QString::number(_date.year()); + p.drawText(colX, 0, colW, height(), Qt::AlignCenter, label); + + p.setPen(palette().mid().color()); + p.drawLine(0, height() - 1, width(), height() - 1); +} + +// --------------------------------------------------------------------------- +// CalendarDayTimeGrid – scrollable single-day painted time grid +// --------------------------------------------------------------------------- + +class CalendarDayTimeGrid : public QWidget { + Q_OBJECT + +public: + explicit CalendarDayTimeGrid(QWidget *parent = nullptr); + void SetDate(const QDate &date); + void SetEvents(const QList &events); + void ScrollToCurrentTime(QScrollArea *sa); + + QSize sizeHint() const override; + +signals: + void SlotClicked(const QDateTime &startTime); + void EventClicked(const QString &id); + void EventDoubleClicked(const QString &id); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + void timerEvent(QTimerEvent *) override; + +private: + struct EventLayout { + CalendarEvent event; + QTime drawStart; // clipped to the day's [00:00, 23:59:59] + QTime drawEnd; + int col; + int numCols; + }; + + int TimeToY(const QTime &t) const; + QTime YToTime(int y) const; + int ColWidth() const; + + QList LayoutDay() const; + QString EventIdAtPoint(const QPoint &pos) const; + QDateTime SlotAtPoint(const QPoint &pos) const; + + void DrawEvent(QPainter &p, const EventLayout &layout, int colW); + + QDate _date; + QList _events; + int _refreshTimerId = 0; +}; + +CalendarDayTimeGrid::CalendarDayTimeGrid(QWidget *parent) : QWidget(parent) +{ + _refreshTimerId = startTimer(60 * 1000); +} + +QSize CalendarDayTimeGrid::sizeHint() const +{ + return QSize(CalendarDayView::TIME_AXIS_WIDTH + 200, + 24 * CalendarDayView::PIXELS_PER_HOUR); +} + +void CalendarDayTimeGrid::timerEvent(QTimerEvent *e) +{ + if (e->timerId() == _refreshTimerId) { + update(); + } +} + +int CalendarDayTimeGrid::TimeToY(const QTime &t) const +{ + return (t.hour() * 60 + t.minute()) * CalendarDayView::PIXELS_PER_HOUR / + 60; +} + +QTime CalendarDayTimeGrid::YToTime(int y) const +{ + int totalMin = y * 60 / CalendarDayView::PIXELS_PER_HOUR; + totalMin = qBound(0, totalMin, 24 * 60 - 1); + totalMin = (totalMin / 15) * 15; // snap to 15-min increments + return QTime(totalMin / 60, totalMin % 60); +} + +int CalendarDayTimeGrid::ColWidth() const +{ + return qMax(0, width() - CalendarDayView::TIME_AXIS_WIDTH); +} + +void CalendarDayTimeGrid::SetDate(const QDate &date) +{ + _date = date; + update(); +} + +void CalendarDayTimeGrid::SetEvents(const QList &events) +{ + _events = events; + update(); +} + +void CalendarDayTimeGrid::ScrollToCurrentTime(QScrollArea *sa) +{ + if (!sa) { + return; + } + const int y = qMax(0, TimeToY(QTime::currentTime().addSecs(-3600))); + sa->verticalScrollBar()->setValue(y); +} + +static bool ClipEventToDayD(const CalendarEvent &ev, const QDate &date, + QTime &drawStart, QTime &drawEnd) +{ + if (!ev.start.isValid()) { + return false; + } + const QDateTime dayStart(date, QTime(0, 0, 0)); + const QDateTime dayEnd(date.addDays(1), QTime(0, 0, 0)); + const QDateTime evEnd = ev.EffectiveEnd(); + + if (ev.start >= dayEnd || evEnd <= dayStart) { + return false; + } + + drawStart = (ev.start < dayStart) ? QTime(0, 0, 0) : ev.start.time(); + drawEnd = (evEnd >= dayEnd) ? QTime(23, 59, 59) : evEnd.time(); + return true; +} + +QList CalendarDayTimeGrid::LayoutDay() const +{ + struct DayEvent { + CalendarEvent ev; + QTime drawStart; + QTime drawEnd; + }; + QList dayEvents; + for (const auto &ev : _events) { + QTime ds, de; + if (ClipEventToDayD(ev, _date, ds, de)) { + dayEvents.append({ev, ds, de}); + } + } + + std::sort(dayEvents.begin(), dayEvents.end(), + [](const DayEvent &a, const DayEvent &b) { + return a.drawStart < b.drawStart; + }); + + QList result; + QList columnEnds; + + for (const auto &de : dayEvents) { + int col = -1; + for (int i = 0; i < columnEnds.size(); ++i) { + if (columnEnds[i] <= de.drawStart) { + col = i; + columnEnds[i] = de.drawEnd; + break; + } + } + if (col == -1) { + col = columnEnds.size(); + columnEnds.append(de.drawEnd); + } + result.append({de.ev, de.drawStart, de.drawEnd, col, 0}); + } + + const int totalCols = qMax(1, (int)columnEnds.size()); + for (auto &layout : result) { + layout.numCols = totalCols; + } + return result; +} + +QString CalendarDayTimeGrid::EventIdAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarDayView::TIME_AXIS_WIDTH) { + return {}; + } + const int colW = ColWidth(); + for (const auto &layout : LayoutDay()) { + const int subW = colW / layout.numCols; + const int x = CalendarDayView::TIME_AXIS_WIDTH + + layout.col * subW + 2; + const int w = subW - 4; + const int y = TimeToY(layout.drawStart); + const int h = qMax(TimeToY(layout.drawEnd) - y, 20); + if (QRect(x, y, w, h).contains(pos)) { + return layout.event.id; + } + } + return {}; +} + +QDateTime CalendarDayTimeGrid::SlotAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarDayView::TIME_AXIS_WIDTH) { + return {}; + } + return QDateTime(_date, YToTime(pos.y())); +} + +void CalendarDayTimeGrid::DrawEvent(QPainter &p, const EventLayout &layout, + int colW) +{ + const auto &ev = layout.event; + const int subW = colW / layout.numCols; + const int x = CalendarDayView::TIME_AXIS_WIDTH + layout.col * subW + 2; + const int w = subW - 4; + const int y = TimeToY(layout.drawStart); + const int endY = TimeToY(layout.drawEnd); + const int h = qMax(endY - y, 20); + const QRect rect(x, y, w, h); + + p.setBrush(ev.color); + p.setPen(ev.color.darker(130)); + p.drawRoundedRect(rect, 3, 3); + + p.fillRect(x, y + 1, 3, h - 2, ev.color.darker(160)); + + p.setPen(Qt::white); + + QFont f = p.font(); + f.setPixelSize(10); + f.setBold(true); + p.setFont(f); + + const QRect textRect = rect.adjusted(6, 2, -2, -2); + if (h >= 34) { + QFont tf = f; + tf.setBold(false); + p.setFont(tf); + p.drawText(textRect, Qt::AlignTop | Qt::AlignLeft, + ev.start.toString("HH:mm")); + p.setFont(f); + p.drawText(textRect.adjusted(0, 14, 0, 0), + Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap, + ev.title); + } else { + p.drawText(textRect, + Qt::AlignVCenter | Qt::AlignLeft | + Qt::TextSingleLine, + ev.title); + } +} + +void CalendarDayTimeGrid::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int w = width(); + const int h = height(); + const int colW = ColWidth(); + const QDate today = QDate::currentDate(); + + p.fillRect(0, 0, w, h, palette().base()); + p.fillRect(0, 0, CalendarDayView::TIME_AXIS_WIDTH, h, + palette().button()); + + // Day column background + const int colX = CalendarDayView::TIME_AXIS_WIDTH; + if (_date == today) { + p.fillRect(colX, 0, colW, h, + palette().highlight().color().lighter(190)); + } else if (_date.dayOfWeek() >= 6) { + p.fillRect(colX, 0, colW, h, palette().alternateBase().color()); + } + + QFont labelFont = p.font(); + labelFont.setPixelSize(10); + p.setFont(labelFont); + + for (int hour = 0; hour < 24; ++hour) { + const int y = hour * CalendarDayView::PIXELS_PER_HOUR; + + p.setPen(palette().text().color()); + p.drawText(2, y, CalendarDayView::TIME_AXIS_WIDTH - 6, + CalendarDayView::PIXELS_PER_HOUR, + Qt::AlignTop | Qt::AlignRight, + QTime(hour, 0).toString("HH:mm")); + + p.setPen(QPen(palette().mid().color(), 1)); + p.drawLine(CalendarDayView::TIME_AXIS_WIDTH, y, w, y); + + QPen halfPen(palette().midlight().color(), 1, Qt::DotLine); + p.setPen(halfPen); + const int yHalf = y + CalendarDayView::PIXELS_PER_HOUR / 2; + p.drawLine(CalendarDayView::TIME_AXIS_WIDTH, yHalf, w, yHalf); + } + + p.setPen(QPen(palette().mid().color(), 1)); + p.drawLine(colX, 0, colX, h); + + for (const auto &layout : LayoutDay()) { + DrawEvent(p, layout, colW); + } + + // Current-time indicator + if (_date == today) { + const int y = TimeToY(QTime::currentTime()); + p.setPen(QPen(QColor(220, 30, 30), 2)); + p.drawLine(colX, y, w, y); + p.setBrush(QColor(220, 30, 30)); + p.setPen(Qt::NoPen); + p.drawEllipse(colX - 4, y - 4, 8, 8); + } +} + +void CalendarDayTimeGrid::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventClicked(id); + return; + } + const QDateTime slot = SlotAtPoint(e->pos()); + if (slot.isValid()) { + emit SlotClicked(slot); + } +} + +void CalendarDayTimeGrid::mouseDoubleClickEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventDoubleClicked(id); + } +} + +// --------------------------------------------------------------------------- +// CalendarDayView +// --------------------------------------------------------------------------- + +CalendarDayView::CalendarDayView(QWidget *parent) + : CalendarView(parent), + _header(new CalendarDayHeader(this)), + _timeGrid(new CalendarDayTimeGrid(this)), + _scrollArea(new QScrollArea(this)) +{ + _timeGrid->setMinimumHeight(24 * CalendarDayView::PIXELS_PER_HOUR); + + _scrollArea->setWidget(_timeGrid); + _scrollArea->setWidgetResizable(true); + _scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + _scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + _scrollArea->setFrameShape(QFrame::NoFrame); + + auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(_header); + layout->addWidget(_scrollArea); + setLayout(layout); + + connect(_timeGrid, &CalendarDayTimeGrid::SlotClicked, this, + &CalendarDayView::SlotClicked); + connect(_timeGrid, &CalendarDayTimeGrid::EventClicked, this, + &CalendarDayView::EventClicked); + connect(_timeGrid, &CalendarDayTimeGrid::EventDoubleClicked, this, + &CalendarDayView::EventDoubleClicked); + + SetDate(QDate::currentDate()); +} + +void CalendarDayView::SetDate(const QDate &date) +{ + _date = date; + UpdateViews(); + emit VisibleRangeChanged(RangeStart(), RangeEnd()); +} + +void CalendarDayView::SetEvents(const QList &events) +{ + _events = events; + _timeGrid->SetEvents(events); +} + +QDate CalendarDayView::CurrentDate() const +{ + return _date; +} + +QDate CalendarDayView::RangeStart() const +{ + return _date; +} + +QDate CalendarDayView::RangeEnd() const +{ + return _date; +} + +void CalendarDayView::UpdateViews() +{ + _header->SetDate(_date); + _timeGrid->SetDate(_date); + _timeGrid->ScrollToCurrentTime(_scrollArea); +} + +} // namespace advss + +// Required for CalendarDayTimeGrid defined in this file +#include "calendar-day-view.moc" diff --git a/plugins/schedule/calendar/calendar-day-view.hpp b/plugins/schedule/calendar/calendar-day-view.hpp new file mode 100644 index 00000000..4d3d0751 --- /dev/null +++ b/plugins/schedule/calendar/calendar-day-view.hpp @@ -0,0 +1,44 @@ +#pragma once +#include "calendar-view.hpp" + +#include + +namespace advss { + +class CalendarDayHeader; +class CalendarDayTimeGrid; + +// Displays a single day column with a vertical time axis (00:00 - 24:00). +// Events are rendered as colored blocks sized to their duration. +// Overlapping events are arranged in side-by-side sub-columns. +// A red indicator marks the current time. +class CalendarDayView : public CalendarView { + Q_OBJECT + +public: + explicit CalendarDayView(QWidget *parent = nullptr); + + void SetDate(const QDate &date) override; + void SetEvents(const QList &events) override; + + QDate CurrentDate() const override; + QDate RangeStart() const override; + QDate RangeEnd() const override; + + // Shared layout constants (used by DayHeader and TimeGrid) + static constexpr int TIME_AXIS_WIDTH = 56; + static constexpr int PIXELS_PER_HOUR = 64; + static constexpr int DAY_HEADER_HEIGHT = 36; + +private: + void UpdateViews(); + + CalendarDayHeader *_header; + CalendarDayTimeGrid *_timeGrid; + QScrollArea *_scrollArea; + + QDate _date; + QList _events; +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-event.hpp b/plugins/schedule/calendar/calendar-event.hpp new file mode 100644 index 00000000..fcc20794 --- /dev/null +++ b/plugins/schedule/calendar/calendar-event.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +namespace advss { + +// Generic calendar event. All fields except id/start are optional. +struct CalendarEvent { + QString id; + QString title; + QDateTime start; + QDateTime end; + QColor color{70, 130, 180}; + QVariant userData; // Caller-defined payload, returned on click signals + + // Returns end if valid and > start, otherwise start + 30 minutes. + QDateTime EffectiveEnd() const + { + static constexpr qint64 defaultDuration = 1800; + return (end.isValid() && end > start) + ? end + : start.addSecs(defaultDuration); + } +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-month-view.cpp b/plugins/schedule/calendar/calendar-month-view.cpp new file mode 100644 index 00000000..efda45bd --- /dev/null +++ b/plugins/schedule/calendar/calendar-month-view.cpp @@ -0,0 +1,284 @@ +#include "calendar-month-view.hpp" +#include "obs-module-helper.hpp" + +#include +#include +#include + +namespace advss { + +CalendarMonthView::CalendarMonthView(QWidget *parent) : CalendarView(parent) +{ + setMinimumSize(320, 240); + SetDate(QDate::currentDate()); +} + +// --------------------------------------------------------------------------- +// Public interface +// --------------------------------------------------------------------------- + +void CalendarMonthView::SetDate(const QDate &date) +{ + _currentDate = date; + + // Grid starts on the Monday of the week that contains the 1st of the month. + const QDate first(date.year(), date.month(), 1); + // Qt: dayOfWeek() -> 1=Mon … 7=Sun + _gridStart = first.addDays(-(first.dayOfWeek() - 1)); + + update(); + emit VisibleRangeChanged(RangeStart(), RangeEnd()); +} + +void CalendarMonthView::SetEvents(const QList &events) +{ + _events = events; + update(); +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +QDate CalendarMonthView::CellDate(int row, int col) const +{ + return _gridStart.addDays(row * COLS + col); +} + +QRect CalendarMonthView::CellRect(int row, int col) const +{ + const int cellW = width() / COLS; + const int cellH = (height() - HEADER_HEIGHT) / ROWS; + return QRect(col * cellW, HEADER_HEIGHT + row * cellH, cellW, cellH); +} + +bool CalendarMonthView::CellFromPoint(const QPoint &pos, int &row, + int &col) const +{ + if (pos.y() < HEADER_HEIGHT) { + return false; + } + const int cellW = width() / COLS; + const int cellH = (height() - HEADER_HEIGHT) / ROWS; + col = pos.x() / cellW; + row = (pos.y() - HEADER_HEIGHT) / cellH; + return col >= 0 && col < COLS && row >= 0 && row < ROWS; +} + +// --------------------------------------------------------------------------- +// Event helpers +// --------------------------------------------------------------------------- + +QList CalendarMonthView::EventsForDate(const QDate &date) const +{ + QList result; + for (const auto &event : _events) { + if (!event.start.isValid()) { + continue; + } + const QDate evStart = event.start.date(); + const QDate evEnd = event.EffectiveEnd().date(); + if (date >= evStart && date <= evEnd) { + result.append(event); + } + } + return result; +} + +QString CalendarMonthView::EventIdAtPoint(const QPoint &pos) const +{ + int row, col; + if (!CellFromPoint(pos, row, col)) { + return {}; + } + + const QRect cell = CellRect(row, col); + const int evTop = cell.top() + DAY_NUM_HEIGHT + CELL_PAD; + const auto events = EventsForDate(CellDate(row, col)); + + for (int i = 0; i < qMin((int)events.size(), MAX_VISIBLE_EVENTS); ++i) { + const QRect evRect(cell.left() + CELL_PAD, + evTop + i * (EVENT_HEIGHT + EVENT_MARGIN), + cell.width() - CELL_PAD * 2, EVENT_HEIGHT); + if (evRect.contains(pos)) { + return events[i].id; + } + } + return {}; +} + +// --------------------------------------------------------------------------- +// Painting +// --------------------------------------------------------------------------- + +void CalendarMonthView::PaintDayNameHeader(QPainter &p) +{ + p.fillRect(0, 0, width(), HEADER_HEIGHT, palette().button()); + + const int cellW = width() / COLS; + QLocale locale; + + p.setPen(palette().buttonText().color()); + QFont f = p.font(); + f.setPixelSize(11); + p.setFont(f); + + for (int col = 0; col < COLS; ++col) { + // dayOfWeek: 1=Mon … 7=Sun (ISO 8601 order matches our columns) + const QString name = + locale.dayName(col + 1, QLocale::ShortFormat); + p.drawText(col * cellW, 0, cellW, HEADER_HEIGHT, + Qt::AlignCenter, name); + } + + // Bottom border + p.setPen(palette().mid().color()); + p.drawLine(0, HEADER_HEIGHT - 1, width(), HEADER_HEIGHT - 1); +} + +void CalendarMonthView::PaintCell(QPainter &p, int row, int col) +{ + const QRect rect = CellRect(row, col); + const QDate date = CellDate(row, col); + const QDate today = QDate::currentDate(); + const bool isToday = (date == today); + const bool inCurrentMonth = (date.month() == _currentDate.month()); + + // --- Background --- + QColor bg; + if (isToday) { + bg = palette().highlight().color().lighter(185); + } else if (!inCurrentMonth) { + bg = palette().alternateBase().color(); + } else { + bg = palette().base().color(); + } + // Weekend tint + if (col >= 5) { + bg = bg.darker(104); + } + p.fillRect(rect, bg); + + // --- Grid border --- + p.setBrush(Qt::NoBrush); + p.setPen(palette().mid().color()); + p.drawRect(rect.adjusted(0, 0, -1, -1)); + + // --- Day number --- + const QRect numArea(rect.left(), rect.top() + CELL_PAD, + rect.width() - CELL_PAD, DAY_NUM_HEIGHT); + + if (isToday) { + // Filled circle behind the number + const int dia = DAY_NUM_HEIGHT - 4; + const QRect circleRect(numArea.right() - dia - 2, + numArea.top() + 1, dia, dia); + p.setBrush(palette().highlight()); + p.setPen(Qt::NoPen); + p.drawEllipse(circleRect); + p.setPen(palette().highlightedText().color()); + } else { + p.setPen(inCurrentMonth ? palette().text().color() + : palette().placeholderText().color()); + } + + QFont f = p.font(); + f.setPixelSize(11); + f.setBold(isToday); + p.setFont(f); + p.drawText(numArea, Qt::AlignRight | Qt::AlignTop, + QString::number(date.day())); + + // --- Event bars --- + const auto events = EventsForDate(date); + const int evTop = rect.top() + DAY_NUM_HEIGHT + CELL_PAD; + const int maxFit = (rect.height() - DAY_NUM_HEIGHT - CELL_PAD * 2) / + (EVENT_HEIGHT + EVENT_MARGIN); + const int visible = + qMin(qMin((int)events.size(), maxFit), MAX_VISIBLE_EVENTS); + + QFont evFont = p.font(); + evFont.setPixelSize(10); + evFont.setBold(false); + p.setFont(evFont); + + for (int i = 0; i < visible; ++i) { + const auto &ev = events[i]; + const QRect evRect(rect.left() + CELL_PAD, + evTop + i * (EVENT_HEIGHT + EVENT_MARGIN), + rect.width() - CELL_PAD * 2, EVENT_HEIGHT); + + p.setBrush(ev.color); + p.setPen(Qt::NoPen); + p.drawRoundedRect(evRect, 2, 2); + + p.setPen(Qt::white); + p.drawText(evRect.adjusted(4, 0, -2, 0), + Qt::AlignVCenter | Qt::AlignLeft | + Qt::TextSingleLine, + ev.title); + } + + // "+N more" overflow label + if ((int)events.size() > visible) { + const int extra = events.size() - visible; + const QRect moreRect( + rect.left() + CELL_PAD, + evTop + visible * (EVENT_HEIGHT + EVENT_MARGIN), + rect.width() - CELL_PAD * 2, EVENT_HEIGHT); + p.setPen(palette().placeholderText().color()); + p.drawText( + moreRect, + Qt::AlignVCenter | Qt::AlignLeft | Qt::TextSingleLine, + QString(obs_module_text( + "AdvSceneSwitcher.calendar.moreEvents")) + .arg(extra)); + } +} + +void CalendarMonthView::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + PaintDayNameHeader(p); + + for (int row = 0; row < ROWS; ++row) { + for (int col = 0; col < COLS; ++col) { + PaintCell(p, row, col); + } + } +} + +// --------------------------------------------------------------------------- +// Mouse events +// --------------------------------------------------------------------------- + +void CalendarMonthView::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventClicked(id); + return; + } + int row, col; + if (CellFromPoint(e->pos(), row, col)) { + emit SlotClicked(QDateTime(CellDate(row, col), QTime(0, 0))); + } +} + +void CalendarMonthView::mouseDoubleClickEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventDoubleClicked(id); + } +} + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-month-view.hpp b/plugins/schedule/calendar/calendar-month-view.hpp new file mode 100644 index 00000000..6fe508f5 --- /dev/null +++ b/plugins/schedule/calendar/calendar-month-view.hpp @@ -0,0 +1,57 @@ +#pragma once +#include "calendar-view.hpp" + +namespace advss { + +// Displays a traditional month grid (6 weeks × 7 days). +// Events are rendered as small colored bars inside each day cell. +// Up to three events are shown per cell; additional events are +// indicated by a "+N more" label. +class CalendarMonthView : public CalendarView { + Q_OBJECT + +public: + explicit CalendarMonthView(QWidget *parent = nullptr); + + void SetDate(const QDate &date) override; + void SetEvents(const QList &events) override; + + QDate CurrentDate() const override { return _currentDate; } + QDate RangeStart() const override { return _gridStart; } + QDate RangeEnd() const override { return _gridStart.addDays(41); } + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + +private: + // Geometry helpers + QDate CellDate(int row, int col) const; + QRect CellRect(int row, int col) const; + bool CellFromPoint(const QPoint &pos, int &row, int &col) const; + + // Event helpers + QList EventsForDate(const QDate &date) const; + QString EventIdAtPoint(const QPoint &pos) const; + + // Painting + void PaintDayNameHeader(QPainter &p); + void PaintCell(QPainter &p, int row, int col); + + static constexpr int ROWS = 6; + static constexpr int COLS = 7; + static constexpr int HEADER_HEIGHT = 28; // day-name row + static constexpr int DAY_NUM_HEIGHT = + 22; // space reserved for day number + static constexpr int EVENT_HEIGHT = 16; + static constexpr int EVENT_MARGIN = 2; + static constexpr int CELL_PAD = 3; + static constexpr int MAX_VISIBLE_EVENTS = 3; + + QDate _currentDate; + QDate _gridStart; // Monday of the week that contains the 1st of the month + QList _events; +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-view.hpp b/plugins/schedule/calendar/calendar-view.hpp new file mode 100644 index 00000000..c3586790 --- /dev/null +++ b/plugins/schedule/calendar/calendar-view.hpp @@ -0,0 +1,49 @@ +#pragma once +#include "calendar-event.hpp" + +#include +#include +#include + +namespace advss { + +// Abstract base class for calendar view modes (month, week, …). +// Subclasses must implement the pure virtual interface and emit the +// signals defined here via Q_SIGNALS so that CalendarWidget can +// connect to them uniformly regardless of the active view. +class CalendarView : public QWidget { + Q_OBJECT + +public: + explicit CalendarView(QWidget *parent = nullptr) : QWidget(parent) {} + + // Navigate to show the given date. The visible range is determined + // by the concrete view (e.g. the whole month, or the week). + virtual void SetDate(const QDate &date) = 0; + + // Replace the full set of events shown in this view. + virtual void SetEvents(const QList &events) = 0; + + // A representative date for the current position (used for navigation). + virtual QDate CurrentDate() const = 0; + + // Inclusive range of dates currently rendered. + virtual QDate RangeStart() const = 0; + virtual QDate RangeEnd() const = 0; + +signals: + // User clicked an empty time slot. + void SlotClicked(const QDateTime &startTime); + + // User single-clicked an event (id = CalendarEvent::id). + void EventClicked(const QString &eventId); + + // User double-clicked an event. + void EventDoubleClicked(const QString &eventId); + + // The visible date range changed; the owner should refresh events. + void VisibleRangeChanged(const QDate &rangeStart, + const QDate &rangeEnd); +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-week-view.cpp b/plugins/schedule/calendar/calendar-week-view.cpp new file mode 100644 index 00000000..c7154376 --- /dev/null +++ b/plugins/schedule/calendar/calendar-week-view.cpp @@ -0,0 +1,542 @@ +#include "calendar-week-view.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace advss { + +// --------------------------------------------------------------------------- +// CalendarWeekDayHeader – fixed day-name strip above the scroll area +// --------------------------------------------------------------------------- + +class CalendarWeekDayHeader : public QWidget { +public: + explicit CalendarWeekDayHeader(QWidget *parent = nullptr); + void SetStartOfWeek(const QDate &date); + +protected: + void paintEvent(QPaintEvent *) override; + +private: + QDate _startOfWeek; +}; + +CalendarWeekDayHeader::CalendarWeekDayHeader(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(CalendarWeekView::DAY_HEADER_HEIGHT); +} + +void CalendarWeekDayHeader::SetStartOfWeek(const QDate &date) +{ + _startOfWeek = date; + update(); +} + +void CalendarWeekDayHeader::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int dayW = + std::max(CalendarWeekView::MIN_DAY_WIDTH, + (width() - CalendarWeekView::TIME_AXIS_WIDTH) / 7); + const QDate today = QDate::currentDate(); + QLocale locale; + + // Time-axis placeholder + p.fillRect(0, 0, CalendarWeekView::TIME_AXIS_WIDTH, height(), + palette().button()); + + for (int d = 0; d < 7; ++d) { + const QDate date = _startOfWeek.addDays(d); + const int x = CalendarWeekView::TIME_AXIS_WIDTH + d * dayW; + const bool isToday = (date == today); + + QColor bg = palette().button().color(); + if (isToday) { + bg = palette().highlight().color().lighter(165); + } else if (date.dayOfWeek() >= 6) { + bg = bg.darker(106); + } + p.fillRect(x, 0, dayW, height(), bg); + + p.setPen(palette().mid().color()); + p.drawLine(x, 0, x, height()); + + QFont f = p.font(); + f.setPixelSize(12); + f.setBold(isToday); + p.setFont(f); + p.setPen(isToday ? palette().highlight().color() + : palette().buttonText().color()); + + const QString label = + locale.dayName(date.dayOfWeek(), QLocale::ShortFormat) + + " " + QString::number(date.day()); + p.drawText(x, 0, dayW, height(), Qt::AlignCenter, label); + } + + p.setPen(palette().mid().color()); + p.drawLine(0, height() - 1, width(), height() - 1); +} + +// --------------------------------------------------------------------------- +// CalendarWeekTimeGrid – scrollable painted time grid +// --------------------------------------------------------------------------- + +class CalendarWeekTimeGrid : public QWidget { + Q_OBJECT + +public: + explicit CalendarWeekTimeGrid(QWidget *parent = nullptr); + void SetStartOfWeek(const QDate &date); + void SetEvents(const QList &events); + void ScrollToCurrentTime(QScrollArea *sa); + + QSize sizeHint() const override; + +signals: + void SlotClicked(const QDateTime &startTime); + void EventClicked(const QString &id); + void EventDoubleClicked(const QString &id); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + void timerEvent(QTimerEvent *) override; + +private: + struct EventLayout { + CalendarEvent event; + QTime drawStart; // clipped to the day's [00:00, 23:59:59] + QTime drawEnd; + int col; + int numCols; + }; + + int TimeToY(const QTime &t) const; + QTime YToTime(int y) const; + int DayWidth() const; + int DayColumnX(int dayIndex) const; + + QList LayoutDay(int dayIndex) const; + QString EventIdAtPoint(const QPoint &pos) const; + QDateTime SlotAtPoint(const QPoint &pos) const; + + void DrawEvent(QPainter &p, const EventLayout &layout, int dayX, + int dayW); + + QDate _startOfWeek; + QList _events; + int _refreshTimerId = 0; +}; + +CalendarWeekTimeGrid::CalendarWeekTimeGrid(QWidget *parent) : QWidget(parent) +{ + _refreshTimerId = startTimer(60 * 1000); +} + +QSize CalendarWeekTimeGrid::sizeHint() const +{ + return QSize(CalendarWeekView::TIME_AXIS_WIDTH + + 7 * CalendarWeekView::MIN_DAY_WIDTH, + 24 * CalendarWeekView::PIXELS_PER_HOUR); +} + +void CalendarWeekTimeGrid::timerEvent(QTimerEvent *e) +{ + if (e->timerId() == _refreshTimerId) { + update(); + } +} + +int CalendarWeekTimeGrid::TimeToY(const QTime &t) const +{ + return (t.hour() * 60 + t.minute()) * + CalendarWeekView::PIXELS_PER_HOUR / 60; +} + +QTime CalendarWeekTimeGrid::YToTime(int y) const +{ + int totalMin = y * 60 / CalendarWeekView::PIXELS_PER_HOUR; + totalMin = qBound(0, totalMin, 24 * 60 - 1); + totalMin = (totalMin / 15) * 15; // snap to 15-min increments + return QTime(totalMin / 60, totalMin % 60); +} + +int CalendarWeekTimeGrid::DayWidth() const +{ + return std::max(CalendarWeekView::MIN_DAY_WIDTH, + (width() - CalendarWeekView::TIME_AXIS_WIDTH) / 7); +} + +int CalendarWeekTimeGrid::DayColumnX(int dayIndex) const +{ + return CalendarWeekView::TIME_AXIS_WIDTH + dayIndex * DayWidth(); +} + +void CalendarWeekTimeGrid::SetStartOfWeek(const QDate &date) +{ + _startOfWeek = date; + update(); +} + +void CalendarWeekTimeGrid::SetEvents(const QList &events) +{ + _events = events; + update(); +} + +void CalendarWeekTimeGrid::ScrollToCurrentTime(QScrollArea *sa) +{ + if (!sa) { + return; + } + const int y = qMax(0, TimeToY(QTime::currentTime().addSecs(-3600))); + sa->verticalScrollBar()->setValue(y); +} + +// Returns the portion of an event visible within [date 00:00, date+1 00:00). +// drawStart / drawEnd are times in that day's coordinate space. +static bool ClipEventToDay(const CalendarEvent &ev, const QDate &date, + QTime &drawStart, QTime &drawEnd) +{ + if (!ev.start.isValid()) { + return false; + } + const QDateTime dayStart(date, QTime(0, 0, 0)); + const QDateTime dayEnd(date.addDays(1), QTime(0, 0, 0)); + const QDateTime evEnd = ev.EffectiveEnd(); + + if (ev.start >= dayEnd || evEnd <= dayStart) { + return false; // no overlap + } + + drawStart = (ev.start < dayStart) ? QTime(0, 0, 0) : ev.start.time(); + drawEnd = (evEnd >= dayEnd) ? QTime(23, 59, 59) : evEnd.time(); + return true; +} + +QList +CalendarWeekTimeGrid::LayoutDay(int dayIndex) const +{ + const QDate date = _startOfWeek.addDays(dayIndex); + + // Collect events that overlap this day, sorted by their clipped start. + struct DayEvent { + CalendarEvent ev; + QTime drawStart; + QTime drawEnd; + }; + QList dayEvents; + for (const auto &ev : _events) { + QTime ds, de; + if (ClipEventToDay(ev, date, ds, de)) { + dayEvents.append({ev, ds, de}); + } + } + + std::sort(dayEvents.begin(), dayEvents.end(), + [](const DayEvent &a, const DayEvent &b) { + return a.drawStart < b.drawStart; + }); + + QList result; + QList columnEnds; + + for (const auto &de : dayEvents) { + int col = -1; + for (int i = 0; i < columnEnds.size(); ++i) { + if (columnEnds[i] <= de.drawStart) { + col = i; + columnEnds[i] = de.drawEnd; + break; + } + } + if (col == -1) { + col = columnEnds.size(); + columnEnds.append(de.drawEnd); + } + result.append({de.ev, de.drawStart, de.drawEnd, col, 0}); + } + + const int totalCols = qMax(1, (int)columnEnds.size()); + for (auto &layout : result) { + layout.numCols = totalCols; + } + return result; +} + +QString CalendarWeekTimeGrid::EventIdAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarWeekView::TIME_AXIS_WIDTH) { + return {}; + } + const int dayW = DayWidth(); + const int dayIdx = (pos.x() - CalendarWeekView::TIME_AXIS_WIDTH) / dayW; + if (dayIdx < 0 || dayIdx >= 7) { + return {}; + } + + const int dayX = DayColumnX(dayIdx); + for (const auto &layout : LayoutDay(dayIdx)) { + const int colW = dayW / layout.numCols; + const int x = dayX + layout.col * colW + 2; + const int w = colW - 4; + const int y = TimeToY(layout.drawStart); + const int h = qMax(TimeToY(layout.drawEnd) - y, 20); + if (QRect(x, y, w, h).contains(pos)) { + return layout.event.id; + } + } + return {}; +} + +QDateTime CalendarWeekTimeGrid::SlotAtPoint(const QPoint &pos) const +{ + if (pos.x() < CalendarWeekView::TIME_AXIS_WIDTH) { + return {}; + } + const int dayW = DayWidth(); + const int dayIdx = (pos.x() - CalendarWeekView::TIME_AXIS_WIDTH) / dayW; + if (dayIdx < 0 || dayIdx >= 7) { + return {}; + } + return QDateTime(_startOfWeek.addDays(dayIdx), YToTime(pos.y())); +} + +void CalendarWeekTimeGrid::DrawEvent(QPainter &p, const EventLayout &layout, + int dayX, int dayW) +{ + const auto &ev = layout.event; + const int colW = dayW / layout.numCols; + const int x = dayX + layout.col * colW + 2; + const int w = colW - 4; + const int y = TimeToY(layout.drawStart); + const int endY = TimeToY(layout.drawEnd); + const int h = qMax(endY - y, 20); + const QRect rect(x, y, w, h); + + p.setBrush(ev.color); + p.setPen(ev.color.darker(130)); + p.drawRoundedRect(rect, 3, 3); + + p.fillRect(x, y + 1, 3, h - 2, ev.color.darker(160)); + + p.setPen(Qt::white); + + QFont f = p.font(); + f.setPixelSize(10); + f.setBold(true); + p.setFont(f); + + const QRect textRect = rect.adjusted(6, 2, -2, -2); + if (h >= 34) { + QFont tf = f; + tf.setBold(false); + p.setFont(tf); + p.drawText(textRect, Qt::AlignTop | Qt::AlignLeft, + ev.start.toString("HH:mm")); + p.setFont(f); + p.drawText(textRect.adjusted(0, 14, 0, 0), + Qt::AlignTop | Qt::AlignLeft | Qt::TextWordWrap, + ev.title); + } else { + p.drawText(textRect, + Qt::AlignVCenter | Qt::AlignLeft | + Qt::TextSingleLine, + ev.title); + } +} + +void CalendarWeekTimeGrid::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + + const int w = width(); + const int h = height(); + const int dayW = DayWidth(); + const QDate today = QDate::currentDate(); + + p.fillRect(0, 0, w, h, palette().base()); + p.fillRect(0, 0, CalendarWeekView::TIME_AXIS_WIDTH, h, + palette().button()); + + for (int d = 0; d < 7; ++d) { + const QDate date = _startOfWeek.addDays(d); + const int x = DayColumnX(d); + if (date == today) { + p.fillRect(x, 0, dayW, h, + palette().highlight().color().lighter(190)); + } else if (date.dayOfWeek() >= 6) { + p.fillRect(x, 0, dayW, h, + palette().alternateBase().color()); + } + } + + QFont labelFont = p.font(); + labelFont.setPixelSize(10); + p.setFont(labelFont); + + for (int hour = 0; hour < 24; ++hour) { + const int y = hour * CalendarWeekView::PIXELS_PER_HOUR; + + p.setPen(palette().text().color()); + p.drawText(2, y, CalendarWeekView::TIME_AXIS_WIDTH - 6, + CalendarWeekView::PIXELS_PER_HOUR, + Qt::AlignTop | Qt::AlignRight, + QTime(hour, 0).toString("HH:mm")); + + p.setPen(QPen(palette().mid().color(), 1)); + p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, y, w, y); + + QPen halfPen(palette().midlight().color(), 1, Qt::DotLine); + p.setPen(halfPen); + const int yHalf = y + CalendarWeekView::PIXELS_PER_HOUR / 2; + p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, yHalf, w, yHalf); + } + + p.setPen(QPen(palette().mid().color(), 1)); + for (int d = 0; d <= 7; ++d) { + const int x = DayColumnX(d); + p.drawLine(x, 0, x, h); + } + p.drawLine(CalendarWeekView::TIME_AXIS_WIDTH, 0, + CalendarWeekView::TIME_AXIS_WIDTH, h); + + for (int d = 0; d < 7; ++d) { + const int dayX = DayColumnX(d); + for (const auto &layout : LayoutDay(d)) { + DrawEvent(p, layout, dayX, dayW); + } + } + + // Current-time indicator + if (_startOfWeek.isValid() && _startOfWeek <= today && + today <= _startOfWeek.addDays(6)) { + const int dayIdx = _startOfWeek.daysTo(today); + const int dayX = DayColumnX(dayIdx); + const int y = TimeToY(QTime::currentTime()); + + p.setPen(QPen(QColor(220, 30, 30), 2)); + p.drawLine(dayX, y, dayX + dayW, y); + + p.setBrush(QColor(220, 30, 30)); + p.setPen(Qt::NoPen); + p.drawEllipse(dayX - 4, y - 4, 8, 8); + } +} + +void CalendarWeekTimeGrid::mousePressEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventClicked(id); + return; + } + const QDateTime slot = SlotAtPoint(e->pos()); + if (slot.isValid()) { + emit SlotClicked(slot); + } +} + +void CalendarWeekTimeGrid::mouseDoubleClickEvent(QMouseEvent *e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + const QString id = EventIdAtPoint(e->pos()); + if (!id.isEmpty()) { + emit EventDoubleClicked(id); + } +} + +// --------------------------------------------------------------------------- +// CalendarWeekView +// --------------------------------------------------------------------------- + +CalendarWeekView::CalendarWeekView(QWidget *parent) : CalendarView(parent) +{ + _header = new CalendarWeekDayHeader(this); + _timeGrid = new CalendarWeekTimeGrid(this); + + // Fix the minimum height so the scroll area cannot shrink the grid + // below the full 24-hour span — which would eliminate the scrollbar. + _timeGrid->setMinimumHeight(24 * CalendarWeekView::PIXELS_PER_HOUR); + + _scrollArea = new QScrollArea(this); + _scrollArea->setWidget(_timeGrid); + // widgetResizable(true) lets the grid expand horizontally to fill the + // viewport width while the fixed minimumHeight enforces vertical scrolling. + _scrollArea->setWidgetResizable(true); + _scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + _scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + _scrollArea->setFrameShape(QFrame::NoFrame); + + auto layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(_header); + layout->addWidget(_scrollArea); + setLayout(layout); + + connect(_timeGrid, &CalendarWeekTimeGrid::SlotClicked, this, + &CalendarWeekView::SlotClicked); + connect(_timeGrid, &CalendarWeekTimeGrid::EventClicked, this, + &CalendarWeekView::EventClicked); + connect(_timeGrid, &CalendarWeekTimeGrid::EventDoubleClicked, this, + &CalendarWeekView::EventDoubleClicked); + + SetDate(QDate::currentDate()); +} + +void CalendarWeekView::SetDate(const QDate &date) +{ + _startOfWeek = date.addDays(-(date.dayOfWeek() - 1)); + UpdateViews(); + emit VisibleRangeChanged(RangeStart(), RangeEnd()); +} + +void CalendarWeekView::SetEvents(const QList &events) +{ + _events = events; + _timeGrid->SetEvents(events); +} + +QDate CalendarWeekView::CurrentDate() const +{ + return _startOfWeek.addDays(3); // Wednesday - stable mid-point +} + +QDate CalendarWeekView::RangeStart() const +{ + return _startOfWeek; +} + +QDate CalendarWeekView::RangeEnd() const +{ + return _startOfWeek.addDays(6); +} + +void CalendarWeekView::UpdateViews() +{ + _header->SetStartOfWeek(_startOfWeek); + _timeGrid->SetStartOfWeek(_startOfWeek); + _timeGrid->ScrollToCurrentTime(_scrollArea); +} + +} // namespace advss + +// Required for CalendarWeekTimeGrid defined in this file +#include "calendar-week-view.moc" diff --git a/plugins/schedule/calendar/calendar-week-view.hpp b/plugins/schedule/calendar/calendar-week-view.hpp new file mode 100644 index 00000000..cabc224b --- /dev/null +++ b/plugins/schedule/calendar/calendar-week-view.hpp @@ -0,0 +1,45 @@ +#pragma once +#include "calendar-view.hpp" + +#include + +namespace advss { + +class CalendarWeekDayHeader; +class CalendarWeekTimeGrid; + +// Displays 7 day columns with a vertical time axis (00:00 - 24:00). +// Events are rendered as colored blocks sized to their duration. +// Overlapping events within the same day are arranged in side-by-side +// sub-columns. A red indicator marks the current time. +class CalendarWeekView : public CalendarView { + Q_OBJECT + +public: + explicit CalendarWeekView(QWidget *parent = nullptr); + + void SetDate(const QDate &date) override; + void SetEvents(const QList &events) override; + + QDate CurrentDate() const override; + QDate RangeStart() const override; + QDate RangeEnd() const override; + + // Shared layout constants (used by DayHeader and TimeGrid) + static constexpr int TIME_AXIS_WIDTH = 56; + static constexpr int PIXELS_PER_HOUR = 64; + static constexpr int DAY_HEADER_HEIGHT = 36; + static constexpr int MIN_DAY_WIDTH = 80; + +private: + void UpdateViews(); + + CalendarWeekDayHeader *_header; + CalendarWeekTimeGrid *_timeGrid; + QScrollArea *_scrollArea; + + QDate _startOfWeek; + QList _events; +}; + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-widget.cpp b/plugins/schedule/calendar/calendar-widget.cpp new file mode 100644 index 00000000..4dc0f906 --- /dev/null +++ b/plugins/schedule/calendar/calendar-widget.cpp @@ -0,0 +1,308 @@ +#include "calendar-widget.hpp" + +#include "obs-module-helper.hpp" +#include "ui-helpers.hpp" + +#include +#include +#include + +namespace advss { + +CalendarWidget::CalendarWidget(QWidget *parent) + : QWidget(parent), + _prevBtn(new QPushButton(this)), + _nextBtn(new QPushButton(this)), + _todayBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.today"), this)), + _navLabel(new QLabel(this)), + _monthBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.month"), this)), + _weekBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.week"), this)), + _dayBtn(new QPushButton( + obs_module_text("AdvSceneSwitcher.calendar.day"), this)), + _viewStack(new QStackedWidget(this)), + _monthView(new CalendarMonthView(this)), + _weekView(new CalendarWeekView(this)), + _dayView(new CalendarDayView(this)) +{ + // --- Navigation bar --- + SetButtonIcon(_prevBtn, GetThemeTypeName() == "Light" + ? "theme:Light/left.svg" + : "theme:Dark/left.svg"); + SetButtonIcon(_nextBtn, GetThemeTypeName() == "Light" + ? "theme:Light/right.svg" + : "theme:Dark/right.svg"); + _navLabel->setAlignment(Qt::AlignCenter); + + _monthBtn->setCheckable(true); + _weekBtn->setCheckable(true); + _dayBtn->setCheckable(true); + _weekBtn->setChecked(true); + + auto navBar = new QHBoxLayout(); + navBar->addWidget(_prevBtn); + navBar->addWidget(_nextBtn); + navBar->addWidget(_todayBtn); + navBar->addStretch(); + navBar->addWidget(_navLabel, 1); + navBar->addStretch(); + navBar->addWidget(_monthBtn); + navBar->addWidget(_weekBtn); + navBar->addWidget(_dayBtn); + + // --- Views --- + _viewStack->addWidget(_monthView); + _viewStack->addWidget(_weekView); + _viewStack->addWidget(_dayView); + + // --- Main layout --- + auto mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->addLayout(navBar); + mainLayout->addWidget(_viewStack, 1); + setLayout(mainLayout); + + // --- Connections --- + connect(_prevBtn, &QPushButton::clicked, this, + &CalendarWidget::OnPrevClicked); + connect(_nextBtn, &QPushButton::clicked, this, + &CalendarWidget::OnNextClicked); + connect(_todayBtn, &QPushButton::clicked, this, + &CalendarWidget::OnTodayClicked); + connect(_monthBtn, &QPushButton::clicked, this, + &CalendarWidget::OnMonthModeClicked); + connect(_weekBtn, &QPushButton::clicked, this, + &CalendarWidget::OnWeekModeClicked); + connect(_dayBtn, &QPushButton::clicked, this, + &CalendarWidget::OnDayModeClicked); + + ConnectView(_monthView); + ConnectView(_weekView); + ConnectView(_dayView); + + // Start in week view + SwitchToView(_weekView); + UpdateNavLabel(); +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +void CalendarWidget::SetEvents(const QList &events) +{ + _events = events; + _monthView->SetEvents(events); + _weekView->SetEvents(events); + _dayView->SetEvents(events); +} + +void CalendarWidget::AddEvent(const CalendarEvent &event) +{ + _events.append(event); + _monthView->SetEvents(_events); + _weekView->SetEvents(_events); + _dayView->SetEvents(_events); +} + +void CalendarWidget::RemoveEvent(const QString &id) +{ + _events.erase(std::remove_if(_events.begin(), _events.end(), + [&id](const CalendarEvent &e) { + return e.id == id; + }), + _events.end()); + _monthView->SetEvents(_events); + _weekView->SetEvents(_events); + _dayView->SetEvents(_events); +} + +void CalendarWidget::ClearEvents() +{ + _events.clear(); + _monthView->SetEvents(_events); + _weekView->SetEvents(_events); + _dayView->SetEvents(_events); +} + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +void CalendarWidget::GoToDate(const QDate &date) +{ + _activeView->SetDate(date); + UpdateNavLabel(); +} + +void CalendarWidget::GoToToday() +{ + GoToDate(QDate::currentDate()); +} + +QDate CalendarWidget::VisibleRangeStart() const +{ + return _activeView ? _activeView->RangeStart() : QDate(); +} + +QDate CalendarWidget::VisibleRangeEnd() const +{ + return _activeView ? _activeView->RangeEnd() : QDate(); +} + +void CalendarWidget::OnPrevClicked() +{ + if (_viewMode == ViewMode::Month) { + _activeView->SetDate(_activeView->CurrentDate().addMonths(-1)); + } else if (_viewMode == ViewMode::Week) { + _activeView->SetDate(_activeView->RangeStart().addDays(-7)); + } else { + _activeView->SetDate(_activeView->CurrentDate().addDays(-1)); + } + UpdateNavLabel(); +} + +void CalendarWidget::OnNextClicked() +{ + if (_viewMode == ViewMode::Month) { + _activeView->SetDate(_activeView->CurrentDate().addMonths(1)); + } else if (_viewMode == ViewMode::Week) { + _activeView->SetDate(_activeView->RangeStart().addDays(7)); + } else { + _activeView->SetDate(_activeView->CurrentDate().addDays(1)); + } + UpdateNavLabel(); +} + +void CalendarWidget::OnTodayClicked() +{ + GoToToday(); +} + +void CalendarWidget::OnMonthModeClicked() +{ + SetViewMode(ViewMode::Month); +} + +void CalendarWidget::OnWeekModeClicked() +{ + SetViewMode(ViewMode::Week); +} + +void CalendarWidget::OnDayModeClicked() +{ + SetViewMode(ViewMode::Day); +} + +void CalendarWidget::OnViewRangeChanged(const QDate &start, const QDate &end) +{ + emit VisibleRangeChanged(start, end); + UpdateNavLabel(); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +void CalendarWidget::ConnectView(CalendarView *view) +{ + connect(view, &CalendarView::SlotClicked, this, + &CalendarWidget::SlotClicked); + connect(view, &CalendarView::EventClicked, this, + &CalendarWidget::EventClicked); + connect(view, &CalendarView::EventDoubleClicked, this, + &CalendarWidget::EventDoubleClicked); + connect(view, &CalendarView::VisibleRangeChanged, this, + &CalendarWidget::OnViewRangeChanged); +} + +void CalendarWidget::SwitchToView(CalendarView *view) +{ + _activeView = view; + _viewStack->setCurrentWidget(view); + view->SetEvents(_events); + UpdateNavLabel(); +} + +void CalendarWidget::SetViewMode(ViewMode mode) +{ + if (_viewMode == mode) { + return; + } + _viewMode = mode; + + // Preserve the current date when switching views + const QDate current = _activeView->CurrentDate(); + + _monthBtn->setChecked(mode == ViewMode::Month); + _weekBtn->setChecked(mode == ViewMode::Week); + _dayBtn->setChecked(mode == ViewMode::Day); + + switch (mode) { + case ViewMode::Month: + SwitchToView(_monthView); + _monthView->SetDate(current); + break; + case ViewMode::Week: + SwitchToView(_weekView); + _weekView->SetDate(current); + break; + case ViewMode::Day: { + SwitchToView(_dayView); + const QDate today = QDate::currentDate(); + const QDate rangeStart = _activeView->RangeStart(); + const QDate rangeEnd = _activeView->RangeEnd(); + const QDate target = (today >= rangeStart && today <= rangeEnd) + ? today + : rangeStart; + _dayView->SetDate(target); + break; + } + } + UpdateNavLabel(); +} + +void CalendarWidget::UpdateNavLabel() +{ + if (!_activeView) { + return; + } + QLocale locale; + const QDate cur = _activeView->CurrentDate(); + + if (_viewMode == ViewMode::Month) { + _navLabel->setText(locale.monthName(cur.month()) + " " + + QString::number(cur.year())); + } else if (_viewMode == ViewMode::Day) { + // Day view: "Monday, April 13, 2026" + _navLabel->setText(locale.dayName(cur.dayOfWeek()) + ", " + + locale.monthName(cur.month()) + " " + + QString::number(cur.day()) + ", " + + QString::number(cur.year())); + } else { + // Week view: "Apr 7 – Apr 13, 2026" + const QDate s = _activeView->RangeStart(); + const QDate e = _activeView->RangeEnd(); + if (s.month() == e.month()) { + _navLabel->setText( + locale.monthName(s.month(), + QLocale::ShortFormat) + + " " + QString::number(s.day()) + " - " + + QString::number(e.day()) + ", " + + QString::number(s.year())); + } else { + _navLabel->setText( + locale.monthName(s.month(), + QLocale::ShortFormat) + + " " + QString::number(s.day()) + " - " + + locale.monthName(e.month(), + QLocale::ShortFormat) + + " " + QString::number(e.day()) + ", " + + QString::number(s.year())); + } + } +} + +} // namespace advss diff --git a/plugins/schedule/calendar/calendar-widget.hpp b/plugins/schedule/calendar/calendar-widget.hpp new file mode 100644 index 00000000..06ac8e41 --- /dev/null +++ b/plugins/schedule/calendar/calendar-widget.hpp @@ -0,0 +1,90 @@ +#pragma once +#include "calendar-day-view.hpp" +#include "calendar-event.hpp" +#include "calendar-month-view.hpp" +#include "calendar-week-view.hpp" + +#include +#include +#include +#include +#include + +namespace advss { + +class CalendarWidget : public QWidget { + Q_OBJECT + +public: + enum class ViewMode { Month, Week, Day }; + + explicit CalendarWidget(QWidget *parent = nullptr); + + // --- View --- + void SetViewMode(ViewMode mode); + ViewMode GetViewMode() const { return _viewMode; } + + // --- Events --- + void SetEvents(const QList &events); + void AddEvent(const CalendarEvent &event); + void RemoveEvent(const QString &id); + void ClearEvents(); + const QList &GetEvents() const { return _events; } + + // --- Navigation --- + void GoToDate(const QDate &date); + void GoToToday(); + + // --- Visible range --- + QDate VisibleRangeStart() const; + QDate VisibleRangeEnd() const; + +signals: + // User clicked an empty slot in the active view. + void SlotClicked(const QDateTime &startTime); + + // User single-clicked an event. + void EventClicked(const QString &eventId); + + // User double-clicked an event (typically: open edit dialog). + void EventDoubleClicked(const QString &eventId); + + // The visible date range changed; reload your events for [start, end]. + void VisibleRangeChanged(const QDate &rangeStart, + const QDate &rangeEnd); + +private slots: + void OnPrevClicked(); + void OnNextClicked(); + void OnTodayClicked(); + void OnMonthModeClicked(); + void OnWeekModeClicked(); + void OnDayModeClicked(); + void OnViewRangeChanged(const QDate &start, const QDate &end); + +private: + void ConnectView(CalendarView *view); + void SwitchToView(CalendarView *view); + void UpdateNavLabel(); + + // Navigation bar widgets + QPushButton *_prevBtn; + QPushButton *_nextBtn; + QPushButton *_todayBtn; + QLabel *_navLabel; + QPushButton *_monthBtn; + QPushButton *_weekBtn; + QPushButton *_dayBtn; + + // View stack + QStackedWidget *_viewStack; + CalendarMonthView *_monthView; + CalendarWeekView *_weekView; + CalendarDayView *_dayView; + CalendarView *_activeView = nullptr; + + ViewMode _viewMode = ViewMode::Week; + QList _events; +}; + +} // namespace advss