15#include "../../utility/BinReloc.h"
17#include <SDL2/SDL_image.h>
38#define mkdir(path, mode) _mkdir(path)
43#define S_ISDIR(mode) (((mode) & _S_IFMT) == _S_IFDIR)
46#define S_ISREG(mode) (((mode) & _S_IFMT) == _S_IFREG)
53namespace fs = std::filesystem;
56static std::string GetWorkspaceTempDirectory(
const std::string& workspacePath)
60 if (!workspacePath.empty()) {
62 tempDir = workspacePath +
"\\temp";
64 tempDir = workspacePath +
"/temp";
68 if (stat(tempDir.c_str(), &st) != 0) {
70 _mkdir(tempDir.c_str());
72 mkdir(tempDir.c_str(), 0755);
74 printf(
"Created temp directory: %s\n", tempDir.c_str());
79 char tempPath[MAX_PATH];
80 DWORD len = GetTempPathA(MAX_PATH, tempPath);
81 if (len > 0 && len < MAX_PATH) {
84 if (!tempDir.empty() && (tempDir.back() ==
'\\' || tempDir.back() ==
'/')) {
88 tempDir =
"C:\\Windows\\Temp";
91 const char* tmpdir = getenv(
"TMPDIR");
92 tempDir = tmpdir ? tmpdir :
"/tmp";
100static std::string GetPEBLMediaPath(
const std::string& peblExePath)
102 if (peblExePath.empty()) {
107 size_t lastSep = peblExePath.find_last_of(
"/\\");
108 if (lastSep == std::string::npos) {
112 std::string exeDir = peblExePath.substr(0, lastSep);
115 size_t parentSep = exeDir.find_last_of(
"/\\");
116 std::string peblRoot;
117 if (parentSep != std::string::npos) {
118 peblRoot = exeDir.substr(0, parentSep);
124 return peblRoot +
"\\media";
126 return peblRoot +
"/media";
132 , mRenderer(renderer)
133 , mSelectedExperiment(-1)
136 , mScreenshotTexture(nullptr)
137 , mScreenshotWidth(0)
138 , mScreenshotHeight(0)
139 , mRunningExperiment(nullptr)
141 , mOutputExpanded(false)
142 , mSelectedStudyIndex(-1)
143 , mSelectedChainIndex(-1)
144 , mSelectedStudyTestIndex(-1)
145 , mStudyTestScreenshot(nullptr)
146 , mStudyTestScreenshotW(0)
147 , mStudyTestScreenshotH(0)
148 , mRunningChain(false)
149 , mCurrentChainItemIndex(-1)
150 , mShowParameterEditor(false)
151 , mShowVariantNameDialog(false)
152 , mEditingTestIndex(-1)
153 , mEditingDefaultParams(true)
154 , mShowDuplicateSubjectWarning(false)
155 , mShowSnapshotCreated(false)
156 , mShowSettings(false)
157 , mShowNewStudyDialog(false)
158 , mShowNewChainDialog(false)
159 , mShowStudySettingsDialog(false)
160 , mShowFirstRunDialog(false)
161 , mShowGettingStartedDialog(false)
162 , mShowEditParticipantCodeDialog(false)
164 , mQuickLaunchSelectedFile(-1)
165 , mShowCodeEditor(false)
166 , mShowScaleBuilder(false)
167 , mSelectedScaleIndex(-1)
168 , mSelectedDimensionIndex(-1)
169 , mScaleBrowserScreenshot(nullptr)
170 , mScaleBrowserScreenshotW(0)
171 , mScaleBrowserScreenshotH(0)
172 , mScaleBrowserScreenshotForIndex(-1)
173 , mScaleTransSelectedKey(-1)
175 mScaleTransLanguage[0] =
'\0';
185 "elseif",
"ElseIf",
"ELSEIF",
186 "else",
"Else",
"ELSE",
187 "loop",
"Loop",
"LOOP",
188 "while",
"While",
"WHILE",
189 "return",
"Return",
"RETURN",
190 "define",
"Define",
"DEFINE",
194 "break",
"Break",
"BREAK"
198 lang.mSingleLineComment =
"#";
199 lang.mCommentStart =
"";
200 lang.mCommentEnd =
"";
203 lang.mTokenRegexStrings = {
212 lang.mCaseSensitive =
true;
213 lang.mAutoIndentation =
true;
220 std::strncpy(mSubjectCode, config->
GetSubjectCode().c_str(),
sizeof(mSubjectCode) - 1);
221 mSubjectCode[
sizeof(mSubjectCode) - 1] =
'\0';
222 mParticipantCode[0] =
'\0';
223 mStudyCode[0] =
'\0';
224 mQuickLaunchPath[0] =
'\0';
225 mQuickLaunchParamFile[0] =
'\0';
226 mLastSnapshotName[0] =
'\0';
227 mLastSnapshotPath[0] =
'\0';
233 if (!peblExpPath.empty()) {
235 if (fs::exists(peblExpPath) && fs::is_directory(peblExpPath)) {
236 mQuickLaunchDirectory = peblExpPath;
239 for (
const auto& entry : fs::directory_iterator(peblExpPath)) {
240 if (!entry.is_regular_file())
continue;
241 std::string name = entry.path().filename().string();
242 if (name.length() > 4 && name.substr(name.length() - 4) ==
".pbl") {
243 mQuickLaunchFiles.push_back(name);
246 std::sort(mQuickLaunchFiles.begin(), mQuickLaunchFiles.end());
251 }
catch (
const fs::filesystem_error&) {
257 std::strncpy(mLanguageCode, config->
GetLanguage().c_str(),
sizeof(mLanguageCode) - 1);
258 mLanguageCode[
sizeof(mLanguageCode) - 1] =
'\0';
260 mExperimentDir[
sizeof(mExperimentDir) - 1] =
'\0';
264 mScreenResolution = 0;
266 mGraphicsDriver[0] =
'\0';
267 mCustomArguments[0] =
'\0';
270 mWorkspace = std::make_shared<WorkspaceManager>();
271 mSnapshots = std::make_shared<SnapshotManager>();
282 std::string workspacePathForScales = mWorkspace->GetWorkspacePath();
283 if (!batteryPathForScales.empty()) {
284 mScaleManager = std::make_shared<ScaleManager>(batteryPathForScales, workspacePathForScales);
285 printf(
"Initialized Scale Manager with battery path: %s, workspace path: %s\n",
286 batteryPathForScales.c_str(), workspacePathForScales.c_str());
289 printf(
"Warning: Battery path not set, Scale Builder will not be available\n");
294 if (mWorkspace->IsFirstRun()) {
295 mShowFirstRunDialog =
true;
299 if (!mWorkspace->Initialize()) {
300 printf(
"Warning: Failed to initialize workspace\n");
305 mPageEditor.
show =
false;
307 mPageEditor.
title[0] =
'\0';
312 mTestEditor.
show =
false;
323 mVariantName[0] =
'\0';
329 mNewStudyName[0] =
'\0';
330 mNewStudyDescription[0] =
'\0';
331 mNewStudyAuthor[0] =
'\0';
334 mNewChainName[0] =
'\0';
335 mNewChainDescription[0] =
'\0';
340 if (batteryPath.empty()) {
341 batteryPath = mExperimentDir;
345 mBatteryPath = batteryPath;
347 if (!batteryPath.empty() && batteryPath.length() <
sizeof(mExperimentDir)) {
348 std::strncpy(mExperimentDir, batteryPath.c_str(),
sizeof(mExperimentDir) - 1);
349 mExperimentDir[
sizeof(mExperimentDir) - 1] =
'\0';
350 ScanExperimentDirectory(batteryPath);
351 printf(
"Scanned battery directory: %s - found %zu tests\n",
352 batteryPath.c_str(), mExperiments.size());
361 if (!lastStudyPath.empty()) {
362 printf(
"Restoring last study: %s\n", lastStudyPath.c_str());
363 LoadStudy(lastStudyPath);
368 if (!lastChainName.empty()) {
369 std::string chainPath = lastStudyPath +
"/chains/" + lastChainName;
370 printf(
"Restoring last chain: %s\n", chainPath.c_str());
371 LoadChain(chainPath);
380 FreeStudyTestScreenshot();
382 if (mScaleBrowserScreenshot) {
383 SDL_DestroyTexture(mScaleBrowserScreenshot);
384 mScaleBrowserScreenshot =
nullptr;
388 if (mRunningExperiment) {
389 delete mRunningExperiment;
390 mRunningExperiment =
nullptr;
397 if (mRunningExperiment && mRunningExperiment->
IsRunning()) {
402 if (mRunningChain && mRunningExperiment) {
403 bool isRunning = mRunningExperiment->
IsRunning();
405 printf(
"DEBUG: Chain item finished (IsRunning=false), advancing...\n");
413 FILE* debugLog = fopen(
"chain_debug.log",
"a");
415 fprintf(debugLog,
"=== Chain item finished ===\n");
416 fprintf(debugLog,
" Exit code: %d\n", exitCode);
417 fprintf(debugLog,
" mCurrentChainItemIndex: %d\n", mCurrentChainItemIndex);
420 printf(
"=== Chain item finished ===\n");
421 printf(
" Exit code from GetExitCode(): %d\n", exitCode);
422 printf(
" mCurrentChainItemIndex: %d\n", mCurrentChainItemIndex);
425 bool shouldAbortChain =
false;
426 bool isConsentDecline =
false;
428 fprintf(debugLog,
" Checking: exitCode=%d, chainItemIndex=%d\n", exitCode, mCurrentChainItemIndex);
430 if (exitCode != 0 && mCurrentChain && mCurrentChainItemIndex >= 0 &&
431 mCurrentChainItemIndex < (
int)mCurrentChain->GetItems().size()) {
432 const ChainItem& currentItem = mCurrentChain->GetItems()[mCurrentChainItemIndex];
444 shouldAbortChain =
true;
445 isConsentDecline =
true;
446 if (debugLog) fprintf(debugLog,
" -> CONSENT/GATE DECLINED (code 1), aborting chain\n");
448 if (debugLog) fprintf(debugLog,
" -> Consent error (code %d), continuing\n", exitCode);
450 if (debugLog) fprintf(debugLog,
" -> Non-consent item or error (code %d), continuing\n", exitCode);
453 if (debugLog) fprintf(debugLog,
" exitCode==0 or invalid index, continuing chain\n");
460 if (shouldAbortChain && isConsentDecline) {
462 printf(
"Chain terminated: User declined consent\n");
465 mChainAccumulatedStdout += mRunningExperiment->
GetStdout();
466 mChainAccumulatedStderr += mRunningExperiment->
GetStderr();
467 mChainAccumulatedStdout +=
"\n=== Chain terminated: User declined consent ===\n";
470 mRunningChain =
false;
471 mCurrentChainItemIndex = -1;
475 mCurrentChain->IncrementParticipantCounter();
476 printf(
"Participant counter incremented to: %d\n", mCurrentChain->GetParticipantCounter());
480 delete mRunningExperiment;
481 mRunningExperiment =
nullptr;
488 mChainAccumulatedStdout += mRunningExperiment->
GetStdout();
489 mChainAccumulatedStderr += mRunningExperiment->
GetStderr();
492 mChainAccumulatedStdout +=
"\n=== End of item " + std::to_string(mCurrentChainItemIndex + 1) +
" ===\n\n";
493 mChainAccumulatedStderr +=
"\n=== End of item " + std::to_string(mCurrentChainItemIndex + 1) +
" ===\n\n";
496 printf(
"Chain item %d finished, advancing...\n", mCurrentChainItemIndex + 1);
497 mCurrentChainItemIndex++;
499 if (mCurrentChain && mCurrentChainItemIndex < (
int)mCurrentChain->GetItems().size()) {
501 const ChainItem& item = mCurrentChain->GetItems()[mCurrentChainItemIndex];
502 printf(
"Advancing to chain item %d/%zu: %s\n",
503 mCurrentChainItemIndex + 1,
504 mCurrentChain->GetItems().size(),
508 delete mRunningExperiment;
509 mRunningExperiment =
nullptr;
512 ExecuteChainItem(mCurrentChainItemIndex);
515 printf(
"Chain execution completed (all %zu items finished)\n", mCurrentChain->GetItems().size());
516 mRunningChain =
false;
517 mCurrentChainItemIndex = -1;
521 mCurrentChain->IncrementParticipantCounter();
522 printf(
"Participant counter incremented to: %d\n", mCurrentChain->GetParticipantCounter());
526 if (mRunningExperiment) {
527 delete mRunningExperiment;
528 mRunningExperiment =
nullptr;
536 ImGuiViewport* viewport = ImGui::GetMainViewport();
537 ImGui::SetNextWindowPos(viewport->WorkPos);
538 ImGui::SetNextWindowSize(viewport->WorkSize);
540 ImGuiWindowFlags window_flags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoTitleBar |
541 ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
542 ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus;
544 ImGui::Begin(
"PEBL Launcher", p_open, window_flags);
549 float outputPanelHeight = mOutputExpanded ? 250.0f : 30.0f;
550 float contentHeight = ImGui::GetContentRegionAvail().y - outputPanelHeight;
551 ImGui::BeginChild(
"MainTabArea", ImVec2(0, contentHeight));
555 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(30, 8));
556 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12, 8));
557 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 8.0f);
558 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
559 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
560 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
562 if (ImGui::BeginTabBar(
"TopLevelTabs", ImGuiTabBarFlags_None)) {
564 ImGui::SetWindowFontScale(1.5f);
565 if (ImGui::BeginTabItem(
"Manage Studies")) {
566 if (mTopLevelTab != 1) {
569 ImGui::SetWindowFontScale(1.0f);
570 ImGui::PopStyleColor(3);
571 ImGui::PopStyleVar(3);
577 ImGui::Dummy(ImVec2(0, 5));
580 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(20, 6));
581 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 6));
582 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 6.0f);
583 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
584 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
585 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
587 if (ImGui::BeginTabBar(
"StudyTabs", ImGuiTabBarFlags_None)) {
588 ImGui::SetWindowFontScale(1.3f);
589 if (ImGui::BeginTabItem(
"Tests")) {
590 ImGui::SetWindowFontScale(1.0f);
591 ImGui::PopStyleColor(3);
592 ImGui::PopStyleVar(3);
597 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(20, 6));
598 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 6));
599 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 6.0f);
600 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
601 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
602 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
603 ImGui::SetWindowFontScale(1.3f);
606 if (ImGui::BeginTabItem(
"Chains")) {
607 ImGui::SetWindowFontScale(1.0f);
608 ImGui::PopStyleColor(3);
609 ImGui::PopStyleVar(3);
614 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(20, 6));
615 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 6));
616 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 6.0f);
617 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
618 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
619 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
620 ImGui::SetWindowFontScale(1.3f);
623 if (ImGui::BeginTabItem(
"Run")) {
624 ImGui::SetWindowFontScale(1.0f);
625 ImGui::PopStyleColor(3);
626 ImGui::PopStyleVar(3);
631 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(20, 6));
632 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8, 6));
633 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 6.0f);
634 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
635 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
636 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
640 ImGui::SetWindowFontScale(1.0f);
641 ImGui::PopStyleColor(3);
642 ImGui::PopStyleVar(3);
647 ImGui::SetWindowFontScale(1.0f);
648 ImGui::PopStyleColor(3);
649 ImGui::PopStyleVar(3);
655 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(30, 8));
656 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12, 8));
657 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 8.0f);
658 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
659 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
660 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
661 ImGui::SetWindowFontScale(1.5f);
665 ImGuiTabItemFlags quickLaunchFlags = (mTopLevelTab == 1) ? ImGuiTabItemFlags_SetSelected : 0;
666 if (ImGui::BeginTabItem(
"Quick Launch",
nullptr, quickLaunchFlags)) {
667 if (mTopLevelTab == 1) {
670 ImGui::SetWindowFontScale(1.0f);
671 ImGui::PopStyleColor(3);
672 ImGui::PopStyleVar(3);
674 RenderQuickLaunchTab();
678 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(30, 8));
679 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12, 8));
680 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 8.0f);
681 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
682 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
683 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
684 ImGui::SetWindowFontScale(1.5f);
688 ImGuiTabItemFlags scaleBuilderFlags = (mTopLevelTab == 2) ? ImGuiTabItemFlags_SetSelected : 0;
689 if (ImGui::BeginTabItem(
"Scales/Surveys",
nullptr, scaleBuilderFlags)) {
690 if (mTopLevelTab == 2) {
693 ImGui::SetWindowFontScale(1.0f);
694 ImGui::PopStyleColor(3);
695 ImGui::PopStyleVar(3);
701 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(30, 8));
702 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12, 8));
703 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 8.0f);
704 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f));
705 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f));
706 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f));
710 ImGui::SetWindowFontScale(1.0f);
711 ImGui::PopStyleColor(3);
712 ImGui::PopStyleVar(3);
717 ImGui::SetWindowFontScale(1.0f);
718 ImGui::PopStyleColor(3);
719 ImGui::PopStyleVar(3);
735 if (mShowVariantNameDialog) {
736 ShowVariantNameDialog();
740 if (mShowParameterEditor) {
741 ShowParameterEditor();
746 ShowSettingsDialog();
750 if (mPageEditor.
show) {
755 if (mTestEditor.
show) {
760 if (mShowCodeEditor) {
765 if (mQuestionEditor.
show) {
766 ShowQuestionEditor();
770 if (mBatchImport.
show) {
771 ShowBatchImportDialog();
775 if (mDimensionEditor.
show) {
776 ShowDimensionEditor();
780 if (mCreateStudyDialog.
show) {
781 ShowCreateStudyFromScaleDialog();
785 if (mCorrectAnswersEditor.
show) {
786 ShowCorrectAnswersEditor();
790 if (mNormsEditor.
show) {
797 bool translationEditorWasShown = mTranslationEditor.
show;
799 ShowTranslationEditorDialog();
802 if (translationEditorWasShown && !mTranslationEditor.
show &&
803 mTranslationEditor.
scaleMode && mCurrentScale && mScaleManager) {
804 auto reloaded = mScaleManager->LoadScale(mCurrentScale->GetScaleInfo().code);
806 mCurrentScale->GetTranslations() = reloaded->GetTranslations();
812 if (mShowNewStudyDialog) {
813 ShowNewStudyDialog();
817 if (mShowNewChainDialog) {
818 ShowNewChainDialog();
822 if (mShowStudySettingsDialog) {
823 ShowStudySettingsDialog();
827 if (mShowFirstRunDialog) {
828 ShowFirstRunDialog();
832 if (mShowGettingStartedDialog) {
833 ShowGettingStartedDialog();
837 if (mShowDuplicateSubjectWarning) {
838 ShowDuplicateSubjectWarning();
842 if (mShowEditParticipantCodeDialog) {
843 ShowEditParticipantCodeDialog();
847 if (mShowSnapshotCreated) {
848 ShowSnapshotCreatedDialog();
852 mOpenScalesBrowser.
Render();
855void LauncherUI::RenderMenuBar()
857 if (ImGui::BeginMenuBar())
859 if (ImGui::BeginMenu(
"File"))
861 if (ImGui::MenuItem(
"New Study...",
"Ctrl+N")) {
862 mShowNewStudyDialog =
true;
865 if (ImGui::MenuItem(
"Open Study...",
"Ctrl+O")) {
867 std::string studiesPath = mWorkspace->GetStudiesPath();
870 std::string command =
"zenity --file-selection --directory --title=\"Select Study Directory\" --filename=\"" + studiesPath +
"/\" 2>/dev/null";
871 FILE* pipe = popen(command.c_str(),
"r");
872 std::string studyPath;
875 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
877 if (!studyPath.empty() && studyPath[studyPath.length()-1] ==
'\n') {
878 studyPath.erase(studyPath.length()-1);
884 std::string studyPath = OpenDirectoryDialog(
"Select Study Directory");
887 if (!studyPath.empty()) {
888 LoadStudy(studyPath);
894 if (ImGui::MenuItem(
"Settings...",
"Ctrl+,")) {
895 mShowSettings =
true;
900 if (ImGui::MenuItem(
"Exit",
"Alt+F4")) {
901 SDL_Event quit_event;
902 quit_event.type = SDL_QUIT;
903 SDL_PushEvent(&quit_event);
908 if (ImGui::BeginMenu(
"Study"))
910 bool hasStudy = (mCurrentStudy !=
nullptr);
912 if (ImGui::MenuItem(
"New Study...")) {
913 mShowNewStudyDialog =
true;
916 if (ImGui::MenuItem(
"Load Study...")) {
917 std::string studiesPath = mWorkspace->GetStudiesPath();
920 std::string command =
"zenity --file-selection --directory --title=\"Select Study Directory\" --filename=\"" + studiesPath +
"/\" 2>/dev/null";
921 FILE* pipe = popen(command.c_str(),
"r");
922 std::string studyPath;
925 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
927 if (!studyPath.empty() && studyPath[studyPath.length()-1] ==
'\n') {
928 studyPath.erase(studyPath.length()-1);
934 std::string studyPath = OpenDirectoryDialog(
"Select Study Directory");
937 if (!studyPath.empty()) {
938 LoadStudy(studyPath);
942 if (ImGui::MenuItem(
"Open Study Directory...",
nullptr,
false, hasStudy)) {
944 std::string studyPath = mCurrentStudy->GetPath();
945 OpenDirectoryInFileBrowser(studyPath);
949 if (ImGui::MenuItem(
"Study Settings...",
nullptr,
false, hasStudy)) {
950 mShowStudySettingsDialog =
true;
955 if (ImGui::MenuItem(
"Create Snapshot...",
nullptr,
false, hasStudy)) {
956 if (mCurrentStudy && mSnapshots) {
958 std::string snapshotsDir = mWorkspace->GetWorkspacePath() +
"/snapshots";
961 if (!fs::exists(snapshotsDir)) {
962 fs::create_directories(snapshotsDir);
965 std::string snapshotName = mSnapshots->CreateSnapshot(mCurrentStudy->GetPath(), snapshotsDir);
967 if (!snapshotName.empty()) {
968 std::string snapshotPath = snapshotsDir +
"/" + snapshotName;
969 printf(
"Created snapshot: %s at %s\n", snapshotName.c_str(), snapshotPath.c_str());
972 std::strncpy(mLastSnapshotName, snapshotName.c_str(),
sizeof(mLastSnapshotName) - 1);
973 mLastSnapshotName[
sizeof(mLastSnapshotName) - 1] =
'\0';
974 std::strncpy(mLastSnapshotPath, snapshotPath.c_str(),
sizeof(mLastSnapshotPath) - 1);
975 mLastSnapshotPath[
sizeof(mLastSnapshotPath) - 1] =
'\0';
978 mShowSnapshotCreated =
true;
980 printf(
"Failed to create snapshot\n");
985 if (ImGui::MenuItem(
"Import Snapshot...")) {
986 std::string snapshotPath = OpenDirectoryDialog(
"Select Snapshot Directory");
987 if (!snapshotPath.empty() && mSnapshots) {
988 ImportSnapshotFromPath(snapshotPath);
992 if (ImGui::MenuItem(
"Import Snapshot ZIP...")) {
993 std::string zipPath = OpenFileDialog(
"Select Snapshot ZIP",
"*.zip", mWorkspace->GetSnapshotsPath());
994 if (!zipPath.empty() && mSnapshots) {
996 std::string tempDir =
"/tmp/pebl_snapshot_import_" + std::to_string(std::time(
nullptr));
997 mkdir(tempDir.c_str(), 0755);
1000 printf(
"Extracting snapshot ZIP...\n");
1002 if (extractResult.success) {
1004 std::string snapshotPath;
1007 std::string studyInfoPath = tempDir +
"/study-info.json";
1008 if (fs::exists(studyInfoPath)) {
1010 snapshotPath = tempDir;
1011 printf(
"Snapshot extracted directly to temp directory\n");
1015 for (
const auto& entry : fs::directory_iterator(tempDir)) {
1016 if (!entry.is_directory())
continue;
1017 std::string candidatePath = entry.path().string();
1018 std::string candidateStudyInfo = candidatePath +
"/study-info.json";
1019 if (fs::exists(candidateStudyInfo)) {
1020 snapshotPath = candidatePath;
1021 printf(
"Snapshot found in subdirectory: %s\n", entry.path().filename().string().c_str());
1025 }
catch (
const fs::filesystem_error&) {
1030 if (!snapshotPath.empty()) {
1031 ImportSnapshotFromPath(snapshotPath);
1033 printf(
"Could not locate snapshot directory in extracted ZIP\n");
1037 std::string cleanupCmd =
"rm -rf " + tempDir;
1038 system(cleanupCmd.c_str());
1040 printf(
"Failed to extract ZIP file: %s\n", extractResult.error.c_str());
1041 rmdir(tempDir.c_str());
1049 if (ImGui::BeginMenu(
"Tools"))
1051 if (ImGui::MenuItem(
"Open Battery Directory...")) {
1053 if (!batteryPath.empty()) {
1054 OpenDirectoryInFileBrowser(batteryPath);
1058 if (ImGui::MenuItem(
"Open Workspace Directory...")) {
1059 std::string workspacePath = mWorkspace->GetWorkspacePath();
1060 if (!workspacePath.empty()) {
1061 OpenDirectoryInFileBrowser(workspacePath);
1065 bool hasStudy = (mCurrentStudy !=
nullptr);
1066 if (ImGui::MenuItem(
"Open Study Directory...",
nullptr,
false, hasStudy)) {
1067 if (mCurrentStudy) {
1068 std::string studyPath = mCurrentStudy->GetPath();
1069 OpenDirectoryInFileBrowser(studyPath);
1075 if (ImGui::MenuItem(
"Data Combiner...")) {
1077 std::string startDir;
1078 if (mCurrentStudy) {
1079 startDir = mCurrentStudy->GetPath() +
"/data";
1081 startDir = mWorkspace->GetWorkspacePath();
1084 std::string selectedDir = OpenDirectoryDialog(
"Select Directory for Data Combiner", startDir);
1085 if (!selectedDir.empty()) {
1086 LaunchDataCombiner(selectedDir);
1089 if (ImGui::IsItemHovered()) {
1090 ImGui::SetTooltip(
"Combine data files from a directory");
1093 if (ImGui::MenuItem(
"Scales/Surveys...",
nullptr,
false, mScaleManager !=
nullptr)) {
1096 if (ImGui::IsItemHovered()) {
1097 ImGui::SetTooltip(
"Create and edit psychological scales/surveys");
1102 if (ImGui::MenuItem(
"Refresh Battery Tests")) {
1104 if (!batteryPath.empty()) {
1105 ScanExperimentDirectory(batteryPath);
1106 printf(
"Refreshed battery: %zu tests found\n", mExperiments.size());
1110 if (ImGui::MenuItem(
"View Launch Log")) {
1112 printf(
"Opening launch log: %s\n", logPath.c_str());
1114 system((
"xdg-open \"" + logPath +
"\" &").c_str());
1115 #elif defined(_WIN32)
1116 system((
"start \"\" \"" + logPath +
"\"").c_str());
1117 #elif defined(__APPLE__)
1118 system((
"open \"" + logPath +
"\"").c_str());
1121 if (ImGui::IsItemHovered()) {
1122 ImGui::SetTooltip(
"View log of launched PEBL processes");
1128 if (ImGui::BeginMenu(
"Help"))
1130 if (ImGui::MenuItem(
"About PEBL Launcher")) {
1134 if (ImGui::MenuItem(
"PEBL Documentation")) {
1136 system(
"xdg-open https://pebl.sourceforge.net/documentation.html &");
1137 #elif defined(_WIN32)
1138 system(
"start https://pebl.sourceforge.net/documentation.html");
1139 #elif defined(__APPLE__)
1140 system(
"open https://pebl.sourceforge.net/documentation.html");
1144 if (ImGui::MenuItem(
"PEBL Manual (PDF)")) {
1146 std::string manualName = std::string(
"PEBLManual") +
PEBL_VERSION +
".pdf";
1148 std::string manualPath;
1152 std::vector<std::string> possiblePaths = {
1153 (fs::path(workspacePath) / manualName).
string(),
1154 (fs::path(workspacePath) /
"doc" / manualName).
string(),
1157 for (
const auto& path : possiblePaths) {
1158 if (fs::exists(path)) {
1164 if (manualPath.empty()) {
1165 manualPath = possiblePaths[0];
1166 printf(
"Warning: Manual not found at expected locations\n");
1170 system((
"xdg-open \"" + manualPath +
"\" &").c_str());
1171 #elif defined(_WIN32)
1172 system((
"start \"\" \"" + manualPath +
"\"").c_str());
1173 #elif defined(__APPLE__)
1174 system((
"open \"" + manualPath +
"\"").c_str());
1178 if (ImGui::MenuItem(
"Function Reference")) {
1180 system(
"xdg-open https://pebl.sourceforge.net/function-reference/index.html &");
1181 #elif defined(_WIN32)
1182 system(
"start https://pebl.sourceforge.net/function-reference/index.html");
1183 #elif defined(__APPLE__)
1184 system(
"open https://pebl.sourceforge.net/function-reference/index.html");
1188 if (ImGui::MenuItem(
"PEBL Website")) {
1190 system(
"xdg-open https://pebl.sourceforge.net &");
1191 #elif defined(_WIN32)
1192 system(
"start https://pebl.sourceforge.net");
1193 #elif defined(__APPLE__)
1194 system(
"open https://pebl.sourceforge.net");
1198 if (ImGui::MenuItem(
"PEBL Online Hub")) {
1200 system(
"xdg-open https://peblhub.online &");
1201 #elif defined(_WIN32)
1202 system(
"start https://peblhub.online");
1203 #elif defined(__APPLE__)
1204 system(
"open https://peblhub.online");
1210 if (ImGui::MenuItem(
"Show First-Run Dialog")) {
1211 mShowFirstRunDialog =
true;
1217 ImGui::EndMenuBar();
1221void LauncherUI::RenderFilePanel()
1223 ImGui::Text(
"Experiment Directory:");
1224 ImGui::PushItemWidth(-1);
1225 if (ImGui::InputText(
"##ExperimentDir", mExperimentDir,
sizeof(mExperimentDir),
1226 ImGuiInputTextFlags_EnterReturnsTrue)) {
1227 ScanExperimentDirectory(mExperimentDir);
1230 ImGui::PopItemWidth();
1233 if (ImGui::Button(
"Browse...")) {
1234 std::string dir = OpenDirectoryDialog(
"Select Experiment Directory");
1236 std::strcpy(mExperimentDir, dir.c_str());
1237 ScanExperimentDirectory(mExperimentDir);
1246 if (!recent.empty()) {
1247 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Recent Tests:");
1249 ImGui::BeginChild(
"RecentList", ImVec2(0, 120),
true);
1250 ImGui::SetWindowFontScale(1.3f);
1252 for (
const auto& exp : recent) {
1254 if (ImGui::Selectable(exp.name.c_str())) {
1256 for (
int i = 0; i < (int)mExperiments.size(); i++) {
1257 if (mExperiments[i].path == exp.path) {
1258 mSelectedExperiment = i;
1259 LoadExperimentInfo(exp.path);
1265 if (ImGui::IsItemHovered()) {
1268 struct tm* timeinfo = localtime(&exp.lastRun);
1269 strftime(timeBuf,
sizeof(timeBuf),
"%Y-%m-%d %H:%M:%S", timeinfo);
1270 ImGui::SetTooltip(
"Last run: %s\n%s", timeBuf, exp.path.c_str());
1274 ImGui::SetWindowFontScale(1.0f);
1280 ImGui::Text(
"Tests (%zu found):", mExperiments.size());
1283 static char filter[256] =
"";
1284 ImGui::PushItemWidth(-1);
1285 ImGui::InputTextWithHint(
"##Filter",
"Filter tests...", filter,
sizeof(filter));
1286 ImGui::PopItemWidth();
1291 ImGui::BeginChild(
"ExperimentList", ImVec2(0, 0),
false);
1294 ImGui::SetWindowFontScale(1.3f);
1296 for (
int i = 0; i < (int)mExperiments.size(); i++) {
1300 if (strlen(filter) > 0 &&
1301 exp.
name.find(filter) == std::string::npos) {
1305 bool is_selected = (mSelectedExperiment == i);
1306 if (ImGui::Selectable(exp.
name.c_str(), is_selected)) {
1307 mSelectedExperiment = i;
1308 LoadExperimentInfo(exp.
path);
1311 if (ImGui::IsItemHovered()) {
1312 ImGui::SetTooltip(
"%s", exp.
path.c_str());
1317 ImGui::SetWindowFontScale(1.0f);
1322void LauncherUI::RenderDetailsPanel()
1325 if (ImGui::BeginTabBar(
"DetailsTabs")) {
1327 if (ImGui::BeginTabItem(
"Details")) {
1329 ImGui::EndTabItem();
1333 if (ImGui::BeginTabItem(
"Study")) {
1335 ImGui::EndTabItem();
1339 if (ImGui::BeginTabItem(
"Chain")) {
1341 ImGui::EndTabItem();
1348void LauncherUI::RenderDetailsTab()
1350 if (mSelectedExperiment < 0 || mSelectedExperiment >= (
int)mExperiments.size()) {
1351 ImGui::TextWrapped(
"Select a test from the list on the left to view its details.");
1358 float availHeight = ImGui::GetContentRegionAvail().y;
1359 float topSectionHeight = availHeight * 0.45f;
1360 float panelWidth = ImGui::GetContentRegionAvail().x;
1361 float screenshotWidth = panelWidth * 0.5f;
1364 ImGui::BeginChild(
"TopSection", ImVec2(0, topSectionHeight),
false);
1367 ImGui::BeginChild(
"ScreenshotPanel", ImVec2(screenshotWidth, 0),
false, ImGuiWindowFlags_NoScrollbar);
1369 if (mScreenshotTexture) {
1371 float aspectRatio = (float)mScreenshotHeight / (
float)mScreenshotWidth;
1372 float displayWidth = screenshotWidth - 20;
1373 float displayHeight = displayWidth * aspectRatio;
1376 float maxHeight = topSectionHeight - 20;
1377 if (displayHeight > maxHeight) {
1378 displayHeight = maxHeight;
1379 displayWidth = displayHeight / aspectRatio;
1382 ImGui::Image((ImTextureID)(intptr_t)mScreenshotTexture,
1383 ImVec2(displayWidth, displayHeight));
1385 ImGui::TextDisabled(
"No screenshot available");
1393 ImGui::BeginChild(
"InfoPanel", ImVec2(0, 0),
false);
1396 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", exp.
name.c_str());
1401 ImGui::Text(
"Path:");
1402 ImGui::TextWrapped(
"%s", exp.
directory.c_str());
1404 if (ImGui::Button(
"Open Experiment Folder", ImVec2(-1, 0))) {
1405 OpenDirectoryInFileBrowser(exp.
directory);
1407 if (ImGui::IsItemHovered()) {
1408 ImGui::SetTooltip(
"Open this experiment's folder in file browser");
1415 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"✓ Parameters available");
1416 if (ImGui::IsItemHovered()) {
1417 ImGui::SetTooltip(
"This experiment has configurable parameters");
1420 ImGui::TextDisabled(
"No parameters");
1425 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"✓ Translations: %zu languages",
1426 mAvailableLanguages.size());
1428 if (ImGui::IsItemHovered()) {
1429 std::string tooltip =
"Available languages:\n";
1430 for (
size_t i = 0; i < mAvailableLanguages.size(); i++) {
1431 tooltip += mAvailableLanguages[i];
1432 if (i < mAvailableLanguages.size() - 1) tooltip +=
", ";
1434 ImGui::SetTooltip(
"%s", tooltip.c_str());
1437 ImGui::TextDisabled(
"No translations");
1445 ImGui::Text(
"Description:");
1446 ImGui::BeginChild(
"Description", ImVec2(0, 100),
true);
1448 ImGui::TextWrapped(
"%s", exp.
description.c_str());
1450 ImGui::TextDisabled(
"No description available");
1462 ImGui::Text(
"Configuration:");
1465 float itemWidth = ImGui::GetContentRegionAvail().x * 0.45f;
1467 ImGui::Text(
"Subject Code:");
1468 ImGui::SameLine(150);
1469 ImGui::PushItemWidth(itemWidth);
1470 if (ImGui::InputText(
"##SubjectCode", mSubjectCode,
sizeof(mSubjectCode))) {
1473 ImGui::PopItemWidth();
1478 ImGui::Text(
"Language:");
1480 ImGui::PushItemWidth(itemWidth);
1481 if (ImGui::BeginCombo(
"##Language", mLanguageCode)) {
1482 for (
const auto& lang : mAvailableLanguages) {
1483 bool is_selected = (strcmp(mLanguageCode, lang.c_str()) == 0);
1484 if (ImGui::Selectable(lang.c_str(), is_selected)) {
1485 std::strcpy(mLanguageCode, lang.c_str());
1489 ImGui::SetItemDefaultFocus();
1494 ImGui::PopItemWidth();
1500 if (ImGui::Checkbox(
"Fullscreen Mode", &mFullscreen)) {
1508 if (ImGui::Button(
"Edit Parameters", ImVec2(-1, 0))) {
1510 std::string expScaleCode = fs::path(exp.
path).stem().string();
1511 SyncScaleSchema(exp.
directory, expScaleCode);
1514 mParameters.clear();
1516 fs::path schemaPath = fs::path(exp.
directory) /
"params" / (fs::path(exp.
path).filename().string() +
".schema.json");
1518 if (fs::exists(schemaPath)) {
1520 std::ifstream schemaFile(schemaPath);
1521 if (!schemaFile.is_open()) {
1522 printf(
"ERROR: Could not open schema file: %s\n", schemaPath.string().c_str());
1525 nlohmann::json schemaJson;
1526 schemaFile >> schemaJson;
1529 if (!schemaJson.contains(
"parameters") || !schemaJson[
"parameters"].is_array()) {
1530 printf(
"ERROR: Schema file does not contain 'parameters' array\n");
1533 for (
const auto& paramJson : schemaJson[
"parameters"]) {
1534 if (!paramJson.contains(
"name") || !paramJson.contains(
"default")) {
1539 param.
name = paramJson[
"name"].get<std::string>();
1542 if (paramJson[
"default"].is_string()) {
1543 param.
defaultValue = paramJson[
"default"].get<std::string>();
1544 }
else if (paramJson[
"default"].is_number_integer()) {
1545 param.
defaultValue = std::to_string(paramJson[
"default"].get<int>());
1546 }
else if (paramJson[
"default"].is_number_float()) {
1547 param.
defaultValue = std::to_string(paramJson[
"default"].get<double>());
1548 }
else if (paramJson[
"default"].is_boolean()) {
1549 param.
defaultValue = paramJson[
"default"].get<
bool>() ?
"true" :
"false";
1550 }
else if (paramJson[
"default"].is_array()) {
1557 if (paramJson.contains(
"description")) {
1558 param.
description = paramJson[
"description"].get<std::string>();
1562 if (paramJson.contains(
"options") && paramJson[
"options"].is_array()) {
1563 for (
const auto& opt : paramJson[
"options"]) {
1564 if (opt.is_string()) {
1565 param.
options.push_back(opt.get<std::string>());
1567 param.
options.push_back(opt.dump());
1573 mParameters.push_back(param);
1575 printf(
"Loaded %zu parameters from schema\n", mParameters.size());
1578 }
catch (
const std::exception& e) {
1579 printf(
"ERROR parsing schema JSON: %s\n", e.what());
1584 mParameterFile = (fs::path(exp.
directory) / (fs::path(exp.
path).stem().string() +
".par.json")).string();
1587 if (fs::exists(mParameterFile)) {
1589 std::ifstream paramFile(mParameterFile);
1590 if (paramFile.is_open()) {
1591 nlohmann::json paramJson;
1592 paramFile >> paramJson;
1596 for (
auto& param : mParameters) {
1597 if (paramJson.contains(param.
name)) {
1599 if (paramJson[param.
name].is_string()) {
1600 param.
value = paramJson[param.
name].get<std::string>();
1601 }
else if (paramJson[param.
name].is_number_integer()) {
1602 param.
value = std::to_string(paramJson[param.
name].get<
int>());
1603 }
else if (paramJson[param.
name].is_number_float()) {
1604 param.
value = std::to_string(paramJson[param.
name].get<
double>());
1605 }
else if (paramJson[param.
name].is_boolean()) {
1606 param.
value = paramJson[param.
name].get<
bool>() ?
"true" :
"false";
1608 param.
value = paramJson[param.
name].dump();
1612 printf(
"Loaded parameter values from: %s\n", mParameterFile.c_str());
1614 }
catch (
const std::exception& e) {
1615 printf(
"ERROR parsing parameter file JSON: %s\n", e.what());
1619 mShowParameterEditor =
true;
1628 float buttonWidth = ImGui::GetContentRegionAvail().x * 0.48f;
1629 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f));
1630 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.4f, 0.6f, 0.9f, 1.0f));
1631 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.2f, 0.4f, 0.7f, 1.0f));
1633 if (ImGui::Button(
"Add to Study", ImVec2(buttonWidth, 40))) {
1637 ImGui::PopStyleColor(3);
1642 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
1643 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
1644 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
1646 if (ImGui::Button(
"Run Test", ImVec2(buttonWidth, 40))) {
1650 ImGui::PopStyleColor(3);
1654 ImGui::TextDisabled(
"Path: %s", exp.
directory.c_str());
1656 if (ImGui::SmallButton(
"Open Folder")) {
1657 OpenDirectoryInFileBrowser(exp.
directory);
1659 if (ImGui::IsItemHovered()) {
1660 ImGui::SetTooltip(
"Open experiment folder in file browser");
1664void LauncherUI::RenderChainTab()
1668 ImGui::BeginChild(
"ChainSelector", ImVec2(0, 150),
true);
1671 ImGui::Text(
"Current Chain:");
1674 const char* currentChainName = mCurrentChain ? mCurrentChain->GetName().c_str() :
"None";
1675 ImGui::PushItemWidth(250);
1676 if (ImGui::BeginCombo(
"##ChainSelect", currentChainName)) {
1678 if (mCurrentStudy) {
1679 auto chainFiles = mCurrentStudy->GetChainFiles();
1682 if (chainFiles.empty()) {
1683 if (ImGui::Selectable(
"None", !mCurrentChain)) {
1684 mCurrentChain.reset();
1688 for (
size_t i = 0; i < chainFiles.size(); i++) {
1689 std::string chainName = fs::path(chainFiles[i]).stem().string();
1690 bool is_selected = (mCurrentChain &&
1691 fs::path(mCurrentChain->GetFilePath()).stem().string() == chainName);
1693 if (ImGui::Selectable(chainName.c_str(), is_selected)) {
1695 std::string fullChainPath = mCurrentStudy->GetPath() +
"/chains/" + chainFiles[i];
1696 LoadChain(fullChainPath);
1697 printf(
"Loaded chain from dropdown: %s\n", chainName.c_str());
1702 if (ImGui::Selectable(
"None", !mCurrentChain)) {
1703 mCurrentChain.reset();
1708 ImGui::PopItemWidth();
1713 if (ImGui::Button(
"New Chain...")) {
1720 if (mCurrentChain) {
1721 if (ImGui::Button(
"Save Chain")) {
1728 if (ImGui::Button(
"Copy Chain...")) {
1729 ImGui::OpenPopup(
"Copy Chain");
1735 if (ImGui::Button(
"Delete Chain")) {
1736 ImGui::OpenPopup(
"Confirm Delete Chain");
1741 if (ImGui::BeginPopupModal(
"Copy Chain",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
1742 ImGui::Text(
"Create a copy of '%s'", mCurrentChain ? mCurrentChain->GetName().c_str() :
"");
1745 static char copyName[256] =
"";
1746 if (ImGui::IsWindowAppearing()) {
1748 if (mCurrentChain) {
1749 std::string defaultName = mCurrentChain->GetName() +
"_copy";
1750 std::strncpy(copyName, defaultName.c_str(),
sizeof(copyName) - 1);
1751 copyName[
sizeof(copyName) - 1] =
'\0';
1753 ImGui::SetKeyboardFocusHere();
1756 ImGui::Text(
"New chain name:");
1757 ImGui::InputText(
"##CopyChainName", copyName,
sizeof(copyName));
1761 if (ImGui::Button(
"Copy", ImVec2(120, 0))) {
1762 if (strlen(copyName) > 0 && mCurrentChain && mCurrentStudy) {
1764 std::string studyPath = mCurrentStudy->GetPath();
1765 std::string newChainPath = (fs::path(studyPath) /
"chains" / (std::string(copyName) +
".json")).string();
1768 std::string oldChainPath = mCurrentChain->GetFilePath();
1770 fs::copy_file(oldChainPath, newChainPath, fs::copy_options::overwrite_existing);
1775 newChain->SetName(copyName);
1779 mCurrentChain = newChain;
1781 printf(
"Created copy of chain: %s\n", copyName);
1783 }
catch (
const std::exception& e) {
1784 printf(
"Error copying chain: %s\n", e.what());
1788 ImGui::CloseCurrentPopup();
1794 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
1796 ImGui::CloseCurrentPopup();
1803 if (ImGui::BeginPopupModal(
"Confirm Delete Chain",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
1804 ImGui::Text(
"Are you sure you want to delete the chain '%s'?", mCurrentChain ? mCurrentChain->GetName().c_str() :
"");
1806 ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f),
"This action cannot be undone.");
1809 if (ImGui::Button(
"Delete", ImVec2(120, 0))) {
1810 if (mCurrentChain && mCurrentStudy) {
1811 std::string chainPath = mCurrentChain->GetFilePath();
1812 std::string chainName = mCurrentChain->GetName();
1814 fs::remove(chainPath);
1815 mCurrentChain.reset();
1817 printf(
"Deleted chain: %s\n", chainName.c_str());
1818 }
catch (
const std::exception& e) {
1819 printf(
"Error deleting chain: %s\n", e.what());
1822 ImGui::CloseCurrentPopup();
1827 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
1828 ImGui::CloseCurrentPopup();
1837 if (mCurrentChain) {
1838 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", mCurrentChain->GetName().c_str());
1839 ImGui::Text(
"Description: %s", mCurrentChain->GetDescription().c_str());
1840 ImGui::Text(
"Items in chain: %zu", mCurrentChain->GetItems().size());
1845 bool uploadEnabled = mCurrentChain->GetUploadEnabled();
1846 if (ImGui::Checkbox(
"Upload data to server", &uploadEnabled)) {
1847 mCurrentChain->SetUploadEnabled(uploadEnabled);
1850 if (uploadEnabled && mCurrentStudy) {
1851 bool hasConfig = !mCurrentStudy->GetStudyToken().empty() &&
1852 !mCurrentStudy->GetUploadServerURL().empty();
1855 ImGui::OpenPopup(
"Upload Config Required");
1859 for (
const auto& item : mCurrentChain->GetItems()) {
1860 if (!item.IsPageItem()) {
1861 if (mCurrentStudy->CreateUploadConfigForTest(item.testName)) {
1867 printf(
"Created %d upload.json file(s) for chain tests\n", created);
1872 mCurrentChain->Save();
1876 if (ImGui::BeginPopupModal(
"Upload Config Required",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
1877 ImGui::TextWrapped(
"To enable data upload, you must first configure the study's upload settings.");
1879 ImGui::TextWrapped(
"Please go to Study Settings and enter:");
1880 ImGui::BulletText(
"Upload Server URL");
1881 ImGui::BulletText(
"Study Token");
1884 if (ImGui::Button(
"OK", ImVec2(120, 0))) {
1886 mCurrentChain->SetUploadEnabled(
false);
1887 ImGui::CloseCurrentPopup();
1892 if (uploadEnabled) {
1894 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"(Enabled)");
1902 bool lslEnabled = mCurrentChain->GetLSLEnabled();
1903 if (ImGui::Checkbox(
"Enable LSL streaming", &lslEnabled)) {
1904 mCurrentChain->SetLSLEnabled(lslEnabled);
1905 mCurrentChain->Save();
1910 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"(Enabled)");
1915 ImGui::Indent(20.0f);
1916 ImGui::Text(
"LSL Stream Name:");
1918 ImGui::TextDisabled(
"(?)");
1919 if (ImGui::IsItemHovered()) {
1920 ImGui::BeginTooltip();
1921 ImGui::Text(
"Stream name template for LSL outlets.");
1922 ImGui::Text(
"Available placeholders:");
1923 ImGui::BulletText(
"{test} - Test name (e.g., 'gonogo')");
1924 ImGui::BulletText(
"{subject} - Subject ID");
1925 ImGui::Text(
"\nExample: PEBL_{test} becomes PEBL_gonogo");
1926 ImGui::EndTooltip();
1929 char streamName[256];
1930 std::strncpy(streamName, mCurrentChain->GetLSLStreamName().c_str(),
sizeof(streamName) - 1);
1931 streamName[
sizeof(streamName) - 1] =
'\0';
1933 if (ImGui::InputText(
"##lsl_stream_name", streamName,
sizeof(streamName))) {
1934 mCurrentChain->SetLSLStreamName(std::string(streamName));
1935 mCurrentChain->Save();
1938 ImGui::Unindent(20.0f);
1941 ImGui::TextDisabled(
"No chain loaded. Create a new chain or select an existing one.");
1949 if (!mCurrentChain || !mCurrentStudy) {
1950 ImGui::BeginDisabled();
1953 float buttonWidth = (ImGui::GetContentRegionAvail().x - 20) / 4.0f;
1955 if (ImGui::Button(
"Add Instruction", ImVec2(buttonWidth, 0))) {
1956 AddInstructionPage();
1961 if (ImGui::Button(
"Add Consent", ImVec2(buttonWidth, 0))) {
1967 if (ImGui::Button(
"Add Completion", ImVec2(buttonWidth, 0))) {
1968 AddCompletionPage();
1973 if (ImGui::Button(
"Add Test", ImVec2(buttonWidth, 0))) {
1977 if (!mCurrentChain || !mCurrentStudy) {
1978 ImGui::EndDisabled();
1986 ImGui::Text(
"Chain Items:");
1987 ImGui::BeginChild(
"ChainItemsList", ImVec2(0, -80),
true);
1989 if (!mCurrentChain) {
1990 ImGui::TextDisabled(
"Load a chain to view items");
1991 }
else if (mCurrentChain->GetItems().empty()) {
1992 ImGui::TextDisabled(
"No items in this chain. Use the buttons above to add items.");
1995 const auto& items = mCurrentChain->GetItems();
1996 for (
size_t i = 0; i < items.size(); i++) {
1999 ImGui::PushID((
int)i);
2002 ImGui::Selectable(
"::##cdh",
false, ImGuiSelectableFlags_AllowOverlap);
2003 if (ImGui::IsItemHovered())
2004 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
2005 if (ImGui::BeginDragDropSource()) {
2006 int dragIdx = (int)i;
2007 ImGui::SetDragDropPayload(
"CHAIN_ITEM", &dragIdx,
sizeof(
int));
2009 ImGui::EndDragDropSource();
2011 if (ImGui::BeginDragDropTarget()) {
2012 if (
const ImGuiPayload* pl = ImGui::AcceptDragDropPayload(
"CHAIN_ITEM")) {
2013 int srcIdx = *(
const int*)pl->Data;
2014 if (srcIdx != (
int)i) {
2015 MoveChainItemTo(srcIdx, (
int)i);
2020 ImGui::EndDragDropTarget();
2025 ImGui::Text(
"%zu.", i + 1);
2029 const char* typeLabel =
"";
2030 ImVec4 typeColor(1.0f, 1.0f, 1.0f, 1.0f);
2033 typeLabel =
"[INS]";
2034 typeColor = ImVec4(0.4f, 0.7f, 1.0f, 1.0f);
2036 typeLabel =
"[CON]";
2037 typeColor = ImVec4(0.7f, 0.4f, 1.0f, 1.0f);
2039 typeLabel =
"[CMP]";
2040 typeColor = ImVec4(0.4f, 1.0f, 0.7f, 1.0f);
2042 typeLabel =
"[TST]";
2043 typeColor = ImVec4(1.0f, 0.7f, 0.4f, 1.0f);
2046 ImGui::TextColored(typeColor,
"%s", typeLabel);
2051 ImGui::Text(
"%s", displayName.c_str());
2056 ImGui::TextDisabled(
"Test: %s | Variant: %s",
2058 item.
paramVariant.empty() ?
"default" : item.paramVariant.c_str());
2062 ImGui::TextDisabled(
" | Group:");
2065 ImGui::PushItemWidth(80);
2066 const char* groupOptions[] = {
"None",
"1",
"2",
"3"};
2068 if (ImGui::Combo(
"##randomgroup", ¤tGroup, groupOptions, 4)) {
2070 ChainItem* mutableItem = mCurrentChain->GetItem(i);
2074 printf(
"Updated randomization group for item %zu to %d\n", i, currentGroup);
2077 ImGui::PopItemWidth();
2079 if (ImGui::IsItemHovered()) {
2080 ImGui::SetTooltip(
"Randomization group (0=None, 1-3=Group number)\n"
2081 "Tests with same non-zero group will be randomized together\n"
2082 "Group 0 (None) keeps test in fixed position");
2085 ImGui::Unindent(40);
2089 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 250);
2093 ImGui::BeginDisabled();
2095 if (ImGui::ArrowButton(
"##up", ImGuiDir_Up)) {
2101 ImGui::EndDisabled();
2103 if (ImGui::IsItemHovered()) {
2104 ImGui::SetTooltip(
"Move up");
2110 if (i >= items.size() - 1) {
2111 ImGui::BeginDisabled();
2113 if (ImGui::ArrowButton(
"##down", ImGuiDir_Down)) {
2114 MoveChainItemDown(i);
2118 if (i >= items.size() - 1) {
2119 ImGui::EndDisabled();
2121 if (ImGui::IsItemHovered()) {
2122 ImGui::SetTooltip(
"Move down");
2128 if (ImGui::SmallButton(
"Test")) {
2131 if (ImGui::IsItemHovered()) {
2132 ImGui::SetTooltip(
"Test run this item");
2138 if (ImGui::SmallButton(
"Edit")) {
2141 if (ImGui::IsItemHovered()) {
2142 ImGui::SetTooltip(
"Edit item");
2148 if (ImGui::SmallButton(
"Remove")) {
2153 if (ImGui::IsItemHovered()) {
2154 ImGui::SetTooltip(
"Remove from chain");
2165 ImGui::TextDisabled(
"Use the 'Test' button to run individual items, or go to the Run tab to execute the full chain.");
2168void LauncherUI::ShowAboutDialog()
2170 ImGui::OpenPopup(
"About PEBL Launcher");
2172 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
2173 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
2175 if (ImGui::BeginPopupModal(
"About PEBL Launcher", &mShowAbout, ImGuiWindowFlags_AlwaysAutoResize))
2177 ImGui::Text(
"PEBL Experiment Launcher");
2180 ImGui::Text(
"Built with Dear ImGui and SDL2");
2182 ImGui::Text(
"Copyright (c) 2026 Shane T. Mueller");
2183 ImGui::Text(
"Licensed under GPL");
2186 if (ImGui::Button(
"Close", ImVec2(120, 0))) {
2188 ImGui::CloseCurrentPopup();
2195void LauncherUI::ShowSettingsDialog()
2197 ImGui::OpenPopup(
"Settings");
2199 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
2200 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
2201 ImGui::SetNextWindowSize(ImVec2(800, 600), ImGuiCond_FirstUseEver);
2203 if (ImGui::BeginPopupModal(
"Settings", &mShowSettings, 0))
2205 ImGui::Text(
"Configure PEBL Launcher settings");
2210 if (ImGui::BeginTabBar(
"SettingsTabs", ImGuiTabBarFlags_None))
2215 if (ImGui::BeginTabItem(
"General"))
2220 ImGui::Text(
"Default Subject Code:");
2221 char subjectCode[256];
2222 std::strncpy(subjectCode, mConfig->
GetSubjectCode().c_str(),
sizeof(subjectCode) - 1);
2223 subjectCode[
sizeof(subjectCode) - 1] =
'\0';
2224 ImGui::PushItemWidth(200);
2225 if (ImGui::InputText(
"##SubjectCode", subjectCode,
sizeof(subjectCode))) {
2228 ImGui::PopItemWidth();
2233 ImGui::Text(
"Default Language:");
2235 std::strncpy(language, mConfig->
GetLanguage().c_str(),
sizeof(language) - 1);
2236 language[
sizeof(language) - 1] =
'\0';
2237 ImGui::PushItemWidth(100);
2238 if (ImGui::InputText(
"##Language", language,
sizeof(language))) {
2241 ImGui::PopItemWidth();
2242 if (ImGui::IsItemHovered()) {
2243 ImGui::SetTooltip(
"Two-letter language code (en, de, es, fr, etc.)");
2250 if (ImGui::Checkbox(
"Run tests in fullscreen mode by default", &fullscreen)) {
2257 ImGui::Text(
"Font Size:");
2259 ImGui::PushItemWidth(100);
2260 if (ImGui::SliderInt(
"##FontSize", &fontSize, 10, 24)) {
2263 ImGui::PopItemWidth();
2264 if (ImGui::IsItemHovered()) {
2265 ImGui::SetTooltip(
"Requires restart to take effect");
2268 ImGui::EndTabItem();
2274 if (ImGui::BeginTabItem(
"File Paths"))
2277 ImGui::TextWrapped(
"Configure file system locations for PEBL. Leave blank for auto-detection.");
2282 ImGui::Text(
"Workspace Path:");
2283 if (ImGui::IsItemHovered()) {
2284 ImGui::SetTooltip(
"Main workspace directory (typically Documents/pebl-exp.%s/)",
PEBL_VERSION);
2286 char workspacePath[512];
2287 std::strncpy(workspacePath, mConfig->
GetWorkspacePath().c_str(),
sizeof(workspacePath) - 1);
2288 workspacePath[
sizeof(workspacePath) - 1] =
'\0';
2289 ImGui::PushItemWidth(-100);
2290 if (ImGui::InputText(
"##WorkspacePath", workspacePath,
sizeof(workspacePath))) {
2293 ImGui::PopItemWidth();
2295 if (ImGui::Button(
"Browse##Workspace")) {
2296 std::string path = OpenDirectoryDialog(
"Select Workspace Directory");
2297 if (!path.empty()) {
2305 ImGui::Text(
"Battery Path:");
2306 if (ImGui::IsItemHovered()) {
2307 ImGui::SetTooltip(
"Directory containing battery tests (e.g., /usr/local/share/pebl/battery/)");
2309 char batteryPath[512];
2310 std::strncpy(batteryPath, mConfig->
GetBatteryPath().c_str(),
sizeof(batteryPath) - 1);
2311 batteryPath[
sizeof(batteryPath) - 1] =
'\0';
2312 ImGui::PushItemWidth(-100);
2313 if (ImGui::InputText(
"##BatteryPath", batteryPath,
sizeof(batteryPath))) {
2316 ImGui::PopItemWidth();
2318 if (ImGui::Button(
"Browse##Battery")) {
2319 std::string path = OpenDirectoryDialog(
"Select Battery Directory");
2320 if (!path.empty()) {
2328 ImGui::Text(
"PEBL Executable Path:");
2329 if (ImGui::IsItemHovered()) {
2330 ImGui::SetTooltip(
"Location of the pebl2 executable (auto-detected on startup)");
2332 char peblExePath[512];
2334 peblExePath[
sizeof(peblExePath) - 1] =
'\0';
2335 ImGui::PushItemWidth(-100);
2336 if (ImGui::InputText(
"##PeblExePath", peblExePath,
sizeof(peblExePath))) {
2339 ImGui::PopItemWidth();
2341 if (ImGui::Button(
"Browse##PeblExe")) {
2342 std::string path = OpenFileDialog(
"Select PEBL Executable");
2343 if (!path.empty()) {
2352 ImGui::TextWrapped(
"Note: Restart the launcher after changing paths for changes to take full effect.");
2354 ImGui::EndTabItem();
2360 if (ImGui::BeginTabItem(
"Upload"))
2363 ImGui::TextWrapped(
"Configure automatic data upload to PEBL Hub or custom server.");
2369 if (ImGui::Checkbox(
"Enable auto-upload after test completion", &autoUpload)) {
2376 ImGui::Text(
"Upload URL:");
2377 char uploadURL[512];
2378 std::strncpy(uploadURL, mConfig->
GetUploadURL().c_str(),
sizeof(uploadURL) - 1);
2379 uploadURL[
sizeof(uploadURL) - 1] =
'\0';
2380 ImGui::PushItemWidth(-1);
2381 if (ImGui::InputText(
"##UploadURL", uploadURL,
sizeof(uploadURL))) {
2384 ImGui::PopItemWidth();
2385 if (ImGui::IsItemHovered()) {
2386 ImGui::SetTooltip(
"Default: https://peblhub.online/api/upload");
2392 ImGui::Text(
"Authentication Token:");
2393 char uploadToken[256];
2394 std::strncpy(uploadToken, mConfig->
GetUploadToken().c_str(),
sizeof(uploadToken) - 1);
2395 uploadToken[
sizeof(uploadToken) - 1] =
'\0';
2396 ImGui::PushItemWidth(-1);
2397 if (ImGui::InputText(
"##UploadToken", uploadToken,
sizeof(uploadToken), ImGuiInputTextFlags_Password)) {
2400 ImGui::PopItemWidth();
2401 if (ImGui::IsItemHovered()) {
2402 ImGui::SetTooltip(
"Get your token from peblhub.online/account");
2406 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
2407 "When enabled, data files will be automatically uploaded after each test completes.");
2409 ImGui::EndTabItem();
2420 if (ImGui::Button(
"Save", ImVec2(120, 0))) {
2422 mShowSettings =
false;
2423 ImGui::CloseCurrentPopup();
2428 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
2431 mShowSettings =
false;
2432 ImGui::CloseCurrentPopup();
2436 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 120);
2438 if (ImGui::Button(
"Apply", ImVec2(120, 0))) {
2446void LauncherUI::ShowParameterEditor()
2448 ImGui::OpenPopup(
"Parameter Editor");
2450 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
2451 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
2452 ImGui::SetNextWindowSize(ImVec2(700, 500), ImGuiCond_FirstUseEver);
2454 if (ImGui::BeginPopupModal(
"Parameter Editor", &mShowParameterEditor, 0))
2457 if (mEditingDefaultParams) {
2458 ImGui::Text(
"Editing default parameters");
2460 ImGui::Text(
"Editing variant: %s", mVariantName);
2462 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 80);
2463 if (ImGui::SmallButton(
"Variants...")) {
2464 mShowVariantNameDialog =
true;
2466 if (ImGui::IsItemHovered()) {
2467 ImGui::SetTooltip(
"Create or switch to a named parameter variant\n(e.g., for different input devices or conditions)");
2469 if (mEditingDefaultParams) {
2470 ImGui::TextDisabled(
"Values here override the built-in defaults from the test definition.");
2472 ImGui::TextDisabled(
"Variants are alternate parameter sets for different conditions.");
2478 ImGui::BeginChild(
"ParameterList", ImVec2(0, -40),
true);
2480 if (mParameters.empty()) {
2481 ImGui::TextDisabled(
"No parameters defined for this experiment");
2484 if (ImGui::BeginTable(
"ParameterTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
2485 ImGui::TableSetupColumn(
"Parameter", ImGuiTableColumnFlags_WidthFixed, 150.0f);
2486 ImGui::TableSetupColumn(
"Value", ImGuiTableColumnFlags_WidthFixed, 150.0f);
2487 ImGui::TableSetupColumn(
"Description (built-in)", ImGuiTableColumnFlags_WidthStretch);
2488 ImGui::TableSetupColumn(
"Reset", ImGuiTableColumnFlags_WidthFixed, 60.0f);
2489 ImGui::TableHeadersRow();
2492 bool shouldFocusFirst = ImGui::IsWindowAppearing();
2493 for (
size_t i = 0; i < mParameters.size(); i++) {
2496 ImGui::PushID((
int)i);
2497 ImGui::TableNextRow();
2500 ImGui::TableSetColumnIndex(0);
2501 ImGui::TextWrapped(
"%s", param.
name.c_str());
2504 ImGui::TableSetColumnIndex(1);
2505 ImGui::PushItemWidth(-1);
2510 int currentIdx = -1;
2511 for (
size_t j = 0; j < param.
options.size(); j++) {
2513 currentIdx = (int)j;
2517 std::string preview = (currentIdx >= 0) ? param.
options[currentIdx] : param.value;
2518 if (ImGui::BeginCombo(
"##value", preview.c_str())) {
2519 for (
size_t j = 0; j < param.
options.size(); j++) {
2520 bool isSelected = ((int)j == currentIdx);
2521 if (ImGui::Selectable(param.
options[j].c_str(), isSelected)) {
2525 ImGui::SetItemDefaultFocus();
2533 std::strncpy(buffer, param.
value.c_str(),
sizeof(buffer) - 1);
2534 buffer[
sizeof(buffer) - 1] =
'\0';
2536 if (shouldFocusFirst && i == 0) {
2537 ImGui::SetKeyboardFocusHere();
2539 if (ImGui::InputText(
"##value", buffer,
sizeof(buffer))) {
2540 param.
value = buffer;
2544 ImGui::PopItemWidth();
2547 ImGui::TableSetColumnIndex(2);
2554 descText +=
" Options: ";
2555 for (
size_t j = 0; j < param.
options.size(); j++) {
2556 if (j > 0) descText +=
", ";
2560 ImGui::TextWrapped(
"%s", descText.c_str());
2563 ImGui::TableSetColumnIndex(3);
2564 if (ImGui::SmallButton(
"Reset")) {
2580 if (ImGui::Button(
"Save", ImVec2(120, 0))) {
2582 if (!mParameterFile.empty()) {
2583 std::ofstream file(mParameterFile);
2584 if (file.is_open()) {
2585 file <<
"{" << std::endl;
2586 for (
size_t i = 0; i < mParameters.size(); i++) {
2587 file <<
" \"" << mParameters[i].name <<
"\": \"" << mParameters[i].value <<
"\"";
2588 if (i < mParameters.size() - 1) {
2593 file <<
"}" << std::endl;
2595 printf(
"Parameters saved to: %s\n", mParameterFile.c_str());
2598 if (!mEditingDefaultParams) {
2599 size_t lastSlash = mParameterFile.find_last_of(
"/\\");
2600 std::string filename = (lastSlash != std::string::npos)
2601 ? mParameterFile.substr(lastSlash + 1)
2603 size_t dotPar = filename.find(
".par.json");
2604 if (dotPar != std::string::npos) {
2605 std::string variantName = filename.substr(0, dotPar);
2607 size_t testsPos = mParameterFile.find(
"/tests/");
2608 if (testsPos != std::string::npos && mCurrentStudy) {
2609 size_t testNameStart = testsPos + 7;
2610 size_t testNameEnd = mParameterFile.find(
"/", testNameStart);
2611 if (testNameEnd != std::string::npos) {
2612 std::string testName = mParameterFile.substr(testNameStart, testNameEnd - testNameStart);
2613 Test* test = mCurrentStudy->GetTest(testName);
2616 variant.
file = variantName +
".par.json";
2619 mCurrentStudy->Save();
2620 printf(
"Registered variant '%s' for test '%s'\n", variantName.c_str(), testName.c_str());
2628 mShowParameterEditor =
false;
2629 ImGui::CloseCurrentPopup();
2634 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
2635 mShowParameterEditor =
false;
2636 ImGui::CloseCurrentPopup();
2643void LauncherUI::ShowVariantNameDialog()
2645 ImGui::OpenPopup(
"Parameter Variants");
2647 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
2648 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
2649 ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
2651 if (ImGui::BeginPopupModal(
"Parameter Variants", &mShowVariantNameDialog, 0))
2654 std::string testName =
"";
2655 const std::map<std::string, ParameterVariant>* variants =
nullptr;
2657 if (mCurrentStudy && mEditingTestIndex >= 0) {
2658 const auto& tests = mCurrentStudy->GetTests();
2659 if (mEditingTestIndex < (
int)tests.size()) {
2660 testName = tests[mEditingTestIndex].testName;
2661 const Test* test = mCurrentStudy->GetTest(testName);
2668 ImGui::TextWrapped(
"Parameter sets for test: %s", testName.c_str());
2669 ImGui::TextDisabled(
"The default parameter set is edited directly. Variants are named alternate configurations.");
2674 if (ImGui::Button(
"Edit Default Parameters", ImVec2(-1, 35))) {
2675 mVariantName[0] =
'\0';
2676 LoadParameterEditorForVariant();
2677 mShowVariantNameDialog =
false;
2678 ImGui::CloseCurrentPopup();
2680 if (ImGui::IsItemHovered()) {
2681 ImGui::SetTooltip(
"Edit the default %s.pbl.par.json file.\nThese are the parameters used when no variant is specified.", testName.c_str());
2689 if (variants && !variants->empty()) {
2690 ImGui::Text(
"Named Variants:");
2693 ImGui::BeginChild(
"ExistingVariants", ImVec2(0, 120),
true);
2694 for (
const auto& pair : *variants) {
2695 ImGui::PushID(pair.first.c_str());
2697 if (ImGui::Selectable(pair.first.c_str(),
false, 0, ImVec2(0, 26))) {
2698 std::strncpy(mVariantName, pair.first.c_str(),
sizeof(mVariantName) - 1);
2699 mVariantName[
sizeof(mVariantName) - 1] =
'\0';
2700 LoadParameterEditorForVariant();
2701 mShowVariantNameDialog =
false;
2703 ImGui::CloseCurrentPopup();
2708 ImGui::TextDisabled(
"(%s)", pair.second.file.c_str());
2718 ImGui::Text(
"Create New Variant:");
2721 ImGui::Text(
"Name:");
2723 ImGui::TextDisabled(
"(?)");
2724 if (ImGui::IsItemHovered()) {
2725 ImGui::SetTooltip(
"Examples: mousebutton, touchscreen, leftclick, arrowLR\n"
2726 "The variant will be saved as %s-<name>.par.json", testName.c_str());
2729 ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - 130);
2730 ImGui::InputText(
"##variantname", mVariantName,
sizeof(mVariantName));
2731 ImGui::PopItemWidth();
2734 bool canCreate = strlen(mVariantName) > 0;
2736 ImGui::BeginDisabled();
2738 if (ImGui::Button(
"Create", ImVec2(120, 0))) {
2739 LoadParameterEditorForVariant();
2740 mShowVariantNameDialog =
false;
2741 ImGui::CloseCurrentPopup();
2744 ImGui::EndDisabled();
2749 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
2750 mShowVariantNameDialog =
false;
2751 ImGui::CloseCurrentPopup();
2758void LauncherUI::LoadParameterEditorForVariant()
2760 if (!mCurrentStudy || mEditingTestIndex < 0) {
2761 printf(
"Error: Invalid state for loading parameter editor\n");
2765 const auto& tests = mCurrentStudy->GetTests();
2766 if (mEditingTestIndex >= (
int)tests.size()) {
2767 printf(
"Error: Invalid test index\n");
2771 const Test& test = tests[mEditingTestIndex];
2772 std::string studyPath = mCurrentStudy->GetPath();
2773 std::string testPath = studyPath +
"/tests/" + test.
testPath;
2774 std::string schemaPath = testPath +
"/params/" + test.
testName +
".pbl.schema.json";
2777 SyncScaleSchema(testPath, test.
testName);
2779 printf(
"Loading parameter schema from: %s\n", schemaPath.c_str());
2782 if (!fs::exists(schemaPath)) {
2783 printf(
"ERROR: Schema file does not exist at: %s\n", schemaPath.c_str());
2784 printf(
"Test path: %s\n", testPath.c_str());
2785 printf(
"Test name: %s\n", test.
testName.c_str());
2786 printf(
"Test testPath: %s\n", test.
testPath.c_str());
2789 std::string paramsDir = testPath +
"/params";
2790 if (fs::exists(paramsDir) && fs::is_directory(paramsDir)) {
2791 printf(
"Files in params directory:\n");
2792 for (
const auto& entry : fs::directory_iterator(paramsDir)) {
2793 printf(
" - %s\n", entry.path().filename().string().c_str());
2796 printf(
"Params directory does not exist: %s\n", paramsDir.c_str());
2803 std::ifstream schemaFile(schemaPath);
2804 if (!schemaFile.is_open()) {
2805 printf(
"ERROR: Could not open schema file (even though it exists): %s\n", schemaPath.c_str());
2810 mParameters.clear();
2811 nlohmann::json schemaJson;
2812 schemaFile >> schemaJson;
2815 if (!schemaJson.contains(
"parameters") || !schemaJson[
"parameters"].is_array()) {
2816 printf(
"ERROR: Schema file does not contain 'parameters' array\n");
2821 for (
const auto& paramJson : schemaJson[
"parameters"]) {
2822 if (!paramJson.contains(
"name") || !paramJson.contains(
"default")) {
2827 param.
name = paramJson[
"name"].get<std::string>();
2830 if (paramJson[
"default"].is_string()) {
2831 param.
defaultValue = paramJson[
"default"].get<std::string>();
2832 }
else if (paramJson[
"default"].is_number_integer()) {
2833 param.
defaultValue = std::to_string(paramJson[
"default"].get<int>());
2834 }
else if (paramJson[
"default"].is_number_float()) {
2835 param.
defaultValue = std::to_string(paramJson[
"default"].get<double>());
2836 }
else if (paramJson[
"default"].is_boolean()) {
2837 param.
defaultValue = paramJson[
"default"].get<
bool>() ?
"true" :
"false";
2842 if (paramJson.contains(
"description")) {
2843 param.
description = paramJson[
"description"].get<std::string>();
2847 if (paramJson.contains(
"options") && paramJson[
"options"].is_array()) {
2848 for (
const auto& opt : paramJson[
"options"]) {
2849 if (opt.is_string()) {
2850 param.
options.push_back(opt.get<std::string>());
2852 param.
options.push_back(opt.dump());
2858 mParameters.push_back(param);
2861 printf(
"Loaded %zu parameters from schema\n", mParameters.size());
2866 std::string variantFileName;
2867 if (strlen(mVariantName) == 0) {
2868 variantFileName = test.
testName +
".pbl.par.json";
2869 mEditingDefaultParams =
true;
2871 variantFileName = test.
testName +
"-" + std::string(mVariantName) +
".par.json";
2872 mEditingDefaultParams =
false;
2874 mParameterFile = testPath +
"/params/" + variantFileName;
2875 printf(
"Parameter file: %s\n", mParameterFile.c_str());
2878 if (fs::exists(mParameterFile)) {
2879 printf(
"Loading existing parameter values from file\n");
2880 std::ifstream paramFile(mParameterFile);
2881 if (paramFile.is_open()) {
2883 std::string paramLine;
2884 while (std::getline(paramFile, paramLine)) {
2886 size_t colonPos = paramLine.find(
':');
2887 if (colonPos != std::string::npos) {
2889 size_t nameStart = paramLine.find(
'"');
2890 size_t nameEnd = paramLine.find(
'"', nameStart + 1);
2891 if (nameStart != std::string::npos && nameEnd != std::string::npos) {
2892 std::string paramName = paramLine.substr(nameStart + 1, nameEnd - nameStart - 1);
2895 size_t valueStart = paramLine.find(
'"', nameEnd + 1);
2896 size_t valueEnd = paramLine.find(
'"', valueStart + 1);
2897 if (valueStart != std::string::npos && valueEnd != std::string::npos) {
2898 std::string paramValue = paramLine.substr(valueStart + 1, valueEnd - valueStart - 1);
2901 for (
auto& param : mParameters) {
2902 if (param.
name == paramName) {
2903 param.
value = paramValue;
2916 mShowParameterEditor =
true;
2918 }
catch (
const std::exception& e) {
2919 printf(
"Error loading parameter schema: %s\n", e.what());
2923void LauncherUI::ScanExperimentDirectory(
const std::string& path)
2925 mExperiments.clear();
2926 mSelectedExperiment = -1;
2929 if (!fs::exists(path) || !fs::is_directory(path)) {
2934 for (
const auto& entry : fs::recursive_directory_iterator(path)) {
2935 if (entry.is_regular_file() && entry.path().extension() ==
".pbl") {
2938 fs::path aboutPath = entry.path().parent_path() / (entry.path().filename().string() +
".about.txt");
2939 if (!fs::exists(aboutPath)) {
2944 info.
path = entry.path().string();
2948 std::string parentDir = entry.path().parent_path().filename().string();
2949 std::string filename = entry.path().stem().string();
2951 if (parentDir != filename) {
2953 info.
name = parentDir +
"/" + filename;
2956 info.
name = filename;
2959 info.
directory = entry.path().parent_path().string();
2963 fs::path paramsDir = entry.path().parent_path() /
"params";
2964 fs::path schemaFile = paramsDir / (entry.path().filename().string() +
".schema.json");
2965 info.
hasParams = fs::exists(schemaFile);
2968 fs::path translationsDir = entry.path().parent_path() /
"translations";
2969 info.
hasTranslations = fs::exists(translationsDir) && fs::is_directory(translationsDir);
2972 fs::path screenshotPath = entry.path().parent_path() / (entry.path().filename().string() +
".png");
2973 if (fs::exists(screenshotPath)) {
2981 mExperiments.push_back(info);
2986 std::sort(mExperiments.begin(), mExperiments.end(),
2988 std::string aLower = a.name;
2989 std::string bLower = b.name;
2990 std::transform(aLower.begin(), aLower.end(), aLower.begin(), ::tolower);
2991 std::transform(bLower.begin(), bLower.end(), bLower.begin(), ::tolower);
2992 return aLower < bLower;
2995 }
catch (
const fs::filesystem_error& e) {
2996 printf(
"Error scanning directory: %s\n", e.what());
3000void LauncherUI::ScanTemplates()
3002 mTemplateNames.clear();
3003 mTemplateFiles.clear();
3006 std::string templatesPath = mBatteryPath +
"/../media/templates";
3008 if (!fs::exists(templatesPath) || !fs::is_directory(templatesPath)) {
3009 printf(
"Warning: Templates directory not found: %s\n", templatesPath.c_str());
3014 for (
const auto& entry : fs::directory_iterator(templatesPath)) {
3015 if (entry.is_regular_file() && entry.path().extension() ==
".pbl") {
3016 std::string filename = entry.path().filename().string();
3017 std::string displayName = filename.substr(0, filename.length() - 4);
3020 std::string niceName;
3021 bool capitalizeNext =
true;
3022 for (
char c : displayName) {
3023 if (c ==
'-' || c ==
'_') {
3025 capitalizeNext =
true;
3026 }
else if (capitalizeNext) {
3027 niceName += toupper(c);
3028 capitalizeNext =
false;
3034 mTemplateFiles.push_back(filename);
3035 mTemplateNames.push_back(niceName);
3040 std::vector<size_t> indices(mTemplateNames.size());
3041 std::iota(indices.begin(), indices.end(), 0);
3042 std::sort(indices.begin(), indices.end(),
3043 [
this](
size_t a,
size_t b) { return mTemplateNames[a] < mTemplateNames[b]; });
3045 std::vector<std::string> sortedNames;
3046 std::vector<std::string> sortedFiles;
3047 for (
size_t i : indices) {
3048 sortedNames.push_back(mTemplateNames[i]);
3049 sortedFiles.push_back(mTemplateFiles[i]);
3051 mTemplateNames = sortedNames;
3052 mTemplateFiles = sortedFiles;
3054 printf(
"Scanned templates: found %zu templates\n", mTemplateFiles.size());
3056 }
catch (
const fs::filesystem_error& e) {
3057 printf(
"Error scanning templates directory: %s\n", e.what());
3061void LauncherUI::LoadScreenshot(
const std::string& imagePath)
3066 if (imagePath.empty() || !fs::exists(imagePath)) {
3071 SDL_Surface* surface = IMG_Load(imagePath.c_str());
3073 printf(
"Failed to load screenshot: %s\n", IMG_GetError());
3078 mScreenshotTexture = SDL_CreateTextureFromSurface(mRenderer, surface);
3079 if (!mScreenshotTexture) {
3080 printf(
"Failed to create texture: %s\n", SDL_GetError());
3081 SDL_FreeSurface(surface);
3085 mScreenshotWidth = surface->w;
3086 mScreenshotHeight = surface->h;
3088 SDL_FreeSurface(surface);
3091void LauncherUI::FreeScreenshot()
3093 if (mScreenshotTexture) {
3094 SDL_DestroyTexture(mScreenshotTexture);
3095 mScreenshotTexture =
nullptr;
3096 mScreenshotWidth = 0;
3097 mScreenshotHeight = 0;
3101void LauncherUI::FreeStudyTestScreenshot()
3103 if (mStudyTestScreenshot) {
3104 SDL_DestroyTexture(mStudyTestScreenshot);
3105 mStudyTestScreenshot =
nullptr;
3106 mStudyTestScreenshotW = 0;
3107 mStudyTestScreenshotH = 0;
3111void LauncherUI::LoadStudyTestPreview(
int testIndex)
3113 FreeStudyTestScreenshot();
3114 mStudyTestDescription.clear();
3116 if (!mCurrentStudy || testIndex < 0)
return;
3118 const auto& tests = mCurrentStudy->GetTests();
3119 if (testIndex >= (
int)tests.size())
return;
3121 const std::string& testName = tests[testIndex].testName;
3122 std::string studyPath = mCurrentStudy->GetPath();
3123 std::string testDir = studyPath +
"/tests/" + testName;
3125 printf(
"LoadStudyTestPreview: testName='%s' studyPath='%s' testDir='%s'\n",
3126 testName.c_str(), studyPath.c_str(), testDir.c_str());
3129 std::string aboutPath = testDir +
"/" + testName +
".pbl.about.txt";
3130 printf(
" aboutPath='%s' exists=%d\n", aboutPath.c_str(), fs::exists(aboutPath) ? 1 : 0);
3131 if (fs::exists(aboutPath)) {
3132 std::ifstream aboutFile(aboutPath);
3133 if (aboutFile.is_open()) {
3134 mStudyTestDescription = std::string(
3135 (std::istreambuf_iterator<char>(aboutFile)),
3136 std::istreambuf_iterator<char>());
3142 if (mStudyTestDescription.empty()) {
3144 for (
const auto& exp : mExperiments) {
3145 fs::path expPath(exp.
path);
3146 std::string expName = expPath.stem().string();
3147 if (expName == testName && !exp.
description.empty()) {
3155 std::string screenshotPath = testDir +
"/" + testName +
".pbl.png";
3156 printf(
" screenshotPath='%s' exists=%d\n", screenshotPath.c_str(), fs::exists(screenshotPath) ? 1 : 0);
3157 if (!fs::exists(screenshotPath)) {
3159 for (
const auto& exp : mExperiments) {
3160 fs::path expPath(exp.
path);
3161 std::string expName = expPath.stem().string();
3169 if (fs::exists(screenshotPath)) {
3170 SDL_Surface* surface = IMG_Load(screenshotPath.c_str());
3172 mStudyTestScreenshot = SDL_CreateTextureFromSurface(mRenderer, surface);
3173 if (mStudyTestScreenshot) {
3174 mStudyTestScreenshotW = surface->w;
3175 mStudyTestScreenshotH = surface->h;
3177 SDL_FreeSurface(surface);
3182void LauncherUI::LoadExperimentInfo(
const std::string& scriptPath)
3184 if (mSelectedExperiment < 0 || mSelectedExperiment >= (
int)mExperiments.size()) {
3191 fs::path aboutPath = fs::path(scriptPath).parent_path() /
3192 (fs::path(scriptPath).filename().string() +
".about.txt");
3194 if (fs::exists(aboutPath)) {
3195 std::ifstream aboutFile(aboutPath);
3196 if (aboutFile.is_open()) {
3197 std::string content((std::istreambuf_iterator<char>(aboutFile)),
3198 std::istreambuf_iterator<char>());
3212 mAvailableLanguages.clear();
3214 fs::path translationsDir = fs::path(scriptPath).parent_path() /
"translations";
3216 std::set<std::string> langSet;
3217 for (
const auto& entry : fs::directory_iterator(translationsDir)) {
3218 if (entry.is_regular_file() && entry.path().extension() ==
".json") {
3219 std::string filename = entry.path().stem().string();
3220 std::string langCode;
3222 size_t dashPos = filename.find_last_of(
'-');
3223 if (dashPos != std::string::npos) {
3224 langCode = filename.substr(dashPos + 1);
3227 size_t dotPos = filename.find_last_of(
'.');
3228 if (dotPos != std::string::npos) {
3229 langCode = filename.substr(dotPos + 1);
3232 if (!langCode.empty()) {
3233 langSet.insert(langCode);
3237 mAvailableLanguages.assign(langSet.begin(), langSet.end());
3240 std::sort(mAvailableLanguages.begin(), mAvailableLanguages.end());
3243 if (!mAvailableLanguages.empty()) {
3245 for (
const auto& lang : mAvailableLanguages) {
3246 if (lang == mLanguageCode) {
3252 std::strcpy(mLanguageCode, mAvailableLanguages[0].c_str());
3256 }
catch (
const fs::filesystem_error& e) {
3257 printf(
"Error scanning translations: %s\n", e.what());
3262void LauncherUI::RunTest()
3264 if (mSelectedExperiment < 0 || mSelectedExperiment >= (
int)mExperiments.size()) {
3269 if (mRunningExperiment) {
3271 printf(
"Warning: Previous test still running\n");
3274 delete mRunningExperiment;
3275 mRunningExperiment =
nullptr;
3281 std::vector<std::string> args;
3284 if (strlen(mSubjectCode) > 0) {
3285 args.push_back(
"-s");
3286 args.push_back(mSubjectCode);
3291 args.push_back(
"-v");
3292 args.push_back(std::string(
"gLanguage=") + mLanguageCode);
3297 args.push_back(
"--fullscreen");
3310 mShowStderr =
false;
3312 printf(
"Failed to run experiment: %s\n", exp.
path.c_str());
3313 delete mRunningExperiment;
3314 mRunningExperiment =
nullptr;
3319void LauncherUI::OpenDirectoryInFileBrowser(
const std::string& path)
3322 printf(
"Cannot open directory: empty path\n");
3326 if (!fs::exists(path)) {
3328 fs::create_directories(path);
3329 printf(
"Created directory: %s\n", path.c_str());
3330 }
catch (
const std::exception& e) {
3331 printf(
"Cannot open directory (does not exist and could not be created): %s\n", path.c_str());
3338 std::string winPath = path;
3339 for (
char& c : winPath) {
3340 if (c ==
'/') c =
'\\';
3342 std::string command =
"explorer \"" + winPath +
"\"";
3343 printf(
"Opening directory: %s\n", winPath.c_str());
3344 system(command.c_str());
3347 std::string command =
"open \"" + path +
"\" &";
3348 system(command.c_str());
3353 static const char* kManagers[] = {
3354 "nautilus",
"dolphin",
"nemo",
"thunar",
"pcmanfm",
nullptr
3356 bool opened =
false;
3357 for (
int i = 0; kManagers[i] !=
nullptr; i++) {
3358 std::string check = std::string(
"which ") + kManagers[i] +
" >/dev/null 2>&1";
3359 if (system(check.c_str()) == 0) {
3360 std::string cmd = std::string(kManagers[i]) +
" \"" + path +
"\" >/dev/null 2>&1 &";
3361 system(cmd.c_str());
3362 printf(
"Opening directory with %s: %s\n", kManagers[i], path.c_str());
3369 std::string command =
"xdg-open \"" + path +
"\" &";
3370 int result = system(command.c_str());
3372 printf(
"Failed to open directory in file browser: %s\n", path.c_str());
3378void LauncherUI::OpenFileInTextEditor(
const std::string& filePath)
3380 if (filePath.empty() || !fs::exists(filePath)) {
3381 printf(
"Cannot open file: %s (file not found)\n", filePath.c_str());
3387 std::string command =
"start \"\" \"" + filePath +
"\"";
3390 std::string command =
"open \"" + filePath +
"\"";
3393 std::string command =
"xdg-open \"" + filePath +
"\" &";
3396 printf(
"Opening file in text editor: %s\n", filePath.c_str());
3397 int result = system(command.c_str());
3399 printf(
"Failed to open file in text editor: %s\n", filePath.c_str());
3403void LauncherUI::LaunchTranslationEditor()
3405 if (mSelectedExperiment < 0 || mSelectedExperiment >= (
int)mExperiments.size()) {
3406 printf(
"ERROR: No experiment selected for translation editor\n");
3414 if (peblExec.empty()) {
3419 std::string translateScript;
3421 fs::path peblDir = fs::path(peblExec).parent_path().parent_path();
3422 fs::path binDir = fs::path(peblExec).parent_path();
3423 std::vector<std::string> possiblePaths = {
3424 (binDir /
"translatetest.pbl").
string(),
3425 (peblDir /
"pebl-lib" /
"translatetest.pbl").
string(),
3426 (fs::path(batteryPath) /
"translatetest" /
"translatetest.pbl").
string(),
3427 (fs::path(batteryPath).parent_path() /
"media" /
"apps" /
"translatetest" /
"translatetest.pbl").
string(),
3428 (peblDir /
"media" /
"apps" /
"translatetest" /
"translatetest.pbl").
string(),
3429 (peblDir /
"battery" /
"translatetest" /
"translatetest.pbl").
string(),
3431 for (
const auto& path : possiblePaths) {
3432 if (fs::exists(path)) {
3433 translateScript = path;
3437 if (translateScript.empty()) {
3438 printf(
"ERROR: Could not find translatetest.pbl in any expected location\n");
3443 std::string scriptPath = exp.
path;
3444 std::string lang = std::string(mLanguageCode);
3449 std::string command =
"\"" + peblExec +
"\" \"" + translateScript +
"\" -v \"" + scriptPath +
"\" --language " + lang;
3451 printf(
"Launching translation editor: %s\n", command.c_str());
3456 int result = system(command.c_str());
3458 printf(
"ERROR: Failed to launch translation editor (exit code %d)\n", result);
3461 system(command.c_str());
3465void LauncherUI::LaunchDataCombiner(
const std::string& workingDirectory)
3468 std::string peblExec =
"pebl2";
3471 ssize_t len = readlink(
"/proc/self/exe", exePath,
sizeof(exePath) - 1);
3473 exePath[len] =
'\0';
3474 std::string path(exePath);
3475 size_t lastSlash = path.find_last_of(
'/');
3476 if (lastSlash != std::string::npos) {
3477 peblExec = path.substr(0, lastSlash + 1) +
"pebl2";
3483 std::string command;
3486 if (!workingDirectory.empty()) {
3488 command =
"cd \"" + workingDirectory +
"\" && " + peblExec +
" combinedatafiles.pbl &";
3490 command =
"cd \"" + workingDirectory +
"\" & " + peblExec +
" combinedatafiles.pbl";
3492 printf(
"Launching data combiner in: %s\n", workingDirectory.c_str());
3494 command = peblExec +
" combinedatafiles.pbl";
3498 printf(
"Launching data combiner\n");
3501 printf(
"Command: %s\n", command.c_str());
3504 int result = system(command.c_str());
3506 printf(
"ERROR: Failed to launch data combiner (exit code %d)\n", result);
3510void LauncherUI::RunChain()
3512 if (!mCurrentChain || mCurrentChain->GetItems().empty()) {
3513 printf(
"Cannot run chain: no chain loaded or chain is empty\n");
3517 if (mRunningChain) {
3518 printf(
"Chain already running\n");
3522 if (!mCurrentStudy) {
3523 printf(
"Cannot run chain: no study loaded\n");
3528 if (strlen(mSubjectCode) == 0) {
3529 printf(
"ERROR: Subject code is required to run chain\n");
3534 std::vector<std::string> existingCodes = CheckExistingSubjectCodes();
3535 std::string currentCode(mSubjectCode);
3537 bool codeExists =
false;
3538 for (
const auto& code : existingCodes) {
3539 if (code == currentCode) {
3546 printf(
"WARNING: Subject code '%s' has already been used in this study!\n", mSubjectCode);
3547 printf(
"Existing subject codes: ");
3548 for (
size_t i = 0; i < existingCodes.size(); i++) {
3549 printf(
"%s%s", existingCodes[i].c_str(), i < existingCodes.size() - 1 ?
", " :
"\n");
3553 mDuplicateWarningCodes = existingCodes;
3554 mShowDuplicateSubjectWarning =
true;
3559 RunChainConfirmed();
3562void LauncherUI::RunChainConfirmed()
3565 if (mRunningExperiment) {
3567 printf(
"Warning: Previous experiment still running\n");
3570 delete mRunningExperiment;
3571 mRunningExperiment =
nullptr;
3575 mChainAccumulatedStdout.clear();
3576 mChainAccumulatedStderr.clear();
3581 const auto& items = mCurrentChain->GetItems();
3582 mChainExecutionOrder.clear();
3583 mChainExecutionOrder.reserve(items.size());
3586 std::map<int, std::vector<int>> groupItems;
3587 for (
size_t i = 0; i < items.size(); i++) {
3588 if (items[i].type ==
ItemType::Test && items[i].randomGroup > 0) {
3589 groupItems[items[i].randomGroup].push_back(i);
3594 std::random_device rd;
3595 std::mt19937 g(rd());
3596 std::map<int, size_t> groupNextIndex;
3597 for (
auto& [groupId, indices] : groupItems) {
3598 std::shuffle(indices.begin(), indices.end(), g);
3599 groupNextIndex[groupId] = 0;
3600 printf(
"Randomized group %d (%zu tests)\n", groupId, indices.size());
3605 for (
size_t i = 0; i < items.size(); i++) {
3606 if (items[i].type ==
ItemType::Test && items[i].randomGroup > 0) {
3607 int groupId = items[i].randomGroup;
3609 size_t& nextIdx = groupNextIndex[groupId];
3610 if (nextIdx < groupItems[groupId].size()) {
3611 mChainExecutionOrder.push_back(groupItems[groupId][nextIdx]);
3616 mChainExecutionOrder.push_back(i);
3620 mRunningChain =
true;
3621 mCurrentChainItemIndex = 0;
3622 printf(
"Starting chain execution (%zu items)\n", mCurrentChain->GetItems().size());
3625 ExecuteChainItem(0);
3628void LauncherUI::ExecuteChainItem(
int index)
3630 if (!mCurrentChain || index < 0 || index >= (
int)mChainExecutionOrder.size()) {
3631 printf(
"Invalid chain item index: %d\n", index);
3632 mRunningChain =
false;
3637 int itemIndex = mChainExecutionOrder[index];
3638 const ChainItem& item = mCurrentChain->GetItems()[itemIndex];
3639 printf(
"Executing chain item %d/%zu: %s (item #%d)\n", index + 1, mChainExecutionOrder.size(),
3647 const Test* test = mCurrentStudy->GetTest(item.
testName);
3649 printf(
"Error: Test '%s' not found in study\n", item.
testName.c_str());
3650 delete mRunningExperiment;
3651 mRunningExperiment =
nullptr;
3652 mRunningChain =
false;
3656 std::string studyPath = mCurrentStudy->GetPath();
3658 std::string baseName = fs::path(item.
testName).filename().string();
3659 std::string testPath = (fs::path(studyPath) /
"tests" / test->
testPath / (baseName +
".pbl")).string();
3661 std::vector<std::string> args;
3668 std::string paramFile;
3672 paramFile = baseName +
".pbl.par.json";
3676 if (variant && !variant->
file.empty()) {
3677 paramFile = variant->
file;
3681 if (!paramFile.empty()) {
3682 args.push_back(
"--pfile");
3683 args.push_back(paramFile);
3688 if (mCurrentChain->GetUploadEnabled()) {
3689 std::string uploadPath = mCurrentStudy->GetUploadConfigPath(item.
testName);
3692 mCurrentStudy->CreateUploadConfigForTest(item.
testName);
3695 args.push_back(
"--upload");
3696 args.push_back(uploadPath);
3697 printf(
"Upload enabled: %s\n", uploadPath.c_str());
3701 if (mCurrentChain->GetLSLEnabled()) {
3702 std::string streamName = mCurrentChain->GetLSLStreamName();
3706 while ((pos = streamName.find(
"{test}")) != std::string::npos) {
3707 streamName.replace(pos, 6, item.
testName);
3709 while ((pos = streamName.find(
"{subject}")) != std::string::npos) {
3710 streamName.replace(pos, 9, mSubjectCode);
3714 args.push_back(
"--lsl");
3715 args.push_back(streamName);
3716 printf(
"LSL enabled: stream=%s\n", streamName.c_str());
3720 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3721 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3724 printf(
"ExecuteChainItem: Passing %zu arguments to PEBL:\n", args.size());
3725 for (
size_t i = 0; i < args.size(); i++) {
3726 printf(
" arg[%zu]: %s\n", i, args[i].c_str());
3730 bool success = mRunningExperiment->
RunExperiment(testPath, args,
3732 item.
language.empty() ? mLanguageCode : item.language,
3736 printf(
"Failed to run test: %s\n", item.
testName.c_str());
3737 delete mRunningExperiment;
3738 mRunningExperiment =
nullptr;
3739 mRunningChain =
false;
3745 std::string tmpDir = GetWorkspaceTempDirectory(mConfig->
GetWorkspacePath());
3748 if (configFile.empty()) {
3749 printf(
"Failed to create page config in: %s\n", tmpDir.c_str());
3750 delete mRunningExperiment;
3751 mRunningExperiment =
nullptr;
3752 mRunningChain =
false;
3758 std::string chainPagePath;
3759 if (!mediaPath.empty()) {
3761 chainPagePath = mediaPath +
"\\apps\\ChainPage\\ChainPage.pbl";
3763 chainPagePath = mediaPath +
"/apps/ChainPage/ChainPage.pbl";
3767 chainPagePath =
"media/apps/ChainPage/ChainPage.pbl";
3768 printf(
"Warning: Could not determine PEBL media path, using relative path\n");
3771 std::vector<std::string> args;
3773 args.push_back(
"-v");
3774 args.push_back(configFile);
3777 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3778 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3780 bool success = mRunningExperiment->
RunExperiment(chainPagePath, args,
3786 printf(
"Failed to run page: %s\n", item.
GetDisplayName().c_str());
3787 delete mRunningExperiment;
3788 mRunningExperiment =
nullptr;
3789 mRunningChain =
false;
3794void LauncherUI::TestChainItem(
int index)
3796 if (!mCurrentChain || index < 0 || index >= (
int)mCurrentChain->GetItems().size()) {
3797 printf(
"Invalid chain item index: %d\n", index);
3801 if (!mCurrentStudy) {
3802 printf(
"Cannot test chain item: no study loaded\n");
3807 if (mRunningExperiment) {
3809 printf(
"Warning: Previous experiment still running\n");
3812 delete mRunningExperiment;
3813 mRunningExperiment =
nullptr;
3816 const ChainItem& item = mCurrentChain->GetItems()[index];
3817 printf(
"Test running chain item: %s\n", item.
GetDisplayName().c_str());
3824 const Test* test = mCurrentStudy->GetTest(item.
testName);
3826 printf(
"Error: Test '%s' not found in study\n", item.
testName.c_str());
3830 std::string studyPath = mCurrentStudy->GetPath();
3832 std::string baseName = fs::path(item.
testName).filename().string();
3833 std::string testPath = (fs::path(studyPath) /
"tests" / test->
testPath / (baseName +
".pbl")).string();
3835 std::vector<std::string> args;
3843 if (variant && !variant->
file.empty()) {
3845 std::string paramFile =
"params/" + variant->
file;
3846 args.push_back(
"--pfile");
3847 args.push_back(paramFile);
3852 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3853 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3855 bool success = mRunningExperiment->
RunExperiment(testPath, args,
3857 item.
language.empty() ? mLanguageCode : item.language,
3861 printf(
"Failed to run test: %s\n", item.
testName.c_str());
3862 delete mRunningExperiment;
3863 mRunningExperiment =
nullptr;
3869 std::string tmpDir = GetWorkspaceTempDirectory(mConfig->
GetWorkspacePath());
3872 if (configFile.empty()) {
3873 printf(
"Failed to create page config in: %s\n", tmpDir.c_str());
3874 delete mRunningExperiment;
3875 mRunningExperiment =
nullptr;
3881 std::string chainPagePath;
3882 if (!mediaPath.empty()) {
3884 chainPagePath = mediaPath +
"\\apps\\ChainPage\\ChainPage.pbl";
3886 chainPagePath = mediaPath +
"/apps/ChainPage/ChainPage.pbl";
3890 chainPagePath =
"media/apps/ChainPage/ChainPage.pbl";
3891 printf(
"Warning: Could not determine PEBL media path, using relative path\n");
3894 std::vector<std::string> args;
3896 args.push_back(
"-v");
3897 args.push_back(configFile);
3900 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3901 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3903 bool success = mRunningExperiment->
RunExperiment(chainPagePath, args,
3909 printf(
"Failed to run page: %s\n", item.
GetDisplayName().c_str());
3910 delete mRunningExperiment;
3911 mRunningExperiment =
nullptr;
3916std::string LauncherUI::OpenDirectoryDialog(
const std::string& title,
const std::string& startDir)
3923 HRESULT hr = CoInitializeEx(
NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
3924 if (SUCCEEDED(hr)) {
3925 IFileDialog* pfd =
nullptr;
3926 hr = CoCreateInstance(CLSID_FileOpenDialog,
NULL, CLSCTX_ALL,
3927 IID_IFileDialog,
reinterpret_cast<void**
>(&pfd));
3929 if (SUCCEEDED(hr)) {
3932 hr = pfd->GetOptions(&dwOptions);
3933 if (SUCCEEDED(hr)) {
3934 hr = pfd->SetOptions(dwOptions | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM);
3938 if (SUCCEEDED(hr) && !title.empty()) {
3939 std::wstring wtitle(title.begin(), title.end());
3940 pfd->SetTitle(wtitle.c_str());
3944 if (SUCCEEDED(hr) && !startDir.empty()) {
3945 IShellItem* psiFolder =
nullptr;
3946 std::wstring wstartDir(startDir.begin(), startDir.end());
3947 hr = SHCreateItemFromParsingName(wstartDir.c_str(),
NULL, IID_PPV_ARGS(&psiFolder));
3948 if (SUCCEEDED(hr)) {
3949 pfd->SetFolder(psiFolder);
3950 psiFolder->Release();
3955 hr = pfd->Show(
NULL);
3956 if (SUCCEEDED(hr)) {
3957 IShellItem* psi =
nullptr;
3958 hr = pfd->GetResult(&psi);
3959 if (SUCCEEDED(hr)) {
3960 PWSTR pszPath =
nullptr;
3961 hr = psi->GetDisplayName(SIGDN_FILESYSPATH, &pszPath);
3962 if (SUCCEEDED(hr)) {
3964 int size_needed = WideCharToMultiByte(CP_UTF8, 0, pszPath, -1,
NULL, 0,
NULL,
NULL);
3965 if (size_needed > 0) {
3966 result.resize(size_needed - 1);
3967 WideCharToMultiByte(CP_UTF8, 0, pszPath, -1, &result[0], size_needed,
NULL,
NULL);
3969 CoTaskMemFree(pszPath);
3982 std::string command =
"osascript -e 'POSIX path of (choose folder";
3983 if (!startDir.empty()) {
3984 command +=
" default location (POSIX file \"" + startDir +
"\")";
3986 command +=
" with prompt \"" + title +
"\")'";
3987 FILE* pipe = popen(command.c_str(),
"r");
3988 if (!pipe)
return "";
3992 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
3995 if (!result.empty() && result[result.length()-1] ==
'\n') {
3996 result.erase(result.length()-1);
4003 std::string command =
"zenity --file-selection --directory --title=\"" + title +
"\"";
4004 if (!startDir.empty()) {
4005 command +=
" --filename=\"" + startDir +
"/\"";
4007 command +=
" 2>/dev/null";
4008 FILE* pipe = popen(command.c_str(),
"r");
4009 if (!pipe)
return "";
4013 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
4016 if (!result.empty() && result[result.length()-1] ==
'\n') {
4017 result.erase(result.length()-1);
4025std::string LauncherUI::OpenFileDialog(
const std::string& title,
const std::string& filter,
const std::string& initialDir)
4029 char filename[MAX_PATH] =
"";
4032 ZeroMemory(&ofn,
sizeof(ofn));
4033 ofn.lStructSize =
sizeof(ofn);
4034 ofn.hwndOwner =
NULL;
4035 ofn.lpstrFile = filename;
4036 ofn.nMaxFile = MAX_PATH;
4039 std::string winFilter;
4040 if (!filter.empty()) {
4042 std::string desc = filter;
4043 if (desc.substr(0, 2) ==
"*.") {
4044 desc = desc.substr(2) +
" files";
4046 if (!desc.empty()) desc[0] = toupper(desc[0]);
4048 winFilter = desc +
'\0' + filter +
'\0' +
"All Files" +
'\0' +
"*.*" +
'\0';
4050 winFilter =
"All Files\0*.*\0";
4053 ofn.lpstrFilter = winFilter.c_str();
4056 ofn.lpstrTitle = title.c_str();
4059 if (!initialDir.empty()) {
4060 ofn.lpstrInitialDir = initialDir.c_str();
4063 ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
4065 if (GetOpenFileNameA(&ofn)) {
4066 return std::string(filename);
4070 std::string command =
"osascript -e 'POSIX path of (choose file with prompt \"" + title +
"\")'";
4071 FILE* pipe = popen(command.c_str(),
"r");
4072 if (!pipe)
return "";
4076 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
4078 if (!result.empty() && result[result.length()-1] ==
'\n') {
4079 result.erase(result.length()-1);
4086 std::string command =
"zenity --file-selection --title=\"" + title +
"\"";
4087 if (!filter.empty()) {
4088 command +=
" --file-filter=\"" + filter +
"\"";
4090 command +=
" 2>/dev/null";
4092 FILE* pipe = popen(command.c_str(),
"r");
4093 if (!pipe)
return "";
4097 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
4099 if (!result.empty() && result[result.length()-1] ==
'\n') {
4100 result.erase(result.length()-1);
4108std::string LauncherUI::SaveFileDialog(
const std::string& title,
const std::string& defaultName)
4112 char filename[MAX_PATH];
4113 strncpy(filename, defaultName.c_str(), MAX_PATH - 1);
4114 filename[MAX_PATH - 1] =
'\0';
4117 ZeroMemory(&ofn,
sizeof(ofn));
4118 ofn.lStructSize =
sizeof(ofn);
4119 ofn.hwndOwner =
NULL;
4120 ofn.lpstrFile = filename;
4121 ofn.nMaxFile = MAX_PATH;
4122 ofn.lpstrFilter =
"All Files\0*.*\0";
4123 ofn.lpstrTitle = title.c_str();
4124 ofn.Flags = OFN_OVERWRITEPROMPT | OFN_NOCHANGEDIR;
4126 if (GetSaveFileNameA(&ofn)) {
4127 return std::string(filename);
4131 std::string command =
"osascript -e 'POSIX path of (choose file name with prompt \"" + title +
"\"";
4132 if (!defaultName.empty()) {
4133 command +=
" default name \"" + defaultName +
"\"";
4137 FILE* pipe = popen(command.c_str(),
"r");
4138 if (!pipe)
return "";
4142 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
4144 if (!result.empty() && result[result.length()-1] ==
'\n') {
4145 result.erase(result.length()-1);
4152 std::string command =
"zenity --file-selection --save --title=\"" + title +
"\"";
4153 if (!defaultName.empty()) {
4154 command +=
" --filename=\"" + defaultName +
"\"";
4156 command +=
" 2>/dev/null";
4158 FILE* pipe = popen(command.c_str(),
"r");
4159 if (!pipe)
return "";
4163 if (fgets(buffer,
sizeof(buffer), pipe) !=
nullptr) {
4165 if (!result.empty() && result[result.length()-1] ==
'\n') {
4166 result.erase(result.length()-1);
4178void LauncherUI::RenderStudyTab()
4180 ImGui::Text(
"Study Management");
4185 ImGui::BeginChild(
"StudySelector", ImVec2(0, 100),
true);
4188 ImGui::Text(
"Current Study:");
4191 const char* currentStudyName = mCurrentStudy ? mCurrentStudy->GetName().c_str() :
"None";
4192 ImGui::PushItemWidth(250);
4193 if (ImGui::BeginCombo(
"##StudySelect", currentStudyName)) {
4195 mStudyList = mWorkspace->GetStudyDirectories();
4198 if (ImGui::Selectable(
"None", !mCurrentStudy)) {
4199 mCurrentStudy.reset();
4203 for (
size_t i = 0; i < mStudyList.size(); i++) {
4204 bool is_selected = (mCurrentStudy && mCurrentStudy->GetName() == mStudyList[i]);
4205 if (ImGui::Selectable(mStudyList[i].c_str(), is_selected)) {
4206 mSelectedStudyIndex = i;
4207 LoadStudy(mStudyList[i]);
4212 ImGui::PopItemWidth();
4217 if (ImGui::Button(
"New Study...")) {
4224 if (mCurrentStudy) {
4225 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", mCurrentStudy->GetName().c_str());
4226 ImGui::Text(
"Description: %s", mCurrentStudy->GetDescription().c_str());
4227 ImGui::Text(
"Tests in study: %zu", mCurrentStudy->GetTests().size());
4229 ImGui::TextDisabled(
"No study loaded. Create a new study or select an existing one.");
4237 ImGui::Text(
"Tests in Study:");
4239 float listWidth = ImGui::GetContentRegionAvail().x * 0.4f;
4242 ImGui::BeginChild(
"StudyTestsList", ImVec2(listWidth, -40),
true);
4244 if (!mCurrentStudy) {
4245 ImGui::TextDisabled(
"Load a study to view tests");
4246 }
else if (mCurrentStudy->GetTests().empty()) {
4247 ImGui::TextDisabled(
"No tests in this study. Use 'Add to Study' button on Details tab to add tests.");
4250 const auto& tests = mCurrentStudy->GetTests();
4251 for (
size_t i = 0; i < tests.size(); i++) {
4252 ImGui::PushID((
int)i);
4255 std::string displayLabel = tests[i].displayName.empty()
4256 ? tests[i].testName : tests[i].displayName;
4258 bool is_selected = (mSelectedStudyTestIndex == (int)i);
4259 if (ImGui::Selectable(displayLabel.c_str(), is_selected)) {
4260 if (mSelectedStudyTestIndex != (
int)i) {
4261 mSelectedStudyTestIndex = (int)i;
4262 LoadStudyTestPreview((
int)i);
4267 if (ImGui::IsItemHovered() && !tests[i].parameterVariants.empty()) {
4268 ImGui::SetTooltip(
"%zu parameter variants", tests[i].parameterVariants.size());
4280 ImGui::BeginChild(
"StudyTestPreview", ImVec2(0, -40),
true);
4282 if (mCurrentStudy && mSelectedStudyTestIndex >= 0 &&
4283 mSelectedStudyTestIndex < (
int)mCurrentStudy->GetTests().size()) {
4285 const auto& test = mCurrentStudy->GetTests()[mSelectedStudyTestIndex];
4289 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", headerName.c_str());
4294 if (ImGui::SmallButton(
"Edit Params")) {
4295 EditTestParameters(mSelectedStudyTestIndex);
4298 if (ImGui::SmallButton(
"Remove from Study")) {
4299 const std::string testName = test.
testName;
4300 RemoveTestFromStudy(testName);
4302 if (mSelectedStudyTestIndex >= (
int)mCurrentStudy->GetTests().size()) {
4303 mSelectedStudyTestIndex = (int)mCurrentStudy->GetTests().size() - 1;
4304 if (mSelectedStudyTestIndex >= 0) {
4305 LoadStudyTestPreview(mSelectedStudyTestIndex);
4307 FreeStudyTestScreenshot();
4308 mStudyTestDescription.clear();
4318 if (mStudyTestScreenshot) {
4319 float aspectRatio = (float)mStudyTestScreenshotH / (
float)mStudyTestScreenshotW;
4320 float displayWidth = ImGui::GetContentRegionAvail().x;
4321 float displayHeight = displayWidth * aspectRatio;
4323 if (displayHeight > 400) {
4324 displayHeight = 400;
4325 displayWidth = displayHeight / aspectRatio;
4328 ImGui::Image((ImTextureID)(intptr_t)mStudyTestScreenshot,
4329 ImVec2(displayWidth, displayHeight));
4334 if (!mStudyTestDescription.empty()) {
4335 ImGui::TextWrapped(
"%s", mStudyTestDescription.c_str());
4337 ImGui::TextDisabled(
"No description available");
4340 ImGui::TextDisabled(
"Select a test to view details");
4348 if (!mCurrentStudy) {
4349 ImGui::BeginDisabled();
4352 if (ImGui::Button(
"Save Study", ImVec2(-1, 0))) {
4353 if (mCurrentStudy) {
4354 mCurrentStudy->Save();
4355 printf(
"Study saved: %s\n", mCurrentStudy->GetName().c_str());
4359 if (!mCurrentStudy) {
4360 ImGui::EndDisabled();
4368void LauncherUI::ShowPageEditor()
4370 ImGui::OpenPopup(
"Page Editor");
4372 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
4373 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
4374 ImGui::SetNextWindowSize(ImVec2(800, 600), ImGuiCond_FirstUseEver);
4376 if (ImGui::BeginPopupModal(
"Page Editor", &mPageEditor.
show, 0))
4378 const char* pageTypes[] = {
"Instruction",
"Consent",
"Completion" };
4380 ImGui::Text(
"Page Type:");
4382 ImGui::PushItemWidth(150);
4383 ImGui::Combo(
"##PageType", &mPageEditor.
pageType, pageTypes, 3);
4384 ImGui::PopItemWidth();
4389 ImGui::Text(
"Title:");
4390 ImGui::PushItemWidth(-1);
4391 if (ImGui::IsWindowAppearing()) {
4392 ImGui::SetKeyboardFocusHere();
4394 ImGui::InputText(
"##Title", mPageEditor.
title,
sizeof(mPageEditor.
title));
4395 ImGui::PopItemWidth();
4400 ImGui::Text(
"Content:");
4401 ImGui::PushItemWidth(-1);
4402 ImGui::InputTextMultiline(
"##Content", mPageEditor.
content,
sizeof(mPageEditor.
content),
4404 ImGui::PopItemWidth();
4411 if (ImGui::Button(
"Save", ImVec2(120, 0))) {
4412 if (!mCurrentChain) {
4413 printf(
"Error: No chain loaded\n");
4419 }
else if (mPageEditor.
pageType == 1) {
4431 mCurrentChain->InsertItem(mPageEditor.
editingIndex, item);
4432 printf(
"Updated page: %s\n", item.
title.c_str());
4435 mCurrentChain->AddItem(item);
4436 printf(
"Added page: %s\n", item.
title.c_str());
4443 mPageEditor.
show =
false;
4444 ImGui::CloseCurrentPopup();
4449 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
4450 mPageEditor.
show =
false;
4451 ImGui::CloseCurrentPopup();
4458void LauncherUI::ShowTestEditor()
4460 const char* dialogTitle = mTestEditor.
editingIndex >= 0 ?
"Edit Test" :
"Add Test to Chain";
4461 ImGui::OpenPopup(dialogTitle);
4463 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
4464 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
4465 ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
4467 if (ImGui::BeginPopupModal(dialogTitle, &mTestEditor.
show, 0))
4469 if (!mCurrentStudy) {
4470 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Error: No study loaded");
4471 if (ImGui::Button(
"Close", ImVec2(120, 0))) {
4472 mTestEditor.
show =
false;
4473 ImGui::CloseCurrentPopup();
4479 const auto& tests = mCurrentStudy->GetTests();
4480 if (tests.empty()) {
4481 ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f),
"No tests in study");
4482 ImGui::TextWrapped(
"Add tests to the study in the Tests tab first.");
4483 if (ImGui::Button(
"Close", ImVec2(120, 0))) {
4484 mTestEditor.
show =
false;
4485 ImGui::CloseCurrentPopup();
4495 ImGui::Text(
"Editing Test:");
4498 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", tests[mTestEditor.
selectedTestIndex].testName.c_str());
4500 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"(test not found)");
4505 ImGui::Text(
"Select Test:");
4508 ImGui::BeginChild(
"TestList", ImVec2(0, 150),
true);
4509 for (
size_t i = 0; i < tests.size(); i++) {
4511 if (ImGui::Selectable(tests[i].testName.c_str(), isSelected)) {
4525 ImGui::Text(
"Parameter Variant:");
4527 ImGui::PushItemWidth(200);
4530 std::vector<std::string> variantNames;
4531 variantNames.push_back(
"default");
4532 for (
const auto& [name, variant] : selectedTest.parameterVariants) {
4533 variantNames.push_back(name);
4541 if (ImGui::BeginCombo(
"##Variant", currentVariant)) {
4542 for (
size_t i = 0; i < variantNames.size(); i++) {
4544 if (ImGui::Selectable(variantNames[i].c_str(), isSelected)) {
4550 ImGui::PopItemWidth();
4555 ImGui::Text(
"Language (optional):");
4559 std::vector<std::string> availableLanguages;
4560 std::string studyPath = mCurrentStudy->GetPath();
4561 std::string testPath = studyPath +
"/tests/" + selectedTest.
testPath;
4562 std::string translationsPath = testPath +
"/translations";
4564 if (fs::exists(translationsPath) && fs::is_directory(translationsPath)) {
4565 for (
const auto& entry : fs::directory_iterator(translationsPath)) {
4566 if (entry.is_regular_file()) {
4567 std::string filename = entry.path().filename().string();
4568 if (filename.size() < 5)
continue;
4569 size_t dotJson = filename.rfind(
".json");
4570 if (dotJson == std::string::npos)
continue;
4574 size_t dashPos = filename.rfind(
'-');
4575 if (dashPos != std::string::npos && dotJson > dashPos) {
4576 lang = filename.substr(dashPos + 1, dotJson - dashPos - 1);
4579 size_t prevDot = filename.rfind(
'.', dotJson - 1);
4580 if (prevDot != std::string::npos && prevDot < dotJson) {
4581 lang = filename.substr(prevDot + 1, dotJson - prevDot - 1);
4584 if (!lang.empty() && lang !=
"json") {
4585 availableLanguages.push_back(lang);
4591 ImGui::PushItemWidth(150);
4592 if (ImGui::BeginCombo(
"##Language", mTestEditor.
language)) {
4594 for (
const auto& lang : availableLanguages) {
4595 bool isSelected = (std::string(mTestEditor.
language) == lang);
4596 if (ImGui::Selectable(lang.c_str(), isSelected)) {
4597 std::strncpy(mTestEditor.
language, lang.c_str(),
sizeof(mTestEditor.
language) - 1);
4604 ImGui::TextDisabled(
"Custom:");
4605 static char customLang[16] =
"";
4606 if (ImGui::InputText(
"##CustomLang", customLang,
sizeof(customLang), ImGuiInputTextFlags_EnterReturnsTrue)) {
4607 std::strncpy(mTestEditor.
language, customLang,
sizeof(mTestEditor.
language) - 1);
4609 ImGui::CloseCurrentPopup();
4614 ImGui::PopItemWidth();
4616 if (!availableLanguages.empty()) {
4618 ImGui::TextDisabled(
"(%zu available)", availableLanguages.size());
4623 if (ImGui::SmallButton(
"Edit Translations...")) {
4626 std::string testPathStr = studyPath +
"/tests/" + selectedTest.
testPath;
4628 std::strncpy(mTranslationEditor.
testPath, testPathStr.c_str(),
sizeof(mTranslationEditor.
testPath) - 1);
4629 mTranslationEditor.
testPath[
sizeof(mTranslationEditor.
testPath) - 1] =
'\0';
4631 if (std::strlen(mTestEditor.
language) > 0) {
4633 mTranslationEditor.
language[
sizeof(mTranslationEditor.
language) - 1] =
'\0';
4635 mTranslationEditor.
language[0] =
'\0';
4638 mTranslationEditor.
show =
true;
4641 if (ImGui::IsItemHovered()) {
4642 ImGui::SetTooltip(
"Open translation editor for this test");
4649 ShowTranslationEditorDialog();
4650 if (!mTranslationEditor.
show) {
4658 ImGui::Text(
"Randomization Group:");
4660 ImGui::PushItemWidth(100);
4661 const char* groupOptions[] = {
"None",
"1",
"2",
"3"};
4662 ImGui::Combo(
"##RandomGroup", &mTestEditor.
randomGroup, groupOptions, 4);
4663 ImGui::PopItemWidth();
4665 ImGui::TextDisabled(
"(?)");
4666 if (ImGui::IsItemHovered()) {
4667 ImGui::SetTooltip(
"Group 0 (None) keeps test in fixed position\n"
4668 "Groups 1-3 allow randomizing order with other tests in same group");
4679 ImGui::BeginDisabled();
4682 if (ImGui::Button(
"Save", ImVec2(120, 0))) {
4683 if (!mCurrentChain) {
4684 printf(
"Error: No chain loaded\n");
4694 std::vector<std::string> variantNames;
4695 for (
const auto& [name, variant] : selectedTest.parameterVariants) {
4696 variantNames.push_back(name);
4706 if (std::strlen(mTestEditor.
language) > 0) {
4716 mCurrentChain->InsertItem(mTestEditor.
editingIndex, item);
4717 printf(
"Updated test item: %s\n", item.
testName.c_str());
4720 mCurrentChain->AddItem(item);
4721 printf(
"Added test to chain: %s\n", item.
testName.c_str());
4728 mTestEditor.
show =
false;
4729 ImGui::CloseCurrentPopup();
4733 ImGui::EndDisabled();
4738 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
4739 mTestEditor.
show =
false;
4740 ImGui::CloseCurrentPopup();
4751void LauncherUI::CreateNewStudy()
4754 printf(
"CreateNewStudy() - not yet implemented\n");
4757void LauncherUI::ImportSnapshotFromPath(
const std::string& snapshotPath)
4759 if (!mSnapshots)
return;
4762 std::string studyName =
"imported_study";
4763 std::string studyInfoPath = snapshotPath +
"/study-info.json";
4764 std::ifstream infoFile(studyInfoPath);
4765 if (infoFile.is_open()) {
4769 studyName = j.value(
"study_name",
"imported_study");
4775 std::string studiesDir = mWorkspace->GetStudiesPath();
4776 std::string newStudyName = studyName +
"_imported";
4778 if (mSnapshots->ImportSnapshot(snapshotPath, studiesDir, newStudyName)) {
4779 std::string newStudyPath = studiesDir +
"/" + newStudyName;
4782 if (!mSnapshots->ConvertSnapshotFormat(newStudyPath)) {
4783 printf(
"Warning: Failed to convert snapshot format, attempting to use as-is\n");
4786 printf(
"Imported snapshot as: %s\n", newStudyName.c_str());
4789 LoadStudy(newStudyPath);
4791 printf(
"Failed to import snapshot\n");
4795void LauncherUI::LoadStudy(
const std::string& studyPath)
4797 printf(
"LoadStudy(%s)\n", studyPath.c_str());
4800 std::string fullPath = studyPath;
4803 if (!studyPath.empty() && studyPath[0] !=
'/' && studyPath.find(
':') == std::string::npos) {
4805 std::string studiesPath = mWorkspace->GetStudiesPath();
4806 fullPath = studiesPath +
"/" + studyPath;
4807 printf(
"Converted to full path: %s\n", fullPath.c_str());
4811 if (mCurrentStudy) {
4812 printf(
"Study loaded: %s\n", mCurrentStudy->GetName().c_str());
4815 mSelectedStudyTestIndex = -1;
4816 FreeStudyTestScreenshot();
4817 mStudyTestDescription.clear();
4824 std::string mainChainPath = fullPath +
"/chains/Main.json";
4825 if (fs::exists(mainChainPath)) {
4826 LoadChain(mainChainPath);
4827 printf(
"Auto-loaded Main chain\n");
4830 auto chainFiles = mCurrentStudy->GetChainFiles();
4831 if (!chainFiles.empty()) {
4832 std::sort(chainFiles.begin(), chainFiles.end());
4833 std::string firstChainPath = fullPath +
"/chains/" + chainFiles[0];
4834 LoadChain(firstChainPath);
4835 printf(
"Auto-loaded first chain: %s\n", chainFiles[0].c_str());
4838 mCurrentChain.reset();
4842 printf(
"Failed to load study from: %s\n", fullPath.c_str());
4845 printf(
"Error: Could not find study-info.json in %s\n", fullPath.c_str());
4846 printf(
"Make sure the selected directory contains a valid PEBL study.\n");
4850void LauncherUI::AddTestToStudy()
4852 if (!mCurrentStudy) {
4853 printf(
"Error: No study loaded. Create or load a study first.\n");
4857 if (mSelectedExperiment < 0 || mSelectedExperiment >= (
int)mExperiments.size()) {
4858 printf(
"Error: No experiment selected\n");
4865 std::string studyPath = mCurrentStudy->GetPath();
4870 std::string testFolderName = sourceDir.filename().string();
4871 std::string testDestDir = studyPath +
"/tests/" + testFolderName;
4875 fs::create_directories(testDestDir);
4878 for (
const auto& entry : fs::directory_iterator(sourceDir)) {
4879 if (entry.is_regular_file()) {
4880 fs::path destFile = fs::path(testDestDir) / entry.path().filename();
4881 fs::copy_file(entry.path(), destFile, fs::copy_options::overwrite_existing);
4882 printf(
"Copied %s\n", entry.path().filename().string().c_str());
4887 for (
const auto& entry : fs::directory_iterator(sourceDir)) {
4888 if (entry.is_directory()) {
4889 std::string subDirName = entry.path().filename().string();
4891 if (subDirName ==
"data")
continue;
4893 std::string subDirDest = testDestDir +
"/" + subDirName;
4894 fs::create_directories(subDirDest);
4895 fs::copy(entry.path(), subDirDest, fs::copy_options::recursive | fs::copy_options::overwrite_existing);
4896 printf(
"Copied %s directory\n", subDirName.c_str());
4907 mCurrentStudy->AddTest(test);
4910 int testIndex = (int)mCurrentStudy->GetTests().size() - 1;
4911 ScanParameterVariants(testIndex);
4913 mCurrentStudy->Save();
4914 printf(
"Added test to study: %s\n", test.
testName.c_str());
4917 std::string mainChainPath = studyPath +
"/chains/Main.json";
4918 if (fs::exists(mainChainPath)) {
4926 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
4927 mCurrentChain->AddItem(item);
4928 mCurrentChain->Save();
4929 printf(
"Added test to Main chain (current chain)\n");
4934 mainChain->AddItem(item);
4936 printf(
"Added test to Main chain\n");
4941 }
catch (
const fs::filesystem_error& e) {
4942 printf(
"Error copying test files: %s\n", e.what());
4946void LauncherUI::AddTestFromFile(
const std::string& filePath)
4948 if (!mCurrentStudy) {
4949 printf(
"Error: No study loaded. Create or load a study first.\n");
4953 if (!fs::exists(filePath)) {
4954 printf(
"Error: File does not exist: %s\n", filePath.c_str());
4959 fs::path path(filePath);
4960 std::string testName = path.stem().string();
4961 std::string sourceDir = path.parent_path().string();
4964 std::string studyPath = mCurrentStudy->GetPath();
4965 std::string testDestDir = studyPath +
"/tests/" + testName;
4969 fs::create_directories(testDestDir);
4972 for (
const auto& entry : fs::directory_iterator(sourceDir)) {
4973 if (entry.is_regular_file()) {
4974 fs::path destFile = fs::path(testDestDir) / entry.path().filename();
4975 fs::copy_file(entry.path(), destFile, fs::copy_options::overwrite_existing);
4976 printf(
"Copied %s\n", entry.path().filename().string().c_str());
4981 for (
const auto& entry : fs::directory_iterator(sourceDir)) {
4982 if (entry.is_directory()) {
4983 std::string subDirName = entry.path().filename().string();
4985 if (subDirName ==
"data")
continue;
4987 std::string subDirDest = testDestDir +
"/" + subDirName;
4988 fs::create_directories(subDirDest);
4989 fs::copy(entry.path(), subDirDest, fs::copy_options::recursive | fs::copy_options::overwrite_existing);
4990 printf(
"Copied %s directory\n", subDirName.c_str());
5000 mCurrentStudy->AddTest(test);
5001 mCurrentStudy->Save();
5002 printf(
"Added test from file to study: %s\n", testName.c_str());
5005 std::string mainChainPath = studyPath +
"/chains/Main.json";
5006 if (fs::exists(mainChainPath)) {
5014 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
5015 mCurrentChain->AddItem(item);
5016 mCurrentChain->Save();
5017 printf(
"Added test to Main chain (current chain)\n");
5022 mainChain->AddItem(item);
5024 printf(
"Added test to Main chain\n");
5029 }
catch (
const fs::filesystem_error& e) {
5030 printf(
"Error copying test files: %s\n", e.what());
5034void LauncherUI::CreateTestFromTemplate(
const std::string& testName,
int templateType)
5036 if (!mCurrentStudy) {
5037 printf(
"Error: No study loaded. Create or load a study first.\n");
5042 std::string studyPath = mCurrentStudy->GetPath();
5043 std::string testDir = studyPath +
"/tests/" + testName;
5047 fs::create_directories(testDir);
5048 fs::create_directories(testDir +
"/params");
5049 fs::create_directories(testDir +
"/translations");
5052 std::string templateFilename;
5053 if (templateType >= 0 && templateType < (
int)mTemplateFiles.size()) {
5054 templateFilename = mTemplateFiles[templateType];
5056 printf(
"Error: Invalid template type: %d\n", templateType);
5061 std::string templatePath = mBatteryPath +
"/../media/templates/" + templateFilename;
5064 std::ifstream templateFile(templatePath);
5065 if (!templateFile.is_open()) {
5066 printf(
"Warning: Could not find template file: %s\n", templatePath.c_str());
5067 printf(
"Using minimal fallback template.\n");
5070 std::string pblFile = testDir +
"/" + testName +
".pbl";
5071 std::ofstream out(pblFile);
5072 if (!out.is_open()) {
5073 printf(
"Error: Could not create .pbl file\n");
5076 out <<
"## " << testName <<
" - PEBL Test\n";
5077 out <<
"## Generated from template\n\n";
5078 out <<
"define Start(p) {\n";
5079 out <<
" gWin <- MakeWindow(\"grey40\")\n";
5080 out <<
" gSleepEasy <- 1\n\n";
5081 out <<
" if(gSubNum+\"\" == \"0\") {\n";
5082 out <<
" gSubNum <- GetSubNum(gWin)\n";
5084 out <<
" ## Your code here\n\n";
5085 out <<
" MessageBox(\"Experiment complete. Thank you!\", gWin)\n";
5086 out <<
" return(0)\n";
5091 std::stringstream buffer;
5092 buffer << templateFile.rdbuf();
5093 std::string templateContent = buffer.str();
5094 templateFile.close();
5097 std::string pblFile = testDir +
"/" + testName +
".pbl";
5098 std::ofstream out(pblFile);
5099 if (!out.is_open()) {
5100 printf(
"Error: Could not create .pbl file\n");
5106 out << templateContent;
5110 printf(
"Created test from template: %s/%s.pbl\n", testDir.c_str(), testName.c_str());
5118 mCurrentStudy->AddTest(test);
5119 mCurrentStudy->Save();
5120 printf(
"Added new test to study: %s\n", testName.c_str());
5122 }
catch (
const fs::filesystem_error& e) {
5123 printf(
"Error creating test from template: %s\n", e.what());
5127void LauncherUI::CreateTestFromGenericStudy(
const std::string& testName)
5129 if (!mCurrentStudy) {
5130 printf(
"Error: No study loaded. Create or load a study first.\n");
5135 std::string studyPath = mCurrentStudy->GetPath();
5136 std::string testDir = studyPath +
"/tests/" + testName;
5140 std::string templateDir = mBatteryPath +
"/template";
5142 if (!fs::exists(templateDir)) {
5143 printf(
"Error: Template directory not found: %s\n", templateDir.c_str());
5148 fs::create_directories(testDir);
5151 if (fs::exists(templateDir +
"/params")) {
5152 fs::copy(templateDir +
"/params", testDir +
"/params",
5153 fs::copy_options::recursive | fs::copy_options::overwrite_existing);
5156 std::string oldSchema = testDir +
"/params/template.pbl.schema.json";
5157 std::string newSchema = testDir +
"/params/" + testName +
".pbl.schema.json";
5158 if (fs::exists(oldSchema)) {
5159 fs::rename(oldSchema, newSchema);
5164 if (fs::exists(templateDir +
"/translations")) {
5165 fs::copy(templateDir +
"/translations", testDir +
"/translations",
5166 fs::copy_options::recursive | fs::copy_options::overwrite_existing);
5169 std::string oldTranslation = testDir +
"/translations/template.pbl-en.json";
5170 std::string newTranslation = testDir +
"/translations/" + testName +
".pbl-en.json";
5171 if (fs::exists(oldTranslation)) {
5172 fs::rename(oldTranslation, newTranslation);
5177 fs::create_directories(testDir +
"/data");
5180 std::string templatePbl = templateDir +
"/template.pbl";
5181 std::string newPbl = testDir +
"/" + testName +
".pbl";
5182 if (fs::exists(templatePbl)) {
5183 fs::copy(templatePbl, newPbl, fs::copy_options::overwrite_existing);
5187 std::string templateAbout = templateDir +
"/template.pbl.about.txt";
5188 std::string newAbout = testDir +
"/" + testName +
".pbl.about.txt";
5189 if (fs::exists(templateAbout)) {
5190 fs::copy(templateAbout, newAbout, fs::copy_options::overwrite_existing);
5193 printf(
"Created test from Generic Study Template: %s\n", testDir.c_str());
5194 printf(
" ✓ Copied params/ directory\n");
5195 printf(
" ✓ Copied translations/ directory\n");
5196 printf(
" ✓ Created %s.pbl\n", testName.c_str());
5197 printf(
" ✓ Created %s.pbl.about.txt\n", testName.c_str());
5205 mCurrentStudy->AddTest(test);
5206 mCurrentStudy->Save();
5207 printf(
"Added new test to study: %s\n", testName.c_str());
5209 }
catch (
const fs::filesystem_error& e) {
5210 printf(
"Error creating test from Generic Study Template: %s\n", e.what());
5214void LauncherUI::RemoveTestFromStudy(
const std::string& testName)
5216 if (!mCurrentStudy) {
5217 printf(
"Error: No study loaded\n");
5221 mCurrentStudy->RemoveTest(testName);
5222 mCurrentStudy->Save();
5223 printf(
"Removed test from study: %s\n", testName.c_str());
5226bool LauncherUI::SyncScaleSchema(
const std::string& testDir,
const std::string& scaleCode)
5231 std::string scaleJsonPath;
5232 std::vector<std::string> candidates = {
5233 testDir +
"/" + scaleCode +
"/" + scaleCode +
".json",
5234 testDir +
"/definitions/" + scaleCode +
".json"
5236 for (
const auto& path : candidates) {
5237 if (fs::exists(path)) {
5238 scaleJsonPath = path;
5244 if (mScaleManager) {
5245 std::string libPath = mScaleManager->GetDefinitionPath(scaleCode);
5246 if (!libPath.empty() && fs::exists(libPath)) {
5248 scaleJsonPath = libPath;
5252 if (scaleJsonPath.empty()) {
5257 nlohmann::json scaleDef;
5259 std::ifstream scaleFile(scaleJsonPath);
5260 if (!scaleFile.is_open())
return false;
5261 scaleFile >> scaleDef;
5263 }
catch (
const std::exception& e) {
5264 printf(
"Error parsing scale JSON %s: %s\n", scaleJsonPath.c_str(), e.what());
5268 printf(
"Syncing schema from scale definition: %s\n", scaleJsonPath.c_str());
5271 nlohmann::json schemaJson = {
5272 {
"test", scaleCode},
5274 {
"description", scaleCode +
" Scale"}
5276 nlohmann::json schemaParams = nlohmann::json::array();
5279 schemaParams.push_back({
5282 {
"default", scaleCode},
5283 {
"description",
"OSD scale code (reads definitions/{code}.json)"},
5288 nlohmann::json parDefaults = {{
"scale", scaleCode}};
5291 if (scaleDef.contains(
"parameters"))
5292 for (
auto& [pName, pDef] : scaleDef[
"parameters"].items()) {
5296 std::string pType =
"string";
5297 if (pDef.contains(
"type")) pType = pDef[
"type"].get<std::string>();
5301 if (pDef.contains(
"default")) {
5302 sp[
"default"] = pDef[
"default"];
5304 parDefaults[pName] = pDef[
"default"];
5307 if (pDef.contains(
"description")) {
5308 sp[
"description"] = pDef[
"description"].get<std::string>();
5312 if (pDef.contains(
"options") && pDef[
"options"].is_array()) {
5313 sp[
"options"] = pDef[
"options"];
5314 }
else if (pType ==
"boolean") {
5315 sp[
"options"] = nlohmann::json::array({0, 1});
5318 schemaParams.push_back(sp);
5322 if (!parDefaults.contains(
"shuffle_questions")) {
5323 schemaParams.push_back({
5324 {
"name",
"shuffle_questions"},
5325 {
"type",
"boolean"},
5327 {
"options", nlohmann::json::array({0, 1})},
5328 {
"description",
"Randomize item order within randomization groups"}
5330 parDefaults[
"shuffle_questions"] = 0;
5334 if (!parDefaults.contains(
"show_header")) {
5335 schemaParams.push_back({
5336 {
"name",
"show_header"},
5337 {
"type",
"boolean"},
5339 {
"options", nlohmann::json::array({0, 1})},
5340 {
"description",
"Display the scale title header above the questionnaire"}
5342 parDefaults[
"show_header"] = 1;
5345 schemaJson[
"parameters"] = schemaParams;
5348 std::string schemaPath = testDir +
"/params/" + scaleCode +
".pbl.schema.json";
5349 fs::create_directories(testDir +
"/params");
5350 std::ofstream schemaFile(schemaPath);
5351 if (schemaFile.is_open()) {
5352 schemaFile << schemaJson.dump(2);
5354 printf(
"Updated schema: %s\n", schemaPath.c_str());
5358 std::string parPath = testDir +
"/params/" + scaleCode +
".pbl.par.json";
5359 nlohmann::json existingParams;
5360 if (fs::exists(parPath)) {
5362 std::ifstream existingFile(parPath);
5363 if (existingFile.is_open()) {
5364 existingFile >> existingParams;
5365 existingFile.close();
5368 existingParams = nlohmann::json::object();
5373 bool updated =
false;
5374 for (
auto& [key, val] : parDefaults.items()) {
5375 if (!existingParams.contains(key)) {
5376 existingParams[key] = val;
5381 if (updated || !fs::exists(parPath)) {
5382 std::ofstream parFile(parPath);
5383 if (parFile.is_open()) {
5384 parFile << existingParams.dump(2);
5386 printf(
"%s params: %s\n", updated ?
"Updated" :
"Created", parPath.c_str());
5394void LauncherUI::EditTestParameters(
int testIndex)
5396 if (!mCurrentStudy) {
5397 printf(
"Error: No study loaded\n");
5401 const auto& tests = mCurrentStudy->GetTests();
5402 if (testIndex < 0 || testIndex >= (
int)tests.size()) {
5403 printf(
"Error: Invalid test index\n");
5407 const Test& test = tests[testIndex];
5408 std::string studyPath = mCurrentStudy->GetPath();
5409 std::string testPath = studyPath +
"/tests/" + test.
testPath;
5412 SyncScaleSchema(testPath, test.
testName);
5414 std::string schemaPath = testPath +
"/params/" + test.
testName +
".pbl.schema.json";
5418 if (stat(schemaPath.c_str(), &st) != 0) {
5419 printf(
"Warning: No parameter schema found at %s\n", schemaPath.c_str());
5420 printf(
"Searched at: %s\n", schemaPath.c_str());
5421 printf(
"Test may not have configurable parameters, or test files may not have been copied to study.\n");
5426 mEditingTestIndex = testIndex;
5429 ScanParameterVariants(testIndex);
5432 mVariantName[0] =
'\0';
5433 LoadParameterEditorForVariant();
5436void LauncherUI::ScanParameterVariants(
int testIndex)
5438 if (!mCurrentStudy)
return;
5440 const auto& tests = mCurrentStudy->GetTests();
5441 if (testIndex < 0 || testIndex >= (
int)tests.size())
return;
5443 Test* test = mCurrentStudy->GetTest(tests[testIndex].testName);
5446 std::string studyPath = mCurrentStudy->GetPath();
5447 std::string paramsDir = studyPath +
"/tests/" + test->
testPath +
"/params";
5453 if (!fs::exists(paramsDir) || !fs::is_directory(paramsDir)) {
5454 printf(
"No params directory found at %s\n", paramsDir.c_str());
5459 for (
const auto& entry : fs::directory_iterator(paramsDir)) {
5460 if (entry.is_regular_file() && entry.path().extension() ==
".json") {
5461 std::string filename = entry.path().filename().string();
5464 if (filename.find(
".par.json") != std::string::npos) {
5466 size_t dashPos = filename.find(
'-');
5467 size_t parPos = filename.find(
".par.json");
5469 if (dashPos != std::string::npos && parPos != std::string::npos && dashPos < parPos) {
5470 std::string variantName = filename.substr(dashPos + 1, parPos - dashPos - 1);
5473 variant.
file = filename;
5474 variant.
description =
"Parameter set: " + variantName;
5477 printf(
"Found parameter variant: %s\n", variantName.c_str());
5483 printf(
"Scanned %zu parameter variants for test %s\n",
5487 mCurrentStudy->Save();
5489 }
catch (
const fs::filesystem_error& e) {
5490 printf(
"Error scanning parameter variants: %s\n", e.what());
5498void LauncherUI::CreateNewChain()
5500 if (!mCurrentStudy) {
5501 printf(
"Error: No study loaded. Create or load a study first.\n");
5505 mShowNewChainDialog =
true;
5508void LauncherUI::LoadChain(
const std::string& chainPath)
5510 printf(
"LoadChain(%s)\n", chainPath.c_str());
5513 if (mCurrentChain) {
5514 printf(
"Chain loaded: %s\n", mCurrentChain->GetName().c_str());
5517 size_t lastSlash = chainPath.find_last_of(
"/\\");
5518 std::string chainName = (lastSlash != std::string::npos)
5519 ? chainPath.substr(lastSlash + 1)
5524 printf(
"Failed to load chain from: %s\n", chainPath.c_str());
5528void LauncherUI::SaveCurrentChain()
5530 if (!mCurrentChain) {
5531 printf(
"Error: No chain loaded\n");
5535 if (mCurrentChain->Save()) {
5536 printf(
"Chain saved: %s\n", mCurrentChain->GetName().c_str());
5538 printf(
"Failed to save chain\n");
5542void LauncherUI::AddInstructionPage()
5544 mPageEditor.
show =
true;
5547 mPageEditor.
title[0] =
'\0';
5548 mPageEditor.
content[0] =
'\0';
5551void LauncherUI::AddConsentPage()
5553 mPageEditor.
show =
true;
5556 mPageEditor.
title[0] =
'\0';
5557 mPageEditor.
content[0] =
'\0';
5560void LauncherUI::AddCompletionPage()
5562 mPageEditor.
show =
true;
5565 mPageEditor.
title[0] =
'\0';
5566 mPageEditor.
content[0] =
'\0';
5569void LauncherUI::AddTestToChain()
5571 if (!mCurrentChain) {
5572 printf(
"Error: No chain loaded\n");
5576 if (!mCurrentStudy) {
5577 printf(
"Error: No study loaded. Tests must come from a study.\n");
5582 mTestEditor.
show =
true;
5589void LauncherUI::RemoveChainItem(
int index)
5591 if (!mCurrentChain) {
5592 printf(
"Error: No chain loaded\n");
5596 mCurrentChain->RemoveItem(index);
5598 printf(
"Removed chain item at index: %d\n", index);
5601void LauncherUI::MoveChainItemUp(
int index)
5603 if (!mCurrentChain) {
5604 printf(
"Error: No chain loaded\n");
5609 mCurrentChain->MoveItem(index, index - 1);
5611 printf(
"Moved chain item up from index %d to %d\n", index, index - 1);
5615void LauncherUI::MoveChainItemDown(
int index)
5617 if (!mCurrentChain) {
5618 printf(
"Error: No chain loaded\n");
5622 if (index < (
int)mCurrentChain->GetItems().size() - 1) {
5624 ChainItem* item1 = mCurrentChain->GetItem(index);
5625 ChainItem* item2 = mCurrentChain->GetItem(index + 1);
5626 if (item1 && item2) {
5631 printf(
"Moved chain item down from index %d to %d\n", index, index + 1);
5636void LauncherUI::MoveChainItemTo(
int from,
int to)
5638 if (!mCurrentChain)
return;
5639 int n = (int)mCurrentChain->GetItems().size();
5640 if (from == to || from < 0 || from >= n || to < 0 || to >= n)
return;
5641 int step = (to > from) ? 1 : -1;
5642 for (
int i = from; i != to; i += step) {
5643 ChainItem* a = mCurrentChain->GetItem(i);
5644 ChainItem* b = mCurrentChain->GetItem(i + step);
5645 if (a && b) std::swap(*a, *b);
5650void LauncherUI::EditChainItem(
int index)
5652 if (!mCurrentChain) {
5653 printf(
"Error: No chain loaded\n");
5657 const auto& items = mCurrentChain->GetItems();
5658 if (index < 0 || index >= (
int)items.size()) {
5659 printf(
"Error: Invalid chain item index: %d\n", index);
5670 mPageEditor.
show =
true;
5681 std::strncpy(mPageEditor.
title, item.
title.c_str(),
sizeof(mPageEditor.
title) - 1);
5685 if (!mCurrentStudy) {
5686 printf(
"Error: No study loaded - cannot edit test item\n");
5691 const auto& tests = mCurrentStudy->GetTests();
5693 for (
size_t i = 0; i < tests.size(); i++) {
5694 if (tests[i].testName == item.
testName) {
5700 if (testIndex < 0) {
5701 printf(
"Warning: Test '%s' not found in study\n", item.
testName.c_str());
5707 int variantIndex = 0;
5709 const Test& test = tests[testIndex];
5711 for (
const auto& [name, variant] : test.parameterVariants) {
5721 mTestEditor.
show =
true;
5735void LauncherUI::RenderStudyBar()
5740 ImGui::Text(
"Study:");
5743 const char* currentStudyName = mCurrentStudy ? mCurrentStudy->GetName().c_str() :
"No study loaded";
5744 ImGui::PushItemWidth(300);
5745 if (ImGui::BeginCombo(
"##StudySelect", currentStudyName)) {
5747 auto studyDirs = mWorkspace->GetStudyDirectories();
5748 for (
size_t i = 0; i < studyDirs.size(); i++) {
5750 std::string studyName = fs::path(studyDirs[i]).filename().string();
5751 bool is_selected = (mCurrentStudy && mCurrentStudy->GetName() == studyName);
5752 if (ImGui::Selectable(studyName.c_str(), is_selected)) {
5753 LoadStudy(studyDirs[i]);
5758 ImGui::PopItemWidth();
5761 if (ImGui::Button(
"New Study")) {
5762 mShowNewStudyDialog =
true;
5766 if (mCurrentStudy) {
5767 if (ImGui::Button(
"Open Directory")) {
5768 std::string studyPath = mCurrentStudy->GetPath();
5769 OpenDirectoryInFileBrowser(studyPath);
5773 if (ImGui::Button(
"Study Settings")) {
5774 mShowStudySettingsDialog =
true;
5779 if (mCurrentStudy) {
5781 ImGui::TextDisabled(
"| %s | %zu tests | %d chains",
5782 mCurrentStudy->GetName().c_str(),
5783 mCurrentStudy->GetTests().size(),
5784 mCurrentStudy->GetChainCount());
5791void LauncherUI::RenderTestsTab()
5793 if (!mCurrentStudy) {
5795 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
5796 ImGui::TextWrapped(
"No study loaded. Browse available tests below, then create or open a study to add them.");
5797 ImGui::PopStyleColor();
5803 RenderBatteryBrowser();
5808 float panelWidth = ImGui::GetContentRegionAvail().x * 0.35f;
5811 ImGui::BeginChild(
"TestsInStudy", ImVec2(panelWidth, 0),
true);
5812 RenderTestsInStudy();
5818 ImGui::BeginChild(
"AddTestPanel", ImVec2(0, 0),
true);
5819 RenderAddTestPanel();
5823void LauncherUI::RenderTestsInStudy()
5825 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Tests in Study");
5829 const auto& tests = mCurrentStudy->GetTests();
5831 if (tests.empty()) {
5832 ImGui::TextDisabled(
"No tests in this study yet.\nUse the panel on the right to add tests.");
5837 float listHeight = (mSelectedStudyTestIndex >= 0) ? ImGui::GetContentRegionAvail().y * 0.4f : -1;
5838 ImGui::BeginChild(
"TestList", ImVec2(0, listHeight),
false);
5840 for (
size_t i = 0; i < tests.size(); i++) {
5841 ImGui::PushID((
int)i);
5843 ImGui::Text(
"%zu.", i + 1);
5847 float availableWidth = ImGui::GetContentRegionAvail().x - 50;
5848 std::string testName = tests[i].testName;
5851 if (!tests[i].parameterVariants.empty()) {
5852 testName +=
" (" + std::to_string(tests[i].parameterVariants.size()) +
" variants)";
5856 std::string displayName = testName;
5857 if (ImGui::CalcTextSize(displayName.c_str()).x > availableWidth) {
5858 while (displayName.length() > 3 && ImGui::CalcTextSize((displayName +
"...").c_str()).x > availableWidth) {
5859 displayName.pop_back();
5861 displayName +=
"...";
5865 bool is_selected = (mSelectedStudyTestIndex == (int)i);
5866 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f));
5867 if (ImGui::Selectable(displayName.c_str(), is_selected, ImGuiSelectableFlags_None, ImVec2(availableWidth, 0))) {
5868 mSelectedStudyTestIndex = (int)i;
5869 LoadStudyTestPreview((
int)i);
5871 ImGui::PopStyleColor();
5872 if (ImGui::IsItemHovered()) {
5873 if (displayName != testName) {
5874 ImGui::SetTooltip(
"%s\nClick to view test details", testName.c_str());
5876 ImGui::SetTooltip(
"Click to view test details");
5880 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 50);
5883 if (ImGui::SmallButton(
"...")) {
5884 ImGui::OpenPopup(
"TestMenu");
5887 if (ImGui::BeginPopup(
"TestMenu")) {
5888 std::string studyPath = mCurrentStudy->GetPath();
5889 fs::path testPath = fs::path(studyPath) /
"tests" / tests[i].testPath;
5891 std::string baseName = fs::path(tests[i].testName).filename().string();
5892 std::string pblFile = (testPath / (baseName +
".pbl")).string();
5895 if (ImGui::MenuItem(
"Quick Launch")) {
5897 std::ifstream file(pblFile);
5898 if (file.is_open()) {
5902 std::strncpy(mQuickLaunchPath, pblFile.c_str(),
sizeof(mQuickLaunchPath) - 1);
5903 mQuickLaunchPath[
sizeof(mQuickLaunchPath) - 1] =
'\0';
5906 mQuickLaunchDirectory = fs::path(pblFile).parent_path().string();
5911 printf(
"Switched to Quick Launch with test: %s\n", baseName.c_str());
5913 printf(
"Error: Could not find test file: %s\n", pblFile.c_str());
5920 if (ImGui::MenuItem(
"Edit Code")) {
5921 std::ifstream file(pblFile);
5922 if (file.is_open()) {
5923 std::stringstream buffer;
5924 buffer << file.rdbuf();
5927 mCodeEditorFilePath = pblFile;
5928 mCodeEditor.
SetText(buffer.str());
5929 mShowCodeEditor =
true;
5931 printf(
"Error: Could not open file for editing: %s\n", pblFile.c_str());
5936 if (ImGui::MenuItem(
"Edit Parameters...")) {
5937 EditTestParameters(i);
5941 if (ImGui::MenuItem(
"Edit Translations...")) {
5944 std::string testPathStr = testPath.string();
5945 std::strncpy(mTranslationEditor.
testPath, testPathStr.c_str(),
sizeof(mTranslationEditor.
testPath) - 1);
5946 mTranslationEditor.
testPath[
sizeof(mTranslationEditor.
testPath) - 1] =
'\0';
5947 mTranslationEditor.
language[0] =
'\0';
5948 mTranslationEditor.
show =
true;
5954 if (ImGui::MenuItem(
"Open Test Directory")) {
5955 OpenDirectoryInFileBrowser(testPath.string());
5959 if (ImGui::MenuItem(
"Combine Data Files...")) {
5960 std::string dataPath = (testPath /
"data").
string();
5963 if (!fs::exists(dataPath)) {
5964 fs::create_directories(dataPath);
5967 LaunchDataCombiner(dataPath);
5973 if (ImGui::MenuItem(
"Remove from Study")) {
5974 const std::string testName = tests[i].testName;
5975 RemoveTestFromStudy(testName);
5991 if (mSelectedStudyTestIndex >= 0 && mSelectedStudyTestIndex < (
int)tests.size()) {
5996 if (mStudyTestScreenshot) {
5997 float aspectRatio = (float)mStudyTestScreenshotH / (
float)mStudyTestScreenshotW;
5998 float displayWidth = ImGui::GetContentRegionAvail().x;
5999 float displayHeight = displayWidth * aspectRatio;
6001 if (displayHeight > 300) {
6002 displayHeight = 300;
6003 displayWidth = displayHeight / aspectRatio;
6006 ImGui::Image((ImTextureID)(intptr_t)mStudyTestScreenshot,
6007 ImVec2(displayWidth, displayHeight));
6012 if (!mStudyTestDescription.empty()) {
6013 ImGui::TextWrapped(
"%s", mStudyTestDescription.c_str());
6018void LauncherUI::RenderAddTestPanel()
6020 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Add Test to Study");
6025 if (ImGui::BeginTabBar(
"AddTestTabs")) {
6026 if (ImGui::BeginTabItem(
"Battery")) {
6028 RenderBatteryBrowser();
6029 ImGui::EndTabItem();
6032 if (ImGui::BeginTabItem(
"Scale")) {
6034 RenderScaleBrowser();
6035 ImGui::EndTabItem();
6038 if (ImGui::BeginTabItem(
"File")) {
6041 ImGui::EndTabItem();
6044 if (ImGui::BeginTabItem(
"New")) {
6046 RenderNewTestTemplate();
6047 ImGui::EndTabItem();
6054void LauncherUI::RenderBatteryBrowser()
6058 static char filter[256] =
"";
6059 ImGui::PushItemWidth(-1);
6060 ImGui::InputTextWithHint(
"##Filter",
"Filter tests...", filter,
sizeof(filter));
6061 ImGui::PopItemWidth();
6066 float listWidth = ImGui::GetContentRegionAvail().x * 0.4f;
6068 ImGui::BeginChild(
"BatteryTestList", ImVec2(listWidth, 0),
true);
6070 ImGui::Text(
"Battery Tests (%zu found):", mExperiments.size());
6074 for (
int i = 0; i < (int)mExperiments.size(); i++) {
6078 if (strlen(filter) > 0 &&
6079 exp.
name.find(filter) == std::string::npos) {
6083 bool is_selected = (mSelectedExperiment == i);
6084 if (ImGui::Selectable(exp.
name.c_str(), is_selected)) {
6085 mSelectedExperiment = i;
6086 LoadExperimentInfo(exp.
path);
6089 if (ImGui::IsItemHovered()) {
6090 ImGui::SetTooltip(
"%s", exp.
path.c_str());
6095 if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) {
6096 int newSelection = mSelectedExperiment;
6098 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
6100 bool foundCurrent = (mSelectedExperiment < 0);
6101 for (
int i = 0; i < (int)mExperiments.size(); i++) {
6102 if (strlen(filter) > 0 && mExperiments[i].name.find(filter) == std::string::npos) {
6109 if (i == mSelectedExperiment) {
6110 foundCurrent =
true;
6113 }
else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
6115 for (
int i = (
int)mExperiments.size() - 1; i >= 0; i--) {
6116 if (strlen(filter) > 0 && mExperiments[i].name.find(filter) == std::string::npos) {
6119 if (i < mSelectedExperiment) {
6127 if (newSelection != mSelectedExperiment && newSelection >= 0 && newSelection < (
int)mExperiments.size()) {
6128 mSelectedExperiment = newSelection;
6129 LoadExperimentInfo(mExperiments[newSelection].path);
6138 ImGui::BeginChild(
"BatteryTestDetails", ImVec2(0, 0),
true);
6140 if (mSelectedExperiment < 0 || mSelectedExperiment >= (
int)mExperiments.size()) {
6141 ImGui::TextDisabled(
"Select a test to view details");
6146 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", exp.
name.c_str());
6151 if (mCurrentStudy) {
6152 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
6153 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
6154 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
6156 if (ImGui::Button(
"Add to Study", ImVec2(-1, 40))) {
6160 ImGui::PopStyleColor(3);
6163 ImGui::BeginDisabled();
6164 ImGui::Button(
"Create or Open a Study First", ImVec2(-1, 40));
6165 ImGui::EndDisabled();
6166 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
6167 ImGui::SetTooltip(
"Create or open a study to add tests");
6176 if (mScreenshotTexture) {
6177 float aspectRatio = (float)mScreenshotHeight / (
float)mScreenshotWidth;
6178 float displayWidth = ImGui::GetContentRegionAvail().x;
6179 float displayHeight = displayWidth * aspectRatio;
6182 if (displayHeight > 400) {
6183 displayHeight = 400;
6184 displayWidth = displayHeight / aspectRatio;
6187 ImGui::Image((ImTextureID)(intptr_t)mScreenshotTexture,
6188 ImVec2(displayWidth, displayHeight));
6194 ImGui::TextWrapped(
"%s", exp.
description.c_str());
6196 ImGui::TextDisabled(
"No description available");
6203 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"✓ Parameters");
6204 if (ImGui::IsItemHovered()) {
6205 ImGui::SetTooltip(
"This test has configurable parameters");
6210 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"✓ Translations");
6211 if (ImGui::IsItemHovered()) {
6212 if (!mAvailableLanguages.empty()) {
6213 std::string tooltip =
"Available languages: ";
6214 for (
size_t i = 0; i < mAvailableLanguages.size(); i++) {
6215 tooltip += mAvailableLanguages[i];
6216 if (i < mAvailableLanguages.size() - 1) {
6220 ImGui::SetTooltip(
"%s", tooltip.c_str());
6222 ImGui::SetTooltip(
"This test has translation support");
6231void LauncherUI::RenderScaleBrowser()
6234 static char filter[256] =
"";
6235 ImGui::PushItemWidth(-1);
6236 ImGui::InputTextWithHint(
"##ScaleFilter",
"Filter scales...", filter,
sizeof(filter));
6237 ImGui::PopItemWidth();
6242 if (mScaleList.empty()) {
6243 mScaleList = mScaleManager->GetAvailableScales();
6247 float listWidth = ImGui::GetContentRegionAvail().x * 0.4f;
6249 ImGui::BeginChild(
"ScaleList", ImVec2(listWidth, 0),
true);
6251 ImGui::Text(
"Available Scales (%zu found):", mScaleList.size());
6255 for (
int i = 0; i < (int)mScaleList.size(); i++) {
6256 const std::string& scaleName = mScaleList[i];
6259 if (strlen(filter) > 0 &&
6260 scaleName.find(filter) == std::string::npos) {
6264 bool is_selected = (mSelectedScaleIndex == i);
6265 if (ImGui::Selectable(scaleName.c_str(), is_selected)) {
6266 mSelectedScaleIndex = i;
6271 if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) {
6272 int newSelection = mSelectedScaleIndex;
6274 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
6276 bool foundCurrent = (mSelectedScaleIndex < 0);
6277 for (
int i = 0; i < (int)mScaleList.size(); i++) {
6278 if (strlen(filter) > 0 && mScaleList[i].find(filter) == std::string::npos) {
6285 if (i == mSelectedScaleIndex) {
6286 foundCurrent =
true;
6289 }
else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
6291 for (
int i = (
int)mScaleList.size() - 1; i >= 0; i--) {
6292 if (strlen(filter) > 0 && mScaleList[i].find(filter) == std::string::npos) {
6295 if (i < mSelectedScaleIndex) {
6303 if (newSelection != mSelectedScaleIndex && newSelection >= 0 && newSelection < (
int)mScaleList.size()) {
6304 mSelectedScaleIndex = newSelection;
6313 ImGui::BeginChild(
"ScaleDetails", ImVec2(0, 0),
true);
6315 if (mSelectedScaleIndex < 0 || mSelectedScaleIndex >= (
int)mScaleList.size()) {
6316 ImGui::TextDisabled(
"Select a scale to view details");
6318 const std::string& scaleCode = mScaleList[mSelectedScaleIndex];
6319 auto metadata = mScaleManager->GetScaleMetadata(scaleCode);
6322 if (!metadata.name.empty()) {
6323 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", metadata.name.c_str());
6325 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", scaleCode.c_str());
6331 if (mSelectedScaleIndex != mScaleBrowserScreenshotForIndex) {
6332 if (mScaleBrowserScreenshot) {
6333 SDL_DestroyTexture(mScaleBrowserScreenshot);
6334 mScaleBrowserScreenshot =
nullptr;
6335 mScaleBrowserScreenshotW = 0;
6336 mScaleBrowserScreenshotH = 0;
6340 std::string defPath = mScaleManager->GetDefinitionPath(scaleCode);
6341 fs::path defDir = fs::path(defPath).parent_path();
6342 std::string screenshotPath = (defDir / (scaleCode +
".pbl.png")).string();
6344 if (fs::exists(screenshotPath)) {
6345 SDL_Surface* surface = IMG_Load(screenshotPath.c_str());
6347 mScaleBrowserScreenshot = SDL_CreateTextureFromSurface(mRenderer, surface);
6348 if (mScaleBrowserScreenshot) {
6349 mScaleBrowserScreenshotW = surface->w;
6350 mScaleBrowserScreenshotH = surface->h;
6352 SDL_FreeSurface(surface);
6356 mScaleBrowserScreenshotForIndex = mSelectedScaleIndex;
6360 if (mScaleBrowserScreenshot) {
6361 float aspectRatio = (float)mScaleBrowserScreenshotH / (
float)mScaleBrowserScreenshotW;
6362 float displayWidth = ImGui::GetContentRegionAvail().x;
6363 float displayHeight = displayWidth * aspectRatio;
6365 if (displayHeight > 300.0f) {
6366 displayHeight = 300.0f;
6367 displayWidth = displayHeight / aspectRatio;
6370 ImGui::Image((ImTextureID)(intptr_t)mScaleBrowserScreenshot,
6371 ImVec2(displayWidth, displayHeight));
6376 if (mCurrentStudy) {
6377 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
6378 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
6379 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
6381 if (ImGui::Button(
"Add to Study", ImVec2(-1, 40))) {
6383 auto scale = mScaleManager->LoadScale(scaleCode);
6385 printf(
"Failed to load scale '%s'\n", scaleCode.c_str());
6387 std::string studyPath = mCurrentStudy->GetPath();
6388 std::string testDir = studyPath +
"/tests/" + scaleCode;
6392 fs::create_directories(testDir);
6393 fs::create_directories(testDir +
"/" + scaleCode);
6394 fs::create_directories(testDir +
"/params");
6397 std::string scaleRunnerSource = mBatteryPath +
"/../media/apps/scales/ScaleRunner.pbl";
6398 std::string scaleRunnerDest = testDir +
"/" + scaleCode +
".pbl";
6400 if (!fs::exists(scaleRunnerSource)) {
6401 printf(
"Error: ScaleRunner.pbl not found at: %s\n", scaleRunnerSource.c_str());
6403 fs::copy_file(scaleRunnerSource, scaleRunnerDest, fs::copy_options::overwrite_existing);
6404 printf(
"Copied ScaleRunner.pbl to %s\n", scaleRunnerDest.c_str());
6407 std::string osdDir = testDir +
"/" + scaleCode;
6409 if (!scale->ExportToOSD(osdDir)) {
6410 printf(
"Error: Failed to export scale OSD\n");
6412 printf(
"Exported scale OSD to %s\n", osdDir.c_str());
6415 SyncScaleSchema(testDir, scaleCode);
6420 test.
displayName = metadata.name.empty() ? scaleCode : metadata.name;
6424 mCurrentStudy->AddTest(test);
6425 mCurrentStudy->Save();
6428 std::string mainChainPath = studyPath +
"/chains/Main.json";
6429 if (fs::exists(mainChainPath)) {
6436 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
6437 mCurrentChain->AddItem(item);
6438 mCurrentChain->Save();
6439 printf(
"Added test to Main chain (current chain)\n");
6443 mainChain->AddItem(item);
6445 printf(
"Added test to Main chain\n");
6450 printf(
"Added scale '%s' to study\n", scaleCode.c_str());
6453 }
catch (
const fs::filesystem_error& e) {
6454 printf(
"Error adding scale to study: %s\n", e.what());
6459 ImGui::PopStyleColor(3);
6462 ImGui::BeginDisabled();
6463 ImGui::Button(
"Create or Open a Study First", ImVec2(-1, 40));
6464 ImGui::EndDisabled();
6465 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
6466 ImGui::SetTooltip(
"Create or open a study to add scales");
6475 if (!metadata.description.empty()) {
6476 ImGui::TextWrapped(
"%s", metadata.description.c_str());
6481 ImGui::Text(
"Code: %s", scaleCode.c_str());
6483 if (!metadata.author.empty()) {
6484 ImGui::Text(
"Author: %s", metadata.author.c_str());
6487 ImGui::Text(
"Questions: %d", metadata.questionCount);
6489 if (!metadata.availableLanguages.empty()) {
6490 ImGui::Text(
"Languages: ");
6492 for (
size_t i = 0; i < metadata.availableLanguages.size(); i++) {
6493 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"%s", metadata.availableLanguages[i].c_str());
6494 if (i < metadata.availableLanguages.size() - 1) {
6506void LauncherUI::RenderFileImport()
6508 ImGui::TextWrapped(
"Import a test from a .pbl file on your computer.");
6513 static char filePath[512] =
"";
6514 ImGui::Text(
"Select .pbl file:");
6515 ImGui::PushItemWidth(-100);
6516 ImGui::InputText(
"##FilePath", filePath,
sizeof(filePath));
6517 ImGui::PopItemWidth();
6520 if (ImGui::Button(
"Browse...")) {
6521 std::string selected = OpenFileDialog(
"Select PEBL Test",
"*.pbl");
6522 if (!selected.empty()) {
6523 std::strncpy(filePath, selected.c_str(),
sizeof(filePath) - 1);
6531 if (strlen(filePath) > 0 && fs::exists(filePath)) {
6532 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"File found: %s", filePath);
6536 if (ImGui::Button(
"Add to Study", ImVec2(200, 40))) {
6537 AddTestFromFile(filePath);
6540 }
else if (strlen(filePath) > 0) {
6541 ImGui::TextColored(ImVec4(0.8f, 0.2f, 0.2f, 1.0f),
"File not found");
6545void LauncherUI::RenderNewTestTemplate()
6547 ImGui::TextWrapped(
"Create a new test from a template.");
6553 ImGui::TextColored(ImVec4(0.4f, 0.8f, 0.4f, 1.0f),
"Complete Study Template:");
6554 ImGui::TextWrapped(
"Creates a complete test with params/, translations/, and example parameter files.");
6557 static char genericTestName[128] =
"";
6558 ImGui::Text(
"Test Name:");
6559 ImGui::InputText(
"##GenericTestName", genericTestName,
sizeof(genericTestName));
6561 if (strlen(genericTestName) > 0) {
6562 if (ImGui::Button(
"Create from Generic Study Template", ImVec2(300, 40))) {
6563 CreateTestFromGenericStudy(genericTestName);
6564 genericTestName[0] =
'\0';
6573 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Simple Test Templates:");
6574 ImGui::TextWrapped(
"Creates a single .pbl file from a template.");
6577 if (mTemplateNames.empty()) {
6578 ImGui::TextColored(ImVec4(0.8f, 0.4f, 0.2f, 1.0f),
6579 "No templates found. Check media/templates/ directory.");
6583 static int selectedTemplate = 0;
6586 std::vector<const char*> templateCStrings;
6587 for (
const auto& name : mTemplateNames) {
6588 templateCStrings.push_back(name.c_str());
6591 ImGui::Text(
"Template:");
6592 ImGui::Combo(
"##Template", &selectedTemplate, templateCStrings.data(), (
int)templateCStrings.size());
6596 static char testName[128] =
"";
6597 ImGui::Text(
"Test Name:");
6598 ImGui::InputText(
"##TestName", testName,
sizeof(testName));
6604 if (strlen(testName) > 0) {
6605 if (ImGui::Button(
"Create Test", ImVec2(200, 40))) {
6606 CreateTestFromTemplate(testName, selectedTemplate);
6612void LauncherUI::RenderChainsTab()
6615 if (!mCurrentStudy) {
6616 ImGui::TextWrapped(
"No study loaded. Chains are associated with studies.");
6623void LauncherUI::RenderRunTab()
6625 if (!mCurrentStudy) {
6626 ImGui::TextWrapped(
"No study loaded. Load or create a study to run tests.");
6633 if (mCurrentStudy && mCurrentChain) {
6635 if (mStudyCode[0] ==
'\0') {
6636 std::string studyCode = mCurrentStudy->GetStudyCode();
6637 std::strncpy(mStudyCode, studyCode.c_str(),
sizeof(mStudyCode) - 1);
6638 mStudyCode[
sizeof(mStudyCode) - 1] =
'\0';
6642 int counter = mCurrentChain->GetParticipantCounter();
6643 char counterStr[16];
6644 snprintf(counterStr,
sizeof(counterStr),
"%d", counter);
6646 ImGui::Text(
"Participant Code:");
6650 ImGui::PushItemWidth(80);
6651 if (ImGui::InputText(
"##StudyCodePrefix", mStudyCode,
sizeof(mStudyCode))) {
6654 ImGui::PopItemWidth();
6661 ImGui::PushItemWidth(80);
6662 static char counterBuffer[16] =
"";
6663 static bool counterBufferInitialized =
false;
6666 if (!counterBufferInitialized || strcmp(counterBuffer, counterStr) != 0) {
6667 strncpy(counterBuffer, counterStr,
sizeof(counterBuffer) - 1);
6668 counterBuffer[
sizeof(counterBuffer) - 1] =
'\0';
6669 counterBufferInitialized =
true;
6672 if (ImGui::InputText(
"##CounterNumber", counterBuffer,
sizeof(counterBuffer), ImGuiInputTextFlags_CharsDecimal)) {
6674 if (strlen(counterBuffer) > 0) {
6675 int newCounter = atoi(counterBuffer);
6676 if (newCounter < 1) newCounter = 1;
6677 mCurrentChain->SetParticipantCounter(newCounter);
6678 mCurrentChain->Save();
6680 snprintf(counterBuffer,
sizeof(counterBuffer),
"%d", newCounter);
6683 ImGui::PopItemWidth();
6690 std::string participantCode = std::string(mStudyCode) +
"_" + counterBuffer;
6691 ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f),
"%s", participantCode.c_str());
6694 std::strncpy(mParticipantCode, participantCode.c_str(),
sizeof(mParticipantCode) - 1);
6695 mParticipantCode[
sizeof(mParticipantCode) - 1] =
'\0';
6696 std::strncpy(mSubjectCode, mParticipantCode,
sizeof(mSubjectCode) - 1);
6697 mSubjectCode[
sizeof(mSubjectCode) - 1] =
'\0';
6700 ImGui::Text(
"Participant Code:");
6702 ImGui::PushItemWidth(200);
6703 ImGui::InputText(
"##ParticipantCodeFallback", mSubjectCode,
sizeof(mSubjectCode));
6704 ImGui::PopItemWidth();
6708 if (mCurrentChain && strlen(mSubjectCode) > 0) {
6709 static std::vector<std::string> cachedExistingCodes;
6710 static std::string lastStudyPath;
6711 static std::string lastChainName;
6712 static bool cacheInitialized =
false;
6715 std::string currentStudyPath = mCurrentStudy ? mCurrentStudy->GetPath() :
"";
6716 std::string currentChainName = mCurrentChain ? mCurrentChain->GetName() :
"";
6718 if (!cacheInitialized || lastStudyPath != currentStudyPath || lastChainName != currentChainName) {
6719 cachedExistingCodes = CheckExistingSubjectCodes();
6720 lastStudyPath = currentStudyPath;
6721 lastChainName = currentChainName;
6722 cacheInitialized =
true;
6725 std::string currentCode(mSubjectCode);
6726 bool codeExists =
false;
6727 for (
const auto& code : cachedExistingCodes) {
6728 if (code == currentCode) {
6736 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.0f, 1.0f),
"âš Code already used!");
6737 if (ImGui::IsItemHovered()) {
6738 ImGui::SetTooltip(
"This subject code has already been used in this study.\nData may be overwritten!");
6743 if (!cachedExistingCodes.empty()) {
6745 ImGui::TextDisabled(
"Existing codes:");
6747 std::string codesList;
6748 for (
size_t i = 0; i < cachedExistingCodes.size() && i < 10; i++) {
6749 if (i > 0) codesList +=
", ";
6750 codesList += cachedExistingCodes[i];
6752 if (cachedExistingCodes.size() > 10) {
6753 codesList +=
"... (" + std::to_string(cachedExistingCodes.size()) +
" total)";
6755 ImGui::TextDisabled(
"%s", codesList.c_str());
6756 ImGui::Unindent(20);
6763 float columnWidth = ImGui::GetContentRegionAvail().x * 0.5f;
6766 ImGui::BeginChild(
"SettingsLeft", ImVec2(columnWidth - 5, 85),
false);
6769 ImGui::Text(
"Language:");
6771 ImGui::PushItemWidth(60);
6772 ImGui::InputText(
"##Language", mLanguageCode,
sizeof(mLanguageCode));
6773 ImGui::PopItemWidth();
6775 ImGui::TextDisabled(
"(en, es, de, fr...)");
6778 ImGui::Checkbox(
"Fullscreen Mode", &mFullscreen);
6781 ImGui::Checkbox(
"Enable VSync", &mVSync);
6782 if (ImGui::IsItemHovered()) {
6783 ImGui::SetTooltip(
"Synchronize with monitor refresh rate");
6791 ImGui::BeginChild(
"SettingsRight", ImVec2(0, 85),
false);
6794 ImGui::Text(
"Resolution:");
6796 ImGui::PushItemWidth(150);
6797 const char* resolutions[] = {
6799 "1920x1080 (Full HD)",
6809 if (ImGui::BeginCombo(
"##Resolution", resolutions[mScreenResolution])) {
6810 for (
int i = 0; i < 10; i++) {
6811 bool is_selected = (mScreenResolution == i);
6812 if (ImGui::Selectable(resolutions[i], is_selected)) {
6813 mScreenResolution = i;
6818 ImGui::PopItemWidth();
6821 if (ImGui::TreeNode(
"Advanced")) {
6822 ImGui::Text(
"Driver:");
6824 ImGui::PushItemWidth(100);
6825 ImGui::InputText(
"##Driver", mGraphicsDriver,
sizeof(mGraphicsDriver));
6826 ImGui::PopItemWidth();
6828 ImGui::Text(
"Args:");
6830 ImGui::PushItemWidth(100);
6831 ImGui::InputText(
"##CustomArgs", mCustomArguments,
sizeof(mCustomArguments));
6832 ImGui::PopItemWidth();
6843 ImGui::Text(
"Select Chain:");
6846 auto chainFiles = mCurrentStudy->GetChainFiles();
6847 if (chainFiles.empty()) {
6848 ImGui::TextDisabled(
"No chains defined. Create a chain in the Chains tab.");
6850 for (
size_t i = 0; i < chainFiles.size(); i++) {
6851 std::string chainName = fs::path(chainFiles[i]).stem().string();
6854 bool isSelected = (mCurrentChain &&
6855 fs::path(mCurrentChain->GetFilePath()).stem().string() == chainName);
6857 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f));
6860 if (ImGui::Button(chainName.c_str(), ImVec2(200, 0))) {
6862 std::string fullChainPath = mCurrentStudy->GetPath() +
"/chains/" + chainFiles[i];
6863 LoadChain(fullChainPath);
6864 printf(
"Loaded chain: %s\n", chainName.c_str());
6868 ImGui::PopStyleColor();
6878 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
6879 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
6880 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
6882 bool canRun = mCurrentChain && !mCurrentChain->GetItems().empty() && !mRunningChain;
6884 ImGui::BeginDisabled();
6887 const char* buttonLabel = mRunningChain ?
"Running..." :
"Run Selected Chain";
6888 if (ImGui::Button(buttonLabel, ImVec2(-1, 50))) {
6893 ImGui::EndDisabled();
6896 ImGui::PopStyleColor(3);
6899void LauncherUI::RenderQuickLaunchTab()
6901 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Quick Launch");
6906 float topLeftWidth = ImGui::GetContentRegionAvail().x * 0.5f;
6909 ImGui::BeginChild(
"InstructionsColumn", ImVec2(topLeftWidth, 100),
false);
6911 ImGui::Text(
"Browse and run .pbl scripts.");
6917 ImGui::Text(
"Directory:");
6918 ImGui::PushItemWidth(-100);
6919 ImGui::InputText(
"##QuickLaunchDir", &mQuickLaunchDirectory[0], 512, ImGuiInputTextFlags_ReadOnly);
6920 ImGui::PopItemWidth();
6922 if (ImGui::Button(
"Browse...", ImVec2(90, 0))) {
6923 std::string dir = OpenDirectoryDialog(
"Select Directory for Quick Launch");
6925 mQuickLaunchDirectory = dir;
6926 mQuickLaunchSelectedFile = -1;
6927 mQuickLaunchPath[0] =
'\0';
6936 ImGui::BeginChild(
"RecentTestsColumn", ImVec2(0, 100),
false);
6939 if (!recent.empty()) {
6940 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Recent Tests:");
6943 ImGui::BeginChild(
"RecentList", ImVec2(0, 0),
true);
6945 for (
size_t i = 0; i < recent.size(); i++) {
6946 const auto& exp = recent[i];
6948 ImGui::PushID(
static_cast<int>(i));
6951 if (ImGui::Selectable(exp.
name.c_str())) {
6953 std::strncpy(mQuickLaunchPath, exp.
path.c_str(),
sizeof(mQuickLaunchPath) - 1);
6954 mQuickLaunchPath[
sizeof(mQuickLaunchPath) - 1] =
'\0';
6957 fs::path filePath(exp.
path);
6958 mQuickLaunchDirectory = filePath.parent_path().string();
6961 if (ImGui::IsItemHovered()) {
6964 struct tm* timeinfo = localtime(&exp.lastRun);
6965 strftime(timeBuf,
sizeof(timeBuf),
"%Y-%m-%d %H:%M:%S", timeinfo);
6966 ImGui::SetTooltip(
"Last run: %s\n%s", timeBuf, exp.
path.c_str());
6974 ImGui::TextDisabled(
"No recent tests");
6984 float leftWidth = ImGui::GetContentRegionAvail().x * 0.5f;
6987 ImGui::BeginChild(
"QuickLaunchFiles", ImVec2(leftWidth, 170),
true);
6988 ImGui::TextColored(ImVec4(0.7f, 0.7f, 1.0f, 1.0f),
"PEBL Scripts");
6992 std::vector<std::string> directories;
6993 std::vector<std::string> pblFiles;
6997 directories.push_back(
"..");
6999 for (
const auto& entry : fs::directory_iterator(mQuickLaunchDirectory)) {
7000 std::string name = entry.path().filename().string();
7002 if (entry.is_directory()) {
7003 directories.push_back(name);
7004 }
else if (entry.is_regular_file() && name.length() > 4 && name.substr(name.length() - 4) ==
".pbl") {
7005 pblFiles.push_back(name);
7009 std::sort(directories.begin(), directories.end());
7010 std::sort(pblFiles.begin(), pblFiles.end());
7011 }
catch (
const fs::filesystem_error&) {
7016 static std::string lastProcessedPath;
7017 if (strlen(mQuickLaunchPath) > 0 && std::string(mQuickLaunchPath) != lastProcessedPath) {
7018 std::string targetFile = mQuickLaunchPath;
7019 lastProcessedPath = targetFile;
7022 targetFile = fs::path(targetFile).filename().string();
7024 for (
int i = 0; i < (int)pblFiles.size(); i++) {
7025 if (pblFiles[i] == targetFile) {
7026 mQuickLaunchSelectedFile = i;
7034 for (
const auto& dir : directories) {
7035 std::string displayName = (dir ==
"..") ?
"[UP] .." :
"[DIR] " + dir;
7036 bool isParentDir = (dir ==
"..");
7038 if (ImGui::Selectable(displayName.c_str(),
false, 0, ImVec2(ImGui::GetContentRegionAvail().x - 60, 0))) {
7042 fs::path currentPath(mQuickLaunchDirectory);
7043 fs::path parentPath = currentPath.parent_path();
7044 if (!parentPath.empty() && parentPath != currentPath) {
7045 mQuickLaunchDirectory = parentPath.string();
7049 mQuickLaunchDirectory = (fs::path(mQuickLaunchDirectory) / dir).
string();
7051 mQuickLaunchSelectedFile = -1;
7052 mQuickLaunchPath[0] =
'\0';
7058 ImGui::PushID(1000 + dirIndex);
7059 if (ImGui::SmallButton(
"Open")) {
7060 std::string fullPath = (fs::path(mQuickLaunchDirectory) / dir).
string();
7061 OpenDirectoryInFileBrowser(fullPath);
7064 if (ImGui::IsItemHovered()) {
7065 ImGui::SetTooltip(
"Open directory in file browser");
7074 for (
const auto& file : pblFiles) {
7075 bool is_selected = (mQuickLaunchSelectedFile == fileIndex);
7078 if (ImGui::Selectable(file.c_str(), is_selected, 0, ImVec2(ImGui::GetContentRegionAvail().x - 60, 0))) {
7080 mQuickLaunchSelectedFile = fileIndex;
7081 std::string fullPath = (fs::path(mQuickLaunchDirectory) / file).
string();
7082 std::strncpy(mQuickLaunchPath, fullPath.c_str(),
sizeof(mQuickLaunchPath) - 1);
7083 mQuickLaunchPath[
sizeof(mQuickLaunchPath) - 1] =
'\0';
7088 ImGui::PushID(fileIndex);
7089 if (ImGui::SmallButton(
"Edit")) {
7090 std::string fullPath = (fs::path(mQuickLaunchDirectory) / file).
string();
7093 std::ifstream fileStream(fullPath);
7094 if (fileStream.is_open()) {
7095 std::stringstream buffer;
7096 buffer << fileStream.rdbuf();
7099 mCodeEditorFilePath = fullPath;
7100 mCodeEditor.
SetText(buffer.str());
7101 mShowCodeEditor =
true;
7103 printf(
"Error: Could not open file for editing: %s\n", fullPath.c_str());
7107 if (ImGui::IsItemHovered()) {
7108 ImGui::SetTooltip(
"Open file in code editor");
7114 if (directories.empty() && pblFiles.empty()) {
7115 ImGui::TextDisabled(
"Empty directory");
7123 ImGui::BeginChild(
"QuickLaunchConfig", ImVec2(0, 170),
true);
7124 ImGui::TextColored(ImVec4(0.7f, 0.7f, 1.0f, 1.0f),
"Configuration");
7128 ImGui::Text(
"Subject Code:");
7129 ImGui::PushItemWidth(-1);
7130 ImGui::InputText(
"##QLSubject", mSubjectCode,
sizeof(mSubjectCode));
7131 ImGui::PopItemWidth();
7135 ImGui::Text(
"Language:");
7136 ImGui::PushItemWidth(-1);
7137 ImGui::InputText(
"##QLLanguage", mLanguageCode,
sizeof(mLanguageCode));
7138 ImGui::PopItemWidth();
7142 ImGui::Text(
"Parameter File (optional):");
7143 ImGui::PushItemWidth(-80);
7144 ImGui::InputText(
"##QLParams", mQuickLaunchParamFile,
sizeof(mQuickLaunchParamFile));
7145 ImGui::PopItemWidth();
7147 if (ImGui::Button(
"...##ParamBrowse", ImVec2(70, 0))) {
7148 std::string file = OpenFileDialog(
"Select Parameter File",
"*.json");
7149 if (!file.empty()) {
7150 std::strncpy(mQuickLaunchParamFile, file.c_str(),
sizeof(mQuickLaunchParamFile) - 1);
7151 mQuickLaunchParamFile[
sizeof(mQuickLaunchParamFile) - 1] =
'\0';
7157 ImGui::Checkbox(
"Fullscreen", &mFullscreen);
7164 bool canRun = (mQuickLaunchPath[0] !=
'\0');
7166 ImGui::BeginDisabled();
7169 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
7170 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
7171 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
7173 if (ImGui::Button(
"Run Script", ImVec2(-1, 50))) {
7175 std::vector<std::string> args;
7176 if (strlen(mQuickLaunchParamFile) > 0) {
7177 args.push_back(
"--pfile");
7178 args.push_back(mQuickLaunchParamFile);
7182 if (mRunningExperiment) {
7183 delete mRunningExperiment;
7188 bool success = mRunningExperiment->
RunExperiment(mQuickLaunchPath, args,
7189 mSubjectCode, mLanguageCode,
7193 std::string scriptPath = mQuickLaunchPath;
7194 std::string scriptName = fs::path(scriptPath).filename().string();
7196 mShowStderr =
false;
7198 printf(
"Failed to run: %s\n", mQuickLaunchPath);
7202 ImGui::PopStyleColor(3);
7205 ImGui::EndDisabled();
7209void LauncherUI::RenderOutputPanel()
7215 const char* toggleLabel = mOutputExpanded ?
"v Output" :
"> Output";
7216 if (ImGui::Button(toggleLabel, ImVec2(100, 0))) {
7217 mOutputExpanded = !mOutputExpanded;
7219 if (ImGui::IsItemHovered()) {
7220 ImGui::SetTooltip(mOutputExpanded ?
"Collapse output panel" :
"Expand output panel");
7223 if (!mOutputExpanded) {
7226 if (mRunningExperiment && mRunningExperiment->
IsRunning()) {
7227 ImGui::TextDisabled(
"(running...)");
7228 }
else if (mRunningExperiment || !mChainAccumulatedStdout.empty() || !mChainAccumulatedStderr.empty()) {
7229 ImGui::TextDisabled(
"(click to expand)");
7236 if (ImGui::RadioButton(
"stdout##bottom", !mShowStderr)) {
7237 mShowStderr =
false;
7240 if (ImGui::RadioButton(
"stderr##bottom", mShowStderr)) {
7245 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 135);
7246 if (ImGui::Button(
"Open in Editor##bottom", ImVec2(130, 0))) {
7248 if (mRunningExperiment) {
7249 if (mRunningChain) {
7250 output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7251 const std::string& currentOutput = mShowStderr ? mRunningExperiment->
GetStderr() :
7253 output += currentOutput;
7255 output = mShowStderr ? mRunningExperiment->
GetStderr() :
7258 }
else if (!mChainAccumulatedStdout.empty() || !mChainAccumulatedStderr.empty()) {
7259 output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7262 if (!output.empty()) {
7264 mCodeEditorFilePath =
"";
7265 mShowCodeEditor =
true;
7270 ImGui::BeginChild(
"BottomOutputPanel", ImVec2(0, 0),
true, ImGuiWindowFlags_HorizontalScrollbar);
7272 if (mRunningExperiment) {
7276 if (mRunningChain) {
7277 output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7278 const std::string& currentOutput = mShowStderr ? mRunningExperiment->
GetStderr() :
7280 output += currentOutput;
7282 output = mShowStderr ? mRunningExperiment->
GetStderr() :
7286 if (!output.empty()) {
7287 ImGui::InputTextMultiline(
"##bottomoutput",
7288 const_cast<char*
>(output.c_str()),
7291 ImGuiInputTextFlags_ReadOnly);
7292 }
else if (mRunningExperiment->
IsRunning()) {
7293 ImGui::TextDisabled(
"Waiting for output...");
7295 ImGui::TextDisabled(
"No output captured");
7297 }
else if (!mChainAccumulatedStdout.empty() || !mChainAccumulatedStderr.empty()) {
7299 const std::string& output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7300 ImGui::InputTextMultiline(
"##bottomoutput",
7301 const_cast<char*
>(output.c_str()),
7304 ImGuiInputTextFlags_ReadOnly);
7306 ImGui::TextDisabled(
"Run a test or chain to see output here");
7312void LauncherUI::ShowNewStudyDialog()
7314 ImGui::OpenPopup(
"New Study");
7316 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7317 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7318 ImGui::SetNextWindowSize(ImVec2(660, 470), ImGuiCond_Always);
7320 if (ImGui::BeginPopupModal(
"New Study", &mShowNewStudyDialog, 0))
7322 ImGui::Text(
"Create a new study");
7326 ImGui::Text(
"Study Name:");
7327 ImGui::PushItemWidth(-1);
7328 if (ImGui::IsWindowAppearing()) {
7329 ImGui::SetKeyboardFocusHere();
7331 ImGui::InputText(
"##StudyName", mNewStudyName,
sizeof(mNewStudyName));
7332 ImGui::PopItemWidth();
7336 ImGui::Text(
"Description:");
7337 ImGui::PushItemWidth(-1);
7338 ImGui::InputTextMultiline(
"##StudyDesc", mNewStudyDescription,
sizeof(mNewStudyDescription),
7340 ImGui::PopItemWidth();
7344 ImGui::Text(
"Author:");
7345 ImGui::PushItemWidth(-1);
7346 ImGui::InputText(
"##StudyAuthor", mNewStudyAuthor,
sizeof(mNewStudyAuthor));
7347 ImGui::PopItemWidth();
7353 if (ImGui::Button(
"Create", ImVec2(120, 0))) {
7354 if (strlen(mNewStudyName) > 0) {
7356 std::string studyPath = mWorkspace->GetStudiesPath() +
"/" + mNewStudyName;
7357 mCurrentStudy =
Study::CreateNew(studyPath, mNewStudyName, mNewStudyAuthor);
7359 if (mCurrentStudy) {
7360 mCurrentStudy->SetDescription(mNewStudyDescription);
7361 mCurrentStudy->Save();
7362 printf(
"Created new study: %s\n", mNewStudyName);
7369 std::string mainChainPath = studyPath +
"/chains/Main.json";
7370 if (fs::exists(mainChainPath)) {
7371 LoadChain(mainChainPath);
7372 printf(
"Auto-loaded Main chain for new study\n");
7377 mNewStudyName[0] =
'\0';
7378 mNewStudyDescription[0] =
'\0';
7379 mNewStudyAuthor[0] =
'\0';
7381 mShowNewStudyDialog =
false;
7382 ImGui::CloseCurrentPopup();
7388 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
7389 mNewStudyName[0] =
'\0';
7390 mNewStudyDescription[0] =
'\0';
7391 mNewStudyAuthor[0] =
'\0';
7392 mShowNewStudyDialog =
false;
7393 ImGui::CloseCurrentPopup();
7400void LauncherUI::ShowNewChainDialog()
7402 ImGui::OpenPopup(
"New Chain");
7404 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7405 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7406 ImGui::SetNextWindowSize(ImVec2(500, 250), ImGuiCond_FirstUseEver);
7408 if (ImGui::BeginPopupModal(
"New Chain", &mShowNewChainDialog, 0))
7410 ImGui::Text(
"Create a new chain");
7414 ImGui::Text(
"Chain Name:");
7415 ImGui::PushItemWidth(-1);
7416 if (ImGui::IsWindowAppearing()) {
7417 ImGui::SetKeyboardFocusHere();
7419 ImGui::InputText(
"##ChainName", mNewChainName,
sizeof(mNewChainName));
7420 ImGui::PopItemWidth();
7424 ImGui::Text(
"Description (optional):");
7425 ImGui::PushItemWidth(-1);
7426 ImGui::InputText(
"##ChainDesc", mNewChainDescription,
sizeof(mNewChainDescription));
7427 ImGui::PopItemWidth();
7433 if (ImGui::Button(
"Create", ImVec2(120, 0))) {
7434 if (strlen(mNewChainName) > 0) {
7436 std::string studyPath = mCurrentStudy->GetPath();
7437 std::string chainPath = studyPath +
"/chains/" + std::string(mNewChainName) +
".json";
7440 mCurrentChain =
Chain::CreateNew(chainPath, mNewChainName, mNewChainDescription);
7442 if (mCurrentChain) {
7443 mCurrentChain->Save();
7444 printf(
"Created new chain: %s\n", mNewChainName);
7447 std::string chainFileName = std::string(mNewChainName) +
".json";
7453 mNewChainName[0] =
'\0';
7454 mNewChainDescription[0] =
'\0';
7456 mShowNewChainDialog =
false;
7457 ImGui::CloseCurrentPopup();
7463 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
7464 mNewChainName[0] =
'\0';
7465 mNewChainDescription[0] =
'\0';
7466 mShowNewChainDialog =
false;
7467 ImGui::CloseCurrentPopup();
7474void LauncherUI::ShowStudySettingsDialog()
7476 if (!mCurrentStudy) {
7477 mShowStudySettingsDialog =
false;
7481 ImGui::OpenPopup(
"Study Settings");
7483 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7484 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7485 ImGui::SetNextWindowSize(ImVec2(800, 600), ImGuiCond_Always);
7487 if (ImGui::BeginPopupModal(
"Study Settings", &mShowStudySettingsDialog, 0))
7489 ImGui::Text(
"Study: %s", mCurrentStudy->GetName().c_str());
7494 static char nameBuffer[256];
7495 static char descBuffer[1024];
7496 static char authorBuffer[256];
7497 static char uploadServerBuffer[512];
7498 static char studyTokenBuffer[256];
7499 static bool initialized =
false;
7502 std::strncpy(nameBuffer, mCurrentStudy->GetName().c_str(),
sizeof(nameBuffer) - 1);
7503 std::strncpy(descBuffer, mCurrentStudy->GetDescription().c_str(),
sizeof(descBuffer) - 1);
7504 std::strncpy(authorBuffer, mCurrentStudy->GetAuthor().c_str(),
sizeof(authorBuffer) - 1);
7505 std::strncpy(uploadServerBuffer, mCurrentStudy->GetUploadServerURL().c_str(),
sizeof(uploadServerBuffer) - 1);
7506 std::strncpy(studyTokenBuffer, mCurrentStudy->GetStudyToken().c_str(),
sizeof(studyTokenBuffer) - 1);
7510 ImGui::Text(
"Name:");
7511 ImGui::PushItemWidth(-1);
7512 if (ImGui::IsWindowAppearing()) {
7513 ImGui::SetKeyboardFocusHere();
7515 ImGui::InputText(
"##Name", nameBuffer,
sizeof(nameBuffer));
7516 ImGui::PopItemWidth();
7520 ImGui::Text(
"Description:");
7521 ImGui::PushItemWidth(-1);
7522 ImGui::InputTextMultiline(
"##Desc", descBuffer,
sizeof(descBuffer), ImVec2(-1, 150));
7523 ImGui::PopItemWidth();
7527 ImGui::Text(
"Author:");
7528 ImGui::PushItemWidth(-1);
7529 ImGui::InputText(
"##Author", authorBuffer,
sizeof(authorBuffer));
7530 ImGui::PopItemWidth();
7533 ImGui::Text(
"Version: %d", mCurrentStudy->GetVersion());
7534 ImGui::Text(
"Created: %s", mCurrentStudy->GetCreatedDate().c_str());
7535 ImGui::Text(
"Modified: %s", mCurrentStudy->GetModifiedDate().c_str());
7542 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Data Upload Configuration");
7544 ImGui::TextWrapped(
"Configure automatic data upload to PEBLOnlinePlatform or compatible server:");
7547 ImGui::Text(
"Upload Server URL:");
7549 if (ImGui::SmallButton(
"?##ServerHelp")) {
7550 ImGui::SetTooltip(
"Server URL (e.g., https://peblhub.online or http://localhost:8080)");
7552 ImGui::PushItemWidth(-1);
7553 ImGui::InputText(
"##UploadServer", uploadServerBuffer,
sizeof(uploadServerBuffer));
7554 ImGui::PopItemWidth();
7558 ImGui::Text(
"Study Token:");
7560 if (ImGui::SmallButton(
"?##TokenHelp")) {
7561 ImGui::SetTooltip(
"Study token from PEBLOnlinePlatform (e.g., STUDY_ABC123...)");
7563 ImGui::PushItemWidth(-1);
7564 ImGui::InputText(
"##StudyToken", studyTokenBuffer,
sizeof(studyTokenBuffer));
7565 ImGui::PopItemWidth();
7570 if (ImGui::Button(
"Load from upload.json...", ImVec2(-1, 0))) {
7571 std::string uploadJsonPath = OpenFileDialog(
"Select upload.json",
"*.json");
7572 if (!uploadJsonPath.empty()) {
7573 std::ifstream file(uploadJsonPath);
7574 if (file.is_open()) {
7575 nlohmann::json uploadConfig;
7577 file >> uploadConfig;
7580 std::string host = uploadConfig.value(
"host",
"");
7581 int port = uploadConfig.value(
"port", 443);
7584 std::string protocol = (port == 443) ?
"https://" :
"http://";
7585 std::string serverUrl = protocol + host;
7586 if ((port != 443 && port != 80) || (protocol ==
"http://" && port != 80)) {
7587 serverUrl +=
":" + std::to_string(port);
7591 std::string token = uploadConfig.value(
"token",
"");
7594 std::strncpy(uploadServerBuffer, serverUrl.c_str(),
sizeof(uploadServerBuffer) - 1);
7595 uploadServerBuffer[
sizeof(uploadServerBuffer) - 1] =
'\0';
7597 std::strncpy(studyTokenBuffer, token.c_str(),
sizeof(studyTokenBuffer) - 1);
7598 studyTokenBuffer[
sizeof(studyTokenBuffer) - 1] =
'\0';
7600 printf(
"Loaded upload configuration from: %s\n", uploadJsonPath.c_str());
7601 }
catch (
const std::exception& e) {
7602 printf(
"Error parsing upload.json: %s\n", e.what());
7606 printf(
"Failed to open upload.json file\n");
7615 if (ImGui::Button(
"Save", ImVec2(120, 0))) {
7616 mCurrentStudy->SetName(nameBuffer);
7617 mCurrentStudy->SetDescription(descBuffer);
7618 mCurrentStudy->SetAuthor(authorBuffer);
7619 mCurrentStudy->SetUploadServerURL(uploadServerBuffer);
7620 mCurrentStudy->SetStudyToken(studyTokenBuffer);
7621 mCurrentStudy->Save();
7623 initialized =
false;
7624 mShowStudySettingsDialog =
false;
7625 ImGui::CloseCurrentPopup();
7630 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
7631 initialized =
false;
7632 mShowStudySettingsDialog =
false;
7633 ImGui::CloseCurrentPopup();
7640void LauncherUI::ShowFirstRunDialog()
7642 ImGui::OpenPopup(
"Welcome to PEBL!");
7644 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7645 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7646 ImGui::SetNextWindowSize(ImVec2(700, 500), ImGuiCond_FirstUseEver);
7648 if (ImGui::BeginPopupModal(
"Welcome to PEBL!",
nullptr, 0))
7650 ImGui::TextWrapped(
"Welcome! This appears to be your first time running PEBL %s.",
PEBL_VERSION);
7655 ImGui::TextWrapped(
"PEBL will create a workspace directory at:");
7657 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
" %s", mWorkspace->GetWorkspacePath().c_str());
7660 ImGui::TextWrapped(
"This workspace will contain:");
7661 ImGui::BulletText(
"my_studies/ - Your study projects");
7662 ImGui::BulletText(
"snapshots/ - Study backups and imports");
7663 ImGui::BulletText(
"doc/ - Documentation");
7664 ImGui::BulletText(
"demo/ - Example experiments");
7665 ImGui::BulletText(
"tutorials/ - Tutorial materials");
7666 ImGui::BulletText(
"logs/ - Launch logs");
7672 ImGui::TextWrapped(
"Resources will be copied from the installation. This may take a minute on first run.");
7679 float buttonWidth = 200.0f;
7680 float windowWidth = ImGui::GetContentRegionAvail().x;
7681 ImGui::SetCursorPosX((windowWidth - buttonWidth) * 0.5f);
7683 if (ImGui::Button(
"Continue", ImVec2(buttonWidth, 40))) {
7685 if (!mWorkspace->Initialize()) {
7686 printf(
"ERROR: Failed to initialize workspace\n");
7689 std::string installPath;
7691 #ifdef ENABLE_BINRELOC
7697 installPath = std::string(prefix);
7699 printf(
"BinReloc found installation at: %s\n", installPath.c_str());
7706 if (installPath.empty()) {
7708 ssize_t len = readlink(
"/proc/self/exe", exePath,
sizeof(exePath) - 1);
7710 exePath[len] =
'\0';
7711 std::string path(exePath);
7712 size_t lastSlash = path.find_last_of(
'/');
7713 if (lastSlash != std::string::npos) {
7714 installPath = path.substr(0, lastSlash);
7716 if (installPath.find(
"/bin") != std::string::npos) {
7717 lastSlash = installPath.find_last_of(
'/');
7718 if (lastSlash != std::string::npos) {
7719 installPath = installPath.substr(0, lastSlash);
7722 printf(
"/proc/self/exe derived installation at: %s\n", installPath.c_str());
7729 if (installPath.empty()) {
7731 if (!batteryPath.empty()) {
7732 size_t lastSlash = batteryPath.find_last_of(
"/\\");
7733 if (lastSlash != std::string::npos) {
7734 installPath = batteryPath.substr(0, lastSlash);
7735 printf(
"Config derived installation at: %s\n", installPath.c_str());
7741 if (!installPath.empty()) {
7742 printf(
"Initializing workspace at: %s\n", mWorkspace->GetWorkspacePath().c_str());
7743 printf(
"Copying resources from: %s\n", installPath.c_str());
7744 mWorkspace->CopyResources(installPath);
7746 printf(
"WARNING: Could not determine installation path - resources not copied\n");
7750 mShowFirstRunDialog =
false;
7751 ImGui::CloseCurrentPopup();
7758void LauncherUI::ShowGettingStartedDialog()
7760 ImGui::OpenPopup(
"Create a Study to Get Started");
7762 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7763 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7764 ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver);
7766 if (ImGui::BeginPopupModal(
"Create a Study to Get Started",
nullptr, ImGuiWindowFlags_NoResize))
7769 ImGui::TextWrapped(
"Welcome! To begin using PEBL, you need to create a study.");
7774 ImGui::TextWrapped(
"A study is a container for:");
7775 ImGui::BulletText(
"Tests from the PEBL battery");
7776 ImGui::BulletText(
"Chains (sequences of tests and instructions)");
7777 ImGui::BulletText(
"Participant data and results");
7783 float buttonWidth = 150.0f;
7784 float spacing = 20.0f;
7785 float totalWidth = buttonWidth * 2 + spacing;
7786 float windowWidth = ImGui::GetContentRegionAvail().x;
7787 ImGui::SetCursorPosX((windowWidth - totalWidth) * 0.5f);
7790 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
7791 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
7792 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
7794 if (ImGui::Button(
"New Study", ImVec2(buttonWidth, 40))) {
7795 mShowGettingStartedDialog =
false;
7796 mShowNewStudyDialog =
true;
7797 ImGui::CloseCurrentPopup();
7800 ImGui::PopStyleColor(3);
7802 ImGui::SameLine(0, spacing);
7805 if (ImGui::Button(
"Browse Tests", ImVec2(buttonWidth, 40))) {
7806 mShowGettingStartedDialog =
false;
7808 ImGui::CloseCurrentPopup();
7811 if (ImGui::IsItemHovered()) {
7812 ImGui::SetTooltip(
"Explore available battery tests before creating a study");
7819void LauncherUI::ShowDuplicateSubjectWarning()
7821 ImGui::OpenPopup(
"Duplicate Subject Code");
7823 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7824 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7825 ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
7827 if (ImGui::BeginPopupModal(
"Duplicate Subject Code", &mShowDuplicateSubjectWarning, 0))
7829 ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f),
"âš Warning: Subject Code Already Used");
7833 ImGui::TextWrapped(
"The subject code '%s' has already been used in this study.", mSubjectCode);
7835 ImGui::TextWrapped(
"Running the chain again with this code may overwrite existing data files!");
7841 ImGui::Text(
"Existing subject codes in this study:");
7842 ImGui::BeginChild(
"ExistingCodes", ImVec2(0, 150),
true);
7843 for (
const auto& code : mDuplicateWarningCodes) {
7844 if (code == std::string(mSubjectCode)) {
7845 ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f),
"• %s (current code)", code.c_str());
7847 ImGui::Text(
"• %s", code.c_str());
7857 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
7858 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
7859 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.6f, 0.1f, 1.0f));
7860 if (ImGui::Button(
"Continue Anyway", ImVec2(150, 0))) {
7861 mShowDuplicateSubjectWarning =
false;
7862 ImGui::CloseCurrentPopup();
7864 RunChainConfirmed();
7866 ImGui::PopStyleColor(3);
7870 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
7871 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f));
7872 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.6f, 0.1f, 0.1f, 1.0f));
7873 if (ImGui::Button(
"Cancel", ImVec2(150, 0))) {
7874 mShowDuplicateSubjectWarning =
false;
7875 ImGui::CloseCurrentPopup();
7877 ImGui::PopStyleColor(3);
7883void LauncherUI::ShowEditParticipantCodeDialog()
7885 ImGui::OpenPopup(
"Edit Participant Code");
7888 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
7889 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
7890 ImGui::SetNextWindowSize(ImVec2(500, 250), ImGuiCond_Appearing);
7892 if (ImGui::BeginPopupModal(
"Edit Participant Code", &mShowEditParticipantCodeDialog, 0))
7894 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Edit Participant Code Components");
7898 ImGui::TextWrapped(
"The participant code is generated from: STUDYCODE_COUNTER");
7899 ImGui::TextWrapped(
"Edit the study code (4 characters) and counter separately below.");
7903 ImGui::Text(
"Study Code (4 chars):");
7905 ImGui::PushItemWidth(100);
7906 if (ImGui::IsWindowAppearing()) {
7907 ImGui::SetKeyboardFocusHere();
7909 ImGui::InputText(
"##StudyCode", mStudyCode,
sizeof(mStudyCode));
7910 ImGui::PopItemWidth();
7915 if (mCurrentChain) {
7916 int counter = mCurrentChain->GetParticipantCounter();
7917 ImGui::Text(
"Counter:");
7919 ImGui::PushItemWidth(100);
7920 if (ImGui::InputInt(
"##Counter", &counter)) {
7921 if (counter < 1) counter = 1;
7922 mCurrentChain->SetParticipantCounter(counter);
7923 mCurrentChain->Save();
7925 ImGui::PopItemWidth();
7930 std::string preview = std::string(mStudyCode) +
"_" + std::to_string(counter);
7931 ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f),
"Preview:");
7933 ImGui::Text(
"%s", preview.c_str());
7941 if (ImGui::Button(
"Done", ImVec2(120, 0))) {
7942 mShowEditParticipantCodeDialog =
false;
7943 ImGui::CloseCurrentPopup();
7950void LauncherUI::ShowCodeEditor()
7952 ImGui::SetNextWindowSize(ImVec2(1200, 800), ImGuiCond_FirstUseEver);
7955 if (ImGui::Begin(
"Code Editor", &open, ImGuiWindowFlags_MenuBar))
7958 if (ImGui::BeginMenuBar())
7960 if (ImGui::BeginMenu(
"File"))
7962 if (ImGui::MenuItem(
"Save",
"Ctrl+S")) {
7964 std::string text = mCodeEditor.
GetText();
7965 std::ofstream outFile(mCodeEditorFilePath);
7966 if (outFile.is_open()) {
7969 printf(
"Saved file: %s\n", mCodeEditorFilePath.c_str());
7971 printf(
"Error: Could not save file: %s\n", mCodeEditorFilePath.c_str());
7975 if (ImGui::MenuItem(
"Open in External Editor")) {
7978 std::string command;
7981 if (editorCmd ==
"start") {
7982 command =
"start \"\" \"" + mCodeEditorFilePath +
"\"";
7984 command = editorCmd +
" \"" + mCodeEditorFilePath +
"\"";
7987 command = editorCmd +
" \"" + mCodeEditorFilePath +
"\" &";
7990 printf(
"Opening in external editor: %s\n", command.c_str());
7991 int result = system(command.c_str());
7993 printf(
"Warning: External editor command may have failed\n");
7999 if (ImGui::MenuItem(
"Close")) {
8006 if (ImGui::BeginMenu(
"Edit"))
8009 if (ImGui::MenuItem(
"Read-only mode",
nullptr, &ro))
8013 if (ImGui::MenuItem(
"Undo",
"Ctrl+Z",
nullptr, !ro && mCodeEditor.
CanUndo()))
8015 if (ImGui::MenuItem(
"Redo",
"Ctrl+Y",
nullptr, !ro && mCodeEditor.
CanRedo()))
8020 if (ImGui::MenuItem(
"Copy",
"Ctrl+C",
nullptr, mCodeEditor.
HasSelection()))
8022 if (ImGui::MenuItem(
"Cut",
"Ctrl+X",
nullptr, !ro && mCodeEditor.
HasSelection()))
8024 if (ImGui::MenuItem(
"Delete",
"Del",
nullptr, !ro && mCodeEditor.
HasSelection()))
8026 if (ImGui::MenuItem(
"Paste",
"Ctrl+V",
nullptr, !ro && ImGui::GetClipboardText() !=
nullptr))
8027 mCodeEditor.
Paste();
8031 if (ImGui::MenuItem(
"Select all",
nullptr,
nullptr))
8037 if (ImGui::BeginMenu(
"View"))
8039 if (ImGui::MenuItem(
"Dark palette"))
8041 if (ImGui::MenuItem(
"Light palette"))
8043 if (ImGui::MenuItem(
"Retro blue palette"))
8047 ImGui::EndMenuBar();
8051 ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f),
"File:");
8053 ImGui::Text(
"%s", mCodeEditorFilePath.c_str());
8055 ImGui::SameLine(ImGui::GetWindowWidth() - 300);
8057 ImGui::Text(
"%d lines | Ln %d, Col %d", mCodeEditor.
GetTotalLines(), cpos.mLine + 1, cpos.mColumn + 1);
8060 mCodeEditor.
Render(
"##TextEditor");
8066 mShowCodeEditor =
false;
8070void LauncherUI::ShowTranslationEditorDialog()
8072 ImGui::OpenPopup(
"Translation Editor");
8074 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
8075 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
8076 ImGui::SetNextWindowSize(ImVec2(1000, 600), ImGuiCond_Appearing);
8078 if (ImGui::BeginPopupModal(
"Translation Editor", &mTranslationEditor.
show, ImGuiWindowFlags_NoScrollbar))
8082 std::string baseName;
8083 std::string translationsDir;
8084 bool isScale = mTranslationEditor.
scaleMode;
8088 if (mTranslationEditor.
scaleCode[0] ==
'\0' || mTranslationEditor.
scaleDir[0] ==
'\0') {
8089 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Error: No scale selected");
8090 if (ImGui::Button(
"Close", ImVec2(120, 0))) {
8091 mTranslationEditor.
show =
false;
8092 mTranslationEditor.
Clear();
8094 ImGui::CloseCurrentPopup();
8099 baseName = mTranslationEditor.
scaleCode;
8100 translationsDir = mTranslationEditor.
scaleDir;
8103 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Scale:");
8105 ImGui::Text(
"%s", mTranslationEditor.
scaleCode);
8108 if (!mCurrentStudy || mTranslationEditor.
testIndex < 0) {
8109 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Error: No test selected");
8110 if (ImGui::Button(
"Close", ImVec2(120, 0))) {
8111 mTranslationEditor.
show =
false;
8112 mTranslationEditor.
Clear();
8113 ImGui::CloseCurrentPopup();
8119 const auto& tests = mCurrentStudy->GetTests();
8120 if (mTranslationEditor.
testIndex >= (
int)tests.size()) {
8121 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Error: Invalid test index");
8122 if (ImGui::Button(
"Close", ImVec2(120, 0))) {
8123 mTranslationEditor.
show =
false;
8124 mTranslationEditor.
Clear();
8125 ImGui::CloseCurrentPopup();
8132 baseName = fs::path(test.
testName).filename().string();
8133 translationsDir = (fs::path(mTranslationEditor.
testPath) /
"translations").
string();
8134 isScale = fs::exists(fs::path(mTranslationEditor.
testPath) /
"definitions");
8137 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Test:");
8139 ImGui::Text(
"%s", test.
testName.c_str());
8144 std::string englishFile;
8146 std::string newPath = (fs::path(translationsDir) / (baseName +
".en.json")).string();
8147 std::string oldPath = (fs::path(translationsDir) / (baseName +
".pbl-en.json")).string();
8148 englishFile = fs::exists(newPath) ? newPath : (fs::exists(oldPath) ? oldPath : newPath);
8150 englishFile = (fs::path(translationsDir) / (baseName +
".pbl-en.json")).string();
8154 std::vector<std::string> availableLanguages;
8155 availableLanguages.push_back(
"en");
8157 if (fs::exists(translationsDir) && fs::is_directory(translationsDir)) {
8158 std::set<std::string> langSet;
8160 std::string scalePrefix = mTranslationEditor.
scaleMode ?
8161 (std::string(mTranslationEditor.
scaleCode) +
".") :
"";
8162 for (
const auto& entry : fs::directory_iterator(translationsDir)) {
8163 if (entry.is_regular_file()) {
8164 std::string filename = entry.path().filename().string();
8166 if (!scalePrefix.empty() && filename.find(scalePrefix) != 0) {
8171 size_t dashPos = filename.rfind(
'-');
8172 size_t dotPos = filename.rfind(
".json");
8173 if (dashPos != std::string::npos && dotPos != std::string::npos && dotPos > dashPos) {
8174 lang = filename.substr(dashPos + 1, dotPos - dashPos - 1);
8175 }
else if (dotPos != std::string::npos) {
8177 size_t lastDot = filename.rfind(
'.', dotPos - 1);
8178 if (lastDot != std::string::npos) {
8179 lang = filename.substr(lastDot + 1, dotPos - lastDot - 1);
8182 if (!lang.empty() && lang !=
"en") {
8183 langSet.insert(lang);
8187 for (
const auto& l : langSet) {
8188 availableLanguages.push_back(l);
8193 ImGui::SameLine(0, 20);
8194 ImGui::Text(
"Language:");
8196 ImGui::PushItemWidth(100);
8198 static char prevLanguage[16] =
"";
8199 if (ImGui::BeginCombo(
"##Language", mTranslationEditor.
language[0] ? mTranslationEditor.
language :
"Select...")) {
8200 for (
const auto& lang : availableLanguages) {
8201 bool isSelected = (std::string(mTranslationEditor.
language) == lang);
8202 if (ImGui::Selectable(lang.c_str(), isSelected)) {
8203 std::strncpy(mTranslationEditor.
language, lang.c_str(),
sizeof(mTranslationEditor.
language) - 1);
8204 mTranslationEditor.
language[
sizeof(mTranslationEditor.
language) - 1] =
'\0';
8209 ImGui::TextDisabled(
"New code (2 chars):");
8210 static char newLang[16] =
"";
8211 ImGui::PushItemWidth(60);
8212 if (ImGui::InputText(
"##NewLang", newLang, 4, ImGuiInputTextFlags_EnterReturnsTrue)) {
8213 if (strlen(newLang) > 0) {
8214 std::strncpy(mTranslationEditor.
language, newLang,
sizeof(mTranslationEditor.
language) - 1);
8215 mTranslationEditor.
language[
sizeof(mTranslationEditor.
language) - 1] =
'\0';
8217 ImGui::CloseCurrentPopup();
8220 ImGui::PopItemWidth();
8223 ImGui::PopItemWidth();
8226 if (strcmp(prevLanguage, mTranslationEditor.
language) != 0) {
8229 std::strncpy(prevLanguage, mTranslationEditor.
language,
sizeof(prevLanguage) - 1);
8230 prevLanguage[
sizeof(prevLanguage) - 1] =
'\0';
8235 mTranslationEditor.
Clear();
8238 if (fs::exists(englishFile)) {
8240 std::ifstream f(englishFile);
8241 nlohmann::json j = nlohmann::json::parse(f);
8242 for (
auto& [key, value] : j.items()) {
8243 mTranslationEditor.
keys.push_back(key);
8244 mTranslationEditor.
englishValues[key] = value.get<std::string>();
8247 }
catch (
const std::exception& e) {
8248 printf(
"Error loading English translation file: %s\n", e.what());
8253 if (std::string(mTranslationEditor.
language) !=
"en") {
8254 std::string targetFile;
8256 std::string newPath = (fs::path(translationsDir) / (baseName +
"." + mTranslationEditor.
language +
".json")).string();
8257 std::string oldPath = (fs::path(translationsDir) / (baseName +
".pbl-" + mTranslationEditor.
language +
".json")).string();
8258 targetFile = fs::exists(newPath) ? newPath : oldPath;
8260 targetFile = (fs::path(translationsDir) / (baseName +
".pbl-" + mTranslationEditor.
language +
".json")).string();
8262 if (fs::exists(targetFile)) {
8264 std::ifstream f(targetFile);
8265 nlohmann::json j = nlohmann::json::parse(f);
8266 for (
auto& [key, value] : j.items()) {
8267 mTranslationEditor.
targetValues[key] = value.get<std::string>();
8270 mTranslationEditor.
keys.push_back(key);
8274 }
catch (
const std::exception& e) {
8275 printf(
"Error loading target translation file: %s\n", e.what());
8284 mTranslationEditor.
dirty =
false;
8285 if (!mTranslationEditor.
keys.empty()) {
8291 if (mTranslationEditor.
dirty) {
8292 ImGui::SameLine(0, 20);
8293 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f),
"(unsaved changes)");
8300 if (!mTranslationEditor.
language[0]) {
8302 ImGui::TextWrapped(
"Select a language to edit translations.");
8303 }
else if (mTranslationEditor.
keys.empty()) {
8305 ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f),
"No English translation file found.");
8306 ImGui::TextWrapped(
"Create translations/");
8307 ImGui::SameLine(0, 0);
8309 ImGui::Text(
"%s.en.json", baseName.c_str());
8311 ImGui::Text(
"%s.pbl-en.json", baseName.c_str());
8313 ImGui::SameLine(0, 0);
8314 ImGui::TextWrapped(
" first.");
8317 float contentHeight = ImGui::GetContentRegionAvail().y - 40;
8320 ImGui::BeginChild(
"KeyList", ImVec2(168, contentHeight),
true);
8321 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
"Keys");
8323 for (
size_t i = 0; i < mTranslationEditor.
keys.size(); i++) {
8324 const std::string& key = mTranslationEditor.
keys[i];
8327 if (ImGui::Selectable(key.c_str(), isSelected)) {
8336 ImGui::BeginChild(
"EditPanel", ImVec2(0, contentHeight),
true);
8340 std::string& englishVal = mTranslationEditor.
englishValues[selectedKey];
8341 std::string& targetVal = mTranslationEditor.
targetValues[selectedKey];
8344 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Key: %s", selectedKey.c_str());
8348 float availHeight = ImGui::GetContentRegionAvail().y;
8349 float boxHeight = (availHeight - 60) / 2;
8352 ImGui::Text(
"Original (reference):");
8354 ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f));
8355 char displayBuf[8192];
8356 std::strncpy(displayBuf, englishVal.c_str(),
sizeof(displayBuf) - 1);
8357 displayBuf[
sizeof(displayBuf) - 1] =
'\0';
8358 ImGui::InputTextMultiline(
"##original_ro", displayBuf,
sizeof(displayBuf), ImVec2(-1, boxHeight),
8359 ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_WordWrap);
8360 ImGui::PopStyleColor();
8365 ImGui::Text(
"%s (editing):", mTranslationEditor.
language);
8367 static char editBuf[8192];
8368 std::strncpy(editBuf, targetVal.c_str(),
sizeof(editBuf) - 1);
8369 editBuf[
sizeof(editBuf) - 1] =
'\0';
8371 if (ImGui::InputTextMultiline(
"##target", editBuf,
sizeof(editBuf), ImVec2(-1, boxHeight), ImGuiInputTextFlags_WordWrap)) {
8372 targetVal = editBuf;
8373 mTranslationEditor.
dirty =
true;
8376 ImGui::TextDisabled(
"Select a key from the list to edit");
8385 bool canSave = mTranslationEditor.
dirty && !mTranslationEditor.
keys.empty();
8387 ImGui::BeginDisabled();
8390 if (ImGui::Button(
"Save", ImVec2(100, 0))) {
8393 fs::create_directories(translationsDir);
8398 for (
const auto& key : mTranslationEditor.keys) {
8402 std::string targetFile;
8403 if (std::string(mTranslationEditor.
language) ==
"en") {
8404 targetFile = englishFile;
8405 }
else if (isScale) {
8406 targetFile = (fs::path(translationsDir) / (baseName +
"." + mTranslationEditor.
language +
".json")).string();
8408 targetFile = (fs::path(translationsDir) / (baseName +
".pbl-" + mTranslationEditor.
language +
".json")).string();
8412 std::ofstream f(targetFile);
8415 mTranslationEditor.
dirty =
false;
8416 printf(
"Saved translations to: %s\n", targetFile.c_str());
8417 }
catch (
const std::exception& e) {
8418 printf(
"Error saving translation file: %s\n", e.what());
8423 ImGui::EndDisabled();
8428 if (ImGui::Button(
"Close", ImVec2(100, 0))) {
8429 if (mTranslationEditor.
dirty) {
8432 mTranslationEditor.
show =
false;
8433 mTranslationEditor.
Clear();
8434 prevLanguage[0] =
'\0';
8435 ImGui::CloseCurrentPopup();
8439 ImGui::SameLine(0, 20);
8440 if (ImGui::Button(
"+ Add Key", ImVec2(100, 0))) {
8441 ImGui::OpenPopup(
"Add Key");
8444 if (ImGui::BeginPopup(
"Add Key")) {
8445 static char newKey[64] =
"";
8446 ImGui::Text(
"Key name:");
8447 if (ImGui::IsWindowAppearing()) {
8448 ImGui::SetKeyboardFocusHere();
8450 if (ImGui::InputText(
"##newkey", newKey,
sizeof(newKey), ImGuiInputTextFlags_EnterReturnsTrue)) {
8451 if (strlen(newKey) > 0) {
8452 std::string key(newKey);
8454 for (
char& c : key) c = toupper(c);
8457 mTranslationEditor.
keys.push_back(key);
8460 mTranslationEditor.
dirty =
true;
8465 ImGui::CloseCurrentPopup();
8475std::vector<std::string> LauncherUI::CheckExistingSubjectCodes()
8477 std::vector<std::string> existingCodes;
8479 if (!mCurrentStudy || !mCurrentChain) {
8480 return existingCodes;
8483 std::string studyPath = mCurrentStudy->GetPath();
8484 const auto& chainItems = mCurrentChain->GetItems();
8487 std::vector<std::string> testsInChain;
8488 for (
const auto& item : chainItems) {
8492 for (
const auto& testName : testsInChain) {
8499 testsInChain.push_back(item.
testName);
8505 for (
const auto& testName : testsInChain) {
8506 std::string dataDir = studyPath +
"/tests/" + testName +
"/data";
8509 if (!fs::exists(dataDir) || !fs::is_directory(dataDir)) {
8513 for (
const auto& entry : fs::directory_iterator(dataDir)) {
8514 if (!entry.is_directory())
continue;
8516 std::string name = entry.path().filename().string();
8520 for (
const auto& code : existingCodes) {
8527 existingCodes.push_back(name);
8530 }
catch (
const fs::filesystem_error&) {
8535 return existingCodes;
8538std::vector<std::string> LauncherUI::BuildAdditionalArguments()
8540 std::vector<std::string> args;
8543 if (mScreenResolution > 0) {
8544 const char* resolutionStrings[] = {
8556 args.push_back(
"--display");
8557 args.push_back(resolutionStrings[mScreenResolution]);
8562 args.push_back(
"--vsyncon");
8566 if (strlen(mGraphicsDriver) > 0) {
8567 args.push_back(
"--driver");
8568 args.push_back(mGraphicsDriver);
8572 if (strlen(mCustomArguments) > 0) {
8573 std::string custom(mCustomArguments);
8574 std::istringstream iss(custom);
8576 while (iss >> arg) {
8577 args.push_back(arg);
8588void LauncherUI::ShowScaleBuilder()
8590 if (!mScaleManager) {
8595 ImGui::BeginChild(
"ScaleLeftPanel", ImVec2(250, 0),
true);
8601 ImGui::BeginChild(
"ScaleRightPanel", ImVec2(0, 0),
true);
8602 if (mCurrentScale) {
8604 if (ImGui::BeginTabBar(
"ScaleEditorTabs"))
8606 if (ImGui::BeginTabItem(
"Scale Info"))
8608 RenderScaleInfoEditor();
8609 ImGui::EndTabItem();
8612 if (ImGui::BeginTabItem(
"Questions"))
8614 RenderQuestionsEditor();
8615 ImGui::EndTabItem();
8618 if (ImGui::BeginTabItem(
"Dimensions & Scoring"))
8620 RenderScoringEditor();
8621 ImGui::EndTabItem();
8624 if (ImGui::BeginTabItem(
"Translations"))
8626 RenderTranslationsEditor();
8627 ImGui::EndTabItem();
8630 if (ImGui::BeginTabItem(
"Sections"))
8632 RenderSectionsTab();
8633 ImGui::EndTabItem();
8636 if (ImGui::BeginTabItem(
"Parameters"))
8638 RenderParametersEditor();
8639 ImGui::EndTabItem();
8645 ImGui::TextWrapped(
"Select a scale from the list or create a new one to begin editing.");
8647 if (ImGui::Button(
"Create New Scale")) {
8649 mScaleTransLanguage[0] =
'\0';
8650 mScaleTransSelectedKey = -1;
8656void LauncherUI::RenderScaleList()
8658 ImGui::Text(
"Available Scales");
8662 float buttonWidth = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2.0f;
8665 if (ImGui::Button(
"New Scale", ImVec2(buttonWidth, 0))) {
8667 mSelectedScaleIndex = -1;
8668 mScaleTransLanguage[0] =
'\0';
8669 mScaleTransSelectedKey = -1;
8671 if (ImGui::IsItemHovered()) {
8672 ImGui::SetTooltip(
"Create a new scale definition (Ctrl+N)");
8678 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.4f, 0.8f, 1.0f));
8679 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.5f, 0.9f, 1.0f));
8680 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.1f, 0.3f, 0.7f, 1.0f));
8682 bool canSaveScale = (mCurrentScale !=
nullptr);
8683 if (!canSaveScale) {
8684 ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f);
8687 if (ImGui::Button(
"Save Scale", ImVec2(buttonWidth, 0))) {
8689 if (mScaleManager->SaveScale(mCurrentScale)) {
8690 printf(
"Scale saved successfully\n");
8691 mScaleList = mScaleManager->GetAvailableScales();
8692 mLooseOSDEntries = mScaleManager->GetLooseOSDEntries();
8694 printf(
"Error: Failed to save scale\n");
8699 if (!canSaveScale) {
8700 ImGui::PopStyleVar();
8702 if (ImGui::IsItemHovered() && !canSaveScale) {
8703 ImGui::SetTooltip(
"Select a scale first");
8704 }
else if (ImGui::IsItemHovered()) {
8705 ImGui::SetTooltip(
"Save the current scale definition (Ctrl+S)");
8708 ImGui::PopStyleColor(3);
8712 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.8f, 0.5f, 0.0f, 1.0f));
8713 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.9f, 0.6f, 0.1f, 1.0f));
8714 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.7f, 0.4f, 0.0f, 1.0f));
8716 bool canTestScale = (mCurrentScale !=
nullptr);
8717 if (!canTestScale) {
8718 ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f);
8721 if (ImGui::Button(
"Test Scale", ImVec2(buttonWidth, 0))) {
8727 if (!canTestScale) {
8728 ImGui::PopStyleVar();
8730 if (ImGui::IsItemHovered() && !canTestScale) {
8731 ImGui::SetTooltip(
"Select a scale first");
8732 }
else if (ImGui::IsItemHovered()) {
8733 ImGui::SetTooltip(
"Preview this scale in ScaleRunner (Ctrl+T)");
8736 ImGui::PopStyleColor(3);
8741 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.6f, 0.0f, 1.0f));
8742 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.7f, 0.0f, 1.0f));
8743 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.0f, 0.5f, 0.0f, 1.0f));
8745 bool canAddToStudy = (mCurrentScale !=
nullptr && mWorkspace !=
nullptr);
8746 if (!canAddToStudy) {
8747 ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f);
8750 if (ImGui::Button(
"Add to Study", ImVec2(buttonWidth, 0))) {
8751 if (canAddToStudy) {
8752 mCreateStudyDialog.
show =
true;
8756 std::strncpy(mCreateStudyDialog.
studyName, mCurrentScale->GetScaleInfo().code.c_str(),
8757 sizeof(mCreateStudyDialog.
studyName) - 1);
8762 mStudyList = mWorkspace->GetStudyDirectories();
8766 if (!canAddToStudy) {
8767 ImGui::PopStyleVar();
8769 if (ImGui::IsItemHovered() && !canAddToStudy) {
8770 ImGui::SetTooltip(
"Select a scale first");
8771 }
else if (ImGui::IsItemHovered()) {
8772 ImGui::SetTooltip(
"Add this scale to a new or existing study");
8775 ImGui::PopStyleColor(3);
8778 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.5f, 0.6f, 1.0f));
8779 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.6f, 0.7f, 1.0f));
8780 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.0f, 0.4f, 0.5f, 1.0f));
8782 if (ImGui::Button(
"Browse OpenScales", ImVec2(-1, 0))) {
8784 std::string scalesDir = mWorkspace->GetWorkspacePath() +
"/scales";
8785 mOpenScalesBrowser.
SetOnDownload([
this](
const std::string& code) {
8788 if (mScaleManager) {
8789 mScaleList = mScaleManager->GetAvailableScales();
8791 printf(
"Downloaded scale %s from OpenScales\n", code.c_str());
8793 mOpenScalesBrowser.
Show(scalesDir);
8796 if (ImGui::IsItemHovered()) {
8797 ImGui::SetTooltip(
"Browse and download scales from the OpenScales repository (openscales.net)");
8800 ImGui::PopStyleColor(3);
8804 std::string testDataDir;
8805 bool hasTestDir =
false;
8806 if (mCurrentScale && mWorkspace) {
8807 testDataDir = mWorkspace->GetWorkspacePath() +
"/temp/scale-test-"
8808 + mCurrentScale->GetScaleInfo().code +
"/data";
8810 std::string tempDir = mWorkspace->GetWorkspacePath() +
"/temp/scale-test-"
8811 + mCurrentScale->GetScaleInfo().code;
8812 hasTestDir = fs::exists(tempDir);
8816 if (ImGui::Button(
"Open Test Data", ImVec2(-1, 0))) {
8817 OpenDirectoryInFileBrowser(testDataDir);
8819 if (ImGui::IsItemHovered()) {
8820 ImGui::SetTooltip(
"Open test output directory: %s", testDataDir.c_str());
8824 ImGui::Dummy(ImVec2(-1, ImGui::GetFrameHeight()));
8831 if (mScaleList.empty()) {
8832 mScaleList = mScaleManager->GetAvailableScales();
8836 if (!mLooseOSDEntriesLoaded) {
8837 mLooseOSDEntries = mScaleManager->GetLooseOSDEntries();
8838 mLooseOSDEntriesLoaded =
true;
8842 ImGui::BeginChild(
"ScaleListScroll", ImVec2(0, 0),
false);
8844 for (
size_t i = 0; i < mScaleList.size(); i++) {
8845 bool isSelected = (i == (size_t)mSelectedScaleIndex);
8846 if (ImGui::Selectable(mScaleList[i].c_str(), isSelected)) {
8847 mSelectedScaleIndex = (int)i;
8849 mCurrentScale = mScaleManager->LoadScale(mScaleList[i]);
8850 mScaleTransLanguage[0] =
'\0';
8851 mScaleTransSelectedKey = -1;
8852 if (mCurrentScale) {
8853 printf(
"Loaded scale: %s\n", mScaleList[i].c_str());
8855 printf(
"Error: Failed to load scale: %s\n", mScaleList[i].c_str());
8861 if (!mLooseOSDEntries.empty()) {
8864 ImGui::TextDisabled(
"Uninstalled OSD files:");
8865 for (
const auto& looseEntry : mLooseOSDEntries) {
8866 std::string label =
"[+] " + looseEntry.name +
" (" + looseEntry.code +
")";
8867 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.8f, 0.4f, 1.0f));
8868 if (ImGui::Selectable(label.c_str(),
false)) {
8869 mInstallOSDDialog.show =
true;
8870 mInstallOSDDialog.entry = looseEntry;
8871 mInstallOSDDialog.errorMessage[0] =
'\0';
8873 ImGui::PopStyleColor();
8874 if (ImGui::IsItemHovered()) {
8875 ImGui::SetTooltip(
"Click to install into scales/%s/", looseEntry.code.c_str());
8883 if (mInstallOSDDialog.show) {
8884 ImGui::OpenPopup(
"Install OSD Scale");
8885 mInstallOSDDialog.show =
false;
8887 if (ImGui::BeginPopupModal(
"Install OSD Scale",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
8888 ImGui::TextWrapped(
"Install \"%s\"?", mInstallOSDDialog.entry.name.c_str());
8890 ImGui::TextDisabled(
"Code: %s", mInstallOSDDialog.entry.code.c_str());
8891 ImGui::TextWrapped(
"The file will be moved to:");
8892 ImGui::TextDisabled(
" scales/%s/%s.osd", mInstallOSDDialog.entry.code.c_str(),
8893 mInstallOSDDialog.entry.code.c_str());
8895 if (mInstallOSDDialog.errorMessage[0] !=
'\0') {
8896 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"%s", mInstallOSDDialog.errorMessage);
8899 if (ImGui::Button(
"Install", ImVec2(120, 0))) {
8900 auto scale = mScaleManager->InstallLooseOSD(mInstallOSDDialog.entry.path);
8903 mScaleList = mScaleManager->GetAvailableScales();
8904 mLooseOSDEntries = mScaleManager->GetLooseOSDEntries();
8905 std::string installedCode = scale->GetScaleInfo().code;
8906 mCurrentScale = scale;
8907 mScaleTransLanguage[0] =
'\0';
8908 mScaleTransSelectedKey = -1;
8909 mSelectedScaleIndex = -1;
8910 for (
size_t i = 0; i < mScaleList.size(); i++) {
8911 if (mScaleList[i] == installedCode) {
8912 mSelectedScaleIndex = (int)i;
8916 ImGui::CloseCurrentPopup();
8918 std::snprintf(mInstallOSDDialog.errorMessage,
8919 sizeof(mInstallOSDDialog.errorMessage),
8920 "Installation failed. Check that the file is a valid .osd bundle.");
8924 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
8925 ImGui::CloseCurrentPopup();
8931void LauncherUI::RenderScaleInfoEditor()
8933 if (!mCurrentScale)
return;
8935 ImGui::Text(
"Basic Information");
8939 auto& info = mCurrentScale->GetScaleInfo();
8943 std::strncpy(name, info.
name.c_str(),
sizeof(name) - 1);
8944 name[
sizeof(name) - 1] =
'\0';
8945 ImGui::TextUnformatted(
"Name:");
8946 ImGui::SetNextItemWidth(-1);
8947 if (ImGui::InputText(
"##Name", name,
sizeof(name))) {
8949 mCurrentScale->SetDirty(
true);
8954 std::strncpy(code, info.code.c_str(),
sizeof(code) - 1);
8955 code[
sizeof(code) - 1] =
'\0';
8956 ImGui::TextUnformatted(
"Code:");
8957 ImGui::SetNextItemWidth(-1);
8958 if (ImGui::InputText(
"##Code", code,
sizeof(code))) {
8960 mCurrentScale->SetDirty(
true);
8962 if (ImGui::IsItemHovered()) {
8963 ImGui::SetTooltip(
"Short identifier (e.g., 'grit', 'crt')");
8968 std::strncpy(abbrev, info.abbreviation.c_str(),
sizeof(abbrev) - 1);
8969 abbrev[
sizeof(abbrev) - 1] =
'\0';
8970 ImGui::TextUnformatted(
"Abbreviation:");
8971 ImGui::SetNextItemWidth(-1);
8972 if (ImGui::InputText(
"##Abbreviation", abbrev,
sizeof(abbrev))) {
8973 info.abbreviation = abbrev;
8974 mCurrentScale->SetDirty(
true);
8979 std::strncpy(desc, info.
description.c_str(),
sizeof(desc) - 1);
8980 desc[
sizeof(desc) - 1] =
'\0';
8981 ImGui::TextUnformatted(
"Description:");
8982 if (ImGui::InputTextMultiline(
"##Description", desc,
sizeof(desc), ImVec2(-1, 80),
8983 ImGuiInputTextFlags_WordWrap)) {
8985 mCurrentScale->SetDirty(
true);
8990 ImGui::Text(
"Publication Info");
8994 char citation[1024];
8995 std::strncpy(citation, info.citation.c_str(),
sizeof(citation) - 1);
8996 citation[
sizeof(citation) - 1] =
'\0';
8997 ImGui::TextUnformatted(
"Citation:");
8998 if (ImGui::InputTextMultiline(
"##Citation", citation,
sizeof(citation), ImVec2(-1, 100),
8999 ImGuiInputTextFlags_WordWrap)) {
9000 info.citation = citation;
9001 mCurrentScale->SetDirty(
true);
9006 std::strncpy(license, info.license.c_str(),
sizeof(license) - 1);
9007 license[
sizeof(license) - 1] =
'\0';
9008 ImGui::TextUnformatted(
"License:");
9009 ImGui::SetNextItemWidth(-1);
9010 if (ImGui::InputText(
"##License", license,
sizeof(license))) {
9011 info.license = license;
9012 mCurrentScale->SetDirty(
true);
9014 if (ImGui::IsItemHovered()) {
9015 ImGui::SetTooltip(
"Short label: CC BY 4.0, Public Domain, free to use, etc.");
9020 std::strncpy(licExpl, info.license_explanation.c_str(),
sizeof(licExpl) - 1);
9021 licExpl[
sizeof(licExpl) - 1] =
'\0';
9022 ImGui::TextUnformatted(
"License Details:");
9023 if (ImGui::InputTextMultiline(
"##LicenseExplanation", licExpl,
sizeof(licExpl), ImVec2(-1, 60),
9024 ImGuiInputTextFlags_WordWrap)) {
9025 info.license_explanation = licExpl;
9026 mCurrentScale->SetDirty(
true);
9028 if (ImGui::IsItemHovered()) {
9029 ImGui::SetTooltip(
"Full license terms or permissions grant.\nCapture the substance of the license so the record\nis self-contained even if external URLs go dead.");
9034 std::strncpy(licUrl, info.license_url.c_str(),
sizeof(licUrl) - 1);
9035 licUrl[
sizeof(licUrl) - 1] =
'\0';
9036 ImGui::TextUnformatted(
"License URL:");
9037 ImGui::SetNextItemWidth(-1);
9038 if (ImGui::InputText(
"##LicenseURL", licUrl,
sizeof(licUrl))) {
9039 info.license_url = licUrl;
9040 mCurrentScale->SetDirty(
true);
9042 if (ImGui::IsItemHovered()) {
9043 ImGui::SetTooltip(
"URL documenting the license terms (e.g., CC deed, author's download page).");
9048 std::strncpy(version, info.version.c_str(),
sizeof(version) - 1);
9049 version[
sizeof(version) - 1] =
'\0';
9050 ImGui::TextUnformatted(
"Version:");
9051 ImGui::SetNextItemWidth(-1);
9052 if (ImGui::InputText(
"##Version", version,
sizeof(version))) {
9053 info.version = version;
9054 mCurrentScale->SetDirty(
true);
9059 std::strncpy(url, info.url.c_str(),
sizeof(url) - 1);
9060 url[
sizeof(url) - 1] =
'\0';
9061 ImGui::TextUnformatted(
"URL:");
9062 ImGui::SetNextItemWidth(-1);
9063 if (ImGui::InputText(
"##URL", url,
sizeof(url))) {
9065 mCurrentScale->SetDirty(
true);
9070 std::strncpy(domain, info.domain.c_str(),
sizeof(domain) - 1);
9071 domain[
sizeof(domain) - 1] =
'\0';
9072 ImGui::TextUnformatted(
"Domain:");
9073 ImGui::SetNextItemWidth(-1);
9074 if (ImGui::InputText(
"##Domain", domain,
sizeof(domain))) {
9075 info.domain = domain;
9076 mCurrentScale->SetDirty(
true);
9078 if (ImGui::IsItemHovered()) {
9079 ImGui::SetTooltip(
"Subject domain for classification (e.g., Mood, Substance Use, Personality, Work, Education).");
9084 ImGui::Text(
"Participant Display (English)");
9088 std::string displayTitle = mCurrentScale->GetTranslation(
"en",
"display_title");
9089 if (displayTitle.empty()) {
9090 displayTitle = info.
name;
9092 char dispTitle[256];
9093 std::strncpy(dispTitle, displayTitle.c_str(),
sizeof(dispTitle) - 1);
9094 dispTitle[
sizeof(dispTitle) - 1] =
'\0';
9095 ImGui::TextUnformatted(
"Display Title:");
9096 ImGui::SetNextItemWidth(-1);
9097 if (ImGui::InputText(
"##DisplayTitle", dispTitle,
sizeof(dispTitle))) {
9098 mCurrentScale->AddTranslation(
"en",
"display_title", dispTitle);
9099 mCurrentScale->SetDirty(
true);
9101 if (ImGui::IsItemHovered()) {
9102 ImGui::SetTooltip(
"Title shown to participants (leave blank to use scale name).\nUse this to avoid revealing the scale's purpose.");
9106 std::string instructions = mCurrentScale->GetTranslation(
"en",
"question_head");
9108 std::strncpy(instr, instructions.c_str(),
sizeof(instr) - 1);
9109 instr[
sizeof(instr) - 1] =
'\0';
9110 ImGui::TextUnformatted(
"Instructions:");
9111 if (ImGui::InputTextMultiline(
"##Instructions", instr,
sizeof(instr), ImVec2(-1, 60),
9112 ImGuiInputTextFlags_WordWrap)) {
9113 mCurrentScale->AddTranslation(
"en",
"question_head", instr);
9114 mCurrentScale->SetDirty(
true);
9116 if (ImGui::IsItemHovered()) {
9117 ImGui::SetTooltip(
"Instructions shown before each question");
9121 std::string debrief = mCurrentScale->GetTranslation(
"en",
"debrief");
9123 std::strncpy(debr, debrief.c_str(),
sizeof(debr) - 1);
9124 debr[
sizeof(debr) - 1] =
'\0';
9125 ImGui::TextUnformatted(
"Debrief Message:");
9126 if (ImGui::InputTextMultiline(
"##DebriefMessage", debr,
sizeof(debr), ImVec2(-1, 60),
9127 ImGuiInputTextFlags_WordWrap)) {
9128 mCurrentScale->AddTranslation(
"en",
"debrief", debr);
9129 mCurrentScale->SetDirty(
true);
9131 if (ImGui::IsItemHovered()) {
9132 ImGui::SetTooltip(
"Message shown after completing the scale");
9137 ImGui::Text(
"Likert Scale Defaults");
9140 auto& likert = mCurrentScale->GetLikertOptions();
9143 int points = likert.points;
9144 if (ImGui::InputInt(
"Default Points", &points)) {
9145 if (points >= 2 && points <= 10) {
9146 likert.points = points;
9149 size_t currentLabelCount = likert.labels.size();
9150 if (
static_cast<size_t>(points) > currentLabelCount) {
9151 std::string scaleCode = mCurrentScale->GetScaleInfo().code;
9152 for (
size_t i = currentLabelCount; i < static_cast<size_t>(points); i++) {
9153 int labelNum =
static_cast<int>(i) + 1;
9154 std::string newKey = scaleCode +
"_response_" + std::to_string(labelNum);
9155 likert.labels.push_back(newKey);
9156 mCurrentScale->AddTranslation(
"en", newKey,
"Response " + std::to_string(labelNum));
9160 mCurrentScale->SetDirty(
true);
9163 if (ImGui::IsItemHovered()) {
9164 ImGui::SetTooltip(
"Default number of response options for Likert questions");
9168 int minVal = likert.min;
9169 if (ImGui::InputInt(
"Default Min Value", &minVal)) {
9170 likert.min = minVal;
9171 mCurrentScale->SetDirty(
true);
9173 if (ImGui::IsItemHovered()) {
9174 ImGui::SetTooltip(
"Default minimum value (-1 = auto: binary scales use 0, regular scales use 1)");
9177 int maxVal = likert.max;
9178 if (ImGui::InputInt(
"Default Max Value", &maxVal)) {
9179 likert.max = maxVal;
9180 mCurrentScale->SetDirty(
true);
9182 if (ImGui::IsItemHovered()) {
9183 ImGui::SetTooltip(
"Default maximum value (-1 = auto: points-1 for binary, points for regular)");
9187 ImGui::Text(
"Response Options (available for all questions)");
9188 if (ImGui::IsItemHovered()) {
9189 ImGui::SetTooltip(
"Define response options that questions can use.\nValues are computed from Points, Min, and Max settings above.");
9193 std::vector<std::string>& labelKeys = likert.labels;
9198 std::vector<int> values;
9199 int actualMin = (likert.min == -1) ? ((points == 2) ? 0 : 1) : likert.min;
9200 int actualMax = (likert.max == -1) ? (actualMin + points - 1) : likert.max;
9202 for (
int i = 1; i <= points; i++) {
9206 value = actualMax - (i - 1);
9209 value = actualMin + (i - 1);
9211 values.push_back(value);
9215 if (ImGui::BeginTable(
"ResponseOptionsTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) {
9216 ImGui::TableSetupColumn(
"Value", ImGuiTableColumnFlags_WidthFixed, 60.0f);
9217 ImGui::TableSetupColumn(
"Label Key", ImGuiTableColumnFlags_WidthFixed, 150.0f);
9218 ImGui::TableSetupColumn(
"Label Text", ImGuiTableColumnFlags_WidthStretch);
9219 ImGui::TableSetupColumn(
"Actions", ImGuiTableColumnFlags_WidthFixed, 80.0f);
9220 ImGui::TableHeadersRow();
9223 int removeIndex = -1;
9226 for (
size_t i = 0; i < labelKeys.size() && i < values.size(); i++) {
9227 ImGui::TableNextRow();
9228 ImGui::PushID(
static_cast<int>(i));
9231 ImGui::TableSetColumnIndex(0);
9232 ImGui::Text(
"%d", values[i]);
9235 ImGui::TableSetColumnIndex(1);
9237 std::strncpy(keyBuffer, labelKeys[i].c_str(),
sizeof(keyBuffer) - 1);
9238 keyBuffer[
sizeof(keyBuffer) - 1] =
'\0';
9239 ImGui::SetNextItemWidth(-FLT_MIN);
9240 if (ImGui::InputText(
"##key", keyBuffer,
sizeof(keyBuffer))) {
9242 std::string oldKey = labelKeys[i];
9243 std::string newKey = keyBuffer;
9244 std::string labelText = mCurrentScale->GetTranslation(
"en", oldKey);
9245 labelKeys[i] = newKey;
9246 mCurrentScale->AddTranslation(
"en", newKey, labelText);
9247 mCurrentScale->SetDirty(
true);
9251 ImGui::TableSetColumnIndex(2);
9252 std::string labelText = mCurrentScale->GetTranslation(
"en", labelKeys[i]);
9253 char textBuffer[256];
9254 std::strncpy(textBuffer, labelText.c_str(),
sizeof(textBuffer) - 1);
9255 textBuffer[
sizeof(textBuffer) - 1] =
'\0';
9256 ImGui::SetNextItemWidth(-FLT_MIN);
9257 if (ImGui::InputText(
"##text", textBuffer,
sizeof(textBuffer))) {
9258 mCurrentScale->AddTranslation(
"en", labelKeys[i], textBuffer);
9259 mCurrentScale->SetDirty(
true);
9263 ImGui::TableSetColumnIndex(3);
9264 if (ImGui::SmallButton(
"Remove")) {
9265 removeIndex =
static_cast<int>(i);
9274 if (removeIndex >= 0) {
9275 labelKeys.erase(labelKeys.begin() + removeIndex);
9277 likert.points =
static_cast<int>(labelKeys.size());
9278 mCurrentScale->SetDirty(
true);
9283 if (ImGui::Button(
"Add Response Option")) {
9285 std::string scaleCode = mCurrentScale->GetScaleInfo().code;
9286 int nextNum =
static_cast<int>(labelKeys.size()) + 1;
9287 std::string newKey = scaleCode +
"_response_" + std::to_string(nextNum);
9288 labelKeys.push_back(newKey);
9289 mCurrentScale->AddTranslation(
"en", newKey,
"Response " + std::to_string(nextNum));
9291 likert.points =
static_cast<int>(labelKeys.size());
9292 mCurrentScale->SetDirty(
true);
9295 if (ImGui::IsItemHovered()) {
9296 ImGui::SetTooltip(
"Add a new response option (will update Points automatically)");
9302 ImGui::Text(
"Required Questions");
9306 int defReq = mCurrentScale->GetDefaultRequired();
9307 const char* defReqItems[] = {
"Per-type defaults",
"All required",
"All optional" };
9308 int defReqIdx = (defReq == -1) ? 0 : (defReq == 1 ? 1 : 2);
9309 if (ImGui::Combo(
"Default Required", &defReqIdx, defReqItems, IM_ARRAYSIZE(defReqItems))) {
9310 int newVal = (defReqIdx == 0) ? -1 : (defReqIdx == 1 ? 1 : 0);
9311 mCurrentScale->SetDefaultRequired(newVal);
9313 if (ImGui::IsItemHovered()) {
9314 ImGui::SetTooltip(
"Scale-level default for whether questions must be answered.\n"
9315 "Per-type defaults: scored types (likert, vas, etc.) are required,\n"
9316 "text entry (short, long) is optional.\n"
9317 "Individual questions can override this in their settings.");
9322void LauncherUI::RenderQuestionsEditor()
9324 if (!mCurrentScale)
return;
9326 ImGui::Text(
"Questions");
9328 if (ImGui::Button(
"Add Question")) {
9329 mQuestionEditor.
show =
true;
9334 const auto& qs = mCurrentScale->GetQuestions();
9335 std::set<std::string> used;
9336 for (
const auto& q : qs) used.insert(q.id);
9338 while (used.count(
"q" + std::to_string(nextNum))) nextNum++;
9339 snprintf(mQuestionEditor.
id,
sizeof(mQuestionEditor.
id),
"q%d", nextNum);
9341 mQuestionEditor.
textKey[0] =
'\0';
9350 mQuestionEditor.
hasGate =
false;
9360 if (ImGui::Button(
"Batch Import...")) {
9361 mBatchImport.
show =
true;
9364 if (mCurrentScale) {
9365 std::strncpy(mBatchImport.
idPrefix, mCurrentScale->GetScaleInfo().code.c_str(),
sizeof(mBatchImport.
idPrefix) - 1);
9367 std::string prefix = mBatchImport.
idPrefix;
9369 for (
const auto& q : mCurrentScale->GetQuestions()) {
9370 if (q.id.size() > prefix.size() && q.id.substr(0, prefix.size()) == prefix) {
9372 int n = std::stoi(q.id.substr(prefix.size()));
9373 if (n > maxNum) maxNum = n;
9381 if (ImGui::Button(
"Add Section")) {
9383 mQuestionEditor.
show =
true;
9388 const auto& qs = mCurrentScale->GetQuestions();
9389 std::set<std::string> used;
9390 for (
const auto& q : qs) used.insert(q.id);
9392 while (used.count(
"sec_" + std::to_string(nextNum))) nextNum++;
9393 std::snprintf(mQuestionEditor.
id,
sizeof(mQuestionEditor.
id),
"sec_%d", nextNum);
9400 auto& questions = mCurrentScale->GetQuestions();
9401 ImGui::Text(
"Total questions: %zu", questions.size());
9405 if (ImGui::BeginTable(
"QuestionsTable", 9, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) {
9406 ImGui::TableSetupColumn(
"##drag", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize, 18);
9407 ImGui::TableSetupColumn(
"ID", ImGuiTableColumnFlags_WidthFixed, 80);
9408 ImGui::TableSetupColumn(
"Type", ImGuiTableColumnFlags_WidthFixed, 70);
9409 ImGui::TableSetupColumn(
"Req", ImGuiTableColumnFlags_WidthFixed, 25);
9410 ImGui::TableSetupColumn(
"Rand", ImGuiTableColumnFlags_WidthFixed, 40);
9411 ImGui::TableSetupColumn(
"Cond", ImGuiTableColumnFlags_WidthFixed, 30);
9412 ImGui::TableSetupColumn(
"Question Text", ImGuiTableColumnFlags_WidthStretch);
9413 ImGui::TableSetupColumn(
"Order", ImGuiTableColumnFlags_WidthFixed, 0);
9414 ImGui::TableSetupColumn(
"Edit", ImGuiTableColumnFlags_WidthFixed, 80);
9415 ImGui::TableHeadersRow();
9421 bool hasLeadingSection = (!questions.empty() && questions[0].type ==
"section");
9422 ImGui::TableNextRow();
9423 ImGui::PushID(-999);
9424 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
9425 ImGui::GetColorU32(ImVec4(0.08f, 0.20f, 0.08f, 0.85f)));
9428 ImGui::TableNextColumn();
9429 ImGui::TextDisabled(
" ");
9432 ImGui::TableNextColumn();
9433 ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f),
"(start)");
9436 ImGui::TableNextColumn();
9437 ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f),
"[section]");
9440 ImGui::TableNextColumn();
9441 ImGui::TableNextColumn();
9442 ImGui::TableNextColumn();
9445 ImGui::TableNextColumn();
9446 if (hasLeadingSection) {
9447 ImGui::TextDisabled(
"(implicit start — first section marker overrides)");
9449 ImGui::TextDisabled(
"Implicit start section (revisable by default)");
9453 ImGui::TableNextColumn();
9456 ImGui::TableNextColumn();
9457 if (!hasLeadingSection) {
9458 if (ImGui::SmallButton(
"Edit##start")) {
9459 auto& e = mQuestionEditor;
9463 e.isVirtualStart =
true;
9464 e.editingIndex = -1;
9465 std::strncpy(e.id,
"sec_start",
sizeof(e.id) - 1);
9466 e.id[
sizeof(e.id) - 1] =
'\0';
9467 e.sectionRevisable =
true;
9469 if (ImGui::IsItemHovered())
9470 ImGui::SetTooltip(
"Add an explicit section marker at the start\n"
9471 "to control Back button behavior, etc.");
9473 ImGui::TextDisabled(
"(see row 1)");
9474 if (ImGui::IsItemHovered())
9475 ImGui::SetTooltip(
"The first item is already a section marker.\n"
9476 "Edit it directly in the row below.");
9482 for (
size_t i = 0; i < questions.size(); i++) {
9483 auto& q = questions[i];
9484 ImGui::TableNextRow();
9485 ImGui::PushID((
int)i);
9488 if (q.type ==
"section") {
9489 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
9490 ImGui::GetColorU32(ImVec4(0.15f, 0.25f, 0.45f, 0.85f)));
9493 ImGui::TableNextColumn();
9494 ImGui::Selectable(
"::##sdh",
false, ImGuiSelectableFlags_AllowOverlap);
9495 if (ImGui::IsItemHovered())
9496 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
9497 if (ImGui::BeginDragDropSource()) {
9498 int dragIdx = (int)i;
9499 ImGui::SetDragDropPayload(
"QUESTION_ROW", &dragIdx,
sizeof(
int));
9500 ImGui::Text(
"Move: %s", q.id.c_str());
9501 ImGui::EndDragDropSource();
9503 if (ImGui::BeginDragDropTarget()) {
9504 if (
const ImGuiPayload* pl = ImGui::AcceptDragDropPayload(
"QUESTION_ROW")) {
9505 int srcIdx = *(
const int*)pl->Data;
9506 if (srcIdx != (
int)i) {
9507 mCurrentScale->MoveQuestion(srcIdx, (
int)i);
9508 mCurrentScale->SetDirty(
true);
9511 ImGui::EndDragDropTarget();
9515 ImGui::TableNextColumn();
9516 ImGui::TextUnformatted(q.id.c_str());
9519 ImGui::TableNextColumn();
9520 ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 1.0f),
"[section]");
9523 ImGui::TableNextColumn();
9526 ImGui::TableNextColumn();
9529 ImGui::TableNextColumn();
9530 if (q.has_visible_when) {
9531 ImGui::TextUnformatted(
"S");
9532 if (ImGui::IsItemHovered())
9533 ImGui::SetTooltip(
"Section has a visible_when condition");
9537 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.2f, 1.0f),
"NR");
9538 if (ImGui::IsItemHovered())
9539 ImGui::SetTooltip(
"Not revisable — Back button disabled in this section");
9543 ImGui::TableNextColumn();
9544 if (!q.text_key.empty() && mCurrentScale) {
9545 std::string title = mCurrentScale->GetTranslation(
"en", q.text_key);
9547 ImGui::TextUnformatted(title.c_str());
9551 ImGui::TableNextColumn();
9553 bool canMoveUp = (i > 0);
9554 bool canMoveDown = (i < questions.size() - 1);
9555 if (!canMoveUp) ImGui::BeginDisabled();
9556 if (ImGui::SmallButton(
"^##sup")) {
9557 mCurrentScale->MoveQuestion((
int)i, (
int)i - 1);
9558 mCurrentScale->SetDirty(
true);
9560 if (!canMoveUp) ImGui::EndDisabled();
9562 if (!canMoveDown) ImGui::BeginDisabled();
9563 if (ImGui::SmallButton(
"v##sdn")) {
9564 mCurrentScale->MoveQuestion((
int)i, (
int)i + 1);
9565 mCurrentScale->SetDirty(
true);
9567 if (!canMoveDown) ImGui::EndDisabled();
9571 ImGui::TableNextColumn();
9572 if (ImGui::SmallButton(
"Edit##s")) {
9573 auto& e = mQuestionEditor;
9576 e.editingIndex = (int)i;
9578 std::strncpy(e.id, q.id.c_str(),
sizeof(e.id) - 1);
9579 e.id[
sizeof(e.id) - 1] =
'\0';
9580 if (!q.text_key.empty() && mCurrentScale) {
9581 std::string titleText = mCurrentScale->GetTranslation(
"en", q.text_key);
9582 std::strncpy(e.questionText, titleText.c_str(),
sizeof(e.questionText) - 1);
9583 e.questionText[
sizeof(e.questionText) - 1] =
'\0';
9585 e.hasVisibleWhen = q.has_visible_when;
9586 e.visibleWhenLogic = (q.visible_when_logic ==
"any") ? 1 : 0;
9587 e.visibleWhenIsComplex = q.visible_when_is_complex;
9588 e.visibleWhenConditions.clear();
9589 for (
const auto& c : q.visible_when_simple) {
9591 ec.
sourceType = (c.source_type ==
"item") ? 1 : 0;
9594 if (c.op ==
"not_equals") ec.
op = 1;
9595 else if (c.op ==
"greater_than") ec.
op = 2;
9596 else if (c.op ==
"less_than") ec.
op = 3;
9597 else if (c.op ==
"in") ec.
op = 4;
9598 else if (c.op ==
"not_in") ec.
op = 5;
9599 else if (c.op ==
"is_answered") ec.
op = 6;
9600 else if (c.op ==
"is_not_answered") ec.
op = 7;
9604 for (
size_t vi = 0; vi < c.values.size(); vi++) {
9605 if (vi) joined +=
",";
9606 joined += c.values[vi];
9608 std::strncpy(ec.
value, joined.c_str(),
sizeof(ec.
value) - 1);
9610 std::strncpy(ec.
value, c.value.c_str(),
sizeof(ec.
value) - 1);
9613 e.visibleWhenConditions.push_back(ec);
9615 e.sectionRevisable = q.revisable;
9616 e.sectionRandomize = q.section_randomize;
9617 std::string fixedStr;
9618 for (
size_t fi = 0; fi < q.section_randomize_fixed.size(); fi++) {
9619 if (fi) fixedStr +=
",";
9620 fixedStr += q.section_randomize_fixed[fi];
9622 std::strncpy(e.sectionRandomizeFixed, fixedStr.c_str(),
sizeof(e.sectionRandomizeFixed) - 1);
9623 e.sectionRandomizeFixed[
sizeof(e.sectionRandomizeFixed) - 1] =
'\0';
9626 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 1.0f));
9627 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
9628 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.9f, 0.1f, 0.1f, 1.0f));
9629 if (ImGui::SmallButton(
"Del##s")) {
9630 mDeleteConfirmIndex = (int)i;
9631 ImGui::OpenPopup(
"Confirm Delete");
9633 ImGui::PopStyleColor(3);
9640 ImGui::TableNextColumn();
9641 ImGui::Selectable(
"::##qdh",
false, ImGuiSelectableFlags_AllowOverlap);
9642 if (ImGui::IsItemHovered())
9643 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
9644 if (ImGui::BeginDragDropSource()) {
9645 int dragIdx = (int)i;
9646 ImGui::SetDragDropPayload(
"QUESTION_ROW", &dragIdx,
sizeof(
int));
9647 ImGui::Text(
"Move: %s", q.id.c_str());
9648 ImGui::EndDragDropSource();
9650 if (ImGui::BeginDragDropTarget()) {
9651 if (
const ImGuiPayload* pl = ImGui::AcceptDragDropPayload(
"QUESTION_ROW")) {
9652 int srcIdx = *(
const int*)pl->Data;
9653 if (srcIdx != (
int)i) {
9654 mCurrentScale->MoveQuestion(srcIdx, (
int)i);
9655 mCurrentScale->SetDirty(
true);
9658 ImGui::EndDragDropTarget();
9662 ImGui::TableNextColumn();
9663 ImGui::Text(
"%s", q.id.c_str());
9666 ImGui::TableNextColumn();
9667 ImGui::Text(
"%s", q.type.c_str());
9670 ImGui::TableNextColumn();
9672 bool isDisplayOnly = (q.type ==
"inst" || q.type ==
"image");
9673 if (!isDisplayOnly) {
9676 std::string tooltipText;
9677 if (q.required_state == 1) {
9679 tooltipText =
"Required (explicit)\nClick to toggle";
9680 }
else if (q.required_state == 0) {
9682 tooltipText =
"Optional (explicit)\nClick to toggle";
9685 int scaleDef = mCurrentScale->GetDefaultRequired();
9686 bool effectiveRequired;
9687 if (scaleDef == 1) {
9688 effectiveRequired =
true;
9689 tooltipText =
"Required (scale default)\nClick to toggle";
9690 }
else if (scaleDef == 0) {
9691 effectiveRequired =
false;
9692 tooltipText =
"Optional (scale default)\nClick to toggle";
9694 effectiveRequired = (q.type !=
"short" && q.type !=
"long");
9695 tooltipText = effectiveRequired ?
"Required (type default)\nClick to toggle" :
"Optional (type default)\nClick to toggle";
9697 symbol = effectiveRequired ?
"(+)" :
"(-)";
9699 ImGui::PushID(
static_cast<int>(i * 100 + 99));
9700 float colWidth = ImGui::GetColumnWidth();
9701 float textWidth = ImGui::CalcTextSize(symbol.c_str()).x;
9702 float indent = (colWidth - textWidth) * 0.5f;
9703 if (indent > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent);
9704 if (ImGui::Selectable(symbol.c_str(),
false, 0, ImVec2(colWidth, 0))) {
9706 if (q.required_state == -1) {
9707 q.required_state = 1;
9708 }
else if (q.required_state == 1) {
9709 q.required_state = 0;
9711 q.required_state = -1;
9713 mCurrentScale->SetDirty(
true);
9716 if (ImGui::IsItemHovered()) {
9717 ImGui::SetTooltip(
"%s", tooltipText.c_str());
9723 ImGui::TableNextColumn();
9724 ImGui::PushItemWidth(40);
9725 int rg = q.random_group;
9726 if (ImGui::InputInt(
"##rg", &rg, 0, 0)) {
9728 q.random_group = rg;
9729 mCurrentScale->SetDirty(
true);
9731 ImGui::PopItemWidth();
9732 if (ImGui::IsItemHovered()) {
9733 ImGui::SetTooltip(
"Randomization group\n0 = fixed position\n1+ = shuffle within group");
9737 ImGui::TableNextColumn();
9739 std::string condLabel;
9740 std::string condTooltip;
9741 bool hasP =
false, hasQ =
false, hasD =
false;
9742 for (
const auto& c : q.visible_when_simple) {
9743 if (c.source_type ==
"parameter") hasP =
true;
9747 if (!q.dimension.empty()) {
9748 for (
const auto& dim : mCurrentScale->GetDimensions()) {
9749 if (dim.id == q.dimension && dim.has_visible_when) {
9755 if (q.has_visible_when && q.visible_when_is_complex) {
9757 condTooltip =
"Complex nested condition (edit in code)";
9759 if (hasP) condLabel +=
"P";
9760 if (hasQ) condLabel +=
"Q";
9761 if (hasD) condLabel +=
"D";
9763 if (!condLabel.empty()) {
9764 ImGui::TextUnformatted(condLabel.c_str());
9765 if (ImGui::IsItemHovered()) {
9766 if (condTooltip.empty()) {
9768 if (hasP) condTooltip +=
"P = parameter condition\n";
9769 if (hasQ) condTooltip +=
"Q = question condition\n";
9770 if (hasD) condTooltip +=
"D = dimension condition";
9772 ImGui::SetTooltip(
"%s", condTooltip.c_str());
9778 ImGui::TableNextColumn();
9779 std::string questionText = mCurrentScale->GetTranslation(
"en", q.text_key);
9780 if (questionText.empty()) {
9781 questionText =
"[" + q.text_key +
"]";
9785 std::string displayText = questionText;
9786 if (displayText.length() > 60) {
9787 displayText = displayText.substr(0, 57) +
"...";
9790 ImGui::TextWrapped(
"%s", displayText.c_str());
9791 if (ImGui::IsItemHovered() && questionText.length() > 60) {
9792 ImGui::BeginTooltip();
9793 ImGui::PushTextWrapPos(400.0f);
9794 ImGui::TextWrapped(
"%s", questionText.c_str());
9795 ImGui::PopTextWrapPos();
9796 ImGui::EndTooltip();
9800 ImGui::TableNextColumn();
9802 bool canMoveUp = (i > 0);
9803 bool canMoveDown = (i < questions.size() - 1);
9804 if (!canMoveUp) ImGui::BeginDisabled();
9805 if (ImGui::SmallButton(
"^##up")) {
9806 mCurrentScale->MoveQuestion((
int)i, (
int)i - 1);
9807 mCurrentScale->SetDirty(
true);
9809 if (!canMoveUp) ImGui::EndDisabled();
9811 if (!canMoveDown) ImGui::BeginDisabled();
9812 if (ImGui::SmallButton(
"v##dn")) {
9813 mCurrentScale->MoveQuestion((
int)i, (
int)i + 1);
9814 mCurrentScale->SetDirty(
true);
9816 if (!canMoveDown) ImGui::EndDisabled();
9820 ImGui::TableNextColumn();
9821 if (ImGui::SmallButton(
"Edit##q")) {
9822 printf(
"Edit question %s\n", q.id.c_str());
9824 mQuestionEditor.
show =
true;
9827 std::strncpy(mQuestionEditor.
id, q.id.c_str(),
sizeof(mQuestionEditor.
id) - 1);
9828 mQuestionEditor.
id[
sizeof(mQuestionEditor.
id) - 1] =
'\0';
9829 std::strncpy(mQuestionEditor.
textKey, q.text_key.c_str(),
sizeof(mQuestionEditor.
textKey) - 1);
9830 mQuestionEditor.
textKey[
sizeof(mQuestionEditor.
textKey) - 1] =
'\0';
9833 std::string qText = mCurrentScale->GetTranslation(
"en", q.text_key);
9838 const char* questionTypes[] = {
"likert",
"multi",
"short",
"long",
"vas",
"inst",
"multicheck",
"grid",
"image",
"imageresponse" };
9839 for (
int ti = 0; ti < 10; ti++) {
9840 if (q.type == questionTypes[ti]) {
9852 auto loadErr = [&](
const std::string& key,
char* buf,
size_t sz) {
9853 std::string txt = key.empty() ?
"" : (mCurrentScale ? mCurrentScale->GetTranslation(
"en", key) :
"");
9854 strncpy(buf, txt.c_str(), sz - 1); buf[sz - 1] =
'\0';
9856 const auto& val = q.validation;
9857 auto& e = mQuestionEditor;
9858 e.
valMinLengthEnabled = val.min_length >= 0; e.valMinLength = val.min_length >= 0 ? val.min_length : 0;
9859 e.valMaxLengthEnabled = val.max_length >= 0; e.valMaxLength = val.max_length >= 0 ? val.max_length : 0;
9860 e.valMinWordsEnabled = val.min_words >= 0; e.valMinWords = val.min_words >= 0 ? val.min_words : 0;
9861 e.valMaxWordsEnabled = val.max_words >= 0; e.valMaxWords = val.max_words >= 0 ? val.max_words : 0;
9862 e.valNumberMinEnabled = val.number_min_set; e.valNumberMin = val.number_min;
9863 e.valNumberMaxEnabled = val.number_max_set; e.valNumberMax = val.number_max;
9864 e.valPatternEnabled = !val.pattern.empty();
9865 strncpy(e.valPattern, val.pattern.c_str(),
sizeof(e.valPattern) - 1); e.valPattern[
sizeof(e.valPattern)-1] =
'\0';
9866 e.valMinSelectedEnabled = val.min_selected >= 0; e.valMinSelected = val.min_selected >= 0 ? val.min_selected : 0;
9867 e.valMaxSelectedEnabled = val.max_selected >= 0; e.valMaxSelected = val.max_selected >= 0 ? val.max_selected : 0;
9868 loadErr(val.min_length_error, e.valMinLengthError,
sizeof(e.valMinLengthError));
9869 loadErr(val.max_length_error, e.valMaxLengthError,
sizeof(e.valMaxLengthError));
9870 loadErr(val.min_words_error, e.valMinWordsError,
sizeof(e.valMinWordsError));
9871 loadErr(val.max_words_error, e.valMaxWordsError,
sizeof(e.valMaxWordsError));
9872 loadErr(val.number_min_error, e.valNumberMinError,
sizeof(e.valNumberMinError));
9873 loadErr(val.number_max_error, e.valNumberMaxError,
sizeof(e.valNumberMaxError));
9874 loadErr(val.pattern_error, e.valPatternError,
sizeof(e.valPatternError));
9875 loadErr(val.min_selected_error, e.valMinSelectedError,
sizeof(e.valMinSelectedError));
9876 loadErr(val.max_selected_error, e.valMaxSelectedError,
sizeof(e.valMaxSelectedError));
9886 mQuestionEditor.
hasGate = q.has_gate;
9891 const char* opNames[] = {
"greater_than",
"less_than",
"equals",
"not_equals" };
9893 for (
int oi = 0; oi < 4; ++oi) {
9894 if (q.gate_operator == opNames[oi]) { mQuestionEditor.
gateOperator = oi;
break; }
9897 mQuestionEditor.
gateValue = q.gate_value;
9901 std::string msgText = (mCurrentScale && !q.gate_terminate_message_key.empty())
9902 ? mCurrentScale->GetTranslation(
"en", q.gate_terminate_message_key) :
"";
9909 mQuestionEditor.
visibleWhenLogic = (q.visible_when_logic ==
"any") ? 1 : 0;
9912 for (
const auto& c : q.visible_when_simple) {
9914 ec.
sourceType = (c.source_type ==
"item") ? 1 : 0;
9918 if (c.op ==
"not_equals") ec.
op = 1;
9919 else if (c.op ==
"greater_than") ec.
op = 2;
9920 else if (c.op ==
"less_than") ec.
op = 3;
9921 else if (c.op ==
"in") ec.
op = 4;
9922 else if (c.op ==
"not_in") ec.
op = 5;
9923 else if (c.op ==
"is_answered") ec.
op = 6;
9924 else if (c.op ==
"is_not_answered") ec.
op = 7;
9928 for (
size_t vi = 0; vi < c.values.size(); vi++) {
9929 if (vi) joined +=
",";
9930 joined += c.values[vi];
9932 strncpy(ec.
value, joined.c_str(),
sizeof(ec.
value) - 1);
9934 strncpy(ec.
value, c.value.c_str(),
sizeof(ec.
value) - 1);
9942 mQuestionEditor.
likertMin = q.likert_min;
9943 mQuestionEditor.
likertMax = q.likert_max;
9956 for (
const auto& a : q.vas_anchors) {
9958 ae.
value = (float)a.value;
9959 std::strncpy(ae.label, a.label.c_str(),
sizeof(ae.label) - 1);
9960 ae.label[
sizeof(ae.label) - 1] =
'\0';
9965 std::string optionsText;
9966 for (
size_t oi = 0; oi < q.options.size(); oi++) {
9967 if (oi > 0) optionsText +=
"\n";
9968 optionsText += q.options[oi];
9974 std::string columnsText;
9975 for (
size_t ci = 0; ci < q.columns.size(); ci++) {
9976 if (ci > 0) columnsText +=
"\n";
9977 columnsText += q.columns[ci];
9982 std::string rowsText;
9983 for (
size_t ri = 0; ri < q.rows.size(); ri++) {
9984 if (ri > 0) rowsText +=
"\n";
9985 rowsText += q.rows[ri];
9987 std::strncpy(mQuestionEditor.
gridRows, rowsText.c_str(),
sizeof(mQuestionEditor.
gridRows) - 1);
9991 std::strncpy(mQuestionEditor.
imagePath, q.image.c_str(),
sizeof(mQuestionEditor.
imagePath) - 1);
9995 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 1.0f));
9996 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.2f, 0.2f, 1.0f));
9997 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.9f, 0.1f, 0.1f, 1.0f));
9998 if (ImGui::SmallButton(
"Del##q")) {
9999 mDeleteConfirmIndex = (int)i;
10000 ImGui::OpenPopup(
"Confirm Delete");
10002 ImGui::PopStyleColor(3);
10008 ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
10009 if (ImGui::BeginPopupModal(
"Confirm Delete",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
10010 if (mDeleteConfirmIndex >= 0 && mDeleteConfirmIndex < (
int)questions.size()) {
10011 const auto& dq = questions[mDeleteConfirmIndex];
10012 ImGui::Text(
"Delete '%s' (%s)?", dq.id.c_str(), dq.type.c_str());
10014 ImGui::TextDisabled(
"Translation text is kept in the language file.");
10015 ImGui::TextDisabled(
"It can be reused if an item with the same ID is added later.");
10017 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.7f, 0.1f, 0.1f, 1.0f));
10018 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.9f, 0.2f, 0.2f, 1.0f));
10019 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1.0f, 0.1f, 0.1f, 1.0f));
10020 if (ImGui::Button(
"Delete", ImVec2(100, 0))) {
10021 questions.erase(questions.begin() + mDeleteConfirmIndex);
10022 mCurrentScale->SetDirty(
true);
10023 mDeleteConfirmIndex = -1;
10024 ImGui::CloseCurrentPopup();
10026 ImGui::PopStyleColor(3);
10028 if (ImGui::Button(
"Cancel", ImVec2(100, 0))) {
10029 mDeleteConfirmIndex = -1;
10030 ImGui::CloseCurrentPopup();
10033 mDeleteConfirmIndex = -1;
10034 ImGui::CloseCurrentPopup();
10043void LauncherUI::RenderScoringEditor()
10045 if (!mCurrentScale)
return;
10047 auto& dimensions = mCurrentScale->GetDimensions();
10048 auto& scoring = mCurrentScale->GetScoring();
10049 auto& questions = mCurrentScale->GetQuestions();
10051 if (dimensions.empty()) {
10052 ImGui::TextWrapped(
"No dimensions defined yet. Add a dimension to configure scoring.");
10054 if (ImGui::Button(
"Add Dimension")) {
10055 mDimensionEditor.
show =
true;
10057 mDimensionEditor.
id[0] =
'\0';
10058 mDimensionEditor.
name[0] =
'\0';
10072 ImGui::BeginChild(
"DimensionList", ImVec2(200, 0),
true);
10074 if (ImGui::Button(
"Add", ImVec2(-1, 0))) {
10075 mDimensionEditor.
show =
true;
10077 mDimensionEditor.
id[0] =
'\0';
10078 mDimensionEditor.
name[0] =
'\0';
10088 ImGui::Separator();
10091 for (
size_t i = 0; i < dimensions.size(); i++) {
10092 bool isSelected = (i ==
static_cast<size_t>(mSelectedDimensionIndex));
10093 if (ImGui::Selectable(dimensions[i].name.c_str(), isSelected)) {
10094 mSelectedDimensionIndex =
static_cast<int>(i);
10103 ImGui::BeginChild(
"ItemSelection", ImVec2(0, 0),
true);
10105 if (mSelectedDimensionIndex >= 0 && mSelectedDimensionIndex <
static_cast<int>(dimensions.size())) {
10106 const auto& selectedDim = dimensions[mSelectedDimensionIndex];
10109 ImGui::Text(
"%s", selectedDim.name.c_str());
10111 ImGui::TextDisabled(
"(%s)", selectedDim.id.c_str());
10115 if (ImGui::SmallButton(
"Edit##dim")) {
10116 mDimensionEditor.
show =
true;
10117 mDimensionEditor.
editingIndex = mSelectedDimensionIndex;
10118 strncpy(mDimensionEditor.
id, selectedDim.id.c_str(),
sizeof(mDimensionEditor.
id) - 1);
10119 strncpy(mDimensionEditor.
name, selectedDim.name.c_str(),
sizeof(mDimensionEditor.
name) - 1);
10120 strncpy(mDimensionEditor.
abbreviation, selectedDim.abbreviation.c_str(),
sizeof(mDimensionEditor.
abbreviation) - 1);
10121 strncpy(mDimensionEditor.
description, selectedDim.description.c_str(),
sizeof(mDimensionEditor.
description) - 1);
10124 mDimensionEditor.
selectable = selectedDim.selectable;
10126 strncpy(mDimensionEditor.
enabledParam, selectedDim.enabled_param.c_str(),
sizeof(mDimensionEditor.
enabledParam) - 1);
10131 mDimensionEditor.
visibleWhenLogic = (selectedDim.visible_when_logic ==
"any") ? 1 : 0;
10133 for (
const auto& c : selectedDim.visible_when) {
10135 ec.
sourceType = (c.source_type ==
"item") ? 1 : 0;
10138 if (c.op ==
"not_equals") ec.
op = 1;
10139 else if (c.op ==
"greater_than") ec.
op = 2;
10140 else if (c.op ==
"less_than") ec.
op = 3;
10141 else if (c.op ==
"in") ec.
op = 4;
10142 else if (c.op ==
"not_in") ec.
op = 5;
10145 std::string joined;
10146 for (
size_t vi = 0; vi < c.values.size(); vi++) {
10147 if (vi) joined +=
",";
10148 joined += c.values[vi];
10150 strncpy(ec.
value, joined.c_str(),
sizeof(ec.
value) - 1);
10152 strncpy(ec.
value, c.value.c_str(),
sizeof(ec.
value) - 1);
10161 if (ImGui::SmallButton(
"Delete##dim")) {
10163 scoring.erase(selectedDim.id);
10165 mCurrentScale->GetDimensions().erase(mCurrentScale->GetDimensions().begin() + mSelectedDimensionIndex);
10166 mCurrentScale->SetDirty(
true);
10167 if (mSelectedDimensionIndex >=
static_cast<int>(mCurrentScale->GetDimensions().size())) {
10168 mSelectedDimensionIndex =
static_cast<int>(mCurrentScale->GetDimensions().size()) - 1;
10175 if (!selectedDim.abbreviation.empty() || !selectedDim.description.empty()) {
10176 if (!selectedDim.abbreviation.empty()) {
10177 ImGui::TextDisabled(
"Abbrev: %s", selectedDim.abbreviation.c_str());
10178 if (!selectedDim.description.empty()) {
10180 ImGui::TextDisabled(
" | %s", selectedDim.description.c_str());
10183 ImGui::TextDisabled(
"%s", selectedDim.description.c_str());
10187 ImGui::Separator();
10191 if (scoring.find(selectedDim.id) == scoring.end()) {
10193 newScoring.
method =
"mean_coded";
10195 scoring[selectedDim.id] = newScoring;
10198 auto& dimScoring = scoring[selectedDim.id];
10201 const char* methodOptions[] = {
"mean_coded",
"sum_coded",
"weighted_sum",
"weighted_mean",
"sum_correct" };
10202 int methodCount = 5;
10203 int currentMethod = 0;
10204 for (
int i = 0; i < methodCount; i++) {
10205 if (dimScoring.method == methodOptions[i]) {
10211 if (ImGui::Combo(
"Scoring Method", ¤tMethod, methodOptions, methodCount)) {
10212 dimScoring.
method = methodOptions[currentMethod];
10213 mCurrentScale->SetDirty(
true);
10216 bool isSumCorrect = (dimScoring.method ==
"sum_correct");
10217 bool isWeighted = (dimScoring.method ==
"weighted_sum" || dimScoring.method ==
"weighted_mean");
10220 if (isSumCorrect) {
10221 ImGui::Text(
"Select items and set correct answers:");
10223 ImGui::TextDisabled(
"(?)");
10224 if (ImGui::IsItemHovered()) {
10225 ImGui::BeginTooltip();
10226 ImGui::PushTextWrapPos(400.0f);
10227 ImGui::TextWrapped(
"Enter acceptable answers separated by | (pipe).\n"
10228 "Matching is case-insensitive with trimmed whitespace.\n"
10229 "Wildcards: * = any characters, ? = any single character.\n"
10230 "Example: 5|five|.05|0.05|$0.05|5 cents|*5*cent*");
10231 ImGui::PopTextWrapPos();
10232 ImGui::EndTooltip();
10234 }
else if (isWeighted) {
10235 ImGui::Text(
"Select items, set coding and weights:");
10237 ImGui::Text(
"Select items and set coding:");
10243 std::string transformBtn = dimScoring.transform.empty()
10245 : (
"Transform (" + std::to_string(dimScoring.transform.size()) +
")");
10248 std::string normsBtn = dimScoring.norms.empty()
10250 : (
"Norms (" + std::to_string(dimScoring.norms.size()) +
")");
10253 std::string vmapBtn = dimScoring.value_map.empty()
10255 : (
"Value Map (" + std::to_string(dimScoring.value_map.size()) +
")");
10257 float btnWidth1 = ImGui::CalcTextSize(normsBtn.c_str()).x + ImGui::GetStyle().FramePadding.x * 2;
10258 float btnWidth2 = ImGui::CalcTextSize(transformBtn.c_str()).x + ImGui::GetStyle().FramePadding.x * 2;
10259 float btnWidth3 = ImGui::CalcTextSize(vmapBtn.c_str()).x + ImGui::GetStyle().FramePadding.x * 2;
10260 float spacing = ImGui::GetStyle().ItemSpacing.x;
10261 ImGui::SameLine(ImGui::GetContentRegionAvail().x - btnWidth1 - btnWidth2 - btnWidth3 - spacing * 3);
10263 if (ImGui::SmallButton(vmapBtn.c_str())) {
10264 ImGui::OpenPopup(
"ValueMapEditor");
10266 if (ImGui::IsItemHovered()) {
10267 ImGui::SetTooltip(
"Define non-linear response recoding.\n"
10268 "Array maps response values to recoded scores:\n"
10269 " index 0 = value for min response, index 1 = min+1, etc.\n"
10270 "Use \"default\" to apply same map to all items;\n"
10271 "add per-item overrides by item ID.");
10274 if (ImGui::SmallButton(transformBtn.c_str())) {
10275 ImGui::OpenPopup(
"TransformEditor");
10277 if (ImGui::IsItemHovered()) {
10278 ImGui::SetTooltip(
"Add arithmetic transform steps to rescale the raw score.\n"
10279 "Steps are applied in order (e.g., subtract min, divide by range, multiply by 100).");
10282 if (ImGui::SmallButton(normsBtn.c_str())) {
10283 mNormsEditor.
show =
true;
10286 mNormsEditor.
rows.clear();
10287 for (
const auto& t : dimScoring.norms) {
10289 te.
minVal = (float)t.min;
10290 te.maxVal = (float)t.max;
10291 strncpy(te.label, t.label.c_str(),
sizeof(te.label) - 1);
10292 te.label[
sizeof(te.label) - 1] =
'\0';
10293 mNormsEditor.
rows.push_back(te);
10298 if (ImGui::BeginPopup(
"TransformEditor")) {
10299 ImGui::Text(
"Score Transform Steps");
10301 ImGui::TextDisabled(
"(?)");
10302 if (ImGui::IsItemHovered()) {
10303 ImGui::SetTooltip(
"Each step applies an arithmetic operation to the running score.\n"
10304 "Example: to rescale a sum (range 10-50) to 0-100:\n"
10305 " 1. subtract 10\n"
10307 " 3. multiply 100");
10309 ImGui::Separator();
10311 int removeIdx = -1;
10312 const char* ops[] = {
"add",
"subtract",
"multiply",
"divide" };
10313 for (
int ti = 0; ti < (int)dimScoring.transform.size(); ti++) {
10314 auto& step = dimScoring.transform[ti];
10318 ImGui::Text(
"%d.", ti + 1);
10323 for (
int oi = 0; oi < 4; oi++) {
10324 if (step.op == ops[oi]) { opIdx = oi;
break; }
10326 ImGui::PushItemWidth(100);
10327 if (ImGui::Combo(
"##op", &opIdx, ops, 4)) {
10328 step.op = ops[opIdx];
10329 mCurrentScale->SetDirty(
true);
10331 ImGui::PopItemWidth();
10335 float val = (float)step.value;
10336 ImGui::PushItemWidth(100);
10337 if (ImGui::InputFloat(
"##val", &val, 0, 0,
"%.4g")) {
10338 step.value = (double)val;
10339 mCurrentScale->SetDirty(
true);
10341 ImGui::PopItemWidth();
10345 if (ImGui::SmallButton(
"X##rm")) {
10352 if (removeIdx >= 0) {
10353 dimScoring.transform.erase(dimScoring.transform.begin() + removeIdx);
10354 mCurrentScale->SetDirty(
true);
10357 if (ImGui::Button(
"+ Add Step")) {
10359 mCurrentScale->SetDirty(
true);
10362 if (!dimScoring.transform.empty()) {
10364 if (ImGui::Button(
"Clear All")) {
10365 dimScoring.transform.clear();
10366 mCurrentScale->SetDirty(
true);
10374 if (ImGui::BeginPopup(
"ValueMapEditor")) {
10375 ImGui::Text(
"Value Map (Response Recoding)");
10377 ImGui::TextDisabled(
"(?)");
10378 if (ImGui::IsItemHovered()) {
10379 ImGui::SetTooltip(
"Each entry maps raw responses to recoded values.\n"
10380 "Array index 0 = recoded value for the scale minimum response,\n"
10381 "index 1 = min+1, etc.\n"
10382 "\"default\" applies to all items; item-specific entries override it.\n"
10383 "Enter comma-separated values (e.g. \"3,2,1,0,0,0,0\" for a 7-point scale).");
10385 ImGui::Separator();
10387 std::string removeKey;
10388 for (
auto& [vmKey, vmArr] : dimScoring.value_map) {
10389 ImGui::PushID(vmKey.c_str());
10392 ImGui::Text(
"%s:", vmKey.c_str());
10396 std::string arrStr;
10397 for (
size_t ai = 0; ai < vmArr.size(); ai++) {
10398 if (ai) arrStr +=
",";
10399 double v = vmArr[ai];
10400 if (v == (
int)v) arrStr += std::to_string((
int)v);
10401 else arrStr += std::to_string(v);
10404 strncpy(buf, arrStr.c_str(),
sizeof(buf) - 1);
10405 buf[
sizeof(buf) - 1] =
'\0';
10407 ImGui::PushItemWidth(250);
10408 if (ImGui::InputText(
"##vm", buf,
sizeof(buf))) {
10411 std::string s(buf);
10412 std::stringstream ss(s);
10414 while (std::getline(ss, token,
',')) {
10416 vmArr.push_back(std::stod(token));
10418 vmArr.push_back(0.0);
10421 mCurrentScale->SetDirty(
true);
10423 ImGui::PopItemWidth();
10426 if (ImGui::SmallButton(
"X##vmrm")) {
10433 if (!removeKey.empty()) {
10434 dimScoring.value_map.erase(removeKey);
10435 mCurrentScale->SetDirty(
true);
10441 static char newVMKey[128] =
"default";
10442 static char newVMValues[256] =
"";
10443 ImGui::PushItemWidth(100);
10444 ImGui::InputText(
"Key##vmk", newVMKey,
sizeof(newVMKey));
10445 ImGui::PopItemWidth();
10447 ImGui::PushItemWidth(200);
10448 ImGui::InputText(
"Values##vmv", newVMValues,
sizeof(newVMValues));
10449 ImGui::PopItemWidth();
10451 if (ImGui::SmallButton(
"+ Add")) {
10452 std::string key(newVMKey);
10453 if (!key.empty()) {
10454 std::vector<double> vals;
10455 std::string s(newVMValues);
10456 std::stringstream ss(s);
10458 while (std::getline(ss, token,
',')) {
10459 try { vals.push_back(std::stod(token)); }
10460 catch (...) { vals.push_back(0.0); }
10462 if (!vals.empty()) {
10463 dimScoring.value_map[key] = vals;
10464 mCurrentScale->SetDirty(
true);
10469 if (!dimScoring.value_map.empty()) {
10471 if (ImGui::SmallButton(
"Clear All##vm")) {
10472 dimScoring.value_map.clear();
10473 mCurrentScale->SetDirty(
true);
10481 ImGui::Separator();
10484 int numCols = isWeighted ? 5 : 4;
10485 if (ImGui::BeginTable(
"ItemScoringTable", numCols, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable)) {
10486 ImGui::TableSetupColumn(
"Include", ImGuiTableColumnFlags_WidthFixed, 60);
10487 ImGui::TableSetupColumn(
"ID", ImGuiTableColumnFlags_WidthFixed, 120);
10488 if (isSumCorrect) {
10489 ImGui::TableSetupColumn(
"Question Text", ImGuiTableColumnFlags_WidthFixed, 200);
10490 ImGui::TableSetupColumn(
"Correct Answers", ImGuiTableColumnFlags_WidthStretch);
10491 }
else if (isWeighted) {
10492 ImGui::TableSetupColumn(
"Question Text", ImGuiTableColumnFlags_WidthStretch);
10493 ImGui::TableSetupColumn(
"Coding", ImGuiTableColumnFlags_WidthFixed, 80);
10494 ImGui::TableSetupColumn(
"Weight", ImGuiTableColumnFlags_WidthFixed, 80);
10496 ImGui::TableSetupColumn(
"Question Text", ImGuiTableColumnFlags_WidthStretch);
10497 ImGui::TableSetupColumn(
"Coding", ImGuiTableColumnFlags_WidthFixed, 120);
10499 ImGui::TableHeadersRow();
10501 for (
const auto& question : questions) {
10502 ImGui::TableNextRow();
10503 ImGui::PushID(question.id.c_str());
10505 auto itemIt = std::find(dimScoring.items.begin(), dimScoring.items.end(), question.id);
10506 bool isIncluded = (itemIt != dimScoring.items.end());
10509 ImGui::TableSetColumnIndex(0);
10510 if (ImGui::Checkbox(
"##include", &isIncluded)) {
10512 dimScoring.items.push_back(question.id);
10513 if (!isSumCorrect && dimScoring.item_coding.find(question.id) == dimScoring.item_coding.end()) {
10514 dimScoring.item_coding[question.id] = 1;
10516 if (isWeighted && dimScoring.weights.find(question.id) == dimScoring.weights.end()) {
10517 dimScoring.weights[question.id] = 1.0;
10520 dimScoring.items.erase(itemIt);
10521 dimScoring.item_coding.erase(question.id);
10522 dimScoring.correct_answers.erase(question.id);
10524 mCurrentScale->SetDirty(
true);
10528 ImGui::TableSetColumnIndex(1);
10529 ImGui::Text(
"%s", question.id.c_str());
10532 ImGui::TableSetColumnIndex(2);
10533 std::string questionText = mCurrentScale->GetTranslation(
"en", question.text_key);
10534 if (questionText.empty()) {
10535 questionText =
"[" + question.text_key +
"]";
10537 std::string displayText = questionText;
10538 int truncLen = isSumCorrect ? 40 : 60;
10539 if (
static_cast<int>(displayText.length()) > truncLen) {
10540 displayText = displayText.substr(0, truncLen - 3) +
"...";
10542 ImGui::TextWrapped(
"%s", displayText.c_str());
10543 if (ImGui::IsItemHovered() &&
static_cast<int>(questionText.length()) > truncLen) {
10544 ImGui::BeginTooltip();
10545 ImGui::PushTextWrapPos(400.0f);
10546 ImGui::TextWrapped(
"%s", questionText.c_str());
10547 ImGui::PopTextWrapPos();
10548 ImGui::EndTooltip();
10552 ImGui::TableSetColumnIndex(3);
10553 if (isSumCorrect) {
10555 auto caIt = dimScoring.correct_answers.find(question.id);
10556 int answerCount = (caIt != dimScoring.correct_answers.end()) ? (
int)caIt->second.size() : 0;
10558 std::string btnLabel;
10559 if (answerCount == 0) {
10560 btnLabel =
"[click to add]##ca";
10562 btnLabel = std::to_string(answerCount) +
" answer" + (answerCount != 1 ?
"s" :
"") +
"##ca";
10565 if (ImGui::SmallButton(btnLabel.c_str())) {
10566 mCorrectAnswersEditor.
show =
true;
10567 mCorrectAnswersEditor.
questionId = question.id;
10568 mCorrectAnswersEditor.
dimensionId = selectedDim.id;
10571 std::string qText = mCurrentScale->GetTranslation(
"en", question.text_key);
10572 if (qText.empty()) qText =
"[" + question.text_key +
"]";
10575 mCorrectAnswersEditor.
answers.clear();
10577 if (caIt != dimScoring.correct_answers.end()) {
10578 for (
const auto& raw : caIt->second) {
10579 if (raw.size() >= 4 && raw.substr(0, 4) ==
"(?c)") {
10580 mCorrectAnswersEditor.
answers.push_back(raw.substr(4));
10583 mCorrectAnswersEditor.
answers.push_back(raw);
10590 if (ImGui::IsItemHovered() && answerCount > 0) {
10591 ImGui::BeginTooltip();
10592 ImGui::PushTextWrapPos(300.0f);
10593 for (
const auto& ans : caIt->second) {
10594 ImGui::BulletText(
"%s", ans.c_str());
10596 ImGui::PopTextWrapPos();
10597 ImGui::EndTooltip();
10600 ImGui::TextDisabled(
"--");
10604 int currentCoding = 1;
10605 auto codingIt = dimScoring.item_coding.find(question.id);
10606 if (codingIt != dimScoring.item_coding.end()) {
10607 currentCoding = codingIt->second;
10610 const char* codingOptions[] = {
"Normal (1)",
"Reverse (-1)",
"Not Scored (0)" };
10611 int codingIndex = (currentCoding == 1) ? 0 : (currentCoding == -1) ? 1 : 2;
10613 if (ImGui::Combo(
"##coding", &codingIndex, codingOptions, 3)) {
10614 switch (codingIndex) {
10615 case 0: dimScoring.item_coding[question.id] = 1;
break;
10616 case 1: dimScoring.item_coding[question.id] = -1;
break;
10617 case 2: dimScoring.item_coding[question.id] = 0;
break;
10619 mCurrentScale->SetDirty(
true);
10622 ImGui::TextDisabled(
"--");
10628 ImGui::TableSetColumnIndex(4);
10630 auto wIt = dimScoring.weights.find(question.id);
10631 float w = (wIt != dimScoring.weights.end()) ? (
float)wIt->second : 1.0f;
10632 ImGui::SetNextItemWidth(70.0f);
10633 if (ImGui::InputFloat(
"##weight", &w, 0.0f, 0.0f,
"%.3f")) {
10634 dimScoring.weights[question.id] = (double)w;
10635 mCurrentScale->SetDirty(
true);
10638 ImGui::TextDisabled(
"--");
10649 ImGui::Text(
"Items in dimension: %zu", dimScoring.items.size());
10651 if (isWeighted && !dimScoring.items.empty()) {
10652 double weightSum = 0.0;
10653 bool hasZeroOrNeg =
false;
10654 for (
const auto& itemId : dimScoring.items) {
10655 auto wIt = dimScoring.weights.find(itemId);
10656 double w = (wIt != dimScoring.weights.end()) ? wIt->second : 1.0;
10658 if (w <= 0.0) hasZeroOrNeg =
true;
10661 ImGui::Text(
" Weight sum: %.4f", weightSum);
10662 if (dimScoring.method ==
"weighted_mean") {
10664 ImGui::TextDisabled(
"(denominator)");
10666 if (hasZeroOrNeg) {
10667 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f));
10668 ImGui::Text(
"Warning: one or more weights are zero or negative.");
10669 ImGui::PopStyleColor();
10674 ImGui::Text(
"Select a dimension from the list");
10681 ImGui::Separator();
10684 auto& computed = mCurrentScale->GetComputed();
10686 bool showComputed = ImGui::CollapsingHeader(
10687 computed.empty() ?
"Computed Variables" : (
"Computed Variables (" + std::to_string(computed.size()) +
")").c_str(),
10688 ImGuiTreeNodeFlags_DefaultOpen * 0);
10690 if (showComputed) {
10691 ImGui::TextDisabled(
"Derived values from expressions referencing score.*, answer.*, computed.*");
10694 std::string removeKey;
10695 for (
auto& [key, cv] : computed) {
10696 ImGui::PushID(key.c_str());
10699 ImGui::Text(
"%s", key.c_str());
10701 ImGui::TextDisabled(
"(%s)", cv.type.c_str());
10703 if (ImGui::SmallButton(
"X##rm")) {
10709 std::strncpy(expr, cv.expression.c_str(),
sizeof(expr) - 1);
10710 expr[
sizeof(expr) - 1] =
'\0';
10711 ImGui::SetNextItemWidth(-1);
10712 if (ImGui::InputText(
"##expr", expr,
sizeof(expr))) {
10713 cv.expression = expr;
10714 mCurrentScale->SetDirty(
true);
10716 if (ImGui::IsItemHovered()) {
10717 ImGui::SetTooltip(
"Expression using score.*, answer.*, computed.* references.\n"
10719 " score.PHQ_total >= 10\n"
10720 " answer.weight / (answer.height * answer.height)\n"
10721 " computed.met_vigorous + computed.met_moderate");
10728 if (!removeKey.empty()) {
10729 computed.erase(removeKey);
10730 mCurrentScale->SetDirty(
true);
10734 static char newComputedName[128] =
"";
10735 static int newComputedType = 0;
10736 ImGui::PushItemWidth(150);
10737 ImGui::InputText(
"##newCVName", newComputedName,
sizeof(newComputedName));
10738 ImGui::PopItemWidth();
10740 const char* cvTypes[] = {
"number",
"boolean" };
10741 ImGui::PushItemWidth(80);
10742 ImGui::Combo(
"##newCVType", &newComputedType, cvTypes, 2);
10743 ImGui::PopItemWidth();
10745 if (ImGui::Button(
"+ Add##cv")) {
10746 if (strlen(newComputedName) > 0 && computed.find(newComputedName) == computed.end()) {
10748 cv.
type = cvTypes[newComputedType];
10749 computed[newComputedName] = cv;
10750 mCurrentScale->SetDirty(
true);
10751 newComputedName[0] =
'\0';
10757void LauncherUI::RenderTranslationsEditor()
10759 if (!mCurrentScale)
return;
10761 auto& translations = mCurrentScale->GetTranslations();
10764 std::vector<std::string> availableLanguages;
10765 availableLanguages.push_back(
"en");
10766 for (
const auto& [lang, _] : translations) {
10767 if (lang !=
"en") {
10768 availableLanguages.push_back(lang);
10773 if (translations.find(
"en") == translations.end()) {
10774 translations[
"en"] = {};
10778 std::vector<std::string> allKeys;
10779 for (
const auto& [key, _] : translations[
"en"]) {
10780 allKeys.push_back(key);
10783 std::sort(allKeys.begin(), allKeys.end());
10786 ImGui::Text(
"Language:");
10788 ImGui::PushItemWidth(100);
10790 if (ImGui::BeginCombo(
"##ScaleTransLang", mScaleTransLanguage[0] ? mScaleTransLanguage :
"Select...")) {
10791 for (
const auto& lang : availableLanguages) {
10792 bool isSelected = (std::string(mScaleTransLanguage) == lang);
10793 if (ImGui::Selectable(lang.c_str(), isSelected)) {
10794 std::strncpy(mScaleTransLanguage, lang.c_str(),
sizeof(mScaleTransLanguage) - 1);
10795 mScaleTransLanguage[
sizeof(mScaleTransLanguage) - 1] =
'\0';
10796 mScaleTransSelectedKey = allKeys.empty() ? -1 : 0;
10800 ImGui::Separator();
10801 ImGui::TextDisabled(
"Add language (2-3 chars):");
10802 static char newScaleLang[16] =
"";
10803 ImGui::PushItemWidth(60);
10804 if (ImGui::InputText(
"##NewScaleLang", newScaleLang, 4, ImGuiInputTextFlags_EnterReturnsTrue)) {
10805 if (strlen(newScaleLang) > 0) {
10807 std::string langCode(newScaleLang);
10808 for (
char& c : langCode) c = tolower(c);
10809 if (translations.find(langCode) == translations.end()) {
10810 translations[langCode] = {};
10811 for (
const auto& [key, _] : translations[
"en"]) {
10812 translations[langCode][key] =
"";
10814 mCurrentScale->SetDirty(
true);
10816 std::strncpy(mScaleTransLanguage, langCode.c_str(),
sizeof(mScaleTransLanguage) - 1);
10817 mScaleTransLanguage[
sizeof(mScaleTransLanguage) - 1] =
'\0';
10818 newScaleLang[0] =
'\0';
10819 ImGui::CloseCurrentPopup();
10822 ImGui::PopItemWidth();
10825 ImGui::PopItemWidth();
10827 ImGui::SameLine(0, 20);
10828 ImGui::TextDisabled(
"%zu keys, %zu languages", allKeys.size(), availableLanguages.size());
10831 ImGui::SameLine(0, 20);
10832 bool canLaunch = (mCurrentScale !=
nullptr) && (mScaleManager !=
nullptr);
10833 if (!canLaunch) ImGui::BeginDisabled();
10834 if (ImGui::Button(
"Launch Translation Editor")) {
10836 mScaleManager->SaveScale(mCurrentScale);
10840 std::string code = mCurrentScale->GetScaleInfo().code;
10841 std::strncpy(mTranslationEditor.
scaleCode, code.c_str(),
sizeof(mTranslationEditor.
scaleCode) - 1);
10843 std::string scaleDir = mScaleManager->GetScalesPath() +
"/" + code;
10844 std::strncpy(mTranslationEditor.
scaleDir, scaleDir.c_str(),
sizeof(mTranslationEditor.
scaleDir) - 1);
10845 mTranslationEditor.
scaleDir[
sizeof(mTranslationEditor.
scaleDir) - 1] =
'\0';
10849 if (mScaleTransLanguage[0]) {
10850 std::strncpy(mTranslationEditor.
language, mScaleTransLanguage,
sizeof(mTranslationEditor.
language) - 1);
10851 mTranslationEditor.
language[
sizeof(mTranslationEditor.
language) - 1] =
'\0';
10853 std::strncpy(mTranslationEditor.
language,
"en",
sizeof(mTranslationEditor.
language) - 1);
10856 mTranslationEditor.
show =
true;
10858 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
10859 ImGui::SetTooltip(
"Save scale and open the full translation editor dialog");
10861 if (!canLaunch) ImGui::EndDisabled();
10863 ImGui::Separator();
10865 if (!mScaleTransLanguage[0]) {
10867 ImGui::TextWrapped(
"Select a language to view or edit translations. English (en) is the base language.");
10871 std::string currentLang(mScaleTransLanguage);
10872 bool isEnglish = (currentLang ==
"en");
10875 if (translations.find(currentLang) == translations.end()) {
10876 translations[currentLang] = {};
10878 auto& targetMap = translations[currentLang];
10879 auto& englishMap = translations[
"en"];
10881 if (allKeys.empty()) {
10883 ImGui::TextWrapped(
"No translation keys defined yet. Keys are created automatically when you add questions in the Questions tab.");
10888 float contentHeight = ImGui::GetContentRegionAvail().y;
10891 ImGui::BeginChild(
"ScaleTransKeyList", ImVec2(180, contentHeight),
true);
10892 ImGui::TextDisabled(
"Keys");
10893 ImGui::Separator();
10895 for (
size_t i = 0; i < allKeys.size(); i++) {
10896 const std::string& key = allKeys[i];
10897 bool isSelected = (mScaleTransSelectedKey == (int)i);
10900 bool untranslated =
false;
10902 auto it = targetMap.find(key);
10903 untranslated = (it == targetMap.end() || it->second.empty());
10906 if (untranslated) {
10907 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.0f, 1.0f));
10910 if (ImGui::Selectable(key.c_str(), isSelected)) {
10911 mScaleTransSelectedKey = (int)i;
10914 if (untranslated) {
10915 ImGui::PopStyleColor();
10923 ImGui::BeginChild(
"ScaleTransEditPanel", ImVec2(0, contentHeight),
true);
10925 if (mScaleTransSelectedKey >= 0 && mScaleTransSelectedKey < (
int)allKeys.size()) {
10926 const std::string& selectedKey = allKeys[mScaleTransSelectedKey];
10928 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"Key: %s", selectedKey.c_str());
10931 float availHeight = ImGui::GetContentRegionAvail().y;
10935 ImGui::Text(
"English (editing):");
10937 std::string& val = englishMap[selectedKey];
10938 char editBuf[8192];
10939 std::strncpy(editBuf, val.c_str(),
sizeof(editBuf) - 1);
10940 editBuf[
sizeof(editBuf) - 1] =
'\0';
10942 if (ImGui::InputTextMultiline(
"##scaletrans_edit", editBuf,
sizeof(editBuf),
10943 ImVec2(-1, availHeight - 30), ImGuiInputTextFlags_WordWrap)) {
10945 mCurrentScale->SetDirty(
true);
10949 float boxHeight = (availHeight - 60) / 2;
10951 ImGui::Text(
"English (reference):");
10952 ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f));
10953 std::string englishVal = englishMap.count(selectedKey) ? englishMap[selectedKey] :
"";
10955 std::strncpy(refBuf, englishVal.c_str(),
sizeof(refBuf) - 1);
10956 refBuf[
sizeof(refBuf) - 1] =
'\0';
10957 ImGui::InputTextMultiline(
"##scaletrans_ref", refBuf,
sizeof(refBuf),
10958 ImVec2(-1, boxHeight), ImGuiInputTextFlags_ReadOnly | ImGuiInputTextFlags_WordWrap);
10959 ImGui::PopStyleColor();
10963 ImGui::Text(
"%s (editing):", currentLang.c_str());
10964 std::string& targetVal = targetMap[selectedKey];
10965 char editBuf[8192];
10966 std::strncpy(editBuf, targetVal.c_str(),
sizeof(editBuf) - 1);
10967 editBuf[
sizeof(editBuf) - 1] =
'\0';
10969 if (ImGui::InputTextMultiline(
"##scaletrans_target", editBuf,
sizeof(editBuf),
10970 ImVec2(-1, boxHeight), ImGuiInputTextFlags_WordWrap)) {
10971 targetVal = editBuf;
10972 mCurrentScale->SetDirty(
true);
10976 ImGui::TextDisabled(
"Select a key from the list to edit");
10982void LauncherUI::RenderSectionsTab()
10984 if (!mCurrentScale)
return;
10986 auto& raw = mCurrentScale->GetRawDefinition();
10987 const auto& questions = mCurrentScale->GetQuestions();
10990 std::vector<std::string> sectionIds;
10991 for (
const auto& q : questions)
10992 if (q.type ==
"section")
10993 sectionIds.push_back(q.id);
10996 ImGui::BeginChild(
"SectionsRandomPanel", ImVec2(320, 0),
true);
10997 ImGui::Text(
"Section Order Randomization (S4)");
10998 ImGui::Separator();
11002 bool randomizeEnabled = raw.contains(
"randomize_sections");
11003 if (ImGui::Checkbox(
"Randomize section order", &randomizeEnabled)) {
11004 if (randomizeEnabled) {
11005 raw[
"randomize_sections"] = {{
"method",
"shuffle"}, {
"fixed", nlohmann::json::array()}};
11007 raw.erase(
"randomize_sections");
11009 mCurrentScale->SetDirty(
true);
11012 if (randomizeEnabled && raw.contains(
"randomize_sections")) {
11013 auto& rs = raw[
"randomize_sections"];
11016 std::set<std::string> fixedSet;
11017 if (rs.contains(
"fixed") && rs[
"fixed"].is_array())
11018 for (
const auto& f : rs[
"fixed"])
11019 if (f.is_string()) fixedSet.insert(f.get<std::string>());
11022 ImGui::TextDisabled(
"Fixed sections are not shuffled:");
11025 if (sectionIds.empty()) {
11026 ImGui::TextDisabled(
"(No sections defined — add sections in the Questions tab)");
11028 for (
const auto& sid : sectionIds) {
11029 bool isFixed = fixedSet.count(sid) > 0;
11030 std::string label =
"Fixed##fix_" + sid;
11031 if (ImGui::Checkbox(label.c_str(), &isFixed)) {
11033 fixedSet.insert(sid);
11035 fixedSet.erase(sid);
11037 rs[
"fixed"] = nlohmann::json::array();
11038 for (
const auto& f : fixedSet)
11039 rs[
"fixed"].push_back(f);
11040 mCurrentScale->SetDirty(
true);
11043 ImGui::TextUnformatted(sid.c_str());
11052 ImGui::BeginChild(
"SectionsBranchPanel", ImVec2(0, 0),
true);
11053 ImGui::Text(
"Branch Groups (A1)");
11054 ImGui::Separator();
11056 ImGui::TextWrapped(
11057 "Branch groups let you randomly assign participants to different "
11058 "sequences of sections. Each group has named arms; the runner picks "
11059 "one arm per participant.");
11063 bool hasBranches = raw.contains(
"branches") && raw[
"branches"].is_array();
11065 if (ImGui::Button(
"Add Branch Group")) {
11066 if (!hasBranches) {
11067 raw[
"branches"] = nlohmann::json::array();
11068 hasBranches =
true;
11070 nlohmann::json newGroup;
11071 newGroup[
"id"] =
"branch_" + std::to_string(raw[
"branches"].size() + 1);
11072 newGroup[
"method"] =
"random";
11073 newGroup[
"arms"] = nlohmann::json::array();
11074 raw[
"branches"].push_back(newGroup);
11075 mSelectedBranchGroupIndex = (int)raw[
"branches"].size() - 1;
11076 mCurrentScale->SetDirty(
true);
11080 auto& branches = raw[
"branches"];
11081 int numGroups = (int)branches.size();
11084 ImGui::BeginChild(
"BranchGroupList", ImVec2(160, 0),
true);
11085 for (
int gi = 0; gi < numGroups; ++gi) {
11086 std::string groupId = branches[gi].value(
"id",
"branch_" + std::to_string(gi+1));
11087 bool selected = (mSelectedBranchGroupIndex == gi);
11088 if (ImGui::Selectable(groupId.c_str(), selected))
11089 mSelectedBranchGroupIndex = gi;
11095 ImGui::BeginChild(
"BranchGroupEditor", ImVec2(0, 0),
true);
11096 if (mSelectedBranchGroupIndex >= 0 && mSelectedBranchGroupIndex < numGroups) {
11097 auto& grp = branches[mSelectedBranchGroupIndex];
11101 std::string gidStr = grp.value(
"id",
"");
11102 std::strncpy(gidBuf, gidStr.c_str(),
sizeof(gidBuf) - 1);
11103 gidBuf[
sizeof(gidBuf)-1] =
'\0';
11104 ImGui::Text(
"Group ID:"); ImGui::SameLine();
11105 ImGui::SetNextItemWidth(150);
11106 if (ImGui::InputText(
"##gid", gidBuf,
sizeof(gidBuf)))
11107 { grp[
"id"] = gidBuf; mCurrentScale->SetDirty(
true); }
11110 ImGui::Text(
"Method:"); ImGui::SameLine();
11111 const char* methods[] = {
"random",
"balanced",
"parameter"};
11113 std::string curMethod = grp.value(
"method",
"random");
11114 for (
int m = 0; m < 3; ++m)
11115 if (curMethod == methods[m]) { methodIdx = m;
break; }
11116 ImGui::SetNextItemWidth(130);
11117 if (ImGui::Combo(
"##gmethod", &methodIdx, methods, 3))
11118 { grp[
"method"] = methods[methodIdx]; mCurrentScale->SetDirty(
true); }
11121 ImGui::Separator();
11122 ImGui::Text(
"Arms:");
11125 if (!grp.contains(
"arms") || !grp[
"arms"].is_array())
11126 grp[
"arms"] = nlohmann::json::array();
11127 auto& arms = grp[
"arms"];
11129 if (ImGui::Button(
"+ Add Arm")) {
11130 nlohmann::json arm;
11131 arm[
"id"] =
"arm_" + std::to_string(arms.size() + 1);
11132 arm[
"sections"] = nlohmann::json::array();
11133 arms.push_back(arm);
11134 mCurrentScale->SetDirty(
true);
11137 int armToDelete = -1;
11138 for (
int ai = 0; ai < (int)arms.size(); ++ai) {
11139 auto& arm = arms[ai];
11144 std::string armIdStr = arm.value(
"id",
"");
11145 std::strncpy(armIdBuf, armIdStr.c_str(),
sizeof(armIdBuf) - 1);
11146 armIdBuf[
sizeof(armIdBuf)-1] =
'\0';
11147 ImGui::SetNextItemWidth(100);
11148 if (ImGui::InputText(
"##armid", armIdBuf,
sizeof(armIdBuf)))
11149 { arm[
"id"] = armIdBuf; mCurrentScale->SetDirty(
true); }
11154 std::string popupId =
"ArmSections##" + std::to_string(ai);
11155 if (!arm.contains(
"sections") || !arm[
"sections"].is_array())
11156 arm[
"sections"] = nlohmann::json::array();
11157 std::set<std::string> armSecs;
11158 for (
const auto& s : arm[
"sections"])
11159 if (s.is_string()) armSecs.insert(s.get<std::string>());
11160 std::string secLabel = std::to_string(armSecs.size()) +
" section(s)";
11161 if (ImGui::Button(secLabel.c_str()))
11162 ImGui::OpenPopup(popupId.c_str());
11163 if (ImGui::BeginPopup(popupId.c_str())) {
11164 ImGui::Text(
"Sections in this arm:");
11165 ImGui::Separator();
11166 if (sectionIds.empty()) {
11167 ImGui::TextDisabled(
"(No sections — add in Questions tab)");
11169 for (
const auto& sid : sectionIds) {
11170 bool inArm = armSecs.count(sid) > 0;
11171 if (ImGui::Checkbox(sid.c_str(), &inArm)) {
11172 if (inArm) armSecs.insert(sid);
11173 else armSecs.erase(sid);
11175 arm[
"sections"] = nlohmann::json::array();
11176 for (
const auto& s : sectionIds)
11177 if (armSecs.
count(s)) arm[
"sections"].push_back(s);
11178 mCurrentScale->SetDirty(
true);
11186 if (ImGui::SmallButton(
"Del")) armToDelete = ai;
11190 if (armToDelete >= 0) {
11191 arms.erase(arms.begin() + armToDelete);
11192 mCurrentScale->SetDirty(
true);
11196 ImGui::Separator();
11197 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.7f,0.15f,0.15f,1.0f));
11198 if (ImGui::Button(
"Delete Branch Group")) {
11199 branches.erase(branches.begin() + mSelectedBranchGroupIndex);
11200 mSelectedBranchGroupIndex = -1;
11201 mCurrentScale->SetDirty(
true);
11203 ImGui::PopStyleColor();
11205 ImGui::TextDisabled(
"Select a branch group from the list.");
11213void LauncherUI::RenderParametersEditor()
11215 if (!mCurrentScale)
return;
11217 auto& params = mCurrentScale->GetParameters();
11221 static const std::set<std::string> baseNames = {
"scale",
"shuffle_questions",
"show_header"};
11223 ImGui::Text(
"Scale Parameters");
11224 ImGui::Separator();
11226 ImGui::TextWrapped(
11227 "Parameters are researcher-configurable values set at study deployment time. They have three uses:\n"
11228 " 1. Text substitution: use {param_name} in any question or instruction string.\n"
11229 " 2. Question/section visibility: visible_when conditions can test parameter values.\n"
11230 " 3. Branch selection: branches with method=\"parameter\" route participants based on a parameter value.\n"
11231 "Common examples: tool name, population (child/adult), condition (pre/post), age threshold.");
11235 static const char* kTypes[] = {
"string",
"integer",
"float",
"boolean",
"choice"};
11238 std::vector<std::string> customKeys;
11239 for (
const auto& [k, _] : params)
11240 if (!baseNames.
count(k)) customKeys.push_back(k);
11242 if (customKeys.empty()) {
11243 ImGui::TextDisabled(
"No custom parameters defined. Use 'Add Parameter' below.");
11246 if (ImGui::BeginTable(
"ParamsTable", 5,
11247 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
11248 ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp,
11251 ImGui::TableSetupScrollFreeze(0, 1);
11252 ImGui::TableSetupColumn(
"Name", ImGuiTableColumnFlags_WidthFixed, 140.0f);
11253 ImGui::TableSetupColumn(
"Type", ImGuiTableColumnFlags_WidthFixed, 80.0f);
11254 ImGui::TableSetupColumn(
"Default", ImGuiTableColumnFlags_WidthFixed, 120.0f);
11255 ImGui::TableSetupColumn(
"Description", ImGuiTableColumnFlags_WidthStretch);
11256 ImGui::TableSetupColumn(
"##del", ImGuiTableColumnFlags_WidthFixed, 50.0f);
11257 ImGui::TableHeadersRow();
11259 std::string toDelete;
11261 for (
const auto& key : customKeys) {
11262 auto& p = params[key];
11263 ImGui::TableNextRow();
11264 ImGui::PushID(key.c_str());
11267 ImGui::TableSetColumnIndex(0);
11268 ImGui::TextUnformatted(key.c_str());
11269 if (ImGui::IsItemHovered())
11270 ImGui::SetTooltip(
"Use {%s} in translation strings to substitute this value.", key.c_str());
11273 ImGui::TableSetColumnIndex(1);
11275 for (
int i = 0; i < 5; i++)
11276 if (p.type == kTypes[i]) { typeIdx = i;
break; }
11277 ImGui::SetNextItemWidth(-FLT_MIN);
11278 if (ImGui::Combo(
"##type", &typeIdx, kTypes, 5)) {
11279 p.type = kTypes[typeIdx];
11281 if (p.type !=
"boolean" && p.type !=
"choice") p.options.clear();
11282 if (p.type ==
"boolean") p.options = {
"0",
"1"};
11283 mCurrentScale->SetDirty(
true);
11287 ImGui::TableSetColumnIndex(2);
11288 if (p.type ==
"boolean") {
11290 bool bval = (p.defaultValue ==
"1" || p.defaultValue ==
"true");
11291 if (ImGui::Checkbox(
"##booldef", &bval)) {
11292 p.defaultValue = bval ?
"1" :
"0";
11293 mCurrentScale->SetDirty(
true);
11297 std::strncpy(defBuf, p.defaultValue.c_str(),
sizeof(defBuf) - 1);
11298 defBuf[
sizeof(defBuf) - 1] =
'\0';
11299 ImGui::SetNextItemWidth(-FLT_MIN);
11300 if (ImGui::InputText(
"##def", defBuf,
sizeof(defBuf))) {
11301 p.defaultValue = defBuf;
11302 mCurrentScale->SetDirty(
true);
11307 ImGui::TableSetColumnIndex(3);
11309 std::strncpy(descBuf, p.description.c_str(),
sizeof(descBuf) - 1);
11310 descBuf[
sizeof(descBuf) - 1] =
'\0';
11311 ImGui::SetNextItemWidth(-FLT_MIN);
11312 if (ImGui::InputText(
"##desc", descBuf,
sizeof(descBuf))) {
11313 p.description = descBuf;
11314 mCurrentScale->SetDirty(
true);
11318 ImGui::TableSetColumnIndex(4);
11319 if (ImGui::SmallButton(
"Del")) toDelete = key;
11326 if (!toDelete.empty()) {
11327 params.erase(toDelete);
11328 mCurrentScale->SetDirty(
true);
11335 ImGui::Text(
"Standard Parameter Defaults");
11336 ImGui::Separator();
11338 ImGui::TextWrapped(
11339 "Override the default values for the built-in ScaleRunner parameters. "
11340 "Leave at system default if no special behaviour is needed.");
11345 auto it = params.find(
"shuffle_questions");
11346 bool inOsd = (it != params.end());
11347 bool shuffleOn = inOsd && (it->second.defaultValue ==
"1");
11351 bool overrideOn = inOsd;
11352 if (ImGui::Checkbox(
"Randomize questions by default (shuffle_questions = 1)", &overrideOn)) {
11355 "Randomize item order within randomization groups (recommended for this scale)");
11356 sp.options = {
"0",
"1"};
11357 params[
"shuffle_questions"] = sp;
11359 params.erase(
"shuffle_questions");
11361 mCurrentScale->SetDirty(
true);
11363 if (ImGui::IsItemHovered())
11365 "Check this to make shuffle_questions default to ON for this scale.\n"
11366 "Recommended for scales where item order effects matter (e.g., BSR).");
11374 auto it = params.find(
"show_header");
11375 bool inOsd = (it != params.end());
11378 bool hideHeader = inOsd && (it->second.defaultValue ==
"0");
11379 if (ImGui::Checkbox(
"Hide scale title header by default (show_header = 0)", &hideHeader)) {
11382 "Hide scale title — recommended when the title would reveal the scale's purpose");
11383 sp.options = {
"0",
"1"};
11384 params[
"show_header"] = sp;
11386 params.erase(
"show_header");
11388 mCurrentScale->SetDirty(
true);
11390 if (ImGui::IsItemHovered())
11392 "Check this to hide the scale title header by default.\n"
11393 "Useful when showing the scale name would reveal its purpose to participants\n"
11394 "(e.g., Bullshit Receptivity Scale).");
11399 bool hasChoiceParams =
false;
11400 for (
const auto& key : customKeys)
11401 if (params[key].type ==
"choice") { hasChoiceParams =
true;
break; }
11403 if (hasChoiceParams) {
11405 ImGui::Text(
"Choice Parameter Options");
11406 ImGui::Separator();
11408 ImGui::TextWrapped(
11409 "For each 'choice' parameter, enter the allowed values as a comma-separated list. "
11410 "The default value must be one of these options. Branch arm IDs and visible_when "
11411 "values should match these exactly.");
11414 for (
const auto& key : customKeys) {
11415 auto& p = params[key];
11416 if (p.type !=
"choice")
continue;
11418 ImGui::PushID(key.c_str());
11419 ImGui::Text(
"%s options:", key.c_str());
11423 static std::map<std::string, std::string> sOptBufs;
11424 if (!sOptBufs.count(key)) {
11425 std::string joined;
11426 for (
size_t i = 0; i < p.options.size(); i++) {
11427 if (i > 0) joined +=
",";
11428 joined += p.options[i];
11430 sOptBufs[key] = joined;
11432 auto& buf = sOptBufs[key];
11434 std::strncpy(cbuf, buf.c_str(),
sizeof(cbuf) - 1);
11435 cbuf[
sizeof(cbuf) - 1] =
'\0';
11436 ImGui::SetNextItemWidth(300.0f);
11437 if (ImGui::InputText(
"##opts", cbuf,
sizeof(cbuf))) {
11441 std::string s = cbuf;
11442 std::stringstream ss(s);
11444 while (std::getline(ss, token,
',')) {
11446 size_t start = token.find_first_not_of(
" \t");
11447 size_t end = token.find_last_not_of(
" \t");
11448 if (start != std::string::npos)
11449 p.options.push_back(token.substr(start, end - start + 1));
11451 mCurrentScale->SetDirty(
true);
11454 ImGui::TextDisabled(
"(comma-separated)");
11462 ImGui::Separator();
11463 ImGui::Text(
"Add Parameter");
11466 static char sNewName[64] =
"";
11467 static char sNewDefault[256] =
"";
11468 static char sNewDesc[512] =
"";
11469 static int sNewTypeIdx = 0;
11472 ImGui::BeginGroup();
11473 ImGui::Text(
"Name");
11474 ImGui::SetNextItemWidth(130.0f);
11475 ImGui::InputText(
"##add_name", sNewName,
sizeof(sNewName));
11480 ImGui::BeginGroup();
11481 ImGui::Text(
"Type");
11482 ImGui::SetNextItemWidth(90.0f);
11483 ImGui::Combo(
"##add_type", &sNewTypeIdx, kTypes, 5);
11488 ImGui::BeginGroup();
11489 ImGui::Text(
"Default");
11490 ImGui::SetNextItemWidth(120.0f);
11491 ImGui::InputText(
"##add_default", sNewDefault,
sizeof(sNewDefault));
11496 ImGui::BeginGroup();
11497 ImGui::Text(
"Description");
11498 ImGui::SetNextItemWidth(220.0f);
11499 ImGui::InputText(
"##add_desc", sNewDesc,
sizeof(sNewDesc));
11504 ImGui::BeginGroup();
11506 bool nameOk = sNewName[0] !=
'\0' && !baseNames.count(sNewName) && !params.count(sNewName);
11507 if (!nameOk) ImGui::BeginDisabled();
11508 if (ImGui::Button(
"Add")) {
11510 sp.
type = kTypes[sNewTypeIdx];
11513 if (sp.
type ==
"boolean") sp.
options = {
"0",
"1"};
11514 params[sNewName] = sp;
11515 mCurrentScale->SetDirty(
true);
11516 sNewName[0] =
'\0';
11517 sNewDefault[0] =
'\0';
11518 sNewDesc[0] =
'\0';
11521 if (!nameOk) ImGui::EndDisabled();
11524 if (sNewName[0] !=
'\0') {
11525 if (baseNames.count(sNewName))
11526 ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1),
11527 "'%s' is a reserved parameter name — edit it in Standard Parameter Defaults above.", sNewName);
11528 else if (params.count(sNewName))
11529 ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1),
11530 "A parameter named '%s' already exists.", sNewName);
11534void LauncherUI::ShowQuestionEditor()
11536 if (!mCurrentScale) {
11537 mQuestionEditor.
show =
false;
11543 RenderSectionEditorForm();
11547 ImGui::SetNextWindowSize(ImVec2(600, 450), ImGuiCond_FirstUseEver);
11548 ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
11550 const char* title = (mQuestionEditor.
editingIndex >= 0) ?
"Edit Question" :
"Add Question";
11551 if (!ImGui::Begin(title, &mQuestionEditor.
show, ImGuiWindowFlags_NoCollapse))
11557 ImGui::Text(
"Question Details");
11558 ImGui::Separator();
11562 ImGui::InputText(
"ID", mQuestionEditor.
id,
sizeof(mQuestionEditor.
id));
11563 if (ImGui::IsItemHovered()) {
11564 ImGui::SetTooltip(
"Unique question identifier (e.g., q1, age, grit2).\n"
11565 "Use lowercase letters, digits, and underscores only.\n"
11566 "Also used as the translation key for the question text.");
11570 ImGui::Text(
"Question Text:");
11572 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 4));
11573 if (ImGui::IsItemHovered()) {
11574 ImGui::SetTooltip(
"The actual question text (English). Quotes are allowed.");
11578 const char* questionTypes[] = {
"likert",
"multi",
"short",
"long",
"vas",
"inst",
"multicheck",
"grid",
"image",
"imageresponse" };
11580 ImGui::Combo(
"Type", &mQuestionEditor.
questionType, questionTypes, IM_ARRAYSIZE(questionTypes));
11587 ImGui::InputInt(
"Randomization Group", &mQuestionEditor.
randomGroup, 1, 1);
11589 if (ImGui::IsItemHovered()) {
11590 ImGui::SetTooltip(
"0 = fixed position\n1+ = shuffle within group when randomization is enabled");
11596 const char* qTypes[] = {
"likert",
"multi",
"short",
"long",
"vas",
"inst",
"multicheck",
"grid",
"image",
"imageresponse" };
11597 std::string currentType = qTypes[mQuestionEditor.
questionType];
11598 bool isDisplayOnly = (currentType ==
"inst" || currentType ==
"image");
11599 std::string defaultLabel =
"Default (";
11600 if (isDisplayOnly) {
11601 defaultLabel +=
"n/a - display only)";
11603 int scaleDef = mCurrentScale ? mCurrentScale->GetDefaultRequired() : -1;
11604 if (scaleDef == 1) {
11605 defaultLabel +=
"required, scale setting)";
11606 }
else if (scaleDef == 0) {
11607 defaultLabel +=
"optional, scale setting)";
11609 bool typeRequired = (currentType !=
"short" && currentType !=
"long");
11610 defaultLabel += typeRequired ?
"required, type default)" :
"optional, type default)";
11613 const char* requiredItems[] = { defaultLabel.c_str(),
"Required",
"Optional" };
11614 int reqComboIdx = (mQuestionEditor.
requiredState == -1) ? 0 : (mQuestionEditor.requiredState == 1 ? 1 : 2);
11615 if (ImGui::Combo(
"Required", &reqComboIdx, requiredItems, IM_ARRAYSIZE(requiredItems))) {
11616 mQuestionEditor.
requiredState = (reqComboIdx == 0) ? -1 : (reqComboIdx == 1 ? 1 : 0);
11618 if (ImGui::IsItemHovered()) {
11619 ImGui::SetTooltip(
"Whether the participant must answer this question.\n"
11620 "Default: scored types are required, text entry is optional.\n"
11621 "Can also be overridden at scale level in the Info tab.");
11627 std::string currentType = questionTypes[mQuestionEditor.
questionType];
11628 bool isText = (currentType ==
"short" || currentType ==
"long");
11629 bool isShort = (currentType ==
"short");
11630 bool isMulti = (currentType ==
"multicheck");
11631 if (isText || isMulti) {
11633 ImGui::Separator();
11634 ImGui::Text(
"Input Validation");
11636 ImGui::TextDisabled(
"Enable individual constraints below. Each can have its own error message.");
11640 auto constraintRow = [&](
const char* label,
bool& enabled,
int& val,
11641 char* errBuf,
size_t errBufSz,
const char* tooltip) {
11642 ImGui::Checkbox(label, &enabled);
11643 if (ImGui::IsItemHovered() && tooltip[0]) ImGui::SetTooltip(
"%s", tooltip);
11646 ImGui::SetNextItemWidth(80);
11647 std::string intId = std::string(
"##v") + label;
11648 ImGui::InputInt(intId.c_str(), &val);
11650 std::string errId = std::string(
"##e") + label;
11651 ImGui::SetNextItemWidth(-FLT_MIN);
11652 ImGui::InputText(errId.c_str(), errBuf, errBufSz);
11653 if (ImGui::IsItemHovered()) ImGui::SetTooltip(
"Error message shown when this constraint fails (English)");
11656 auto constraintRowDbl = [&](
const char* label,
bool& enabled,
double& val,
11657 char* errBuf,
size_t errBufSz,
const char* tooltip) {
11658 ImGui::Checkbox(label, &enabled);
11659 if (ImGui::IsItemHovered() && tooltip[0]) ImGui::SetTooltip(
"%s", tooltip);
11662 ImGui::SetNextItemWidth(100);
11663 std::string dblId = std::string(
"##v") + label;
11664 ImGui::InputDouble(dblId.c_str(), &val, 1.0, 10.0,
"%.2f");
11666 std::string errId = std::string(
"##e") + label;
11667 ImGui::SetNextItemWidth(-FLT_MIN);
11668 ImGui::InputText(errId.c_str(), errBuf, errBufSz);
11669 if (ImGui::IsItemHovered()) ImGui::SetTooltip(
"Error message shown when this constraint fails (English)");
11673 auto& e = mQuestionEditor;
11675 ImGui::TextDisabled(
"Characters:"); ImGui::SameLine(); ImGui::TextDisabled(
"value"); ImGui::SameLine(); ImGui::TextDisabled(
" error message");
11676 constraintRow(
"Min characters", e.valMinLengthEnabled, e.valMinLength, e.valMinLengthError,
sizeof(e.valMinLengthError),
"Minimum number of characters required");
11677 constraintRow(
"Max characters", e.valMaxLengthEnabled, e.valMaxLength, e.valMaxLengthError,
sizeof(e.valMaxLengthError),
"Maximum number of characters allowed");
11679 ImGui::TextDisabled(
"Words:"); ImGui::SameLine(); ImGui::TextDisabled(
"value"); ImGui::SameLine(); ImGui::TextDisabled(
" error message");
11680 constraintRow(
"Min words", e.valMinWordsEnabled, e.valMinWords, e.valMinWordsError,
sizeof(e.valMinWordsError),
"Minimum number of words required");
11681 constraintRow(
"Max words", e.valMaxWordsEnabled, e.valMaxWords, e.valMaxWordsError,
sizeof(e.valMaxWordsError),
"Maximum number of words allowed");
11685 ImGui::TextDisabled(
"Numeric range (also restricts input to digits):");
11686 constraintRowDbl(
"Min value", e.valNumberMinEnabled, e.valNumberMin, e.valNumberMinError,
sizeof(e.valNumberMinError),
"Minimum numeric value");
11687 constraintRowDbl(
"Max value", e.valNumberMaxEnabled, e.valNumberMax, e.valNumberMaxError,
sizeof(e.valNumberMaxError),
"Maximum numeric value");
11689 ImGui::Checkbox(
"Pattern (regex)", &e.valPatternEnabled);
11690 if (e.valPatternEnabled) {
11691 ImGui::SetNextItemWidth(200);
11692 ImGui::InputText(
"##vpat", e.valPattern,
sizeof(e.valPattern));
11693 if (ImGui::IsItemHovered()) ImGui::SetTooltip(
"Regular expression. Supports: . ^ $ * + ? [] \\s \\w \\d | ()");
11695 ImGui::SetNextItemWidth(-FLT_MIN);
11696 ImGui::InputText(
"##epat", e.valPatternError,
sizeof(e.valPatternError));
11697 if (ImGui::IsItemHovered()) ImGui::SetTooltip(
"Error message shown when pattern does not match (English)");
11701 constraintRow(
"Min selected", e.valMinSelectedEnabled, e.valMinSelected, e.valMinSelectedError,
sizeof(e.valMinSelectedError),
"Minimum number of options to select");
11702 constraintRow(
"Max selected", e.valMaxSelectedEnabled, e.valMaxSelected, e.valMaxSelectedError,
sizeof(e.valMaxSelectedError),
"Maximum number of options to select");
11709 ImGui::Separator();
11710 ImGui::Text(
"Conditional Display");
11713 RenderVisibleWhenEditor(mQuestionEditor);
11718 ImGui::Separator();
11719 ImGui::Text(
"Likert Options");
11722 ImGui::InputInt(
"Number of Points", &mQuestionEditor.
likertPoints);
11723 if (ImGui::IsItemHovered()) {
11724 ImGui::SetTooltip(
"Number of response options (-1 = use scale default)");
11730 ImGui::InputInt(
"Min Value", &mQuestionEditor.
likertMin);
11731 if (ImGui::IsItemHovered()) {
11732 ImGui::SetTooltip(
"Minimum value (-1 = use scale default)");
11735 ImGui::InputInt(
"Max Value", &mQuestionEditor.
likertMax);
11736 if (ImGui::IsItemHovered()) {
11737 ImGui::SetTooltip(
"Maximum value (-1 = use scale default)");
11740 ImGui::Checkbox(
"Reverse display order", &mQuestionEditor.
likertReverse);
11741 if (ImGui::IsItemHovered()) {
11742 ImGui::SetTooltip(
"Display buttons right-to-left (highest value on the left).\n"
11743 "The stored value is unchanged — this only affects\n"
11744 "the visual layout. Useful for descending scales\n"
11745 "(e.g., quality-of-life ladders: 10 on left, 0 on right).");
11750 ImGui::Text(
"Response Options for this Question");
11751 if (ImGui::IsItemHovered()) {
11752 ImGui::SetTooltip(
"Select which response options to use (leave all unchecked to use scale defaults)");
11756 auto& scaleLikert = mCurrentScale->GetLikertOptions();
11757 auto& scaleLabels = scaleLikert.labels;
11760 std::vector<std::string> currentLabels;
11762 auto& questions = mCurrentScale->GetQuestions();
11763 if (mQuestionEditor.
editingIndex < (
int)questions.size()) {
11764 currentLabels = questions[mQuestionEditor.
editingIndex].likert_labels;
11769 if (scaleLabels.empty()) {
11770 ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f),
"No response options defined at scale level.");
11771 ImGui::TextWrapped(
"Go to the Info tab to add response options.");
11777 for (
size_t i = 0; i < scaleLabels.size(); i++) {
11778 mQuestionEditor.
selectedResponseOptions[i] = (std::find(currentLabels.begin(), currentLabels.end(), scaleLabels[i]) != currentLabels.end());
11782 for (
size_t i = 0; i < scaleLabels.size(); i++) {
11783 std::string labelText = mCurrentScale->GetTranslation(
"en", scaleLabels[i]);
11784 std::string displayText = scaleLabels[i] +
": " + labelText;
11787 if (ImGui::Checkbox(displayText.c_str(), &selected)) {
11792 ImGui::Text(
"(Leave all unchecked to use all scale-level options)");
11799 ImGui::Separator();
11800 ImGui::Text(
"VAS (Visual Analog Scale) Options");
11803 ImGui::InputInt(
"Min Value", &mQuestionEditor.
vasMinValue);
11804 if (ImGui::IsItemHovered()) {
11805 ImGui::SetTooltip(
"Minimum value for the scale (e.g., 0)");
11808 ImGui::InputInt(
"Max Value", &mQuestionEditor.
vasMaxValue);
11809 if (ImGui::IsItemHovered()) {
11810 ImGui::SetTooltip(
"Maximum value for the scale (e.g., 100)");
11814 if (ImGui::IsItemHovered()) {
11815 ImGui::SetTooltip(
"Text for top of vertical scale (e.g., 'Extremely')");
11819 if (ImGui::IsItemHovered()) {
11820 ImGui::SetTooltip(
"Text for bottom of vertical scale (e.g., 'Not at all')");
11824 const char* orientOptions[] = {
"horizontal",
"vertical" };
11825 ImGui::Combo(
"Orientation", &mQuestionEditor.
vasOrientationIdx, orientOptions, 2);
11826 if (ImGui::IsItemHovered()) {
11827 ImGui::SetTooltip(
"Vertical orientation is recommended when there are many\nanchors or when anchor labels are long.");
11832 ImGui::Text(
"Named Anchors");
11834 ImGui::TextDisabled(
"(?)");
11835 if (ImGui::IsItemHovered()) {
11836 ImGui::SetTooltip(
"Positioned text labels along the slider.\nWhen anchors are present, min/max labels are ignored.\nEach anchor has a numeric position and a translation key.");
11839 int removeAnchorIdx = -1;
11840 for (
int ai = 0; ai < (int)mQuestionEditor.
vasAnchors.size(); ai++) {
11844 ImGui::PushItemWidth(80);
11845 ImGui::InputFloat(
"##ancVal", &anc.value, 0, 0,
"%.4g");
11846 ImGui::PopItemWidth();
11849 ImGui::PushItemWidth(200);
11850 ImGui::InputText(
"##ancLabel", anc.label,
sizeof(anc.label));
11851 ImGui::PopItemWidth();
11854 if (ImGui::SmallButton(
"X##anc")) {
11855 removeAnchorIdx = ai;
11861 if (removeAnchorIdx >= 0) {
11865 if (ImGui::SmallButton(
"+ Add Anchor")) {
11874 ImGui::Separator();
11875 const char* typeLabel = (mQuestionEditor.
questionType == 1) ?
"Multiple Choice" :
"Multiple Check";
11876 ImGui::Text(
"%s Options", typeLabel);
11879 ImGui::Text(
"Options (one per line):");
11881 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 6));
11882 if (ImGui::IsItemHovered()) {
11883 ImGui::SetTooltip(
"Enter one option per line. These will become answer choices.");
11886 ImGui::Checkbox(
"Randomize option order", &mQuestionEditor.
randomizeOptions);
11887 if (ImGui::IsItemHovered()) {
11888 ImGui::SetTooltip(
"Shuffle the order of response options for each participant.\nUseful to reduce order effects in multiple-choice questions.");
11895 ImGui::Separator();
11896 ImGui::Text(
"Grid Question Options");
11899 ImGui::Text(
"Column Headers (one per line):");
11900 ImGui::InputTextMultiline(
"##GridColumns", mQuestionEditor.
gridColumns,
sizeof(mQuestionEditor.
gridColumns),
11901 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 3));
11902 if (ImGui::IsItemHovered()) {
11903 ImGui::SetTooltip(
"Rating scale labels (e.g., 'Never', 'Sometimes', 'Always')");
11906 ImGui::Text(
"Row Labels/Sub-questions (one per line):");
11907 ImGui::InputTextMultiline(
"##GridRows", mQuestionEditor.
gridRows,
sizeof(mQuestionEditor.
gridRows),
11908 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 6));
11909 if (ImGui::IsItemHovered()) {
11910 ImGui::SetTooltip(
"Sub-questions that will be rated using the column headers");
11917 ImGui::Separator();
11918 const char* typeLabel = (mQuestionEditor.
questionType == 8) ?
"Image Display" :
"Image Response";
11919 ImGui::Text(
"%s Options", typeLabel);
11922 ImGui::InputText(
"Image Path", mQuestionEditor.
imagePath,
sizeof(mQuestionEditor.
imagePath));
11923 if (ImGui::IsItemHovered()) {
11924 ImGui::SetTooltip(
"Path to image file (relative to test directory or absolute)");
11932 if (qt == 0 || qt == 1 || qt == 4 || qt == 6 || qt == 7) {
11934 ImGui::Separator();
11936 ImGui::Text(
"Question Head (override)");
11937 ImGui::TextDisabled(
"Translation key for a question stem shown above this item. Leave blank to use scale default.");
11939 if (ImGui::IsItemHovered()) {
11940 ImGui::SetTooltip(
"Overrides the scale-level question_head for this item.\n"
11941 "Useful when a block of items shares a different stem\n"
11942 "than the rest of the scale (e.g., 'In the past month...').");
11950 ImGui::Separator();
11952 ImGui::Text(
"Answer Alias");
11953 ImGui::TextDisabled(
"Optional: give this answer a semantic name for use in {answer.alias} piping.");
11954 ImGui::InputText(
"Alias##answerAlias", mQuestionEditor.
answerAlias,
sizeof(mQuestionEditor.
answerAlias));
11955 if (ImGui::IsItemHovered())
11956 ImGui::SetTooltip(
"e.g. 'favorite_activity' lets later questions use {answer.favorite_activity}");
11963 bool isGateable = (qt == 1 || qt == 2);
11965 ImGui::Separator();
11967 ImGui::Text(
"Gate (Blocking)");
11969 ImGui::TextDisabled(
"Available for multi and short questions only.");
11971 ImGui::Checkbox(
"This question is a gate", &mQuestionEditor.
hasGate);
11972 if (ImGui::IsItemHovered()) {
11973 ImGui::SetTooltip(
"If the participant does not give the required response,\n"
11974 "the scale terminates early, saves data, and shows a message.");
11976 if (mQuestionEditor.
hasGate) {
11980 if (ImGui::IsItemHovered()) {
11981 ImGui::SetTooltip(
"The option value that allows the participant to continue.\n"
11982 "Any other selection terminates the scale.");
11986 const char* opLabels[] = {
"> greater than",
"< less than",
"= equals",
"\xe2\x89\xa0 not equals" };
11987 ImGui::Combo(
"Operator##gateOp", &mQuestionEditor.
gateOperator, opLabels, 4);
11988 if (ImGui::IsItemHovered())
11989 ImGui::SetTooltip(
"Continue if response is: response <op> threshold\nE.g., age > 17 passes participants who are 18+.");
11990 ImGui::InputDouble(
"Threshold##gateVal", &mQuestionEditor.
gateValue, 0.0, 0.0,
"%.4g");
11992 ImGui::Text(
"Termination message (English):");
11993 if (ImGui::IsItemHovered())
11994 ImGui::SetTooltip(
"Message shown to ineligible participants.\nTranslation key is auto-generated as {id}_gate_msg.");
11997 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 3));
12003 ImGui::Separator();
12007 const char* buttonLabel = (mQuestionEditor.
editingIndex >= 0) ?
"Save" :
"Add Question";
12008 if (ImGui::Button(buttonLabel, ImVec2(120, 0))) {
12010 if (strlen(mQuestionEditor.
id) == 0) {
12011 printf(
"Error: Question ID cannot be empty\n");
12014 auto& questions = mCurrentScale->GetQuestions();
12015 if (mQuestionEditor.
editingIndex < (
int)questions.size()) {
12016 questions[mQuestionEditor.
editingIndex].id = mQuestionEditor.
id;
12017 questions[mQuestionEditor.
editingIndex].text_key = mQuestionEditor.
id;
12024 auto& val = questions[mQuestionEditor.
editingIndex].validation;
12025 std::string qid = mQuestionEditor.
id;
12026 auto& e = mQuestionEditor;
12028 auto storeErr = [&](
const char* buf,
const std::string& suffix) -> std::string {
12029 if (buf[0] && mCurrentScale) {
12030 std::string key = qid +
"_" + suffix;
12031 mCurrentScale->AddTranslation(
"en", key, buf);
12037 val.min_length = e.valMinLengthEnabled ? e.valMinLength : -1;
12038 val.max_length = e.valMaxLengthEnabled ? e.valMaxLength : -1;
12039 val.min_words = e.valMinWordsEnabled ? e.valMinWords : -1;
12040 val.max_words = e.valMaxWordsEnabled ? e.valMaxWords : -1;
12041 val.number_min_set = e.valNumberMinEnabled;
12042 val.number_min = e.valNumberMinEnabled ? e.valNumberMin : 0.0;
12043 val.number_max_set = e.valNumberMaxEnabled;
12044 val.number_max = e.valNumberMaxEnabled ? e.valNumberMax : 0.0;
12045 val.pattern = e.valPatternEnabled ? e.valPattern :
"";
12046 val.min_selected = e.valMinSelectedEnabled ? e.valMinSelected : -1;
12047 val.max_selected = e.valMaxSelectedEnabled ? e.valMaxSelected : -1;
12049 val.min_length_error = e.valMinLengthEnabled ? storeErr(e.valMinLengthError,
"min_length_err") :
"";
12050 val.max_length_error = e.valMaxLengthEnabled ? storeErr(e.valMaxLengthError,
"max_length_err") :
"";
12051 val.min_words_error = e.valMinWordsEnabled ? storeErr(e.valMinWordsError,
"min_words_err") :
"";
12052 val.max_words_error = e.valMaxWordsEnabled ? storeErr(e.valMaxWordsError,
"max_words_err") :
"";
12053 val.number_min_error = e.valNumberMinEnabled ? storeErr(e.valNumberMinError,
"number_min_err") :
"";
12054 val.number_max_error = e.valNumberMaxEnabled ? storeErr(e.valNumberMaxError,
"number_max_err") :
"";
12055 val.pattern_error = e.valPatternEnabled ? storeErr(e.valPatternError,
"pattern_err") :
"";
12056 val.min_selected_error = e.valMinSelectedEnabled ? storeErr(e.valMinSelectedError,
"min_selected_err") :
"";
12057 val.max_selected_error = e.valMaxSelectedEnabled ? storeErr(e.valMaxSelectedError,
"max_selected_err") :
"";
12070 q2.has_gate = mQuestionEditor.
hasGate && gateAllowed;
12071 const char* opNames[] = {
"greater_than",
"less_than",
"equals",
"not_equals" };
12072 if (q2.has_gate && mQuestionEditor.
questionType == 2) {
12074 q2.gate_required_value =
"";
12075 q2.gate_operator = opNames[mQuestionEditor.
gateOperator];
12076 q2.gate_value = mQuestionEditor.
gateValue;
12079 q2.gate_operator =
"";
12080 q2.gate_value = 0.0;
12083 std::string autoQid = questions[mQuestionEditor.
editingIndex].id;
12087 q2.gate_terminate_message_key = gateKey;
12089 q2.gate_terminate_message_key = q2.has_gate
12098 questions[mQuestionEditor.
editingIndex].visible_when_simple.clear();
12100 const char* opNames[] = {
"equals",
"not_equals",
"greater_than",
"less_than",
"in",
"not_in",
"is_answered",
"is_not_answered" };
12101 for (
const auto& ec : mQuestionEditor.visibleWhenConditions) {
12105 c.
op = opNames[ec.
op < 8 ? ec.
op : 0];
12106 if (ec.
op == 4 || ec.
op == 5) {
12108 std::istringstream ss(ec.
value);
12110 while (std::getline(ss, token,
',')) {
12111 auto s = token.find_first_not_of(
" \t");
12112 auto e2 = token.find_last_not_of(
" \t");
12113 if (s != std::string::npos)
12114 c.
values.push_back(token.substr(s, e2 - s + 1));
12120 questions[mQuestionEditor.
editingIndex].visible_when_simple.push_back(c);
12123 questions[mQuestionEditor.
editingIndex].visible_when_is_complex =
false;
12138 auto& scaleLikert = mCurrentScale->GetLikertOptions();
12139 auto& scaleLabels = scaleLikert.labels;
12140 questions[mQuestionEditor.
editingIndex].likert_labels.clear();
12141 bool anySelected =
false;
12144 questions[mQuestionEditor.
editingIndex].likert_labels.push_back(scaleLabels[i]);
12145 anySelected =
true;
12149 if (!anySelected) {
12150 questions[mQuestionEditor.
editingIndex].likert_labels.clear();
12161 questions[mQuestionEditor.
editingIndex].vas_anchors.clear();
12162 for (
const auto& ae : mQuestionEditor.vasAnchors) {
12164 va.
value = (double)ae.value;
12165 va.label = ae.label;
12166 questions[mQuestionEditor.
editingIndex].vas_anchors.push_back(va);
12173 questions[mQuestionEditor.
editingIndex].options.clear();
12174 std::string optionsStr = mQuestionEditor.
multiOptions;
12175 std::istringstream iss(optionsStr);
12177 while (std::getline(iss, line)) {
12179 line.erase(0, line.find_first_not_of(
" \t\r\n"));
12180 line.erase(line.find_last_not_of(
" \t\r\n") + 1);
12181 if (!line.empty()) {
12182 questions[mQuestionEditor.
editingIndex].options.push_back(line);
12190 questions[mQuestionEditor.
editingIndex].columns.clear();
12191 std::string columnsStr = mQuestionEditor.
gridColumns;
12192 std::istringstream colIss(columnsStr);
12194 while (std::getline(colIss, line)) {
12195 line.erase(0, line.find_first_not_of(
" \t\r\n"));
12196 line.erase(line.find_last_not_of(
" \t\r\n") + 1);
12197 if (!line.empty()) {
12198 questions[mQuestionEditor.
editingIndex].columns.push_back(line);
12204 std::string rowsStr = mQuestionEditor.
gridRows;
12205 std::istringstream rowIss(rowsStr);
12206 while (std::getline(rowIss, line)) {
12207 line.erase(0, line.find_first_not_of(
" \t\r\n"));
12208 line.erase(line.find_last_not_of(
" \t\r\n") + 1);
12209 if (!line.empty()) {
12210 questions[mQuestionEditor.
editingIndex].rows.push_back(line);
12221 std::string textKey = questions[mQuestionEditor.
editingIndex].text_key;
12222 std::string questionText = mQuestionEditor.
questionText;
12223 mCurrentScale->AddTranslation(
"en", textKey, questionText);
12225 printf(
"Updated question: %s (type: %s)\n", questions[mQuestionEditor.
editingIndex].id.c_str(), questions[mQuestionEditor.
editingIndex].type.c_str());
12226 mCurrentScale->SetDirty(
true);
12228 mQuestionEditor.
show =
false;
12231 auto& existingQs = mCurrentScale->GetQuestions();
12232 bool isDuplicate =
false;
12233 for (
const auto& eq : existingQs) {
12234 if (eq.id == mQuestionEditor.
id) { isDuplicate =
true;
break; }
12237 printf(
"Error: Question ID '%s' already exists\n", mQuestionEditor.
id);
12241 newQuestion.
id = mQuestionEditor.
id;
12250 std::string qid = mQuestionEditor.
id;
12251 auto& e = mQuestionEditor;
12253 auto storeErr = [&](
const char* buf,
const std::string& suffix) -> std::string {
12254 if (buf[0] && mCurrentScale) {
12255 std::string key = qid +
"_" + suffix;
12256 mCurrentScale->AddTranslation(
"en", key, buf);
12262 val.min_length = e.valMinLengthEnabled ? e.valMinLength : -1;
12263 val.max_length = e.valMaxLengthEnabled ? e.valMaxLength : -1;
12264 val.min_words = e.valMinWordsEnabled ? e.valMinWords : -1;
12265 val.max_words = e.valMaxWordsEnabled ? e.valMaxWords : -1;
12266 val.number_min_set = e.valNumberMinEnabled;
12267 val.number_min = e.valNumberMinEnabled ? e.valNumberMin : 0.0;
12268 val.number_max_set = e.valNumberMaxEnabled;
12269 val.number_max = e.valNumberMaxEnabled ? e.valNumberMax : 0.0;
12270 val.pattern = e.valPatternEnabled ? e.valPattern :
"";
12271 val.min_selected = e.valMinSelectedEnabled ? e.valMinSelected : -1;
12272 val.max_selected = e.valMaxSelectedEnabled ? e.valMaxSelected : -1;
12274 val.min_length_error = e.valMinLengthEnabled ? storeErr(e.valMinLengthError,
"min_length_err") :
"";
12275 val.max_length_error = e.valMaxLengthEnabled ? storeErr(e.valMaxLengthError,
"max_length_err") :
"";
12276 val.min_words_error = e.valMinWordsEnabled ? storeErr(e.valMinWordsError,
"min_words_err") :
"";
12277 val.max_words_error = e.valMaxWordsEnabled ? storeErr(e.valMaxWordsError,
"max_words_err") :
"";
12278 val.number_min_error = e.valNumberMinEnabled ? storeErr(e.valNumberMinError,
"number_min_err") :
"";
12279 val.number_max_error = e.valNumberMaxEnabled ? storeErr(e.valNumberMaxError,
"number_max_err") :
"";
12280 val.pattern_error = e.valPatternEnabled ? storeErr(e.valPatternError,
"pattern_err") :
"";
12281 val.min_selected_error = e.valMinSelectedEnabled ? storeErr(e.valMinSelectedError,
"min_selected_err") :
"";
12282 val.max_selected_error = e.valMaxSelectedEnabled ? storeErr(e.valMaxSelectedError,
"max_selected_err") :
"";
12295 const char* opNames[] = {
"greater_than",
"less_than",
"equals",
"not_equals" };
12306 std::string autoQid = mQuestionEditor.
id;
12320 const char* opNames[] = {
"equals",
"not_equals",
"greater_than",
"less_than",
"in",
"not_in",
"is_answered",
"is_not_answered" };
12321 for (
const auto& ec : mQuestionEditor.visibleWhenConditions) {
12325 c.
op = opNames[ec.
op < 8 ? ec.
op : 0];
12326 if (ec.
op == 4 || ec.
op == 5) {
12328 std::istringstream ss(ec.
value);
12330 while (std::getline(ss, token,
',')) {
12331 auto s = token.find_first_not_of(
" \t");
12332 auto e2 = token.find_last_not_of(
" \t");
12333 if (s != std::string::npos)
12334 c.
values.push_back(token.substr(s, e2 - s + 1));
12352 auto& scaleLikert = mCurrentScale->GetLikertOptions();
12353 auto& scaleLabels = scaleLikert.labels;
12354 bool anySelected =
false;
12358 anySelected =
true;
12362 if (!anySelected) {
12374 for (
const auto& ae : mQuestionEditor.vasAnchors) {
12376 va.
value = (double)ae.value;
12377 va.label = ae.label;
12386 std::string optionsStr = mQuestionEditor.
multiOptions;
12387 std::istringstream iss(optionsStr);
12389 while (std::getline(iss, line)) {
12391 line.erase(0, line.find_first_not_of(
" \t\r\n"));
12392 line.erase(line.find_last_not_of(
" \t\r\n") + 1);
12393 if (!line.empty()) {
12394 newQuestion.
options.push_back(line);
12402 std::string columnsStr = mQuestionEditor.
gridColumns;
12403 std::istringstream colIss(columnsStr);
12405 while (std::getline(colIss, line)) {
12406 line.erase(0, line.find_first_not_of(
" \t\r\n"));
12407 line.erase(line.find_last_not_of(
" \t\r\n") + 1);
12408 if (!line.empty()) {
12409 newQuestion.
columns.push_back(line);
12414 std::string rowsStr = mQuestionEditor.
gridRows;
12415 std::istringstream rowIss(rowsStr);
12416 while (std::getline(rowIss, line)) {
12417 line.erase(0, line.find_first_not_of(
" \t\r\n"));
12418 line.erase(line.find_last_not_of(
" \t\r\n") + 1);
12419 if (!line.empty()) {
12420 newQuestion.
rows.push_back(line);
12431 mCurrentScale->GetQuestions().push_back(newQuestion);
12434 std::string textKey = newQuestion.
text_key;
12435 std::string questionText = mQuestionEditor.
questionText;
12436 mCurrentScale->AddTranslation(
"en", textKey, questionText);
12438 printf(
"Added question: %s (type: %s)\n", newQuestion.
id.c_str(), newQuestion.
type.c_str());
12439 mCurrentScale->SetDirty(
true);
12441 mQuestionEditor.
show =
false;
12446 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
12447 mQuestionEditor.
show =
false;
12456 ImGui::TextWrapped(
"This item has nested conditional logic.");
12457 ImGui::TextWrapped(
"Conditions are preserved — use code editor to modify.");
12462 if (ImGui::IsItemHovered())
12463 ImGui::SetTooltip(
"When checked, this item is only shown when conditions are met");
12466 const char* logicItems[] = {
"AND (all must match)",
"OR (any must match)" };
12467 ImGui::Combo(
"Combine with", &e.
visibleWhenLogic, logicItems, IM_ARRAYSIZE(logicItems));
12469 int removeIndex = -1;
12474 const char* sourceTypes[] = {
"Parameter",
"Item" };
12475 ImGui::PushItemWidth(90);
12476 ImGui::Combo(
"##src", &cond.sourceType, sourceTypes, IM_ARRAYSIZE(sourceTypes));
12477 ImGui::PopItemWidth();
12480 ImGui::PushItemWidth(100);
12481 ImGui::InputText(
"##name", cond.sourceName,
sizeof(cond.sourceName));
12482 ImGui::PopItemWidth();
12485 const char* operators[] = {
"equals",
"not_equals",
"greater_than",
"less_than",
"in",
"not_in",
"is_answered",
"is_not_answered" };
12486 ImGui::PushItemWidth(100);
12487 ImGui::Combo(
"##op", &cond.op, operators, IM_ARRAYSIZE(operators));
12488 ImGui::PopItemWidth();
12492 ImGui::PushItemWidth((cond.op == 4 || cond.op == 5) ? 200 : 100);
12493 ImGui::InputText(
"##val", cond.value,
sizeof(cond.value));
12494 if ((cond.op == 4 || cond.op == 5) && ImGui::IsItemHovered())
12495 ImGui::SetTooltip(
"Comma-separated list of values, e.g. edu_grad,edu_phd");
12496 ImGui::PopItemWidth();
12500 if (ImGui::SmallButton(
"X"))
12505 if (removeIndex >= 0)
12508 if (ImGui::SmallButton(
"+ Add Condition")) {
12516void LauncherUI::RenderSectionEditorForm()
12518 auto& e = mQuestionEditor;
12519 if (!e.
show)
return;
12521 const char* title = (e.
editingIndex >= 0) ?
"Edit Section"
12522 : e.isVirtualStart ?
"Edit Start Section"
12524 ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
12525 ImGui::SetNextWindowPos(
12526 ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f),
12527 ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
12529 if (!ImGui::Begin(title, &e.
show, ImGuiWindowFlags_NoCollapse)) {
12534 ImGui::Text(
"Section Marker");
12535 ImGui::Separator();
12538 ImGui::InputText(
"Section ID", e.
id,
sizeof(e.
id));
12539 if (ImGui::IsItemHovered())
12540 ImGui::SetTooltip(
"Unique section identifier (e.g., sec_1, demographics).\n"
12541 "Use lowercase letters, digits, and underscores only.");
12543 ImGui::Text(
"Title (optional):");
12544 ImGui::SetNextItemWidth(-FLT_MIN);
12546 if (ImGui::IsItemHovered())
12547 ImGui::SetTooltip(
"Displayed as a section heading by runners that support it.\n"
12548 "Stored in translation file under the section ID as key.");
12551 ImGui::Separator();
12552 ImGui::Text(
"Conditional Display");
12555 RenderVisibleWhenEditor(e);
12558 ImGui::Separator();
12559 ImGui::Text(
"Navigation");
12562 ImGui::Checkbox(
"Revisable (allow Back button within section)", &e.
sectionRevisable);
12563 if (ImGui::IsItemHovered())
12564 ImGui::SetTooltip(
"When checked (default), runners show a Back button allowing\n"
12565 "participants to revise answers within this section.\n"
12566 "When unchecked, responses are final once submitted.");
12570 ImGui::Checkbox(
"Randomize questions within section", &e.
sectionRandomize);
12571 if (ImGui::IsItemHovered())
12572 ImGui::SetTooltip(
"When checked, questions in this section are presented in random order.\n"
12573 "Questions with random_group=0 are always fixed.\n"
12574 "Additional questions can be pinned using the Fixed IDs field below.");
12576 ImGui::SetNextItemWidth(-FLT_MIN);
12578 if (ImGui::IsItemHovered())
12579 ImGui::SetTooltip(
"Comma-separated question IDs to keep in their original position\n"
12580 "(e.g. inst1,sec_break). Leave blank to shuffle all questions.");
12581 ImGui::TextDisabled(
"Fixed IDs (comma-separated, optional)");
12585 ImGui::Separator();
12588 bool canSave = (e.
id[0] !=
'\0');
12589 if (!canSave) ImGui::BeginDisabled();
12590 const char* saveLabel = (e.
editingIndex < 0) ?
"Add Section" :
"Save Section";
12591 if (ImGui::Button(saveLabel, ImVec2(120, 0))) {
12594 sec.
type =
"section";
12604 std::istringstream ss(fixedStr);
12606 while (std::getline(ss, token,
',')) {
12607 auto start = token.find_first_not_of(
" \t");
12608 auto end = token.find_last_not_of(
" \t");
12609 if (start != std::string::npos)
12614 const char* opNames[] = {
"equals",
"not_equals",
"greater_than",
"less_than",
"in",
"not_in",
"is_answered",
"is_not_answered" };
12615 for (
const auto& ec : e.visibleWhenConditions) {
12619 c.
op = opNames[ec.
op < 8 ? ec.
op : 0];
12620 if (ec.
op == 4 || ec.
op == 5) {
12622 std::istringstream ss(ec.
value);
12624 while (std::getline(ss, token,
',')) {
12625 auto s = token.find_first_not_of(
" \t");
12626 auto e2 = token.find_last_not_of(
" \t");
12627 if (s != std::string::npos)
12628 c.
values.push_back(token.substr(s, e2 - s + 1));
12642 mCurrentScale->InsertQuestion(0, sec);
12644 mCurrentScale->AddQuestion(sec);
12646 auto& questions = mCurrentScale->GetQuestions();
12649 mCurrentScale->SetDirty(
true);
12654 if (!canSave) ImGui::EndDisabled();
12656 if (ImGui::Button(
"Cancel", ImVec2(80, 0)))
12662void LauncherUI::ShowBatchImportDialog()
12664 if (!mCurrentScale) {
12665 mBatchImport.
show =
false;
12669 ImGui::SetNextWindowSize(ImVec2(700, 600), ImGuiCond_FirstUseEver);
12670 ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
12672 if (!ImGui::Begin(
"Batch Import Questions", &mBatchImport.
show, ImGuiWindowFlags_NoCollapse))
12678 ImGui::TextWrapped(
"Paste your questions below, one per line. Each line will become a separate question.");
12680 ImGui::Separator();
12684 ImGui::Text(
"Common Settings for All Questions");
12688 ImGui::InputText(
"ID Prefix", mBatchImport.
idPrefix,
sizeof(mBatchImport.
idPrefix));
12689 if (ImGui::IsItemHovered()) {
12690 ImGui::SetTooltip(
"Will be combined with zero-padded numbers (e.g., 'MOCI' becomes MOCI001, MOCI002, ...)");
12694 ImGui::InputInt(
"Start Number", &mBatchImport.
startNumber);
12697 const char* questionTypes[] = {
"likert",
"multi",
"short",
"long",
"vas",
"inst",
"multicheck",
"grid",
"image",
"imageresponse" };
12698 if (ImGui::Combo(
"Type", &mBatchImport.
questionType, questionTypes, IM_ARRAYSIZE(questionTypes))) {
12709 ImGui::Text(
"Likert Response Options:");
12713 const char* likertPresets[] = {
12715 "TRUE / FALSE (2-point)",
12716 "Disagree / Agree (5-point)",
12717 "Strongly Disagree / Strongly Agree (7-point)",
12718 "Never / Always (5-point)",
12719 "Not at all / Extremely (5-point)"
12722 if (ImGui::Combo(
"Preset", &mBatchImport.
likertPreset, likertPresets, IM_ARRAYSIZE(likertPresets))) {
12731 std::strncpy(mBatchImport.
likertLabels,
"Strongly Disagree|Disagree|Neutral|Agree|Strongly Agree",
sizeof(mBatchImport.
likertLabels) - 1);
12735 std::strncpy(mBatchImport.
likertLabels,
"Strongly Disagree|Disagree|Somewhat Disagree|Neutral|Somewhat Agree|Agree|Strongly Agree",
sizeof(mBatchImport.
likertLabels) - 1);
12739 std::strncpy(mBatchImport.
likertLabels,
"Never|Rarely|Sometimes|Often|Always",
sizeof(mBatchImport.
likertLabels) - 1);
12743 std::strncpy(mBatchImport.
likertLabels,
"Not at all|A little|Moderately|Quite a bit|Extremely",
sizeof(mBatchImport.
likertLabels) - 1);
12754 ImGui::InputInt(
"Number of Points", &mBatchImport.
likertPoints);
12758 ImGui::InputInt(
"Min Value", &mBatchImport.
likertMin);
12759 if (ImGui::IsItemHovered()) {
12760 ImGui::SetTooltip(
"Minimum value for responses (-1 = use default based on points)");
12763 ImGui::InputInt(
"Max Value", &mBatchImport.
likertMax);
12764 if (ImGui::IsItemHovered()) {
12765 ImGui::SetTooltip(
"Maximum value for responses (-1 = use default based on points)");
12769 if (ImGui::IsItemHovered()) {
12770 ImGui::SetTooltip(
"Pipe-separated labels (e.g., 'True|False' or 'Strongly Disagree|...|Strongly Agree')");
12778 ImGui::Separator();
12782 ImGui::Text(
"Question Text (one per line):");
12783 ImGui::InputTextMultiline(
"##QuestionText", mBatchImport.
questionText,
sizeof(mBatchImport.
questionText), ImVec2(-1, 250));
12786 ImGui::Separator();
12791 for (
int i = 0; mBatchImport.
questionText[i] !=
'\0'; i++) {
12792 if (mBatchImport.
questionText[i] ==
'\n') lineCount++;
12797 ImGui::Text(
"Questions to import: %d", lineCount);
12802 if (ImGui::Button(
"Import Questions", ImVec2(150, 0))) {
12803 if (strlen(mBatchImport.
idPrefix) == 0) {
12804 printf(
"Error: ID Prefix cannot be empty\n");
12807 std::vector<std::string> lines;
12810 size_t end = text.find(
'\n');
12812 while (end != std::string::npos) {
12813 std::string line = text.substr(start, end - start);
12815 while (!line.empty() && isspace(line.front())) line.erase(0, 1);
12816 while (!line.empty() && isspace(line.back())) line.pop_back();
12817 if (!line.empty()) {
12818 lines.push_back(line);
12821 end = text.find(
'\n', start);
12825 if (start < text.length()) {
12826 std::string line = text.substr(start);
12827 while (!line.empty() && isspace(line.front())) line.erase(0, 1);
12828 while (!line.empty() && isspace(line.back())) line.pop_back();
12829 if (!line.empty()) {
12830 lines.push_back(line);
12835 std::vector<std::string> likertLabelsList;
12839 size_t end = labelsStr.find(
'|');
12840 while (end != std::string::npos) {
12841 likertLabelsList.push_back(labelsStr.substr(start, end - start));
12843 end = labelsStr.find(
'|', start);
12846 if (start < labelsStr.length()) {
12847 likertLabelsList.push_back(labelsStr.substr(start));
12855 std::vector<std::string> labelKeys;
12856 if (mBatchImport.
questionType == 0 && !likertLabelsList.empty()) {
12858 for (
size_t i = 0; i < likertLabelsList.size(); i++) {
12859 std::string labelKey = std::string(mBatchImport.
idPrefix) +
"_response_" + std::to_string(i + 1);
12860 labelKeys.push_back(labelKey);
12862 mCurrentScale->GetTranslations()[
"en"][labelKey] = likertLabelsList[i];
12866 mCurrentScale->GetLikertOptions().points = mBatchImport.
likertPoints;
12867 mCurrentScale->GetLikertOptions().labels = labelKeys;
12868 mCurrentScale->GetLikertOptions().min = mBatchImport.
likertMin;
12869 mCurrentScale->GetLikertOptions().max = mBatchImport.
likertMax;
12873 std::set<std::string> existingIds;
12874 for (
const auto& q : mCurrentScale->GetQuestions()) existingIds.insert(q.id);
12876 for (
const auto& line : lines) {
12880 snprintf(numStr,
sizeof(numStr),
"%03d", questionNumber);
12881 newQuestion.
id = std::string(mBatchImport.
idPrefix) + numStr;
12886 if (existingIds.count(newQuestion.
id)) {
12887 printf(
"Warning: Skipping duplicate question ID '%s'\n", newQuestion.
id.c_str());
12891 existingIds.insert(newQuestion.
id);
12902 mCurrentScale->GetQuestions().push_back(newQuestion);
12905 mCurrentScale->GetTranslations()[
"en"][newQuestion.
text_key] = line;
12910 printf(
"Batch imported %zu questions\n", lines.size());
12911 mBatchImport.
show =
false;
12915 if (ImGui::Button(
"Cancel", ImVec2(150, 0))) {
12916 mBatchImport.
show =
false;
12922void LauncherUI::ShowDimensionEditor()
12924 if (!mCurrentScale) {
12925 mDimensionEditor.
show =
false;
12929 ImGui::SetNextWindowSize(ImVec2(500, 400), ImGuiCond_FirstUseEver);
12930 ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
12932 bool isEditing = (mDimensionEditor.
editingIndex >= 0);
12933 const char* windowTitle = isEditing ?
"Edit Dimension" :
"Add Dimension";
12935 if (!ImGui::Begin(windowTitle, &mDimensionEditor.
show, ImGuiWindowFlags_NoCollapse))
12941 ImGui::Text(
"Dimension Details");
12942 ImGui::Separator();
12947 ImGui::InputText(
"ID", mDimensionEditor.
id,
sizeof(mDimensionEditor.
id), ImGuiInputTextFlags_ReadOnly);
12948 if (ImGui::IsItemHovered()) {
12949 ImGui::SetTooltip(
"ID cannot be changed after creation (used in scoring references)");
12952 ImGui::InputText(
"ID", mDimensionEditor.
id,
sizeof(mDimensionEditor.
id));
12953 if (ImGui::IsItemHovered()) {
12954 ImGui::SetTooltip(
"Short identifier used in scoring (e.g., 'checking', 'cleaning')");
12959 ImGui::InputText(
"Name", mDimensionEditor.
name,
sizeof(mDimensionEditor.
name));
12960 if (ImGui::IsItemHovered()) {
12961 ImGui::SetTooltip(
"Full name (e.g., 'Checking Behaviors')");
12966 if (ImGui::IsItemHovered()) {
12967 ImGui::SetTooltip(
"Short abbreviation (e.g., 'CHK')");
12972 ImGui::Text(
"Description:");
12973 ImGui::InputTextMultiline(
"##DimDescription", mDimensionEditor.
description,
sizeof(mDimensionEditor.
description), ImVec2(-1, 100));
12977 ImGui::Separator();
12978 ImGui::Text(
"Dimension Selection");
12981 ImGui::Checkbox(
"Allow researcher to enable/disable this dimension##selectable", &mDimensionEditor.
selectable);
12982 if (ImGui::IsItemHovered()) {
12983 ImGui::SetTooltip(
"When checked, a boolean parameter is auto-generated in the study\nschema so researchers can turn this dimension on or off.");
12987 ImGui::Checkbox(
"Enabled by default##defEnabled", &mDimensionEditor.
defaultEnabled);
12988 ImGui::SetNextItemWidth(200);
12989 ImGui::InputText(
"Parameter name (optional)##enabledParam", mDimensionEditor.
enabledParam,
sizeof(mDimensionEditor.
enabledParam));
12990 if (ImGui::IsItemHovered()) {
12991 ImGui::SetTooltip(
"Leave blank to auto-name as do_{id}. Must match enabled_param\nin the OSD if you want to reference it elsewhere.");
12997 ImGui::Separator();
12998 ImGui::Text(
"Conditional Display");
13001 ImGui::Checkbox(
"Show conditionally##dim", &mDimensionEditor.
hasVisibleWhen);
13002 if (ImGui::IsItemHovered()) {
13003 ImGui::SetTooltip(
"When set, all questions in this dimension are shown/hidden\nbased on these conditions. Evaluated dynamically —\ncan reference previous answers.");
13007 const char* logicItems[] = {
"AND (all must match)",
"OR (any must match)" };
13008 ImGui::Combo(
"Combine with##dim", &mDimensionEditor.
visibleWhenLogic, logicItems, IM_ARRAYSIZE(logicItems));
13010 int removeIndex = -1;
13015 const char* sourceTypes[] = {
"Parameter",
"Item" };
13016 ImGui::PushItemWidth(90);
13017 ImGui::Combo(
"##src", &cond.sourceType, sourceTypes, IM_ARRAYSIZE(sourceTypes));
13018 ImGui::PopItemWidth();
13021 ImGui::PushItemWidth(100);
13022 ImGui::InputText(
"##name", cond.sourceName,
sizeof(cond.sourceName));
13023 ImGui::PopItemWidth();
13026 const char* operators[] = {
"equals",
"not_equals",
"greater_than",
"less_than",
"in",
"not_in",
"is_answered",
"is_not_answered" };
13027 ImGui::PushItemWidth(100);
13028 ImGui::Combo(
"##op", &cond.op, operators, IM_ARRAYSIZE(operators));
13029 ImGui::PopItemWidth();
13032 ImGui::PushItemWidth(100);
13033 ImGui::InputText(
"##val", cond.value,
sizeof(cond.value));
13034 ImGui::PopItemWidth();
13037 if (ImGui::SmallButton(
"X")) {
13043 if (removeIndex >= 0) {
13048 if (ImGui::SmallButton(
"+ Add Condition##dim")) {
13055 ImGui::Separator();
13061 dim.visible_when_logic = (mDimensionEditor.
visibleWhenLogic == 1) ?
"any" :
"all";
13062 dim.visible_when.clear();
13064 const char* opNames[] = {
"equals",
"not_equals",
"greater_than",
"less_than",
"in",
"not_in",
"is_answered",
"is_not_answered" };
13065 for (
const auto& ec : mDimensionEditor.visibleWhenConditions) {
13069 c.
op = opNames[ec.
op < 8 ? ec.
op : 0];
13070 if (ec.
op == 4 || ec.
op == 5) {
13072 std::istringstream ss(ec.
value);
13074 while (std::getline(ss, token,
',')) {
13075 auto s = token.find_first_not_of(
" \t");
13076 auto e2 = token.find_last_not_of(
" \t");
13077 if (s != std::string::npos)
13078 c.
values.push_back(token.substr(s, e2 - s + 1));
13084 dim.visible_when.push_back(c);
13090 const char* okLabel = isEditing ?
"Save" :
"Create";
13091 if (ImGui::Button(okLabel, ImVec2(120, 0))) {
13093 if (strlen(mDimensionEditor.
id) == 0) {
13094 printf(
"Error: Dimension ID cannot be empty\n");
13095 }
else if (strlen(mDimensionEditor.
name) == 0) {
13096 printf(
"Error: Dimension name cannot be empty\n");
13097 }
else if (isEditing) {
13099 auto& dim = mCurrentScale->GetDimensions()[mDimensionEditor.
editingIndex];
13100 dim.name = mDimensionEditor.
name;
13103 dim.selectable = mDimensionEditor.
selectable;
13106 saveDimVisibleWhen(dim);
13107 mCurrentScale->SetDirty(
true);
13108 mDimensionEditor.
show =
false;
13112 newDimension.
id = mDimensionEditor.
id;
13113 newDimension.
name = mDimensionEditor.
name;
13119 saveDimVisibleWhen(newDimension);
13121 mCurrentScale->GetDimensions().push_back(newDimension);
13122 mCurrentScale->SetDirty(
true);
13123 printf(
"Added dimension: %s (%s)\n", newDimension.
name.c_str(), newDimension.
id.c_str());
13125 mDimensionEditor.
show =
false;
13129 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
13130 mDimensionEditor.
show =
false;
13136void LauncherUI::ShowCorrectAnswersEditor()
13138 if (!mCurrentScale) {
13139 mCorrectAnswersEditor.
show =
false;
13143 ImGui::SetNextWindowSize(ImVec2(800, 500), ImGuiCond_FirstUseEver);
13144 ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f),
13145 ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
13147 std::string title =
"Correct Answers: " + mCorrectAnswersEditor.
questionId;
13148 if (!ImGui::Begin(title.c_str(), &mCorrectAnswersEditor.
show, ImGuiWindowFlags_NoCollapse))
13155 ImGui::TextWrapped(
"Q: %s", mCorrectAnswersEditor.
questionText.c_str());
13157 ImGui::TextDisabled(
"(%s)", mCorrectAnswersEditor.
questionType.c_str());
13159 if (mCorrectAnswersEditor.
questionType ==
"multicheck") {
13160 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f),
13161 "Multicheck: enter the set of options that should all be selected.");
13162 }
else if (mCorrectAnswersEditor.
questionType ==
"grid") {
13163 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f),
13164 "Grid: enter one correct value per row (column number, 1-based).");
13166 ImGui::TextDisabled(
"Response is correct if it matches ANY entry below. "
13167 "Use * for any chars, ? for single char. Case-insensitive by default.");
13170 ImGui::Separator();
13173 float matchCaseWidth = 80.0f;
13174 float removeWidth = 60.0f;
13175 float spacing = ImGui::GetStyle().ItemSpacing.x;
13178 ImGui::Text(
"Answer / Pattern");
13179 ImGui::SameLine(ImGui::GetContentRegionAvail().x - matchCaseWidth - removeWidth - spacing);
13181 if (ImGui::IsItemHovered()) {
13182 ImGui::SetTooltip(
"Match case: when checked, comparison is case-sensitive");
13186 int removeIndex = -1;
13188 ImGui::BeginChild(
"AnswerList", ImVec2(0, -35),
true);
13189 for (
size_t i = 0; i < mCorrectAnswersEditor.
answers.size(); i++) {
13190 ImGui::PushID((
int)i);
13194 std::strncpy(buf, mCorrectAnswersEditor.
answers[i].c_str(),
sizeof(buf) - 1);
13195 buf[
sizeof(buf) - 1] =
'\0';
13197 float inputWidth = ImGui::GetContentRegionAvail().x - matchCaseWidth - removeWidth - spacing * 2;
13198 ImGui::SetNextItemWidth(inputWidth);
13199 if (ImGui::InputText(
"##ans", buf,
sizeof(buf))) {
13200 mCorrectAnswersEditor.
answers[i] = buf;
13210 if (ImGui::Checkbox(
"Match##cs", &cs)) {
13216 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 1.0f));
13217 if (ImGui::Button(
"X", ImVec2(25, 0))) {
13218 removeIndex = (int)i;
13220 ImGui::PopStyleColor();
13227 if (removeIndex >= 0) {
13228 mCorrectAnswersEditor.
answers.erase(mCorrectAnswersEditor.
answers.begin() + removeIndex);
13229 if (removeIndex <
static_cast<int>(mCorrectAnswersEditor.
caseSensitive.size())) {
13235 if (ImGui::Button(
"+ Add")) {
13236 mCorrectAnswersEditor.
answers.push_back(
"");
13240 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 160);
13242 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.6f, 0.2f, 1.0f));
13243 if (ImGui::Button(
"OK", ImVec2(70, 0))) {
13245 auto& scoring = mCurrentScale->GetScoring();
13246 auto it = scoring.find(mCorrectAnswersEditor.
dimensionId);
13247 if (it != scoring.end()) {
13248 std::vector<std::string> cleaned;
13249 for (
size_t i = 0; i < mCorrectAnswersEditor.
answers.size(); i++) {
13250 const auto& ans = mCorrectAnswersEditor.
answers[i];
13251 if (!ans.empty()) {
13254 cleaned.push_back(
"(?c)" + ans);
13256 cleaned.push_back(ans);
13260 if (cleaned.empty()) {
13261 it->second.correct_answers.erase(mCorrectAnswersEditor.
questionId);
13263 it->second.correct_answers[mCorrectAnswersEditor.
questionId] = cleaned;
13265 mCurrentScale->SetDirty(
true);
13267 mCorrectAnswersEditor.
show =
false;
13269 ImGui::PopStyleColor();
13272 if (ImGui::Button(
"Cancel", ImVec2(70, 0))) {
13273 mCorrectAnswersEditor.
show =
false;
13279void LauncherUI::ShowNormsEditor()
13281 if (!mCurrentScale) {
13282 mNormsEditor.
show =
false;
13286 ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
13287 ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f),
13288 ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
13290 std::string title =
"Norms Editor — " + mNormsEditor.
dimensionName;
13291 if (!ImGui::Begin(title.c_str(), &mNormsEditor.
show, ImGuiWindowFlags_NoCollapse))
13297 ImGui::TextDisabled(
"Set score ranges and interpretation labels for the report.");
13298 ImGui::Separator();
13300 int removeIndex = -1;
13302 ImGui::BeginChild(
"ThresholdList", ImVec2(0, -40),
true);
13303 if (ImGui::BeginTable(
"NormsTable", 4, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp))
13305 ImGui::TableSetupColumn(
"Min", ImGuiTableColumnFlags_WidthFixed, 80.0f);
13306 ImGui::TableSetupColumn(
"Max", ImGuiTableColumnFlags_WidthFixed, 80.0f);
13307 ImGui::TableSetupColumn(
"Label", ImGuiTableColumnFlags_WidthStretch);
13308 ImGui::TableSetupColumn(
"##del", ImGuiTableColumnFlags_WidthFixed, 30.0f);
13309 ImGui::TableHeadersRow();
13311 for (
size_t i = 0; i < mNormsEditor.
rows.size(); i++) {
13312 ImGui::PushID((
int)i);
13313 auto& row = mNormsEditor.
rows[i];
13315 ImGui::TableNextRow();
13316 ImGui::TableSetColumnIndex(0);
13317 ImGui::SetNextItemWidth(-1);
13318 ImGui::InputFloat(
"##min", &row.minVal, 0, 0,
"%.1f");
13320 ImGui::TableSetColumnIndex(1);
13321 ImGui::SetNextItemWidth(-1);
13322 ImGui::InputFloat(
"##max", &row.maxVal, 0, 0,
"%.1f");
13324 ImGui::TableSetColumnIndex(2);
13325 ImGui::SetNextItemWidth(-1);
13326 ImGui::InputText(
"##label", row.label,
sizeof(row.label));
13328 ImGui::TableSetColumnIndex(3);
13329 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 1.0f));
13330 if (ImGui::SmallButton(
"x")) {
13331 removeIndex = (int)i;
13333 ImGui::PopStyleColor();
13341 if (removeIndex >= 0) {
13342 mNormsEditor.
rows.erase(mNormsEditor.
rows.begin() + removeIndex);
13345 if (ImGui::Button(
"+ Add Threshold")) {
13347 if (!mNormsEditor.
rows.empty()) {
13348 float prevMax = mNormsEditor.
rows.back().maxVal;
13349 te.
minVal = prevMax + 1.0f;
13352 mNormsEditor.
rows.push_back(te);
13355 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 160);
13357 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.6f, 0.2f, 1.0f));
13358 if (ImGui::Button(
"Save", ImVec2(70, 0))) {
13359 auto& scoring = mCurrentScale->GetScoring();
13360 auto it = scoring.find(mNormsEditor.
dimensionId);
13361 if (it != scoring.end()) {
13362 it->second.norms.clear();
13363 for (
const auto& row : mNormsEditor.rows) {
13365 nt.
min = (double)row.minVal;
13366 nt.max = (double)row.maxVal;
13367 nt.label = row.label;
13368 it->second.norms.push_back(nt);
13370 mCurrentScale->SetDirty(
true);
13372 mNormsEditor.
show =
false;
13374 ImGui::PopStyleColor();
13377 if (ImGui::Button(
"Cancel", ImVec2(70, 0))) {
13378 mNormsEditor.
show =
false;
13384void LauncherUI::ShowCreateStudyFromScaleDialog()
13389 mCreateStudyDialog.
show =
false;
13394 mCreateStudyDialog.
show =
false;
13398 ImGui::SetNextWindowSize(ImVec2(500, 350), ImGuiCond_FirstUseEver);
13399 ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f));
13401 if (!ImGui::Begin(
"Add Scale to Study", &mCreateStudyDialog.
show, ImGuiWindowFlags_NoCollapse))
13407 ImGui::Text(
"Add this scale as a test in a study.");
13409 ImGui::Separator();
13414 ImGui::Text(
"Select Scale:");
13418 :
"Select a scale...";
13420 if (ImGui::BeginCombo(
"##ScaleSelect", currentScaleName)) {
13421 for (
size_t i = 0; i < mScaleList.size(); i++) {
13423 if (ImGui::Selectable(mScaleList[i].c_str(), is_selected)) {
13425 std::strncpy(mCreateStudyDialog.
studyName, mScaleList[i].c_str(),
13426 sizeof(mCreateStudyDialog.
studyName) - 1);
13432 ImGui::SetItemDefaultFocus();
13441 if (ImGui::RadioButton(
"Create new study", !mCreateStudyDialog.
addToExisting)) {
13447 if (ImGui::RadioButton(
"Add to existing study", mCreateStudyDialog.
addToExisting)) {
13457 ImGui::Text(
"Select Study:");
13461 :
"Select a study...";
13463 if (ImGui::BeginCombo(
"##ExistingStudySelect", currentStudyName)) {
13464 for (
size_t i = 0; i < mStudyList.size(); i++) {
13466 if (ImGui::Selectable(mStudyList[i].c_str(), is_selected)) {
13471 ImGui::SetItemDefaultFocus();
13478 ImGui::Text(
"Study Name:");
13479 if (ImGui::InputText(
"##StudyName", mCreateStudyDialog.
studyName,
sizeof(mCreateStudyDialog.
studyName))) {
13483 if (ImGui::IsItemHovered()) {
13484 ImGui::SetTooltip(
"The name of the study directory to create in my_studies/");
13492 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.2f, 0.2f, 1.0f));
13493 ImGui::TextWrapped(
"%s", mCreateStudyDialog.
errorMessage);
13494 ImGui::PopStyleColor();
13498 ImGui::Separator();
13502 const char* buttonText = mCreateStudyDialog.
addToExisting ?
"Add" :
13504 if (ImGui::Button(buttonText, ImVec2(120, 0))) {
13506 std::shared_ptr<ScaleDefinition> scaleToUse = mCurrentScale;
13509 std::strncpy(mCreateStudyDialog.
errorMessage,
"Please select a scale first.",
13511 scaleToUse =
nullptr;
13514 scaleToUse = mScaleManager->LoadScale(scaleCode);
13516 std::strncpy(mCreateStudyDialog.
errorMessage,
"Failed to load selected scale.",
13527 std::strncpy(mCreateStudyDialog.
errorMessage,
"Please select a study.",
13531 std::string studyPath = mWorkspace->GetStudiesPath() +
"/" + studyDir;
13533 if (mScaleManager->AddScaleToStudy(scaleToUse, studyPath)) {
13534 printf(
"Scale '%s' added to study '%s'\n",
13535 scaleToUse->GetScaleInfo().code.c_str(), studyDir.c_str());
13538 std::string mainChainPath = studyPath +
"/chains/Main.json";
13539 if (fs::exists(mainChainPath)) {
13541 item.
testName = scaleToUse->GetScaleInfo().code;
13546 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
13547 mCurrentChain->AddItem(item);
13548 mCurrentChain->Save();
13549 printf(
"Added scale to Main chain (current chain)\n");
13553 mainChain->AddItem(item);
13555 printf(
"Added scale to Main chain\n");
13560 mCreateStudyDialog.
show =
false;
13561 mStudyList = mWorkspace->GetStudyDirectories();
13564 if (mCurrentStudy && mCurrentStudy->GetPath() == studyPath) {
13565 LoadStudy(studyPath);
13569 "Failed to add scale to study. Check console for details.",
13575 if (strlen(mCreateStudyDialog.
studyName) == 0) {
13576 std::strncpy(mCreateStudyDialog.
errorMessage,
"Study name cannot be empty.",
13579 std::string studyPath = mWorkspace->GetStudiesPath() +
"/" + std::string(mCreateStudyDialog.
studyName);
13581 std::string warnMsg =
"Study '" + std::string(mCreateStudyDialog.
studyName) +
"' already exists. Click Update to overwrite it.";
13582 std::strncpy(mCreateStudyDialog.
errorMessage, warnMsg.c_str(),
13587 std::string customName = std::string(mCreateStudyDialog.
studyName);
13588 if (mScaleManager->CreateStudyFromScale(scaleToUse, mWorkspace->GetStudiesPath(), customName)) {
13589 printf(
"Scale study '%s' created successfully in my_studies/\n", mCreateStudyDialog.
studyName);
13590 mCreateStudyDialog.
show =
false;
13592 mStudyList = mWorkspace->GetStudyDirectories();
13595 "Failed to create study. Check console for details.",
13604 if (ImGui::Button(
"Cancel", ImVec2(120, 0))) {
13605 mCreateStudyDialog.
show =
false;
13612void LauncherUI::TestCurrentScale()
13614 if (!mCurrentScale) {
13615 printf(
"Error: No scale loaded to test\n");
13620 printf(
"Error: No workspace loaded\n");
13624 printf(
"Testing scale: %s\n", mCurrentScale->GetScaleInfo().code.c_str());
13627 std::string scaleCode = mCurrentScale->GetScaleInfo().code;
13630 std::string tempDir = mWorkspace->GetWorkspacePath() +
"/temp/scale-test-" + scaleCode;
13633 if (fs::exists(tempDir)) {
13634 fs::remove_all(tempDir);
13645 fs::create_directories(tempDir);
13646 fs::create_directories(tempDir +
"/" + scaleCode);
13647 fs::create_directories(tempDir +
"/definitions");
13648 fs::create_directories(tempDir +
"/translations");
13649 fs::create_directories(tempDir +
"/params");
13650 fs::create_directories(tempDir +
"/data");
13652 printf(
"Created temp test directory: %s\n", tempDir.c_str());
13655 std::string scaleRunnerSource = mBatteryPath +
"/../media/apps/scales/ScaleRunner.pbl";
13656 std::string scaleRunnerDest = tempDir +
"/" + scaleCode +
".pbl";
13658 if (!fs::exists(scaleRunnerSource)) {
13659 printf(
"Error: ScaleRunner.pbl not found at: %s\n", scaleRunnerSource.c_str());
13663 fs::copy_file(scaleRunnerSource, scaleRunnerDest, fs::copy_options::overwrite_existing);
13664 printf(
"Copied ScaleRunner.pbl\n");
13667 if (!mCurrentScale->ExportToOSD(tempDir +
"/" + scaleCode)) {
13668 printf(
"Warning: Failed to export OSD bundle (non-fatal)\n");
13670 printf(
"Exported OSD bundle\n");
13674 if (!mCurrentScale->ExportToJSON(tempDir +
"/definitions", tempDir +
"/translations")) {
13675 printf(
"Error: Failed to export scale JSON files\n");
13679 printf(
"Exported scale definition and translations\n");
13682 SyncScaleSchema(tempDir, scaleCode);
13684 printf(
"Preparing to test scale...\n");
13687 if (mRunningExperiment) {
13689 printf(
"Warning: Previous test still running\n");
13692 delete mRunningExperiment;
13693 mRunningExperiment =
nullptr;
13699 std::vector<std::string> args = {
13700 "--pfile", scaleCode +
".pbl.par.json",
13704 bool success = mRunningExperiment->
RunExperiment(scaleRunnerDest, args,
13705 (
"TEST_" + scaleCode).c_str(),
13709 printf(
"Scale test started successfully\n");
13710 printf(
"Working directory: %s\n", tempDir.c_str());
13711 printf(
"Data will be saved to: %s/data/\n", tempDir.c_str());
13712 mShowStderr =
false;
13714 printf(
"Failed to run scale test\n");
13715 delete mRunningExperiment;
13716 mRunningExperiment =
nullptr;
13719 }
catch (
const std::exception& e) {
13720 printf(
"Exception while testing scale: %s\n", e.what());
13724void LauncherUI::ShowSnapshotCreatedDialog()
13726 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
13727 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
13728 ImGui::SetNextWindowSize(ImVec2(650, 300), ImGuiCond_FirstUseEver);
13730 if (!ImGui::Begin(
"Snapshot Created", &mShowSnapshotCreated, ImGuiWindowFlags_NoCollapse))
13736 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"✓ Snapshot created successfully!");
13737 ImGui::Separator();
13741 ImGui::Text(
"Snapshot Name:");
13743 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f),
"%s", mLastSnapshotName);
13747 ImGui::Text(
"Location:");
13748 ImGui::TextWrapped(
"%s", mLastSnapshotPath);
13751 ImGui::Separator();
13754 ImGui::TextWrapped(
"This snapshot excludes data/ directories and is ready to upload to PEBLHub or share with others.");
13758 if (ImGui::Button(
"Open in File Manager", ImVec2(200, 0))) {
13759 OpenDirectoryInFileBrowser(std::string(mLastSnapshotPath));
13761 if (ImGui::IsItemHovered()) {
13762 ImGui::SetTooltip(
"Open the snapshot directory in your file manager");
13768 if (ImGui::Button(
"Create ZIP", ImVec2(150, 0))) {
13770 std::string zipPath = std::string(mLastSnapshotPath) +
".zip";
13771 printf(
"Creating ZIP file: %s\n", zipPath.c_str());
13776 std::string command =
"powershell -Command \"Compress-Archive -Path '" +
13777 std::string(mLastSnapshotPath) +
"' -DestinationPath '" +
13778 zipPath +
"' -Force\"";
13781 std::string command =
"cd \"" + fs::path(mLastSnapshotPath).parent_path().string() +
"\" && " +
13782 "zip -r \"" + fs::path(zipPath).filename().string() +
"\" \"" +
13783 fs::path(mLastSnapshotPath).filename().string() +
"\"";
13786 printf(
"Running: %s\n", command.c_str());
13787 int result = system(command.c_str());
13790 printf(
"ZIP file created: %s\n", zipPath.c_str());
13792 ImGui::OpenPopup(
"ZIP Created");
13794 printf(
"Failed to create ZIP file (exit code: %d)\n", result);
13795 ImGui::OpenPopup(
"ZIP Failed");
13798 if (ImGui::IsItemHovered()) {
13799 ImGui::SetTooltip(
"Create a ZIP file from this snapshot for easy sharing/upload");
13804 if (ImGui::Button(
"Close", ImVec2(100, 0))) {
13805 mShowSnapshotCreated =
false;
13809 if (ImGui::BeginPopupModal(
"ZIP Created",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
13810 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f),
"ZIP file created successfully!");
13811 ImGui::Text(
"Location: %s.zip", mLastSnapshotPath);
13813 if (ImGui::Button(
"OK", ImVec2(120, 0))) {
13814 ImGui::CloseCurrentPopup();
13819 if (ImGui::BeginPopupModal(
"ZIP Failed",
nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
13820 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"Failed to create ZIP file");
13821 ImGui::Text(
"You can manually ZIP the snapshot directory:");
13822 ImGui::TextWrapped(
"%s", mLastSnapshotPath);
13824 if (ImGui::Button(
"OK", ImVec2(120, 0))) {
13825 ImGui::CloseCurrentPopup();
char * br_find_prefix(const char *default_prefix)
int br_init(BrInitError *error)
static std::shared_ptr< Chain > LoadFromFile(const std::string &path)
static std::shared_ptr< Chain > CreateNew(const std::string &path, const std::string &name, const std::string &description="")
std::string GetStdout() const
bool RunExperiment(const std::string &scriptPath, const std::vector< std::string > &args, const std::string &subjectCode="", const std::string &language="", bool fullscreen=false)
static std::string GetLaunchLogPath()
std::string GetStderr() const
std::string GetBatteryPath() const
const std::vector< RecentExperiment > & GetRecentExperiments() const
bool GetAutoUpload() const
void SetAutoUpload(bool autoUpload)
void SetFullscreen(bool fullscreen)
void SetWorkspacePath(const std::string &path)
bool GetFullscreen() const
std::string GetUploadURL() const
void SetLanguage(const std::string &lang)
std::string GetExperimentDirectory() const
void SetSubjectCode(const std::string &code)
std::string GetLanguage() const
std::string GetSubjectCode() const
std::string GetExternalEditor() const
void SetFontSize(int size)
void AddRecentExperiment(const std::string &path, const std::string &name)
std::string GetCurrentChainName() const
std::string GetPeblExecutablePath() const
void SetUploadToken(const std::string &token)
void SetBatteryPath(const std::string &path)
void SetUploadURL(const std::string &url)
std::string GetUploadToken() const
void SetPeblExecutablePath(const std::string &path)
std::string GetCurrentStudyPath() const
std::string GetWorkspacePath() const
void SetCurrentChainName(const std::string &name)
void SetCurrentStudyPath(const std::string &path)
void SetExperimentDirectory(const std::string &dir)
void Render(bool *p_open)
LauncherUI(LauncherConfig *config, SDL_Renderer *renderer)
void Show(const std::string &scalesDir)
void SetOnDownload(std::function< void(const std::string &)> cb)
static std::shared_ptr< ScaleDefinition > CreateNew(const std::string &code)
static std::shared_ptr< Study > CreateNew(const std::string &path, const std::string &name, const std::string &author="")
static std::shared_ptr< Study > LoadFromDirectory(const std::string &path)
static const Palette & GetLightPalette()
static const Palette & GetDarkPalette()
static const Palette & GetRetroBluePalette()
Coordinates GetCursorPosition() const
void SetShowWhitespaces(bool aValue)
bool HasSelection() const
void SetReadOnly(bool aValue)
std::string GetText() const
int GetTotalLines() const
void Render(const char *aTitle, const ImVec2 &aSize=ImVec2(), bool aBorder=false)
void SetPalette(const Palette &aValue)
void SetText(const std::string &aText)
void SetTabSize(int aValue)
void SetSelection(const Coordinates &aStart, const Coordinates &aEnd, SelectionMode aMode=SelectionMode::Normal)
void SetLanguageDefinition(const LanguageDefinition &aLanguageDef)
std::string CreateChainPageConfig(const std::string &tempDir) const
std::string GetDisplayName() const
std::vector< std::string > answers
std::vector< bool > caseSensitive
std::vector< EditorCondition > visibleWhenConditions
std::string screenshotPath
std::vector< ThresholdEdit > rows
std::string dimensionName
std::vector< std::string > options
char gateTerminateMessageText[512]
std::vector< bool > selectedResponseOptions
char sectionRandomizeFixed[512]
char gateRequiredValue[64]
char gateTerminateMessageKey[64]
std::vector< AnchorEdit > vasAnchors
bool visibleWhenIsComplex
std::vector< EditorCondition > visibleWhenConditions
std::string enabled_param
std::vector< std::string > options
std::string vas_orientation
std::string gate_terminate_message_key
std::vector< VisibleWhenCondition > visible_when_simple
std::vector< std::string > rows
std::vector< VasAnchor > vas_anchors
QuestionValidation validation
std::vector< std::string > options
std::string gate_required_value
std::vector< std::string > section_randomize_fixed
std::vector< std::string > columns
std::vector< std::string > likert_labels
std::string visible_when_logic
bool visible_when_is_complex
std::string question_head
std::string gate_operator
std::map< std::string, ParameterVariant > parameterVariants
const ParameterVariant * GetVariant(const std::string &variantName) const
std::map< std::string, std::string > targetValues
std::vector< std::string > keys
std::map< std::string, std::string > englishValues
std::vector< std::string > values