This commit is contained in:
WarmUpTill 2026-05-07 19:59:12 +00:00 committed by GitHub
commit 25489dea6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 4388 additions and 54 deletions

View File

@ -134,6 +134,9 @@ target_sources(
lib/macro/macro-dock-window.hpp
lib/macro/macro-edit.cpp
lib/macro/macro-edit.hpp
lib/macro/macro-export-builtin-extensions.cpp
lib/macro/macro-export-extensions.cpp
lib/macro/macro-export-extensions.hpp
lib/macro/macro-export-import-dialog.cpp
lib/macro/macro-export-import-dialog.hpp
lib/macro/macro-helpers.cpp

View File

@ -202,6 +202,13 @@ AdvSceneSwitcher.macroTab.remove="Remove"
AdvSceneSwitcher.macroTab.export="Export"
AdvSceneSwitcher.macroTab.export.info="Paste the string below into the import dialog to import the selected macros:"
AdvSceneSwitcher.macroTab.export.usePlainText="Use plain text"
AdvSceneSwitcher.macroTab.export.additionalContent="Export Additional Content"
AdvSceneSwitcher.macroTab.export.variables="Variables"
AdvSceneSwitcher.macroTab.export.actionQueues="Action Queues"
AdvSceneSwitcher.macroTab.export.httpServers="HTTP Servers"
AdvSceneSwitcher.macroTab.export.macroScheduleEntries="Macro Schedule"
AdvSceneSwitcher.macroTab.export.twitchConnections="Twitch Connections"
AdvSceneSwitcher.macroTab.export.websocketConnections="Websocket Connections"
AdvSceneSwitcher.macroTab.import="Import"
AdvSceneSwitcher.macroTab.import.info="Paste the export string into the below text box to import macros:"
AdvSceneSwitcher.macroTab.import.invalid="Invalid import data provided!"
@ -2705,6 +2712,64 @@ 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"
# Macro Schedule tab
AdvSceneSwitcher.macroScheduleTab.title="Macro Schedule"
AdvSceneSwitcher.macroScheduleTab.help="No scheduled entries.\nClick '+' to schedule a macro."
AdvSceneSwitcher.macroScheduleTab.add="Add"
AdvSceneSwitcher.macroScheduleTab.add.tooltip="Add schedule entry"
AdvSceneSwitcher.macroScheduleTab.edit="Edit"
AdvSceneSwitcher.macroScheduleTab.remove="Remove"
AdvSceneSwitcher.macroScheduleTab.remove.tooltip="Remove schedule entry"
AdvSceneSwitcher.macroScheduleTab.enable="Enable"
AdvSceneSwitcher.macroScheduleTab.disable="Disable"
AdvSceneSwitcher.macroScheduleTab.showAll="Show All"
AdvSceneSwitcher.macroScheduleTab.column.name="Name"
AdvSceneSwitcher.macroScheduleTab.column.macro="Macro"
AdvSceneSwitcher.macroScheduleTab.column.schedule="Schedule"
AdvSceneSwitcher.macroScheduleTab.column.nextTrigger="Next Trigger"
AdvSceneSwitcher.macroScheduleTab.column.status="Status"
AdvSceneSwitcher.macroScheduleTab.remove.confirm="Are you sure you want to remove \"%1\"?"
AdvSceneSwitcher.macroScheduleTab.remove.confirmMultiple="Are you sure you want to remove %1 entries?"
AdvSceneSwitcher.macroScheduleTab.status.active="Active"
AdvSceneSwitcher.macroScheduleTab.status.disabled="Disabled"
AdvSceneSwitcher.macroScheduleTab.status.expired="Expired"
# Macro Schedule entry dialog
AdvSceneSwitcher.macroScheduleEntry.dialog.title.add="Add Schedule Entry"
AdvSceneSwitcher.macroScheduleEntry.dialog.title.edit="Edit Schedule Entry"
AdvSceneSwitcher.macroScheduleEntry.name="Name (optional):"
AdvSceneSwitcher.macroScheduleEntry.name.placeholder="e.g. Daily stream start"
AdvSceneSwitcher.macroScheduleEntry.macro="Macro:"
AdvSceneSwitcher.macroScheduleEntry.startDateTime="Start:"
AdvSceneSwitcher.macroScheduleEntry.endDate="End date:"
AdvSceneSwitcher.macroScheduleEntry.endDate.help="Controls how far the entry spans visually in the calendar and optionally triggers an action when the date is reached."
AdvSceneSwitcher.macroScheduleEntry.endDate.action.none="No action"
AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry="Disable schedule entry"
AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro="Pause macro"
AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro="Stop macro"
AdvSceneSwitcher.macroScheduleEntry.repeat="Repeat"
AdvSceneSwitcher.macroScheduleEntry.repeat.once="Once"
AdvSceneSwitcher.macroScheduleEntry.repeat.every="Every %1"
AdvSceneSwitcher.macroScheduleEntry.repeat.interval="Repeat interval:"
AdvSceneSwitcher.macroScheduleEntry.noMacro="(no macro)"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd="End repeat:"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never="Never"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes="After"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes.suffix="occurrence(s)"
AdvSceneSwitcher.macroScheduleEntry.repeatEnd.untilDate="Until"
AdvSceneSwitcher.macroScheduleEntry.color="Color:"
AdvSceneSwitcher.macroScheduleEntry.color.dialog="Pick entry color"
AdvSceneSwitcher.macroScheduleEntry.checkConditions="Check macro conditions before running"
AdvSceneSwitcher.macroScheduleEntry.runElseActionsOnConditionFailure="Run else actions if conditions are not met"
AdvSceneSwitcher.macroScheduleEntry.enabled="Enabled"
# Legacy tabs below - please don't waste your time adding translations for these :)
# Transition Tab
AdvSceneSwitcher.transitionTab.title="Transition"

View File

@ -0,0 +1,89 @@
#include "macro-export-extensions.hpp"
#include "action-queue.hpp"
#include "variable.hpp"
#include <obs.hpp>
namespace advss {
static bool setup();
static bool setupDone = setup();
static bool setup()
{
// --- Variables ---
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.variables", "variables",
[](obs_data_t *data, const QStringList &selectedIds) {
if (selectedIds.isEmpty()) {
SaveVariables(data);
return;
}
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &v : GetVariables()) {
const QString name =
QString::fromStdString(v->Name());
if (!selectedIds.contains(name)) {
continue;
}
OBSDataAutoRelease item = obs_data_create();
v->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "variables", array);
},
[](obs_data_t *data, const QStringList &) {
ImportVariables(data);
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &v : GetVariables()) {
const QString name =
QString::fromStdString(v->Name());
items.append({name, name});
}
return items;
}});
// --- Action Queues ---
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.actionQueues",
"actionQueues",
[](obs_data_t *data, const QStringList &selectedIds) {
if (selectedIds.isEmpty()) {
SaveActionQueues(data);
return;
}
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &q : GetActionQueues()) {
const QString name =
QString::fromStdString(q->Name());
if (!selectedIds.contains(name)) {
continue;
}
OBSDataAutoRelease item = obs_data_create();
q->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "actionQueues", array);
},
[](obs_data_t *data, const QStringList &) {
ImportQueues(data);
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &q : GetActionQueues()) {
const QString name =
QString::fromStdString(q->Name());
items.append({name, name});
}
return items;
}});
return true;
}
} // namespace advss

View File

@ -0,0 +1,17 @@
#include "macro-export-extensions.hpp"
namespace advss {
static std::vector<MacroExportExtension> extensions;
void AddMacroExportExtension(const MacroExportExtension &ext)
{
extensions.emplace_back(ext);
}
const std::vector<MacroExportExtension> &GetMacroExportExtensions()
{
return extensions;
}
} // namespace advss

View File

@ -0,0 +1,41 @@
#pragma once
#include "export-symbol-helper.hpp"
#include <functional>
#include <obs-data.h>
#include <QList>
#include <QPair>
#include <QString>
#include <vector>
namespace advss {
struct MacroExportExtension {
const char *displayNameKey;
const char *jsonKey;
// Export callback.
// Write content into 'data'. 'selectedIds' contains the item IDs the
// user explicitly checked; if empty (no per-item widget provided) save
// everything.
std::function<void(obs_data_t *data, const QStringList &selectedIds)>
save;
// Import callback.
// Read content from 'data'. 'selectedIds' contains the item IDs the
// user explicitly checked; if empty import every item found in 'data'.
std::function<void(obs_data_t *data, const QStringList &selectedIds)>
load;
// Optional: enumerate items available for export as (id, displayName)
// pairs. When nullptr the extension appears as a single all-or-nothing
// checkbox in the export dialog.
std::function<QList<QPair<QString, QString>>()> getExportItems =
nullptr;
};
EXPORT void AddMacroExportExtension(const MacroExportExtension &ext);
const std::vector<MacroExportExtension> &GetMacroExportExtensions();
} // namespace advss

View File

@ -1,22 +1,160 @@
#include "macro-export-import-dialog.hpp"
#include "macro-export-extensions.hpp"
#include "obs-module-helper.hpp"
#include <obs.hpp>
#include <QLayout>
#include <QLabel>
#include <QDialogButtonBox>
#include <QLabel>
#include <QLayout>
#include <algorithm>
#include <numeric>
namespace advss {
static bool usePlainText = false;
MacroExportImportDialog::MacroExportImportDialog(Type type)
// ---------------------------------------------------------------------------
// Compress / decompress helpers
// ---------------------------------------------------------------------------
static QString compressMacroString(const QString &input)
{
QByteArray inputData = input.toUtf8();
auto compressedData = qCompress(inputData);
QByteArray encodedData = compressedData.toBase64();
return QString::fromUtf8(encodedData);
}
static QString decompressMacroString(const QString &input)
{
QByteArray encodedData = input.toUtf8();
QByteArray compressedData = QByteArray::fromBase64(encodedData);
auto outputData = qUncompress(compressedData);
return QString::fromUtf8(outputData);
}
static bool isValidData(const QString &json)
{
OBSDataAutoRelease data =
obs_data_create_from_json(json.toStdString().c_str());
return !!data;
}
// ---------------------------------------------------------------------------
// Extension section
// ---------------------------------------------------------------------------
Section *MacroExportImportDialog::BuildExtensionSection()
{
auto outerSection = new Section(300, this);
outerSection->AddHeaderWidget(new QLabel(obs_module_text(
"AdvSceneSwitcher.macroTab.export.additionalContent")));
auto outerContent = new QWidget();
auto outerLayout = new QVBoxLayout(outerContent);
outerLayout->setContentsMargins(4, 4, 4, 4);
outerLayout->setSpacing(4);
const auto &extensions = GetMacroExportExtensions();
// Build a sorted index list (alphabetical by translated display name).
std::vector<int> order(extensions.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int a, int b) {
return QString::localeAwareCompare(
obs_module_text(extensions[a].displayNameKey),
obs_module_text(extensions[b].displayNameKey)) <
0;
});
for (int extIdx : order) {
const auto &ext = extensions[extIdx];
ExtensionUI ui;
// Simple all-or-nothing checkbox (no sub-items).
if (!ext.getExportItems) {
ui.mainCheck = new QCheckBox(
obs_module_text(ext.displayNameKey),
outerContent);
ui.mainCheck->setChecked(false);
connect(ui.mainCheck, &QCheckBox::stateChanged, this,
&MacroExportImportDialog::UpdateExportString);
outerLayout->addWidget(ui.mainCheck);
_extensionUIs.append(std::move(ui));
continue;
}
// Extensions with per-item selection get their own inner
// Section: the main checkbox sits in the header (next to the
// toggle arrow) and the per-item checkboxes live in the
// collapsible content area.
const auto items = ext.getExportItems();
if (items.isEmpty()) {
continue;
}
auto innerSection = new Section(200, outerContent);
ui.mainCheck =
new QCheckBox(obs_module_text(ext.displayNameKey));
ui.mainCheck->setChecked(false);
innerSection->AddHeaderWidget(ui.mainCheck);
auto subWidget = new QWidget();
auto subLayout = new QVBoxLayout(subWidget);
subLayout->setContentsMargins(4, 2, 4, 2);
subLayout->setSpacing(2);
for (const auto &[id, displayName] : items) {
auto cb = new QCheckBox(displayName, subWidget);
cb->setChecked(false);
subLayout->addWidget(cb);
ui.itemChecks.append({id, cb});
connect(cb, &QCheckBox::stateChanged, this,
&MacroExportImportDialog::UpdateExportString);
}
// Main checkbox toggles all sub-items.
// Capture uiIdx (position in _extensionUIs after append).
const int uiIdx = _extensionUIs.size();
connect(ui.mainCheck, &QCheckBox::stateChanged, this,
[this, uiIdx](int state) {
if (state == Qt::PartiallyChecked)
return;
const bool checked = (state == Qt::Checked);
if (uiIdx < _extensionUIs.size()) {
for (auto &[id, cb] :
_extensionUIs[uiIdx].itemChecks) {
QSignalBlocker b(cb);
cb->setChecked(checked);
}
}
UpdateExportString();
});
innerSection->SetContent(subWidget, false);
outerLayout->addWidget(innerSection);
_extensionUIs.append(std::move(ui));
}
outerSection->SetContent(outerContent, true);
return outerSection;
}
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
MacroExportImportDialog::MacroExportImportDialog(Type type,
const QString &baseJson)
: QDialog(nullptr),
_baseJson(baseJson),
_importExportString(new QPlainTextEdit(this)),
_usePlainText(new QCheckBox(obs_module_text(
"AdvSceneSwitcher.macroTab.export.usePlainText")))
{
_importExportString->setReadOnly(type == Type::EXPORT_MACRO);
auto label = new QLabel(obs_module_text(
type == Type::EXPORT_MACRO
? "AdvSceneSwitcher.macroTab.export.info"
@ -39,7 +177,12 @@ MacroExportImportDialog::MacroExportImportDialog(Type type)
connect(_usePlainText, &QCheckBox::stateChanged, this,
&MacroExportImportDialog::UsePlainTextChanged);
auto layout = new QVBoxLayout;
auto layout = new QVBoxLayout(this);
if (type == Type::EXPORT_MACRO && !GetMacroExportExtensions().empty()) {
layout->addWidget(BuildExtensionSection());
}
layout->addWidget(label);
layout->addWidget(_importExportString);
layout->addWidget(_usePlainText);
@ -47,32 +190,57 @@ MacroExportImportDialog::MacroExportImportDialog(Type type)
setLayout(layout);
setWindowTitle(obs_module_text("AdvSceneSwitcher.windowTitle"));
if (type == Type::EXPORT_MACRO) {
RefreshExportText();
}
}
QString compressMacroString(const QString &input)
// ---------------------------------------------------------------------------
// Export string helpers
// ---------------------------------------------------------------------------
QString MacroExportImportDialog::BuildExportJson() const
{
QByteArray inputData = input.toUtf8();
auto compressedData = qCompress(inputData);
QByteArray encodedData = compressedData.toBase64();
return QString::fromUtf8(encodedData);
OBSDataAutoRelease data =
obs_data_create_from_json(_baseJson.toStdString().c_str());
if (!data) {
return _baseJson;
}
const auto &extensions = GetMacroExportExtensions();
for (int i = 0;
i < (int)extensions.size() && i < (int)_extensionUIs.size(); ++i) {
if (!_extensionUIs[i].mainCheck->isChecked()) {
continue;
}
QStringList selectedIds;
for (const auto &[id, cb] : _extensionUIs[i].itemChecks) {
if (cb->isChecked()) {
selectedIds << id;
}
}
extensions[i].save(data, selectedIds);
}
const char *json = obs_data_get_json(data);
return json ? QString::fromUtf8(json) : QString{};
}
QString decompressMacroString(const QString &input)
void MacroExportImportDialog::RefreshExportText()
{
QByteArray encodedData = input.toUtf8();
QByteArray compressedData = QByteArray::fromBase64(encodedData);
auto outputData = qUncompress(compressedData);
return QString::fromUtf8(outputData);
const QString json = BuildExportJson();
if (usePlainText) {
_importExportString->setPlainText(json);
} else {
_importExportString->setPlainText(compressMacroString(json));
}
}
void MacroExportImportDialog::ExportMacros(const QString &json)
void MacroExportImportDialog::UpdateExportString()
{
MacroExportImportDialog dialog(
MacroExportImportDialog::Type::EXPORT_MACRO);
dialog._importExportString->setPlainText(compressMacroString(json));
dialog.adjustSize();
dialog.updateGeometry();
dialog.exec();
RefreshExportText();
}
void MacroExportImportDialog::UsePlainTextChanged(int value)
@ -90,26 +258,45 @@ void MacroExportImportDialog::UsePlainTextChanged(int value)
usePlainText = value;
}
static bool isValidData(const QString &json)
// ---------------------------------------------------------------------------
// Static entry points
// ---------------------------------------------------------------------------
void MacroExportImportDialog::ExportMacros(obs_data_t *macroData)
{
OBSDataAutoRelease data =
obs_data_create_from_json(json.toStdString().c_str());
return !!data;
const char *rawJson = obs_data_get_json(macroData);
const QString baseJson = rawJson ? QString::fromUtf8(rawJson)
: QString{};
MacroExportImportDialog dialog(Type::EXPORT_MACRO, baseJson);
dialog.adjustSize();
dialog.updateGeometry();
dialog.exec();
}
bool MacroExportImportDialog::ImportMacros(QString &json)
{
MacroExportImportDialog dialog(
MacroExportImportDialog::Type::IMPORT_MACRO);
if (dialog.exec() == QDialog::Accepted) {
json = decompressMacroString(
dialog._importExportString->toPlainText());
if (!isValidData(json)) { // Fallback to support raw json format
json = dialog._importExportString->toPlainText();
}
return true;
MacroExportImportDialog dialog(Type::IMPORT_MACRO);
if (dialog.exec() != QDialog::Accepted) {
return false;
}
return false;
json = decompressMacroString(dialog._importExportString->toPlainText());
if (!isValidData(json)) {
// Fallback: support raw (uncompressed) JSON pasted directly.
json = dialog._importExportString->toPlainText();
}
// Invoke all extension load callbacks.
OBSDataAutoRelease data =
obs_data_create_from_json(json.toStdString().c_str());
if (data) {
for (const auto &ext : GetMacroExportExtensions()) {
ext.load(data, {});
}
}
return true;
}
} // namespace advss

View File

@ -1,25 +1,47 @@
#pragma once
#include "section.hpp"
#include <QCheckBox>
#include <QDialog>
#include <QList>
#include <QPlainTextEdit>
#include <obs-data.h>
namespace advss {
class MacroExportImportDialog : public QDialog {
Q_OBJECT
public:
enum class Type { EXPORT_MACRO, IMPORT_MACRO };
MacroExportImportDialog(Type type);
static void ExportMacros(const QString &json);
static void ExportMacros(obs_data_t *macroData);
static bool ImportMacros(QString &json);
private slots:
void UsePlainTextChanged(int);
void UpdateExportString();
private:
explicit MacroExportImportDialog(Type type,
const QString &baseJson = {});
// Per-extension widgets
struct ExtensionUI {
QCheckBox *mainCheck = nullptr;
// (itemId, checkbox) pairs - empty when no per-item selection.
QList<QPair<QString, QCheckBox *>> itemChecks;
};
Section *BuildExtensionSection();
QString BuildExportJson() const;
void RefreshExportText();
QString _baseJson;
QPlainTextEdit *_importExportString;
QCheckBox *_usePlainText;
QList<ExtensionUI> _extensionUIs;
};
} // namespace advss

View File

@ -214,6 +214,20 @@ bool MacroIsPaused(const Macro *macro)
return macro ? macro->Paused() : true;
}
void SetMacroPaused(Macro *macro, bool paused)
{
if (macro) {
macro->SetPaused(paused);
}
}
void StopMacro(Macro *macro)
{
if (macro) {
macro->Stop();
}
}
bool MacroWasPausedSince(
const Macro *macro,
const std::chrono::high_resolution_clock::time_point &time)
@ -237,14 +251,20 @@ void AddMacroHelperThread(Macro *macro, std::thread &&newThread)
macro->AddHelperThread(std::move(newThread));
}
bool RunMacroActions(Macro *macro)
bool RunMacroActions(Macro *macro, bool forceParallel, bool ignorePause)
{
return macro && macro->PerformActions(true);
return macro && macro->PerformActions(true, forceParallel, ignorePause);
}
bool RunMacroElseActions(Macro *macro)
bool RunMacroElseActions(Macro *macro, bool forceParallel, bool ignorePause)
{
return macro && macro->PerformActions(false);
return macro &&
macro->PerformActions(false, forceParallel, ignorePause);
}
bool CheckMacroConditions(Macro *macro, bool ignorePause)
{
return macro && macro->CheckConditions(ignorePause);
}
void ResetMacroConditionTimers(Macro *macro)

View File

@ -58,6 +58,8 @@ LastMacroConditionCheckTime(const Macro *);
EXPORT bool MacroIsStopped(const Macro *);
EXPORT bool MacroIsPaused(const Macro *);
EXPORT void SetMacroPaused(Macro *, bool paused);
EXPORT void StopMacro(Macro *);
EXPORT bool
MacroWasPausedSince(const Macro *,
const std::chrono::high_resolution_clock::time_point &);
@ -66,10 +68,14 @@ EXPORT bool MacroWasCheckedSinceLastStart(const Macro *);
EXPORT void AddMacroHelperThread(Macro *, std::thread &&);
EXPORT bool CheckMacros();
EXPORT bool CheckMacroConditions(Macro *, bool ignorePause = false);
EXPORT bool RunMacroActions(Macro *);
bool RunMacroElseActions(Macro *);
EXPORT bool RunMacroActions(Macro *, bool forceParallel = false,
bool ignorePause = false);
EXPORT bool RunMacroElseActions(Macro *, bool forceParallel = false,
bool ignorePause = false);
EXPORT bool RunMacros();
void StopAllMacros();
EXPORT void LoadMacros(obs_data_t *obj);

View File

@ -1,6 +1,8 @@
#include "macro-ref.hpp"
#include "macro.hpp"
#include "plugin-state-helpers.hpp"
namespace advss {
MacroRef::MacroRef(std::string name)
@ -19,6 +21,7 @@ void MacroRef::Load(obs_data_t *obj)
auto name = obs_data_get_string(obj, "macro");
_postLoadName = name;
_macro = GetWeakMacroByName(name);
AddPostLoadStep([this]() { MacroRef::PostLoad(); });
}
void MacroRef::PostLoad()

View File

@ -1,5 +1,4 @@
#include "advanced-scene-switcher.hpp"
#include "action-queue.hpp"
#include "macro-action-edit.hpp"
#include "macro-condition-edit.hpp"
#include "macro-export-import-dialog.hpp"
@ -316,13 +315,9 @@ void AdvSceneSwitcher::ExportMacros() const
obs_data_array_push_back(macroArray, obj);
}
obs_data_set_array(data, "macros", macroArray);
SaveVariables(data);
SaveActionQueues(data);
obs_data_set_string(data, "version", g_GIT_TAG);
auto json = obs_data_get_json(data);
QString exportString(json);
MacroExportImportDialog::ExportMacros(exportString);
MacroExportImportDialog::ExportMacros(data);
}
bool AdvSceneSwitcher::ResolveMacroImportNameConflict(
@ -384,8 +379,6 @@ void AdvSceneSwitcher::ImportMacros()
ImportMacros();
return;
}
ImportVariables(data);
ImportQueues(data);
auto version = obs_data_get_string(data, "version");
if (strcmp(version, g_GIT_TAG) != 0) {
@ -557,6 +550,10 @@ void AdvSceneSwitcher::HighlightOnChange() const
return;
}
if (macro->Paused()) {
return;
}
if (macro->ActionTriggerModePreventedActionsSince(
lastOnChangeHighlightCheckTime)) {
HighlightWidget(ui->actionTriggerMode, Qt::yellow,

View File

@ -380,7 +380,7 @@ bool Macro::ShouldRunActions() const
!_paused && (_matched || _elseActions.size() > 0) &&
_actionModeMatch;
if (VerboseLoggingEnabled() && !_actionModeMatch) {
if (VerboseLoggingEnabled() && !_actionModeMatch && !_paused) {
if (_matched && _actions.size() > 0) {
blog(LOG_INFO, "skip actions for Macro %s (on change)",
_name.c_str());

View File

@ -11,6 +11,7 @@ static void populateUnits(QComboBox *list)
list->addItem(obs_module_text("AdvSceneSwitcher.unit.seconds"));
list->addItem(obs_module_text("AdvSceneSwitcher.unit.minutes"));
list->addItem(obs_module_text("AdvSceneSwitcher.unit.hours"));
list->addItem(obs_module_text("AdvSceneSwitcher.unit.days"));
}
DurationSelection::DurationSelection(QWidget *parent, bool showUnitSelection,
@ -66,6 +67,8 @@ static int durationUnitToMultiplier(Duration::Unit u)
return 60;
case Duration::Unit::HOURS:
return 3600;
case Duration::Unit::DAYS:
return 86400;
default:
break;
}

View File

@ -121,6 +121,8 @@ static std::string durationUnitToString(Duration::Unit u)
return obs_module_text("AdvSceneSwitcher.unit.minutes");
case Duration::Unit::HOURS:
return obs_module_text("AdvSceneSwitcher.unit.hours");
case Duration::Unit::DAYS:
return obs_module_text("AdvSceneSwitcher.unit.days");
default:
break;
}
@ -151,6 +153,8 @@ static int durationUnitToMultiplier(Duration::Unit u)
return 60;
case Duration::Unit::HOURS:
return 3600;
case Duration::Unit::DAYS:
return 86400;
default:
break;
}

View File

@ -27,6 +27,7 @@ public:
SECONDS,
MINUTES,
HOURS,
DAYS,
};
EXPORT Unit GetUnit() const { return _unit; }

View File

@ -7,8 +7,6 @@ endmacro()
# ---------------------------------------------------------------------------- #
add_subdirectory(base)
# Add plugins below, which have dependencies to external libraries or other
# components which might potentially not be fulfilled.
@ -28,10 +26,12 @@ install_advss_plugin_dependency(...)
... to install the plugin and its dependencies.
#]]
add_plugin(base)
add_plugin(http)
add_plugin(midi)
add_plugin(mqtt)
add_plugin(openvr)
add_plugin(schedule)
add_plugin(scripting)
add_plugin(stream-deck)
add_plugin(twitch)

View File

@ -1,5 +1,6 @@
#include "connection-manager.hpp"
#include "layout-helpers.hpp"
#include "macro-export-extensions.hpp"
#include "name-dialog.hpp"
#include "obs-module-helper.hpp"
#include "plugin-state-helpers.hpp"
@ -24,6 +25,51 @@ bool setup()
AddSaveStep(saveConnections);
AddLoadStep(loadConnections);
AddPluginCleanupStep([]() { connections.clear(); });
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.websocketConnections",
"websocketConnections",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &c : connections) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(c->Name())))
continue;
OBSDataAutoRelease item = obs_data_create();
c->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "websocketConnections",
array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array = obs_data_get_array(
data, "websocketConnections");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
auto con = WSConnection::Create();
con->Load(item);
if (!GetWeakConnectionByName(con->Name())
.expired())
continue;
connections.emplace_back(con);
ConnectionSelectionSignalManager::Instance()
->Add(QString::fromStdString(
con->Name()));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &c : connections) {
const QString name =
QString::fromStdString(c->Name());
items.append({name, name});
}
return items;
}});
return true;
}

View File

@ -1,6 +1,7 @@
#include "http-server.hpp"
#include "layout-helpers.hpp"
#include "log-helper.hpp"
#include "macro-export-extensions.hpp"
#include "obs-module-helper.hpp"
#include "plugin-state-helpers.hpp"
@ -32,6 +33,49 @@ bool setup()
}
httpServers.clear();
});
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.httpServers", "httpServers",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &s : httpServers) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(s->Name())))
continue;
OBSDataAutoRelease item = obs_data_create();
s->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "httpServers", array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array =
obs_data_get_array(data, "httpServers");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
auto server = HttpServer::Create();
server->Load(item);
if (!GetWeakHttpServerByName(server->Name())
.expired())
continue;
httpServers.emplace_back(server);
HttpServerSignalManager::Instance()->Add(
QString::fromStdString(
server->Name()));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &s : httpServers) {
const QString name =
QString::fromStdString(s->Name());
items.append({name, name});
}
return items;
}});
return true;
}

View File

@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.14)
project(advanced-scene-switcher-macro-schedule)
add_library(${PROJECT_NAME} MODULE)
target_sources(
${PROJECT_NAME}
PRIVATE macro-schedule.cpp
macro-schedule.hpp
macro-schedule-entry-dialog.cpp
macro-schedule-entry-dialog.hpp
macro-schedule-tab.cpp
macro-schedule-tab.hpp
calendar/calendar-event.hpp
calendar/calendar-view.hpp
calendar/calendar-month-view.hpp
calendar/calendar-month-view.cpp
calendar/calendar-day-view.hpp
calendar/calendar-day-view.cpp
calendar/calendar-week-view.hpp
calendar/calendar-week-view.cpp
calendar/calendar-widget.hpp
calendar/calendar-widget.cpp)
setup_advss_plugin(${PROJECT_NAME})
set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "")
install_advss_plugin(${PROJECT_NAME})

View File

@ -0,0 +1,493 @@
#include "calendar-day-view.hpp"
#include <QLocale>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QTimerEvent>
#include <QVBoxLayout>
#include <algorithm>
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<CalendarEvent> &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<EventLayout> 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<CalendarEvent> _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<CalendarEvent> &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::EventLayout> CalendarDayTimeGrid::LayoutDay() const
{
struct DayEvent {
CalendarEvent ev;
QTime drawStart;
QTime drawEnd;
};
QList<DayEvent> 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<EventLayout> result;
QList<QTime> 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<CalendarEvent> &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"

View File

@ -0,0 +1,44 @@
#pragma once
#include "calendar-view.hpp"
#include <QScrollArea>
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<CalendarEvent> &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<CalendarEvent> _events;
};
} // namespace advss

View File

@ -0,0 +1,29 @@
#pragma once
#include <QColor>
#include <QDateTime>
#include <QString>
#include <QVariant>
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

View File

@ -0,0 +1,284 @@
#include "calendar-month-view.hpp"
#include "obs-module-helper.hpp"
#include <QLocale>
#include <QMouseEvent>
#include <QPainter>
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<CalendarEvent> &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<CalendarEvent> CalendarMonthView::EventsForDate(const QDate &date) const
{
QList<CalendarEvent> 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

View File

@ -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<CalendarEvent> &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<CalendarEvent> 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<CalendarEvent> _events;
};
} // namespace advss

View File

@ -0,0 +1,49 @@
#pragma once
#include "calendar-event.hpp"
#include <QDate>
#include <QList>
#include <QWidget>
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<CalendarEvent> &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

View File

@ -0,0 +1,542 @@
#include "calendar-week-view.hpp"
#include <QLocale>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QTimerEvent>
#include <QVBoxLayout>
#include <algorithm>
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<CalendarEvent> &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<EventLayout> 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<CalendarEvent> _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<CalendarEvent> &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::EventLayout>
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<DayEvent> 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<EventLayout> result;
QList<QTime> 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<CalendarEvent> &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"

View File

@ -0,0 +1,45 @@
#pragma once
#include "calendar-view.hpp"
#include <QScrollArea>
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<CalendarEvent> &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<CalendarEvent> _events;
};
} // namespace advss

View File

@ -0,0 +1,308 @@
#include "calendar-widget.hpp"
#include "obs-module-helper.hpp"
#include "ui-helpers.hpp"
#include <QHBoxLayout>
#include <QLocale>
#include <QVBoxLayout>
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<CalendarEvent> &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

View File

@ -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 <QLabel>
#include <QList>
#include <QPushButton>
#include <QStackedWidget>
#include <QWidget>
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<CalendarEvent> &events);
void AddEvent(const CalendarEvent &event);
void RemoveEvent(const QString &id);
void ClearEvents();
const QList<CalendarEvent> &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<CalendarEvent> _events;
};
} // namespace advss

View File

@ -0,0 +1,373 @@
#include "macro-schedule-entry-dialog.hpp"
#include "help-icon.hpp"
#include "obs-module-helper.hpp"
#include "ui-helpers.hpp"
#include <QColorDialog>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
namespace advss {
bool MacroScheduleEntryDialog::AskForSettings(QWidget *parent,
MacroScheduleEntry &entry,
bool isNew)
{
MacroScheduleEntryDialog dialog(parent, entry, isNew);
if (dialog.exec() != QDialog::Accepted) {
return false;
}
dialog.ApplyToEntry(entry);
return true;
}
MacroScheduleEntryDialog::MacroScheduleEntryDialog(
QWidget *parent, const MacroScheduleEntry &entry, bool isNew)
: QDialog(parent),
_name(new QLineEdit(this)),
_macroSel(new MacroSelection(this)),
_startDateTime(new QDateTimeEdit(this)),
_hasEndDate(new QCheckBox(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.endDate"),
this)),
_endDate(new QDateTimeEdit(this)),
_endDateAction(new QComboBox(this)),
_doesRepeat(new QCheckBox(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.repeat"),
this)),
_repeatInterval(new DurationSelection(this, true, 0.1)),
_repeatEndGroup(new QGroupBox(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd"),
this)),
_endNever(new QRadioButton(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.never"),
_repeatEndGroup)),
_endAfterN(new QRadioButton(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes"),
_repeatEndGroup)),
_endCount(new QSpinBox(_repeatEndGroup)),
_endUntil(new QRadioButton(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.untilDate"),
_repeatEndGroup)),
_endUntilDate(new QDateEdit(_repeatEndGroup)),
_colorBtn(new QPushButton(this)),
_checkConditions(new QCheckBox(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.checkConditions"),
this)),
_runElseActionsOnConditionFailure(new QCheckBox(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.runElseActionsOnConditionFailure"),
this)),
_enabled(new QCheckBox(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.enabled"),
this))
{
setWindowTitle(obs_module_text(
isNew ? "AdvSceneSwitcher.macroScheduleEntry.dialog.title.add"
: "AdvSceneSwitcher.macroScheduleEntry.dialog.title.edit"));
setMinimumWidth(420);
auto form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
// --- Name ---
_name->setPlaceholderText(obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.name.placeholder"));
form->addRow(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.name"),
_name);
// --- Macro ---
form->addRow(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.macro"),
_macroSel);
// --- Start date/time ---
_startDateTime->setDisplayFormat("yyyy-MM-dd HH:mm:ss");
_startDateTime->setCalendarPopup(true);
_startDateTime->setDateTime(QDateTime::currentDateTime());
form->addRow(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.startDateTime"),
_startDateTime);
// --- Optional end date ---
_endDate->setDisplayFormat("yyyy-MM-dd HH:mm:ss");
_endDate->setCalendarPopup(true);
_endDate->setDateTime(QDateTime::currentDateTime().addSecs(60 * 60));
_endDate->setEnabled(false);
auto endDateHelp = new HelpIcon(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.help"),
this);
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.none"),
static_cast<int>(MacroScheduleEntry::EndDateAction::NONE));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.disableEntry"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::DISABLE_ENTRY));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.pauseMacro"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::PAUSE_MACRO));
_endDateAction->addItem(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.endDate.action.stopMacro"),
static_cast<int>(
MacroScheduleEntry::EndDateAction::STOP_MACRO));
_endDateAction->setEnabled(false);
auto endDateRow = new QHBoxLayout();
endDateRow->addWidget(_hasEndDate);
endDateRow->addWidget(_endDate);
endDateRow->addWidget(_endDateAction);
endDateRow->addWidget(endDateHelp);
endDateRow->addStretch();
form->addRow(QString(), endDateRow);
connect(_hasEndDate, &QCheckBox::toggled, _endDate,
&QDateTimeEdit::setEnabled);
connect(_hasEndDate, &QCheckBox::toggled, _endDateAction,
&QComboBox::setEnabled);
// Keep end date minimum in sync with the start date.
_endDate->setMinimumDateTime(_startDateTime->dateTime());
connect(_startDateTime, &QDateTimeEdit::dateTimeChanged, this,
[this](const QDateTime &dt) {
_endDate->setMinimumDateTime(dt);
if (_endDate->dateTime() < dt) {
_endDate->setDateTime(dt);
}
});
// --- Repeat ---
form->addRow(QString(), _doesRepeat);
_repeatInterval->setVisible(false);
form->addRow(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.interval"),
_repeatInterval);
if (auto *label = form->labelForField(_repeatInterval)) {
label->setVisible(false);
}
connect(_doesRepeat, &QCheckBox::toggled, this,
[form, this](bool checked) {
_repeatInterval->setVisible(checked);
if (auto *label =
form->labelForField(_repeatInterval)) {
label->setVisible(checked);
}
_repeatEndGroup->setVisible(checked);
});
// --- Repeat end group (hidden when NONE) ---
auto repeatEndLayout = new QVBoxLayout(_repeatEndGroup);
_endNever->setChecked(true);
repeatEndLayout->addWidget(_endNever);
auto afterNRow = new QHBoxLayout();
_endCount->setMinimum(1);
_endCount->setMaximum(99999);
_endCount->setValue(1);
_endCount->setEnabled(false);
auto afterNSuffix = new QLabel(
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeatEnd.afterNTimes.suffix"),
_repeatEndGroup);
afterNRow->addWidget(_endAfterN);
afterNRow->addWidget(_endCount);
afterNRow->addWidget(afterNSuffix);
afterNRow->addStretch();
repeatEndLayout->addLayout(afterNRow);
auto untilRow = new QHBoxLayout();
_endUntilDate->setCalendarPopup(true);
_endUntilDate->setDate(QDate::currentDate().addMonths(1));
_endUntilDate->setEnabled(false);
untilRow->addWidget(_endUntil);
untilRow->addWidget(_endUntilDate);
untilRow->addStretch();
repeatEndLayout->addLayout(untilRow);
_repeatEndGroup->setVisible(false);
form->addRow(_repeatEndGroup);
// Enable/disable child widgets based on radio selection
connect(_endAfterN, &QRadioButton::toggled, _endCount,
&QSpinBox::setEnabled);
connect(_endUntil, &QRadioButton::toggled, _endUntilDate,
&QDateEdit::setEnabled);
// --- Color ---
_colorBtn->setFixedSize(48, 22);
_colorBtn->setFlat(false);
connect(_colorBtn, &QPushButton::clicked, this, [this]() {
const QColor picked = QColorDialog::getColor(
_color, this,
obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.color.dialog"));
if (picked.isValid()) {
_color = picked;
UpdateColorButton();
}
});
form->addRow(
obs_module_text("AdvSceneSwitcher.macroScheduleEntry.color"),
_colorBtn);
// --- Options ---
form->addRow(QString(), _checkConditions);
_runElseActionsOnConditionFailure->setVisible(false);
form->addRow(QString(), _runElseActionsOnConditionFailure);
connect(_checkConditions, &QCheckBox::toggled,
_runElseActionsOnConditionFailure, &QCheckBox::setVisible);
_enabled->setChecked(true);
form->addRow(QString(), _enabled);
// --- Buttons ---
auto buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
// --- Main layout ---
auto mainLayout = new QVBoxLayout(this);
mainLayout->addLayout(form);
mainLayout->addWidget(buttons);
setLayout(mainLayout);
PopulateFromEntry(entry);
}
void MacroScheduleEntryDialog::PopulateFromEntry(const MacroScheduleEntry &entry)
{
_name->setText(QString::fromStdString(entry.name));
MacroRef ref = entry.macro;
_macroSel->SetCurrentMacro(ref);
if (entry.startDateTime.isValid()) {
_startDateTime->setDateTime(entry.startDateTime);
}
_hasEndDate->setChecked(entry.hasEndDate);
if (entry.hasEndDate && entry.endDate.isValid()) {
_endDate->setDateTime(entry.endDate);
}
{
const int idx = _endDateAction->findData(
static_cast<int>(entry.endDateAction));
if (idx >= 0) {
_endDateAction->setCurrentIndex(idx);
}
}
_doesRepeat->setChecked(entry.doesRepeat);
_repeatInterval->SetDuration(entry.repeatInterval);
_doesRepeat->toggled(entry.doesRepeat);
switch (entry.repeatEndType) {
case MacroScheduleEntry::RepeatEndType::NEVER:
_endNever->setChecked(true);
break;
case MacroScheduleEntry::RepeatEndType::AFTER_N_TIMES:
_endAfterN->setChecked(true);
_endCount->setValue(entry.repeatMaxCount);
break;
case MacroScheduleEntry::RepeatEndType::UNTIL_DATE:
_endUntil->setChecked(true);
if (entry.repeatUntilDate.isValid()) {
_endUntilDate->setDate(entry.repeatUntilDate.date());
}
break;
}
_color = entry.color.isValid() ? entry.color : QColor(70, 130, 180);
UpdateColorButton();
_checkConditions->setChecked(entry.checkConditions);
_runElseActionsOnConditionFailure->setChecked(
entry.runElseActionsOnConditionFailure);
_runElseActionsOnConditionFailure->setVisible(entry.checkConditions);
_enabled->setChecked(entry.enabled);
}
void MacroScheduleEntryDialog::ApplyToEntry(MacroScheduleEntry &entry) const
{
entry.name = _name->text().toStdString();
const QString macroName = _macroSel->currentText();
entry.macro = macroName;
const QDateTime newStartDateTime = _startDateTime->dateTime();
const bool startChanged = (newStartDateTime != entry.startDateTime);
entry.startDateTime = newStartDateTime;
if (startChanged) {
entry.FastForwardTo(QDateTime::currentDateTime());
}
entry.hasEndDate = _hasEndDate->isChecked();
if (entry.hasEndDate) {
entry.endDate = _endDate->dateTime();
entry.endDateAction =
static_cast<MacroScheduleEntry::EndDateAction>(
_endDateAction->currentData().toInt());
} else {
entry.endDate = QDateTime();
entry.endDateAction = MacroScheduleEntry::EndDateAction::NONE;
}
entry.endDateActionApplied = false;
entry.doesRepeat = _doesRepeat->isChecked();
entry.repeatInterval = _repeatInterval->GetDuration();
if (_endNever->isChecked()) {
entry.repeatEndType = MacroScheduleEntry::RepeatEndType::NEVER;
entry.repeatUntilDate = QDateTime();
} else if (_endAfterN->isChecked()) {
entry.repeatEndType =
MacroScheduleEntry::RepeatEndType::AFTER_N_TIMES;
entry.repeatMaxCount = _endCount->value();
entry.repeatUntilDate = QDateTime();
} else if (_endUntil->isChecked()) {
entry.repeatEndType =
MacroScheduleEntry::RepeatEndType::UNTIL_DATE;
entry.repeatUntilDate =
QDateTime(_endUntilDate->date(), QTime(23, 59, 59));
}
entry.color = _color;
entry.checkConditions = _checkConditions->isChecked();
entry.runElseActionsOnConditionFailure =
_runElseActionsOnConditionFailure->isChecked();
entry.enabled = _enabled->isChecked();
}
void MacroScheduleEntryDialog::UpdateColorButton()
{
// Fill the button with the chosen color so it acts as a swatch
const QString style =
QString("background-color: %1; border: 1px solid #888;")
.arg(_color.name());
_colorBtn->setStyleSheet(style);
}
} // namespace advss

View File

@ -0,0 +1,71 @@
#pragma once
#include "duration-control.hpp"
#include "macro-schedule.hpp"
#include "macro-selection.hpp"
#include <QCheckBox>
#include <QColor>
#include <QDateEdit>
#include <QDateTimeEdit>
#include <QDialog>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QComboBox>
namespace advss {
// Dialog for creating or editing a MacroScheduleEntry.
class MacroScheduleEntryDialog : public QDialog {
Q_OBJECT
public:
static bool AskForSettings(QWidget *parent, MacroScheduleEntry &entry,
bool isNew);
private:
explicit MacroScheduleEntryDialog(QWidget *parent,
const MacroScheduleEntry &entry,
bool isNew);
void PopulateFromEntry(const MacroScheduleEntry &entry);
void ApplyToEntry(MacroScheduleEntry &entry) const;
// Basic fields
QLineEdit *_name;
MacroSelection *_macroSel;
QDateTimeEdit *_startDateTime;
// Optional end date
QCheckBox *_hasEndDate;
QDateTimeEdit *_endDate;
QComboBox *_endDateAction;
// Repeat
QCheckBox *_doesRepeat;
DurationSelection *_repeatInterval;
// Repeat end (hidden when type == NONE)
QGroupBox *_repeatEndGroup;
QRadioButton *_endNever;
QRadioButton *_endAfterN;
QSpinBox *_endCount;
QRadioButton *_endUntil;
QDateEdit *_endUntilDate;
// Color
QPushButton *_colorBtn;
QColor _color;
void UpdateColorButton();
// Options
QCheckBox *_checkConditions;
QCheckBox *_runElseActionsOnConditionFailure;
QCheckBox *_enabled;
};
} // namespace advss

View File

@ -0,0 +1,698 @@
#include "macro-schedule-tab.hpp"
#include "calendar/calendar-event.hpp"
#include "macro-schedule-entry-dialog.hpp"
#include "macro-helpers.hpp"
#include "macro-signals.hpp"
#include "obs-module-helper.hpp"
#include "sync-helpers.hpp"
#include "tab-helpers.hpp"
#include "ui-helpers.hpp"
#include <QKeyEvent>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#include <QVBoxLayout>
namespace advss {
// ---------------------------------------------------------------------------
// Tab registration
// ---------------------------------------------------------------------------
static bool registerTab();
static bool registerTabDone = registerTab();
static MacroScheduleTab *tabWidget = nullptr;
static constexpr int MACRO_COUNT_THRESHOLD = 5;
static void setTabVisible(QTabWidget *tab, bool visible)
{
SetTabVisibleByName(
tab, visible,
obs_module_text("AdvSceneSwitcher.macroScheduleTab.title"));
}
static bool enoughMacros()
{
return (int)GetAllMacros().size() >= MACRO_COUNT_THRESHOLD;
}
static void setupTab(QTabWidget *tab)
{
if (!GetScheduleEntries().empty()) {
setTabVisible(tab, true);
return;
}
if (!enoughMacros()) {
setTabVisible(tab, false);
}
QWidget::connect(MacroSignalManager::Instance(),
&MacroSignalManager::Add, tab, [tab](const QString &) {
if (enoughMacros()) {
setTabVisible(tab, true);
}
});
}
static bool registerTab()
{
AddSetupTabCallback("macroScheduleTab", MacroScheduleTab::Create,
setupTab);
return true;
}
// ---------------------------------------------------------------------------
// Entry -> CalendarEvent conversion
// ---------------------------------------------------------------------------
static QColor ColorForEntry(const MacroScheduleEntry &entry)
{
if (!entry.enabled || entry.IsExpired()) {
return QColor(150, 150, 150);
}
return entry.color.isValid() ? entry.color : QColor(70, 130, 180);
}
// Returns one CalendarEvent per occurrence of entry within [rangeStart, rangeEnd].
// For non-repeating entries this is at most one event.
// A hard cap of 500 occurrences prevents infinite loops for misconfigured entries.
static QList<CalendarEvent>
EntryToCalendarEvents(const MacroScheduleEntry &entry, const QDate &rangeStart,
const QDate &rangeEnd)
{
QList<CalendarEvent> result;
const QDateTime windowStart(rangeStart, QTime(0, 0));
const QDateTime windowEnd(rangeEnd, QTime(23, 59, 59));
// Walk occurrences starting from the entry's first trigger time.
// We use a copy to advance lastTriggered without modifying the real entry.
MacroScheduleEntry cursor = entry;
cursor.lastTriggered =
QDateTime(); // start from the very first occurrence
cursor.timesTriggered = 0;
static constexpr int MAX_OCCURRENCES = 500;
for (int i = 0; i < MAX_OCCURRENCES; ++i) {
const QDateTime next = cursor.NextTriggerTime();
if (!next.isValid() || next > windowEnd) {
break;
}
if (next >= windowStart) {
CalendarEvent ev;
// Use id + occurrence index to make each event unique
// while still allowing the tab to look up the parent entry.
ev.id = QString::fromStdString(entry.id);
ev.title = entry.GetSummary();
ev.color = ColorForEntry(entry);
ev.userData = ev.id;
ev.start = next;
if (entry.hasEndDate && entry.endDate.isValid()) {
ev.end = entry.endDate;
}
result.append(ev);
}
if (!entry.doesRepeat) {
break; // one-shot — no more occurrences
}
// Advance cursor past this occurrence
cursor.lastTriggered = next;
++cursor.timesTriggered;
if (cursor.IsExpired()) {
break;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Table columns
// ---------------------------------------------------------------------------
static constexpr int COL_NAME = 0;
static constexpr int COL_MACRO = 1;
static constexpr int COL_SCHEDULE = 2;
static constexpr int COL_NEXT_TRIGGER = 3;
static constexpr int COL_STATUS = 4;
static constexpr int COL_COUNT = 5;
// ---------------------------------------------------------------------------
// MacroScheduleTab
// ---------------------------------------------------------------------------
MacroScheduleTab *MacroScheduleTab::Create()
{
tabWidget = new MacroScheduleTab();
return tabWidget;
}
MacroScheduleTab::MacroScheduleTab(QWidget *parent)
: QWidget(parent),
_calendar(new CalendarWidget(this)),
_table(new QTableWidget(0, COL_COUNT, this)),
_helpLabel(new QLabel(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.help"),
this)),
_addBtn(new QToolButton(this)),
_editBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.edit"),
this)),
_removeBtn(new QToolButton(this)),
_toggleBtn(new QPushButton(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.enable"),
this)),
_refreshTimer(new QTimer(this))
{
// --- Add / Remove: icon buttons (same style as ResourceTable) ---
_addBtn->setProperty("themeID",
QVariant(QString::fromUtf8("addIconSmall")));
_addBtn->setProperty("class", QVariant(QString::fromUtf8("icon-plus")));
_addBtn->setToolTip(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.add.tooltip"));
_removeBtn->setProperty("themeID",
QVariant(QString::fromUtf8("removeIconSmall")));
_removeBtn->setProperty("class",
QVariant(QString::fromUtf8("icon-trash")));
_removeBtn->setToolTip(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.remove.tooltip"));
if (GetScheduleEntries().empty()) {
_addButtonHighlight =
HighlightWidget(_addBtn, QColor(Qt::green));
}
// --- Table ---
_table->setSelectionBehavior(QAbstractItemView::SelectRows);
_table->setSelectionMode(QAbstractItemView::ExtendedSelection);
_table->setContextMenuPolicy(Qt::CustomContextMenu);
_table->installEventFilter(this);
_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
_table->setAlternatingRowColors(true);
_table->horizontalHeader()->setSectionResizeMode(
QHeaderView::ResizeToContents);
_table->horizontalHeader()->setStretchLastSection(true);
_table->verticalHeader()->setVisible(false);
_table->setHorizontalHeaderLabels(
{obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.name"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.macro"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.schedule"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.nextTrigger"),
obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.column.status")});
// --- Help label ---
_helpLabel->setAlignment(Qt::AlignCenter);
_helpLabel->setWordWrap(true);
// --- Button row below the table ---
auto buttonRow = new QHBoxLayout();
buttonRow->setContentsMargins(0, 0, 0, 0);
buttonRow->addWidget(_addBtn);
buttonRow->addWidget(_removeBtn);
buttonRow->addStretch();
buttonRow->addWidget(_editBtn);
buttonRow->addWidget(_toggleBtn);
// --- Right panel: help + table + buttons ---
auto rightPanel = new QWidget(this);
auto rightLayout = new QVBoxLayout(rightPanel);
rightLayout->setContentsMargins(0, 0, 0, 0);
rightLayout->addWidget(_helpLabel);
rightLayout->addWidget(_table, 1);
rightLayout->addLayout(buttonRow);
rightPanel->setLayout(rightLayout);
// --- Splitter ---
auto splitter = new QSplitter(Qt::Horizontal, this);
splitter->addWidget(_calendar);
splitter->addWidget(rightPanel);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 1);
// --- Main layout ---
auto mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(4, 4, 4, 4);
mainLayout->addWidget(splitter, 1);
setLayout(mainLayout);
// --- Connections: toolbar buttons ---
connect(_addBtn, &QPushButton::clicked, this, &MacroScheduleTab::Add);
connect(_editBtn, &QPushButton::clicked, this, &MacroScheduleTab::Edit);
connect(_removeBtn, &QPushButton::clicked, this,
&MacroScheduleTab::Remove);
connect(_toggleBtn, &QPushButton::clicked, this,
&MacroScheduleTab::ToggleEnabled);
connect(_table, &QWidget::customContextMenuRequested, this,
&MacroScheduleTab::ShowContextMenu);
// --- Connections: table ---
connect(_table, &QTableWidget::doubleClicked, this,
[this]() { Edit(); });
connect(_table->selectionModel(),
&QItemSelectionModel::selectionChanged, this,
&MacroScheduleTab::OnSelectionChanged);
// --- Connections: calendar ---
connect(_calendar, &CalendarWidget::SlotClicked, this,
&MacroScheduleTab::AddAtTime);
connect(_calendar, &CalendarWidget::EventClicked, this,
&MacroScheduleTab::SelectTableRowById);
connect(_calendar, &CalendarWidget::EventDoubleClicked, this,
&MacroScheduleTab::EditById);
connect(_calendar, &CalendarWidget::VisibleRangeChanged, this,
[this](const QDate &, const QDate &) {
std::deque<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
snapshot = GetScheduleEntries();
}
RefreshCalendar(snapshot);
});
// --- Periodic refresh ---
_refreshTimer->setInterval(30000); // 30 seconds
connect(_refreshTimer, &QTimer::timeout, this,
&MacroScheduleTab::Refresh);
_refreshTimer->start();
// Defer the initial refresh: Create() is called while the context lock
// is already held, so we cannot call LockContext() here directly.
QTimer::singleShot(0, this, &MacroScheduleTab::Refresh);
}
// ---------------------------------------------------------------------------
// Refresh
// ---------------------------------------------------------------------------
void MacroScheduleTab::Refresh()
{
std::deque<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
snapshot = GetScheduleEntries();
}
PopulateTable();
RefreshCalendar(snapshot);
UpdateButtonStates();
}
void MacroScheduleTab::RefreshCalendar(
const std::deque<MacroScheduleEntry> &entries)
{
const QDate rangeStart = _calendar->VisibleRangeStart();
const QDate rangeEnd = _calendar->VisibleRangeEnd();
QList<CalendarEvent> events;
for (const auto &entry : entries) {
events.append(
EntryToCalendarEvents(entry, rangeStart, rangeEnd));
}
_calendar->SetEvents(events);
}
void MacroScheduleTab::PopulateTable()
{
// Remember which entry was selected so we can restore it after repopulating
const int prevIdx = SelectedEntryIndex();
QString prevId;
std::deque<MacroScheduleEntry> snapshot;
{
auto lock = LockContext();
const auto &entries = GetScheduleEntries();
if (prevIdx >= 0 && prevIdx < (int)entries.size()) {
prevId = QString::fromStdString(entries[prevIdx].id);
}
snapshot = entries;
}
_table->setRowCount(0);
for (const auto &entry : snapshot) {
const int row = _table->rowCount();
_table->insertRow(row);
auto makeItem = [](const QString &text) {
auto item = new QTableWidgetItem(text);
item->setFlags(item->flags() & ~Qt::ItemIsEditable);
return item;
};
_table->setItem(row, COL_NAME, makeItem(entry.GetSummary()));
_table->setItem(
row, COL_MACRO,
makeItem(QString::fromStdString(entry.macro.Name())));
_table->setItem(row, COL_SCHEDULE,
makeItem(entry.GetRepeatDescription()));
_table->setItem(row, COL_NEXT_TRIGGER,
makeItem(entry.GetNextTriggerString()));
QString status;
if (!entry.enabled) {
status = obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.disabled");
} else if (entry.IsExpired()) {
status = obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.expired");
} else {
status = obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.active");
}
_table->setItem(row, COL_STATUS, makeItem(status));
_table->item(row, COL_NAME)
->setData(Qt::UserRole,
QString::fromStdString(entry.id));
}
SetHelpVisible(_table->rowCount() == 0);
// Restore selection
if (!prevId.isEmpty()) {
SelectTableRowById(prevId);
}
}
void MacroScheduleTab::SetHelpVisible(bool visible)
{
_helpLabel->setVisible(visible);
_table->setVisible(!visible);
}
// ---------------------------------------------------------------------------
// Selection helpers
// ---------------------------------------------------------------------------
int MacroScheduleTab::SelectedEntryIndex() const
{
const auto selected = _table->selectionModel()->selectedRows();
if (selected.isEmpty()) {
return -1;
}
const QString id = _table->item(selected.first().row(), COL_NAME)
->data(Qt::UserRole)
.toString();
const auto &entries = GetScheduleEntries();
for (int i = 0; i < (int)entries.size(); ++i) {
if (QString::fromStdString(entries[i].id) == id) {
return i;
}
}
return -1;
}
void MacroScheduleTab::SelectTableRowById(const QString &entryId)
{
for (int row = 0; row < _table->rowCount(); ++row) {
auto item = _table->item(row, COL_NAME);
if (item && item->data(Qt::UserRole).toString() == entryId) {
_table->selectRow(row);
_table->scrollToItem(item);
return;
}
}
}
QStringList MacroScheduleTab::SelectedEntryIds() const
{
QStringList ids;
for (const auto &index : _table->selectionModel()->selectedRows()) {
auto item = _table->item(index.row(), COL_NAME);
if (item) {
ids.append(item->data(Qt::UserRole).toString());
}
}
return ids;
}
bool MacroScheduleTab::eventFilter(QObject *obj, QEvent *event)
{
if (obj == _table && event->type() == QEvent::KeyPress) {
const auto keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Delete) {
Remove();
return true;
}
}
return QWidget::eventFilter(obj, event);
}
void MacroScheduleTab::UpdateButtonStates()
{
const QStringList ids = SelectedEntryIds();
const bool hasSelection = !ids.isEmpty();
const bool singleSelection = ids.size() == 1;
_editBtn->setEnabled(singleSelection);
_removeBtn->setEnabled(hasSelection);
_toggleBtn->setEnabled(hasSelection);
if (hasSelection) {
// Label reflects whether the action will enable or disable:
// disable if all selected are enabled, otherwise enable.
bool allEnabled = true;
{
auto lock = LockContext();
for (const auto &entry : GetScheduleEntries()) {
if (ids.contains(
QString::fromStdString(entry.id)) &&
!entry.enabled) {
allEnabled = false;
break;
}
}
}
_toggleBtn->setText(obs_module_text(
allEnabled
? "AdvSceneSwitcher.macroScheduleTab.disable"
: "AdvSceneSwitcher.macroScheduleTab.enable"));
}
}
void MacroScheduleTab::OnSelectionChanged()
{
UpdateButtonStates();
// Highlight the corresponding calendar event
const int idx = SelectedEntryIndex();
if (idx >= 0) {
QDate triggerDate;
{
auto lock = LockContext();
const auto &entries = GetScheduleEntries();
if (idx < (int)entries.size()) {
const QDateTime next =
entries[idx].NextTriggerTime();
if (next.isValid()) {
triggerDate = next.date();
}
}
}
if (triggerDate.isValid()) {
_calendar->GoToDate(triggerDate);
}
}
}
// ---------------------------------------------------------------------------
// CRUD actions
// ---------------------------------------------------------------------------
void MacroScheduleTab::Add()
{
AddAtTime(QDateTime::currentDateTime());
}
void MacroScheduleTab::AddAtTime(const QDateTime &startTime)
{
MacroScheduleEntry entry;
entry.startDateTime = startTime;
if (!MacroScheduleEntryDialog::AskForSettings(this, entry, true)) {
return;
}
const QString newId = QString::fromStdString(entry.id);
{
auto lock = LockContext();
GetScheduleEntries().emplace_back(std::move(entry));
}
Refresh();
SelectTableRowById(newId);
if (_addButtonHighlight) {
_addButtonHighlight->deleteLater();
_addButtonHighlight = nullptr;
}
}
void MacroScheduleTab::Edit()
{
const int idx = SelectedEntryIndex();
if (idx < 0) {
return;
}
MacroScheduleEntry copy;
{
auto lock = LockContext();
copy = GetScheduleEntries()[idx];
}
const QString id = QString::fromStdString(copy.id);
if (!MacroScheduleEntryDialog::AskForSettings(this, copy, false)) {
return;
}
{
auto lock = LockContext();
GetScheduleEntries()[idx] = std::move(copy);
}
Refresh();
SelectTableRowById(id);
}
void MacroScheduleTab::EditById(const QString &entryId)
{
SelectTableRowById(entryId);
Edit();
}
void MacroScheduleTab::Remove()
{
const QStringList ids = SelectedEntryIds();
if (ids.isEmpty()) {
return;
}
// Build a confirmation message
QString msg;
if (ids.size() == 1) {
QString name;
{
auto lock = LockContext();
const int idx = SelectedEntryIndex();
if (idx >= 0) {
name = GetScheduleEntries()[idx].GetSummary();
}
}
msg = QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.remove.confirm"))
.arg(name);
} else {
msg = QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.remove.confirmMultiple"))
.arg(ids.size());
}
if (!DisplayMessage(msg, true)) {
return;
}
{
auto lock = LockContext();
auto &entries = GetScheduleEntries();
entries.erase(
std::remove_if(entries.begin(), entries.end(),
[&ids](const MacroScheduleEntry &e) {
return ids.contains(
QString::fromStdString(
e.id));
}),
entries.end());
}
Refresh();
}
void MacroScheduleTab::ToggleEnabled()
{
const QStringList ids = SelectedEntryIds();
if (ids.isEmpty()) {
return;
}
// If all selected entries are enabled, disable them; otherwise enable all.
{
auto lock = LockContext();
bool allEnabled = true;
for (const auto &entry : GetScheduleEntries()) {
if (ids.contains(QString::fromStdString(entry.id)) &&
!entry.enabled) {
allEnabled = false;
break;
}
}
const bool newState = !allEnabled;
for (auto &entry : GetScheduleEntries()) {
if (ids.contains(QString::fromStdString(entry.id))) {
entry.enabled = newState;
}
}
}
Refresh();
}
// ---------------------------------------------------------------------------
// Context menu
// ---------------------------------------------------------------------------
void MacroScheduleTab::ShowContextMenu(const QPoint &pos)
{
const QStringList ids = SelectedEntryIds();
const bool hasSelection = !ids.isEmpty();
const bool singleSelection = ids.size() == 1;
QMenu menu(this);
auto addAction = menu.addAction(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.add"), this,
&MacroScheduleTab::Add);
(void)addAction;
menu.addSeparator();
auto editAction = menu.addAction(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.edit"), this,
&MacroScheduleTab::Edit);
editAction->setEnabled(singleSelection);
auto removeAction = menu.addAction(
obs_module_text("AdvSceneSwitcher.macroScheduleTab.remove"),
this, &MacroScheduleTab::Remove);
removeAction->setEnabled(hasSelection);
bool allEnabled = true;
if (hasSelection) {
auto lock = LockContext();
for (const auto &entry : GetScheduleEntries()) {
if (ids.contains(QString::fromStdString(entry.id)) &&
!entry.enabled) {
allEnabled = false;
break;
}
}
}
auto toggleAction = menu.addAction(
obs_module_text(
allEnabled
? "AdvSceneSwitcher.macroScheduleTab.disable"
: "AdvSceneSwitcher.macroScheduleTab.enable"),
this, &MacroScheduleTab::ToggleEnabled);
toggleAction->setEnabled(hasSelection);
menu.exec(_table->viewport()->mapToGlobal(pos));
}
} // namespace advss

View File

@ -0,0 +1,70 @@
#pragma once
#include "calendar/calendar-widget.hpp"
#include "macro-schedule.hpp"
#include <QEvent>
#include <QLabel>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTimer>
#include <QToolButton>
#include <QWidget>
class QTabWidget;
namespace advss {
// The main tab widget registered with the settings window.
class MacroScheduleTab : public QWidget {
Q_OBJECT
public:
static MacroScheduleTab *Create();
// Reload all data into the table and calendar
void Refresh();
private slots:
void Add();
void AddAtTime(const QDateTime &startTime);
void Edit();
void EditById(const QString &entryId);
void Remove();
void ToggleEnabled();
void ShowContextMenu(const QPoint &pos);
void OnSelectionChanged();
private:
explicit MacroScheduleTab(QWidget *parent = nullptr);
bool eventFilter(QObject *obj, QEvent *event) override;
void PopulateTable();
void RefreshCalendar(const std::deque<MacroScheduleEntry> &entries);
void SelectTableRowById(const QString &entryId);
void UpdateButtonStates();
void SetHelpVisible(bool visible);
// Returns the index in GetScheduleEntries() for the first selected row,
// or -1 if nothing is selected.
int SelectedEntryIndex() const;
// Returns the IDs of all selected rows.
QStringList SelectedEntryIds() const;
CalendarWidget *_calendar;
QTableWidget *_table;
QLabel *_helpLabel;
QToolButton *_addBtn;
QPushButton *_editBtn;
QToolButton *_removeBtn;
QPushButton *_toggleBtn;
// Periodic refresh to update "Next Trigger" column
QTimer *_refreshTimer;
QObject *_addButtonHighlight = nullptr;
};
} // namespace advss

View File

@ -0,0 +1,489 @@
#include "macro-schedule.hpp"
#include "log-helper.hpp"
#include "macro-export-extensions.hpp"
#include "macro-helpers.hpp"
#include "obs-module-helper.hpp"
#include "plugin-state-helpers.hpp"
#include "sync-helpers.hpp"
#include <obs-data.h>
#include <QUuid>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
namespace advss {
static std::deque<MacroScheduleEntry> scheduleEntries;
static std::thread schedulerThread;
static std::atomic<bool> schedulerRunning{false};
static std::mutex schedulerWaitMutex;
static std::condition_variable schedulerWaitCV;
static void initScheduler();
static void cleanupScheduler();
static void saveEntries(obs_data_t *obj);
static void loadEntries(obs_data_t *obj);
static bool setup();
static bool setupDone = setup();
static bool setup()
{
AddSaveStep(saveEntries);
AddLoadStep(loadEntries);
AddStartStep(initScheduler);
AddStopStep(cleanupScheduler);
AddPluginCleanupStep([]() {
cleanupScheduler();
scheduleEntries.clear();
});
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.macroScheduleEntries",
"macroScheduleEntries",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
auto lock = LockContext();
for (const auto &entry : scheduleEntries) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(entry.id)))
continue;
OBSDataAutoRelease item = obs_data_create();
entry.Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "macroScheduleEntries",
array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array = obs_data_get_array(
data, "macroScheduleEntries");
if (!array) {
return;
}
const size_t count = obs_data_array_count(array);
auto lock = LockContext();
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
MacroScheduleEntry entry;
entry.Load(item);
// Skip if an entry with this id already exists.
const auto &id = entry.id;
const bool exists = std::any_of(
scheduleEntries.begin(),
scheduleEntries.end(),
[&id](const MacroScheduleEntry &e) {
return e.id == id;
});
if (exists) {
continue;
}
// Reset runtime state so the entry fires fresh.
entry.timesTriggered = 0;
entry.lastTriggered = QDateTime();
entry.endDateActionApplied = false;
scheduleEntries.emplace_back(std::move(entry));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
auto lock = LockContext();
for (const auto &entry : scheduleEntries) {
items.append({QString::fromStdString(entry.id),
entry.GetSummary()});
}
return items;
}});
return true;
}
// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------
MacroScheduleEntry::MacroScheduleEntry()
: id(QUuid::createUuid().toString(QUuid::WithoutBraces).toStdString()),
startDateTime(QDateTime::currentDateTime())
{
}
static void saveDateTime(obs_data_t *obj, const char *key, const QDateTime &dt)
{
if (dt.isValid()) {
obs_data_set_string(
obj, key,
dt.toString(Qt::ISODate).toUtf8().constData());
}
}
static QDateTime loadDateTime(obs_data_t *obj, const char *key)
{
const char *str = obs_data_get_string(obj, key);
if (!str || !*str) {
return QDateTime();
}
return QDateTime::fromString(QString::fromUtf8(str), Qt::ISODate);
}
void MacroScheduleEntry::Save(obs_data_t *obj) const
{
obs_data_set_string(obj, "id", id.c_str());
obs_data_set_string(obj, "name", name.c_str());
macro.Save(obj);
obs_data_set_string(obj, "color", color.name().toUtf8().constData());
obs_data_set_bool(obj, "checkConditions", checkConditions);
obs_data_set_bool(obj, "runElseActionsOnConditionFailure",
runElseActionsOnConditionFailure);
obs_data_set_bool(obj, "enabled", enabled);
saveDateTime(obj, "startDateTime", startDateTime);
obs_data_set_bool(obj, "hasEndDate", hasEndDate);
if (hasEndDate) {
saveDateTime(obj, "endDate", endDate);
obs_data_set_int(obj, "endDateAction",
static_cast<int>(endDateAction));
}
obs_data_set_bool(obj, "doesRepeat", doesRepeat);
repeatInterval.Save(obj, "repeatInterval");
obs_data_set_int(obj, "repeatEndType", static_cast<int>(repeatEndType));
obs_data_set_int(obj, "repeatMaxCount", repeatMaxCount);
if (repeatEndType == RepeatEndType::UNTIL_DATE &&
repeatUntilDate.isValid()) {
saveDateTime(obj, "repeatUntilDate", repeatUntilDate);
}
obs_data_set_int(obj, "timesTriggered", timesTriggered);
saveDateTime(obj, "lastTriggered", lastTriggered);
obs_data_set_bool(obj, "endDateActionApplied", endDateActionApplied);
}
void MacroScheduleEntry::Load(obs_data_t *obj)
{
const char *idStr = obs_data_get_string(obj, "id");
if (idStr && *idStr) {
id = idStr;
}
const char *nameStr = obs_data_get_string(obj, "name");
if (nameStr) {
name = nameStr;
}
macro.Load(obj);
const char *colorStr = obs_data_get_string(obj, "color");
if (colorStr && *colorStr) {
const QColor loaded(QString::fromUtf8(colorStr));
if (loaded.isValid()) {
color = loaded;
}
}
checkConditions = obs_data_get_bool(obj, "checkConditions");
runElseActionsOnConditionFailure =
obs_data_get_bool(obj, "runElseActionsOnConditionFailure");
obs_data_set_default_bool(obj, "enabled", true);
enabled = obs_data_get_bool(obj, "enabled");
startDateTime = loadDateTime(obj, "startDateTime");
hasEndDate = obs_data_get_bool(obj, "hasEndDate");
if (hasEndDate) {
endDate = loadDateTime(obj, "endDate");
endDateAction = static_cast<EndDateAction>(
obs_data_get_int(obj, "endDateAction"));
}
doesRepeat = obs_data_get_bool(obj, "doesRepeat");
repeatInterval.Load(obj, "repeatInterval");
repeatEndType = static_cast<RepeatEndType>(
obs_data_get_int(obj, "repeatEndType"));
obs_data_set_default_int(obj, "repeatMaxCount", 1);
repeatMaxCount = (int)obs_data_get_int(obj, "repeatMaxCount");
repeatUntilDate = loadDateTime(obj, "repeatUntilDate");
timesTriggered = (int)obs_data_get_int(obj, "timesTriggered");
lastTriggered = loadDateTime(obj, "lastTriggered");
endDateActionApplied = obs_data_get_bool(obj, "endDateActionApplied");
}
QDateTime MacroScheduleEntry::advanceFrom(const QDateTime &base) const
{
const qint64 ms = qRound64(repeatInterval.Seconds() * 1000.0);
if (ms <= 0) {
return QDateTime();
}
return base.addMSecs(ms);
}
QDateTime MacroScheduleEntry::NextTriggerTime() const
{
if (!lastTriggered.isValid()) {
return startDateTime;
}
if (!doesRepeat) {
// If the start was moved beyond the last trigger, treat as not yet fired.
if (startDateTime > lastTriggered) {
return startDateTime;
}
return QDateTime(); // one-shot already triggered
}
QDateTime next = startDateTime;
while (next <= lastTriggered) {
QDateTime candidate = advanceFrom(next);
if (!candidate.isValid()) {
return QDateTime();
}
next = candidate;
}
return next;
}
bool MacroScheduleEntry::IsExpired() const
{
if (!doesRepeat && lastTriggered.isValid()) {
return lastTriggered >= startDateTime;
}
switch (repeatEndType) {
case RepeatEndType::NEVER:
return false;
case RepeatEndType::AFTER_N_TIMES:
return timesTriggered >= repeatMaxCount;
case RepeatEndType::UNTIL_DATE:
return repeatUntilDate.isValid() &&
QDateTime::currentDateTime() > repeatUntilDate;
}
return false;
}
bool MacroScheduleEntry::ShouldTrigger(const QDateTime &now) const
{
if (!enabled) {
return false;
}
if (IsExpired()) {
return false;
}
if (!startDateTime.isValid()) {
return false;
}
const QDateTime next = NextTriggerTime();
if (!next.isValid()) {
return false;
}
return next <= now;
}
void MacroScheduleEntry::MarkTriggered(const QDateTime &now)
{
++timesTriggered;
lastTriggered = now;
}
void MacroScheduleEntry::FastForwardTo(const QDateTime &now)
{
timesTriggered = 0;
lastTriggered = QDateTime();
if (!startDateTime.isValid()) {
return;
}
if (!doesRepeat) {
// One-shot: count it as triggered if the start lies in the past.
if (startDateTime <= now) {
timesTriggered = 1;
lastTriggered = startDateTime;
}
return;
}
// Repeating: walk intervals from startDateTime and count each one
// that falls at or before 'now'.
QDateTime t = startDateTime;
while (t <= now) {
++timesTriggered;
lastTriggered = t;
const QDateTime next = advanceFrom(t);
if (!next.isValid()) {
break;
}
t = next;
}
}
QString MacroScheduleEntry::GetRepeatDescription() const
{
if (!doesRepeat) {
return obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.once");
}
return QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.repeat.every"))
.arg(QString::fromStdString(repeatInterval.ToString()));
}
QString MacroScheduleEntry::GetSummary() const
{
if (!name.empty()) {
return QString::fromStdString(name);
}
const std::string macroName = macro.Name();
return macroName.empty()
? QString(obs_module_text(
"AdvSceneSwitcher.macroScheduleEntry.noMacro"))
: QString::fromStdString(macroName);
}
QString MacroScheduleEntry::GetNextTriggerString() const
{
if (!enabled) {
return obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.disabled");
}
if (IsExpired()) {
return obs_module_text(
"AdvSceneSwitcher.macroScheduleTab.status.expired");
}
const QDateTime next = NextTriggerTime();
if (!next.isValid()) {
return QString("-");
}
return next.toString("yyyy-MM-dd HH:mm");
}
std::deque<MacroScheduleEntry> &GetScheduleEntries()
{
return scheduleEntries;
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
static void saveEntries(obs_data_t *obj)
{
OBSDataArrayAutoRelease array = obs_data_array_create();
for (const auto &entry : scheduleEntries) {
OBSDataAutoRelease item = obs_data_create();
entry.Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(obj, "macroScheduleEntries", array);
}
static void loadEntries(obs_data_t *obj)
{
scheduleEntries.clear();
OBSDataArrayAutoRelease array =
obs_data_get_array(obj, "macroScheduleEntries");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item = obs_data_array_item(array, i);
scheduleEntries.emplace_back();
scheduleEntries.back().Load(item);
}
}
// ---------------------------------------------------------------------------
// Scheduler thread
// ---------------------------------------------------------------------------
static void applyEndDateAction(MacroScheduleEntry &entry)
{
if (!entry.hasEndDate || !entry.endDate.isValid()) {
return;
}
if (entry.endDateAction == MacroScheduleEntry::EndDateAction::NONE) {
return;
}
switch (entry.endDateAction) {
case MacroScheduleEntry::EndDateAction::DISABLE_ENTRY:
entry.enabled = false;
break;
case MacroScheduleEntry::EndDateAction::PAUSE_MACRO:
StopMacro(entry.macro.GetMacro().get());
SetMacroPaused(entry.macro.GetMacro().get(), true);
break;
case MacroScheduleEntry::EndDateAction::STOP_MACRO:
StopMacro(entry.macro.GetMacro().get());
break;
default:
break;
}
}
static void checkAndFireEntries()
{
const QDateTime now = QDateTime::currentDateTime();
auto lock = LockContext();
for (auto &entry : scheduleEntries) {
// Apply end-date action once when the entry transitions to expired.
// We detect the transition by checking whether the end date has
// just passed while the entry is still nominally enabled.
if (entry.hasEndDate && entry.endDate.isValid() &&
entry.enabled && now >= entry.endDate &&
entry.endDateAction !=
MacroScheduleEntry::EndDateAction::NONE &&
!entry.endDateActionApplied) {
applyEndDateAction(entry);
entry.endDateActionApplied = true;
}
if (!entry.ShouldTrigger(now)) {
continue;
}
auto macro = entry.macro.GetMacro();
if (!macro) {
// Advance state so we don't spam-check a missing macro
entry.MarkTriggered(now);
blog(LOG_WARNING,
"[macro-schedule] Scheduled macro '%s' not found, skipping.",
entry.macro.Name().c_str());
continue;
}
if (entry.checkConditions) {
if (CheckMacroConditions(macro.get(), true)) {
RunMacroActions(macro.get(), true, true);
} else if (entry.runElseActionsOnConditionFailure) {
RunMacroElseActions(macro.get(), true, true);
}
} else {
RunMacroActions(macro.get(), true, true);
}
entry.MarkTriggered(now);
}
}
static void initScheduler()
{
if (schedulerRunning.exchange(true)) {
return; // already running
}
schedulerThread = std::thread([]() {
while (schedulerRunning) {
checkAndFireEntries();
std::unique_lock<std::mutex> lock(schedulerWaitMutex);
schedulerWaitCV.wait_for(
lock, std::chrono::seconds(1),
[]() { return !schedulerRunning.load(); });
}
});
}
static void cleanupScheduler()
{
schedulerRunning = false;
schedulerWaitCV.notify_all();
if (schedulerThread.joinable()) {
schedulerThread.join();
}
}
} // namespace advss

View File

@ -0,0 +1,73 @@
#pragma once
#include "duration.hpp"
#include "macro-ref.hpp"
#include <QColor>
#include <QDateTime>
#include <QString>
#include <deque>
namespace advss {
class MacroScheduleEntry {
public:
enum class RepeatEndType {
NEVER,
AFTER_N_TIMES,
UNTIL_DATE,
};
enum class EndDateAction {
NONE,
DISABLE_ENTRY,
PAUSE_MACRO,
STOP_MACRO,
};
MacroScheduleEntry();
void Save(obs_data_t *obj) const;
void Load(obs_data_t *obj);
QDateTime NextTriggerTime() const;
bool ShouldTrigger(const QDateTime &now) const;
void MarkTriggered(const QDateTime &now);
bool IsExpired() const;
void FastForwardTo(const QDateTime &now);
QString GetSummary() const;
QString GetRepeatDescription() const;
QString GetNextTriggerString() const;
std::string id;
std::string name;
MacroRef macro;
QColor color{70, 130, 180};
bool checkConditions = false;
bool runElseActionsOnConditionFailure = false;
bool enabled = true;
QDateTime startDateTime;
bool hasEndDate = false;
QDateTime endDate;
EndDateAction endDateAction = EndDateAction::NONE;
bool doesRepeat = false;
Duration repeatInterval = 120;
RepeatEndType repeatEndType = RepeatEndType::NEVER;
int repeatMaxCount = 1;
QDateTime repeatUntilDate;
// Persisted runtime state
int timesTriggered = 0;
QDateTime lastTriggered;
bool endDateActionApplied = false;
private:
QDateTime advanceFrom(const QDateTime &base) const;
};
std::deque<MacroScheduleEntry> &GetScheduleEntries();
} // namespace advss

View File

@ -4,6 +4,7 @@
#include <help-icon.hpp>
#include <layout-helpers.hpp>
#include <log-helper.hpp>
#include <macro-export-extensions.hpp>
#include <obs-module-helper.hpp>
#include <plugin-state-helpers.hpp>
#include <QDesktopServices>
@ -88,6 +89,49 @@ static bool setupTwitchTokenSupport()
AddSaveStep(saveConnections);
AddLoadStep(loadConnections);
AddPluginCleanupStep([]() { twitchTokens.clear(); });
AddMacroExportExtension(
{"AdvSceneSwitcher.macroTab.export.twitchConnections",
"twitchConnections",
[](obs_data_t *data, const QStringList &selectedIds) {
OBSDataArrayAutoRelease array =
obs_data_array_create();
for (const auto &t : twitchTokens) {
if (!selectedIds.isEmpty() &&
!selectedIds.contains(
QString::fromStdString(t->Name())))
continue;
OBSDataAutoRelease item = obs_data_create();
t->Save(item);
obs_data_array_push_back(array, item);
}
obs_data_set_array(data, "twitchConnections", array);
},
[](obs_data_t *data, const QStringList &) {
OBSDataArrayAutoRelease array =
obs_data_get_array(data, "twitchConnections");
const size_t count = obs_data_array_count(array);
for (size_t i = 0; i < count; ++i) {
OBSDataAutoRelease item =
obs_data_array_item(array, i);
auto token = TwitchToken::Create();
token->Load(item);
if (!GetWeakTwitchTokenByName(token->Name())
.expired())
continue;
twitchTokens.emplace_back(token);
TwitchConnectionSignalManager::Instance()->Add(
QString::fromStdString(token->Name()));
}
},
[]() -> QList<QPair<QString, QString>> {
QList<QPair<QString, QString>> items;
for (const auto &t : twitchTokens) {
const QString name =
QString::fromStdString(t->Name());
items.append({name, name});
}
return items;
}});
return true;
}