PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
LauncherUI.cpp
Go to the documentation of this file.
1// LauncherUI.cpp - User interface implementation for PEBL Launcher
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "LauncherUI.h"
6#include "LauncherConfig.h"
7#include "ExperimentRunner.h"
8#include "Study.h"
9#include "Chain.h"
10#include "WorkspaceManager.h"
11#include "SnapshotManager.h"
12#include "ZipExtractor.h"
13#include "ScaleDefinition.h"
14#include "ScaleManager.h"
15#include "../../utility/BinReloc.h"
16#include "imgui.h"
17#include <SDL2/SDL_image.h>
18#include <cstring>
19#include <cctype>
20#include <ctime>
21#include <algorithm>
22#include <random>
23#include <map>
24#include <set>
25#include <filesystem>
26#include <fstream>
27#include <sstream>
28#include <numeric>
29#include <sys/stat.h>
30#include <json.hpp>
31
32#ifdef _WIN32
33#include <windows.h>
34#include <shlobj.h>
35#include <commdlg.h>
36#include <direct.h>
37#include <io.h>
38#define mkdir(path, mode) _mkdir(path)
39#ifndef stat
40#define stat _stat
41#endif
42#ifndef S_ISDIR
43#define S_ISDIR(mode) (((mode) & _S_IFMT) == _S_IFDIR)
44#endif
45#ifndef S_ISREG
46#define S_ISREG(mode) (((mode) & _S_IFMT) == _S_IFREG)
47#endif
48#else
49#include <dirent.h>
50#include <unistd.h>
51#endif
52
53namespace fs = std::filesystem;
54
55// Helper function to get (and create if needed) temp directory in workspace
56static std::string GetWorkspaceTempDirectory(const std::string& workspacePath)
57{
58 std::string tempDir;
59
60 if (!workspacePath.empty()) {
61#ifdef _WIN32
62 tempDir = workspacePath + "\\temp";
63#else
64 tempDir = workspacePath + "/temp";
65#endif
66 // Create the temp directory if it doesn't exist
67 struct stat st;
68 if (stat(tempDir.c_str(), &st) != 0) {
69#ifdef _WIN32
70 _mkdir(tempDir.c_str());
71#else
72 mkdir(tempDir.c_str(), 0755);
73#endif
74 printf("Created temp directory: %s\n", tempDir.c_str());
75 }
76 } else {
77 // Fallback to system temp if no workspace
78#ifdef _WIN32
79 char tempPath[MAX_PATH];
80 DWORD len = GetTempPathA(MAX_PATH, tempPath);
81 if (len > 0 && len < MAX_PATH) {
82 tempDir = tempPath;
83 // Remove trailing backslash if present
84 if (!tempDir.empty() && (tempDir.back() == '\\' || tempDir.back() == '/')) {
85 tempDir.pop_back();
86 }
87 } else {
88 tempDir = "C:\\Windows\\Temp";
89 }
90#else
91 const char* tmpdir = getenv("TMPDIR");
92 tempDir = tmpdir ? tmpdir : "/tmp";
93#endif
94 }
95
96 return tempDir;
97}
98
99// Helper function to get the PEBL media directory from executable path
100static std::string GetPEBLMediaPath(const std::string& peblExePath)
101{
102 if (peblExePath.empty()) {
103 return "";
104 }
105
106 // Find the directory containing the executable
107 size_t lastSep = peblExePath.find_last_of("/\\");
108 if (lastSep == std::string::npos) {
109 return "";
110 }
111
112 std::string exeDir = peblExePath.substr(0, lastSep);
113
114 // Go up one level (from bin/ to PEBL root)
115 size_t parentSep = exeDir.find_last_of("/\\");
116 std::string peblRoot;
117 if (parentSep != std::string::npos) {
118 peblRoot = exeDir.substr(0, parentSep);
119 } else {
120 peblRoot = ".";
121 }
122
123#ifdef _WIN32
124 return peblRoot + "\\media";
125#else
126 return peblRoot + "/media";
127#endif
128}
129
130LauncherUI::LauncherUI(LauncherConfig* config, SDL_Renderer* renderer)
131 : mConfig(config)
132 , mRenderer(renderer)
133 , mSelectedExperiment(-1)
134 , mFullscreen(false)
135 , mShowAbout(false)
136 , mScreenshotTexture(nullptr)
137 , mScreenshotWidth(0)
138 , mScreenshotHeight(0)
139 , mRunningExperiment(nullptr)
140 , mShowStderr(false)
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)
163 , mAddTestSubTab(0)
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)
174{
175 mScaleTransLanguage[0] = '\0';
176 // Configure PEBL syntax highlighting based on doc/pebl.lang
177 auto lang = TextEditor::LanguageDefinition();
178
179 lang.mName = "PEBL";
180
181 // PEBL keywords (case-insensitive in actual language, but TextEditor is case-sensitive)
182 // Include both cases for common usage
183 lang.mKeywords = {
184 "if", "If", "IF",
185 "elseif", "ElseIf", "ELSEIF",
186 "else", "Else", "ELSE",
187 "loop", "Loop", "LOOP",
188 "while", "While", "WHILE",
189 "return", "Return", "RETURN",
190 "define", "Define", "DEFINE",
191 "and", "And", "AND",
192 "or", "Or", "OR",
193 "not", "Not", "NOT",
194 "break", "Break", "BREAK"
195 };
196
197 // PEBL uses # for single-line comments (shell-like)
198 lang.mSingleLineComment = "#";
199 lang.mCommentStart = ""; // No multi-line comments in PEBL
200 lang.mCommentEnd = "";
201
202 // PEBL-specific token regex patterns (regex, palette index)
203 lang.mTokenRegexStrings = {
204 { "\\b[0-9]*\\.[0-9]+\\b", TextEditor::PaletteIndex::Number }, // Floats first
205 { "\\b[0-9]+\\b", TextEditor::PaletteIndex::Number }, // Then integers
206 { "\\bg[A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)?\\b", TextEditor::PaletteIndex::Identifier }, // Global variables
207 { "\\b[a-fh-z][A-Za-z0-9_]*(\\.[A-Za-z0-9_]+)?\\b", TextEditor::PaletteIndex::Identifier }, // Local variables
208 { ":?[A-Z][A-Za-z0-9_]*(?=\\s*\\()", TextEditor::PaletteIndex::KnownIdentifier }, // Function names
209 { "<-", TextEditor::PaletteIndex::Preprocessor }, // Assignment operator
210 };
211
212 lang.mCaseSensitive = true; // For variable names (g vs others)
213 lang.mAutoIndentation = true;
214
215 mCodeEditor.SetLanguageDefinition(lang);
216 mCodeEditor.SetShowWhitespaces(false);
217 mCodeEditor.SetTabSize(4);
219 // Initialize UI state from config
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';
228
229 // Set Quick Launch to start in workspace directory
230 // Portable mode: portable root directory (for access to PEBL/battery, demo, tutorial)
231 // Installed mode: Documents/pebl-exp.2.4
232 std::string peblExpPath = config->GetWorkspacePath();
233 if (!peblExpPath.empty()) {
234 try {
235 if (fs::exists(peblExpPath) && fs::is_directory(peblExpPath)) {
236 mQuickLaunchDirectory = peblExpPath;
237
238 // Scan for .pbl files on startup
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);
244 }
245 }
246 std::sort(mQuickLaunchFiles.begin(), mQuickLaunchFiles.end());
247 } else {
248 // Fall back to config directory
249 mQuickLaunchDirectory = config->GetExperimentDirectory();
250 }
251 } catch (const fs::filesystem_error&) {
252 // Fall back to config directory
253 mQuickLaunchDirectory = config->GetExperimentDirectory();
254 }
255 }
256
257 std::strncpy(mLanguageCode, config->GetLanguage().c_str(), sizeof(mLanguageCode) - 1);
258 mLanguageCode[sizeof(mLanguageCode) - 1] = '\0';
259 std::strncpy(mExperimentDir, config->GetExperimentDirectory().c_str(), sizeof(mExperimentDir) - 1);
260 mExperimentDir[sizeof(mExperimentDir) - 1] = '\0';
261 mFullscreen = config->GetFullscreen();
262
263 // Initialize additional run settings
264 mScreenResolution = 0; // Default to first resolution (auto)
265 mVSync = false;
266 mGraphicsDriver[0] = '\0';
267 mCustomArguments[0] = '\0';
268
269 // Initialize study system
270 mWorkspace = std::make_shared<WorkspaceManager>();
271 mSnapshots = std::make_shared<SnapshotManager>();
272
273 // If the config has an explicit workspace path (loaded from saved settings), use that.
274 // This keeps WorkspaceManager in sync with LauncherConfig so scale tests and the UI
275 // settings panel both operate on the same directory.
276 if (!config->GetWorkspacePath().empty()) {
277 mWorkspace->SetWorkspacePath(config->GetWorkspacePath());
278 }
279
280 // Initialize scale manager with battery and workspace paths
281 std::string batteryPathForScales = config->GetBatteryPath();
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());
287 fflush(stdout);
288 } else {
289 printf("Warning: Battery path not set, Scale Builder will not be available\n");
290 fflush(stdout);
291 }
292
293 // Check for first run
294 if (mWorkspace->IsFirstRun()) {
295 mShowFirstRunDialog = true;
296 } else {
297 // Initialize workspace (creates directories) only if not first run
298 // On first run, we wait for user confirmation in the dialog
299 if (!mWorkspace->Initialize()) {
300 printf("Warning: Failed to initialize workspace\n");
301 }
302 }
303
304 // Initialize page editor state
305 mPageEditor.show = false;
306 mPageEditor.editingIndex = -1;
307 mPageEditor.title[0] = '\0';
308 mPageEditor.content[0] = '\0';
309 mPageEditor.pageType = 0;
310
311 // Initialize test editor state
312 mTestEditor.show = false;
313 mTestEditor.editingIndex = -1;
314 mTestEditor.selectedTestIndex = -1;
315 mTestEditor.selectedVariantIndex = 0; // Default variant
316 mTestEditor.language[0] = '\0';
317 mTestEditor.randomGroup = 0; // No randomization by default
318
319 // Initialize translation editor state (handled by constructor now)
320 // mTranslationEditor is default-constructed
321
322 // Initialize variant naming dialog
323 mVariantName[0] = '\0';
324
325 // Initialize top-level tab (default to Study)
326 mTopLevelTab = 0;
327
328 // Initialize new study dialog state
329 mNewStudyName[0] = '\0';
330 mNewStudyDescription[0] = '\0';
331 mNewStudyAuthor[0] = '\0';
332
333 // Initialize new chain dialog state
334 mNewChainName[0] = '\0';
335 mNewChainDescription[0] = '\0';
336
337 // Scan battery directory for tests
338 // Use battery path from config, fall back to experiment directory
339 std::string batteryPath = config->GetBatteryPath();
340 if (batteryPath.empty()) {
341 batteryPath = mExperimentDir;
342 }
343
344 // Store battery path for template loading
345 mBatteryPath = batteryPath;
346
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());
353 fflush(stdout);
354 }
355
356 // Scan templates directory
357 ScanTemplates();
358
359 // Restore previously selected study and chain
360 std::string lastStudyPath = config->GetCurrentStudyPath();
361 if (!lastStudyPath.empty()) {
362 printf("Restoring last study: %s\n", lastStudyPath.c_str());
363 LoadStudy(lastStudyPath);
364
365 // If study loaded successfully, restore the chain
366 if (mCurrentStudy) {
367 std::string lastChainName = config->GetCurrentChainName();
368 if (!lastChainName.empty()) {
369 std::string chainPath = lastStudyPath + "/chains/" + lastChainName;
370 printf("Restoring last chain: %s\n", chainPath.c_str());
371 LoadChain(chainPath);
372 }
373 }
374 }
375}
376
378{
379 FreeScreenshot();
380 FreeStudyTestScreenshot();
381
382 if (mScaleBrowserScreenshot) {
383 SDL_DestroyTexture(mScaleBrowserScreenshot);
384 mScaleBrowserScreenshot = nullptr;
385 }
386
387 // Clean up running experiment if any
388 if (mRunningExperiment) {
389 delete mRunningExperiment;
390 mRunningExperiment = nullptr;
391 }
392}
393
394void LauncherUI::Render(bool* p_open)
395{
396 // Update running experiment output if any
397 if (mRunningExperiment && mRunningExperiment->IsRunning()) {
398 mRunningExperiment->UpdateOutput();
399 }
400
401 // Check if chain execution needs to advance to next item
402 if (mRunningChain && mRunningExperiment) {
403 bool isRunning = mRunningExperiment->IsRunning();
404 if (!isRunning) {
405 printf("DEBUG: Chain item finished (IsRunning=false), advancing...\n");
406
407 // Check exit code - only abort chain if it's a consent form with exit code 1
408 // Exit code 1 = user explicitly declined consent
409 // Other non-zero codes = crash or error, not consent decline
410 int exitCode = mRunningExperiment->GetExitCode();
411
412 // Debug logging to file and stdout
413 FILE* debugLog = fopen("chain_debug.log", "a");
414 if (debugLog) {
415 fprintf(debugLog, "=== Chain item finished ===\n");
416 fprintf(debugLog, " Exit code: %d\n", exitCode);
417 fprintf(debugLog, " mCurrentChainItemIndex: %d\n", mCurrentChainItemIndex);
418 fflush(debugLog);
419 }
420 printf("=== Chain item finished ===\n");
421 printf(" Exit code from GetExitCode(): %d\n", exitCode);
422 printf(" mCurrentChainItemIndex: %d\n", mCurrentChainItemIndex);
423
424 // Get the current chain item to check its type
425 bool shouldAbortChain = false;
426 bool isConsentDecline = false;
427 if (debugLog) {
428 fprintf(debugLog, " Checking: exitCode=%d, chainItemIndex=%d\n", exitCode, mCurrentChainItemIndex);
429 }
430 if (exitCode != 0 && mCurrentChain && mCurrentChainItemIndex >= 0 &&
431 mCurrentChainItemIndex < (int)mCurrentChain->GetItems().size()) {
432 const ChainItem& currentItem = mCurrentChain->GetItems()[mCurrentChainItemIndex];
433 if (debugLog) {
434 fprintf(debugLog, " Item type=%d (Consent=%d)\n", (int)currentItem.type, (int)ItemType::Consent);
435 fflush(debugLog);
436 }
437
438 // Treat exit code 1 as "declined/ineligible" for:
439 // - ItemType::Consent (built-in consent page)
440 // - ItemType::Test when exit code is 1 (ScaleRunner gateTriggered path)
441 // Other non-zero codes = crash/error, continue chain.
442 if (exitCode == 1 && (currentItem.type == ItemType::Consent ||
443 currentItem.type == ItemType::Test)) {
444 shouldAbortChain = true;
445 isConsentDecline = true;
446 if (debugLog) fprintf(debugLog, " -> CONSENT/GATE DECLINED (code 1), aborting chain\n");
447 } else if (currentItem.type == ItemType::Consent && exitCode != 0) {
448 if (debugLog) fprintf(debugLog, " -> Consent error (code %d), continuing\n", exitCode);
449 } else {
450 if (debugLog) fprintf(debugLog, " -> Non-consent item or error (code %d), continuing\n", exitCode);
451 }
452 } else {
453 if (debugLog) fprintf(debugLog, " exitCode==0 or invalid index, continuing chain\n");
454 }
455 if (debugLog) {
456 fflush(debugLog);
457 fclose(debugLog);
458 }
459
460 if (shouldAbortChain && isConsentDecline) {
461 // Exit code 1 on consent form - user explicitly declined consent
462 printf("Chain terminated: User declined consent\n");
463
464 // Accumulate output from this final item
465 mChainAccumulatedStdout += mRunningExperiment->GetStdout();
466 mChainAccumulatedStderr += mRunningExperiment->GetStderr();
467 mChainAccumulatedStdout += "\n=== Chain terminated: User declined consent ===\n";
468
469 // Stop chain execution
470 mRunningChain = false;
471 mCurrentChainItemIndex = -1;
472
473 // Increment participant counter so next run gets a fresh subject number
474 if (mCurrentChain) {
475 mCurrentChain->IncrementParticipantCounter();
476 printf("Participant counter incremented to: %d\n", mCurrentChain->GetParticipantCounter());
477 }
478
479 // Clean up runner
480 delete mRunningExperiment;
481 mRunningExperiment = nullptr;
482
483 // Return early - don't continue to next item
484 goto render_ui;
485 }
486
487 // Accumulate output from completed item BEFORE deleting runner
488 mChainAccumulatedStdout += mRunningExperiment->GetStdout();
489 mChainAccumulatedStderr += mRunningExperiment->GetStderr();
490
491 // Add separator between items for clarity
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";
494
495 // Current item has finished, advance to next
496 printf("Chain item %d finished, advancing...\n", mCurrentChainItemIndex + 1);
497 mCurrentChainItemIndex++;
498
499 if (mCurrentChain && mCurrentChainItemIndex < (int)mCurrentChain->GetItems().size()) {
500 // Execute next item
501 const ChainItem& item = mCurrentChain->GetItems()[mCurrentChainItemIndex];
502 printf("Advancing to chain item %d/%zu: %s\n",
503 mCurrentChainItemIndex + 1,
504 mCurrentChain->GetItems().size(),
505 item.GetDisplayName().c_str());
506
507 // Clean up previous runner (output already accumulated)
508 delete mRunningExperiment;
509 mRunningExperiment = nullptr;
510
511 // Execute the next item
512 ExecuteChainItem(mCurrentChainItemIndex);
513 } else {
514 // Chain completed
515 printf("Chain execution completed (all %zu items finished)\n", mCurrentChain->GetItems().size());
516 mRunningChain = false;
517 mCurrentChainItemIndex = -1;
518
519 // Increment participant counter for next run
520 if (mCurrentChain) {
521 mCurrentChain->IncrementParticipantCounter();
522 printf("Participant counter incremented to: %d\n", mCurrentChain->GetParticipantCounter());
523 }
524
525 // Clean up runner (output already accumulated)
526 if (mRunningExperiment) {
527 delete mRunningExperiment;
528 mRunningExperiment = nullptr;
529 }
530 }
531 }
532 }
533
534render_ui:
535 // Main window takes up full viewport
536 ImGuiViewport* viewport = ImGui::GetMainViewport();
537 ImGui::SetNextWindowPos(viewport->WorkPos);
538 ImGui::SetNextWindowSize(viewport->WorkSize);
539
540 ImGuiWindowFlags window_flags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoTitleBar |
541 ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
542 ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus;
543
544 ImGui::Begin("PEBL Launcher", p_open, window_flags);
545
546 RenderMenuBar();
547
548 // Reserve space at bottom for output panel, then wrap tab content
549 float outputPanelHeight = mOutputExpanded ? 250.0f : 30.0f;
550 float contentHeight = ImGui::GetContentRegionAvail().y - outputPanelHeight;
551 ImGui::BeginChild("MainTabArea", ImVec2(0, contentHeight));
552
553 // Top-level tabbed interface: Manage Studies vs Quick Launch
554 // Make tab headers larger and more prominent (colors, padding, and larger font)
555 ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(30, 8)); // Larger horizontal and vertical padding
556 ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(12, 8)); // More spacing around tabs
557 ImGui::PushStyleVar(ImGuiStyleVar_TabRounding, 8.0f); // Rounded corners for pill effect
558 ImGui::PushStyleColor(ImGuiCol_Tab, ImVec4(0.35f, 0.40f, 0.48f, 1.0f)); // Darker blue for inactive
559 ImGui::PushStyleColor(ImGuiCol_TabHovered, ImVec4(0.35f, 0.60f, 0.85f, 1.0f)); // Brighter blue on hover
560 ImGui::PushStyleColor(ImGuiCol_TabActive, ImVec4(0.20f, 0.60f, 0.95f, 1.0f)); // Vivid blue for active
561
562 if (ImGui::BeginTabBar("TopLevelTabs", ImGuiTabBarFlags_None)) {
563 // Manage Studies tab
564 ImGui::SetWindowFontScale(1.5f); // Large font for tab header
565 if (ImGui::BeginTabItem("Manage Studies")) {
566 if (mTopLevelTab != 1) { // Don't override if we're switching to Quick Launch
567 mTopLevelTab = 0;
568 }
569 ImGui::SetWindowFontScale(1.0f);
570 ImGui::PopStyleColor(3);
571 ImGui::PopStyleVar(3);
572
573 // Show study bar (study selector, new study button, etc.)
574 RenderStudyBar();
575
576 // Small spacing before second-level tabs
577 ImGui::Dummy(ImVec2(0, 5));
578
579 // Second-level tab styling
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));
586
587 if (ImGui::BeginTabBar("StudyTabs", ImGuiTabBarFlags_None)) {
588 ImGui::SetWindowFontScale(1.3f); // Medium font for second-level tabs
589 if (ImGui::BeginTabItem("Tests")) {
590 ImGui::SetWindowFontScale(1.0f);
591 ImGui::PopStyleColor(3);
592 ImGui::PopStyleVar(3);
593
594 RenderTestsTab();
595 ImGui::EndTabItem();
596
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);
604 }
605
606 if (ImGui::BeginTabItem("Chains")) {
607 ImGui::SetWindowFontScale(1.0f);
608 ImGui::PopStyleColor(3);
609 ImGui::PopStyleVar(3);
610
611 RenderChainsTab();
612 ImGui::EndTabItem();
613
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);
621 }
622
623 if (ImGui::BeginTabItem("Run")) {
624 ImGui::SetWindowFontScale(1.0f);
625 ImGui::PopStyleColor(3);
626 ImGui::PopStyleVar(3);
627
628 RenderRunTab();
629 ImGui::EndTabItem();
630
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));
637 }
638
639 // Final cleanup for second-level tabs
640 ImGui::SetWindowFontScale(1.0f);
641 ImGui::PopStyleColor(3);
642 ImGui::PopStyleVar(3);
643
644 ImGui::EndTabBar();
645 } else {
646 // If tab bar didn't begin, clean up styles
647 ImGui::SetWindowFontScale(1.0f);
648 ImGui::PopStyleColor(3);
649 ImGui::PopStyleVar(3);
650 }
651
652 ImGui::EndTabItem();
653
654 // Restore styles for next top-level tab header
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);
662 }
663
664 // Quick Launch tab
665 ImGuiTabItemFlags quickLaunchFlags = (mTopLevelTab == 1) ? ImGuiTabItemFlags_SetSelected : 0;
666 if (ImGui::BeginTabItem("Quick Launch", nullptr, quickLaunchFlags)) {
667 if (mTopLevelTab == 1) {
668 mTopLevelTab = -1; // Reset flag after first frame
669 }
670 ImGui::SetWindowFontScale(1.0f);
671 ImGui::PopStyleColor(3);
672 ImGui::PopStyleVar(3);
673
674 RenderQuickLaunchTab();
675 ImGui::EndTabItem();
676
677 // Restore styles for next top-level tab header
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);
685 }
686
687 // Scales/Surveys tab
688 ImGuiTabItemFlags scaleBuilderFlags = (mTopLevelTab == 2) ? ImGuiTabItemFlags_SetSelected : 0;
689 if (ImGui::BeginTabItem("Scales/Surveys", nullptr, scaleBuilderFlags)) {
690 if (mTopLevelTab == 2) {
691 mTopLevelTab = -1; // Reset flag after first frame
692 }
693 ImGui::SetWindowFontScale(1.0f);
694 ImGui::PopStyleColor(3);
695 ImGui::PopStyleVar(3);
696
697 ShowScaleBuilder();
698 ImGui::EndTabItem();
699
700 // Restore styles for consistency
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));
707 }
708
709 // Final cleanup - pop the styles that are still on the stack
710 ImGui::SetWindowFontScale(1.0f);
711 ImGui::PopStyleColor(3);
712 ImGui::PopStyleVar(3);
713
714 ImGui::EndTabBar();
715 } else {
716 // If tab bar didn't begin, clean up styles
717 ImGui::SetWindowFontScale(1.0f);
718 ImGui::PopStyleColor(3);
719 ImGui::PopStyleVar(3);
720 }
721
722 ImGui::EndChild(); // MainTabArea
723
724 // Output panel at bottom of window
725 RenderOutputPanel();
726
727 ImGui::End();
728
729 // Show about dialog if requested
730 if (mShowAbout) {
731 ShowAboutDialog();
732 }
733
734 // Show variant naming dialog if requested
735 if (mShowVariantNameDialog) {
736 ShowVariantNameDialog();
737 }
738
739 // Show parameter editor if requested
740 if (mShowParameterEditor) {
741 ShowParameterEditor();
742 }
743
744 // Show settings dialog if requested
745 if (mShowSettings) {
746 ShowSettingsDialog();
747 }
748
749 // Show page editor if requested
750 if (mPageEditor.show) {
751 ShowPageEditor();
752 }
753
754 // Show test editor if requested
755 if (mTestEditor.show) {
756 ShowTestEditor();
757 }
758
759 // Show code editor if requested
760 if (mShowCodeEditor) {
761 ShowCodeEditor();
762 }
763
764 // Show question editor dialog if requested
765 if (mQuestionEditor.show) {
766 ShowQuestionEditor();
767 }
768
769 // Show batch import dialog if requested
770 if (mBatchImport.show) {
771 ShowBatchImportDialog();
772 }
773
774 // Show dimension editor dialog if requested
775 if (mDimensionEditor.show) {
776 ShowDimensionEditor();
777 }
778
779 // Show create study from scale dialog if requested
780 if (mCreateStudyDialog.show) {
781 ShowCreateStudyFromScaleDialog();
782 }
783
784 // Show correct answers editor dialog if requested
785 if (mCorrectAnswersEditor.show) {
786 ShowCorrectAnswersEditor();
787 }
788
789 // Show norms editor dialog if requested
790 if (mNormsEditor.show) {
791 ShowNormsEditor();
792 }
793
794 // Show translation editor dialog if requested.
795 // When opened from inside the test editor popup (fromTestEditor), it renders
796 // inline there (correct ImGui stack level). Skip rendering it here in that case.
797 bool translationEditorWasShown = mTranslationEditor.show;
798 if (mTranslationEditor.show && !mTranslationEditor.fromTestEditor) {
799 ShowTranslationEditorDialog();
800 }
801 // When scale-mode translation editor closes, reload translations from disk into mCurrentScale
802 if (translationEditorWasShown && !mTranslationEditor.show &&
803 mTranslationEditor.scaleMode && mCurrentScale && mScaleManager) {
804 auto reloaded = mScaleManager->LoadScale(mCurrentScale->GetScaleInfo().code);
805 if (reloaded) {
806 mCurrentScale->GetTranslations() = reloaded->GetTranslations();
807 }
808 mTranslationEditor.ClearScaleMode();
809 }
810
811 // Show new study dialog if requested
812 if (mShowNewStudyDialog) {
813 ShowNewStudyDialog();
814 }
815
816 // Show new chain dialog if requested
817 if (mShowNewChainDialog) {
818 ShowNewChainDialog();
819 }
820
821 // Show study settings dialog if requested
822 if (mShowStudySettingsDialog) {
823 ShowStudySettingsDialog();
824 }
825
826 // Show first run dialog if requested
827 if (mShowFirstRunDialog) {
828 ShowFirstRunDialog();
829 }
830
831 // Show getting started dialog if there are no studies
832 if (mShowGettingStartedDialog) {
833 ShowGettingStartedDialog();
834 }
835
836 // Show duplicate subject code warning if requested
837 if (mShowDuplicateSubjectWarning) {
838 ShowDuplicateSubjectWarning();
839 }
840
841 // Show edit participant code dialog if requested
842 if (mShowEditParticipantCodeDialog) {
843 ShowEditParticipantCodeDialog();
844 }
845
846 // Show snapshot created dialog if requested
847 if (mShowSnapshotCreated) {
848 ShowSnapshotCreatedDialog();
849 }
850
851 // Show OpenScales browser if requested
852 mOpenScalesBrowser.Render();
853}
854
855void LauncherUI::RenderMenuBar()
856{
857 if (ImGui::BeginMenuBar())
858 {
859 if (ImGui::BeginMenu("File"))
860 {
861 if (ImGui::MenuItem("New Study...", "Ctrl+N")) {
862 mShowNewStudyDialog = true;
863 }
864
865 if (ImGui::MenuItem("Open Study...", "Ctrl+O")) {
866 // Start in workspace studies directory
867 std::string studiesPath = mWorkspace->GetStudiesPath();
868
869 #ifdef __linux__
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;
873 if (pipe) {
874 char buffer[1024];
875 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
876 studyPath = buffer;
877 if (!studyPath.empty() && studyPath[studyPath.length()-1] == '\n') {
878 studyPath.erase(studyPath.length()-1);
879 }
880 }
881 pclose(pipe);
882 }
883 #else
884 std::string studyPath = OpenDirectoryDialog("Select Study Directory");
885 #endif
886
887 if (!studyPath.empty()) {
888 LoadStudy(studyPath);
889 }
890 }
891
892 ImGui::Separator();
893
894 if (ImGui::MenuItem("Settings...", "Ctrl+,")) {
895 mShowSettings = true;
896 }
897
898 ImGui::Separator();
899
900 if (ImGui::MenuItem("Exit", "Alt+F4")) {
901 SDL_Event quit_event;
902 quit_event.type = SDL_QUIT;
903 SDL_PushEvent(&quit_event);
904 }
905 ImGui::EndMenu();
906 }
907
908 if (ImGui::BeginMenu("Study"))
909 {
910 bool hasStudy = (mCurrentStudy != nullptr);
911
912 if (ImGui::MenuItem("New Study...")) {
913 mShowNewStudyDialog = true;
914 }
915
916 if (ImGui::MenuItem("Load Study...")) {
917 std::string studiesPath = mWorkspace->GetStudiesPath();
918
919 #ifdef __linux__
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;
923 if (pipe) {
924 char buffer[1024];
925 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
926 studyPath = buffer;
927 if (!studyPath.empty() && studyPath[studyPath.length()-1] == '\n') {
928 studyPath.erase(studyPath.length()-1);
929 }
930 }
931 pclose(pipe);
932 }
933 #else
934 std::string studyPath = OpenDirectoryDialog("Select Study Directory");
935 #endif
936
937 if (!studyPath.empty()) {
938 LoadStudy(studyPath);
939 }
940 }
941
942 if (ImGui::MenuItem("Open Study Directory...", nullptr, false, hasStudy)) {
943 if (mCurrentStudy) {
944 std::string studyPath = mCurrentStudy->GetPath();
945 OpenDirectoryInFileBrowser(studyPath);
946 }
947 }
948
949 if (ImGui::MenuItem("Study Settings...", nullptr, false, hasStudy)) {
950 mShowStudySettingsDialog = true;
951 }
952
953 ImGui::Separator();
954
955 if (ImGui::MenuItem("Create Snapshot...", nullptr, false, hasStudy)) {
956 if (mCurrentStudy && mSnapshots) {
957 // Create snapshot in workspace/snapshots directory
958 std::string snapshotsDir = mWorkspace->GetWorkspacePath() + "/snapshots";
959
960 // Create snapshots directory if it doesn't exist
961 if (!fs::exists(snapshotsDir)) {
962 fs::create_directories(snapshotsDir);
963 }
964
965 std::string snapshotName = mSnapshots->CreateSnapshot(mCurrentStudy->GetPath(), snapshotsDir);
966
967 if (!snapshotName.empty()) {
968 std::string snapshotPath = snapshotsDir + "/" + snapshotName;
969 printf("Created snapshot: %s at %s\n", snapshotName.c_str(), snapshotPath.c_str());
970
971 // Store snapshot info for dialog
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';
976
977 // Show success dialog
978 mShowSnapshotCreated = true;
979 } else {
980 printf("Failed to create snapshot\n");
981 }
982 }
983 }
984
985 if (ImGui::MenuItem("Import Snapshot...")) {
986 std::string snapshotPath = OpenDirectoryDialog("Select Snapshot Directory");
987 if (!snapshotPath.empty() && mSnapshots) {
988 ImportSnapshotFromPath(snapshotPath);
989 }
990 }
991
992 if (ImGui::MenuItem("Import Snapshot ZIP...")) {
993 std::string zipPath = OpenFileDialog("Select Snapshot ZIP", "*.zip", mWorkspace->GetSnapshotsPath());
994 if (!zipPath.empty() && mSnapshots) {
995 // Create temporary directory for extraction
996 std::string tempDir = "/tmp/pebl_snapshot_import_" + std::to_string(std::time(nullptr));
997 mkdir(tempDir.c_str(), 0755);
998
999 // Extract ZIP
1000 printf("Extracting snapshot ZIP...\n");
1001 auto extractResult = ZipExtractor::ExtractAll(zipPath, tempDir);
1002 if (extractResult.success) {
1003 // Determine snapshot directory
1004 std::string snapshotPath;
1005
1006 // Check if tempDir itself is the snapshot (has study-info.json)
1007 std::string studyInfoPath = tempDir + "/study-info.json";
1008 if (fs::exists(studyInfoPath)) {
1009 // Files extracted directly to tempDir
1010 snapshotPath = tempDir;
1011 printf("Snapshot extracted directly to temp directory\n");
1012 } else {
1013 // Look for subdirectory containing study-info.json
1014 try {
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());
1022 break;
1023 }
1024 }
1025 } catch (const fs::filesystem_error&) {
1026 // Directory doesn't exist or can't be read
1027 }
1028 }
1029
1030 if (!snapshotPath.empty()) {
1031 ImportSnapshotFromPath(snapshotPath);
1032 } else {
1033 printf("Could not locate snapshot directory in extracted ZIP\n");
1034 }
1035
1036 // Clean up temp directory
1037 std::string cleanupCmd = "rm -rf " + tempDir;
1038 system(cleanupCmd.c_str());
1039 } else {
1040 printf("Failed to extract ZIP file: %s\n", extractResult.error.c_str());
1041 rmdir(tempDir.c_str());
1042 }
1043 }
1044 }
1045
1046 ImGui::EndMenu();
1047 }
1048
1049 if (ImGui::BeginMenu("Tools"))
1050 {
1051 if (ImGui::MenuItem("Open Battery Directory...")) {
1052 std::string batteryPath = mConfig->GetBatteryPath();
1053 if (!batteryPath.empty()) {
1054 OpenDirectoryInFileBrowser(batteryPath);
1055 }
1056 }
1057
1058 if (ImGui::MenuItem("Open Workspace Directory...")) {
1059 std::string workspacePath = mWorkspace->GetWorkspacePath();
1060 if (!workspacePath.empty()) {
1061 OpenDirectoryInFileBrowser(workspacePath);
1062 }
1063 }
1064
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);
1070 }
1071 }
1072
1073 ImGui::Separator();
1074
1075 if (ImGui::MenuItem("Data Combiner...")) {
1076 // Start directory picker in current study's data directory if available
1077 std::string startDir;
1078 if (mCurrentStudy) {
1079 startDir = mCurrentStudy->GetPath() + "/data";
1080 } else {
1081 startDir = mWorkspace->GetWorkspacePath();
1082 }
1083
1084 std::string selectedDir = OpenDirectoryDialog("Select Directory for Data Combiner", startDir);
1085 if (!selectedDir.empty()) {
1086 LaunchDataCombiner(selectedDir);
1087 }
1088 }
1089 if (ImGui::IsItemHovered()) {
1090 ImGui::SetTooltip("Combine data files from a directory");
1091 }
1092
1093 if (ImGui::MenuItem("Scales/Surveys...", nullptr, false, mScaleManager != nullptr)) {
1094 mTopLevelTab = 2; // Switch to Scales/Surveys tab
1095 }
1096 if (ImGui::IsItemHovered()) {
1097 ImGui::SetTooltip("Create and edit psychological scales/surveys");
1098 }
1099
1100 ImGui::Separator();
1101
1102 if (ImGui::MenuItem("Refresh Battery Tests")) {
1103 std::string batteryPath = mConfig->GetBatteryPath();
1104 if (!batteryPath.empty()) {
1105 ScanExperimentDirectory(batteryPath);
1106 printf("Refreshed battery: %zu tests found\n", mExperiments.size());
1107 }
1108 }
1109
1110 if (ImGui::MenuItem("View Launch Log")) {
1111 std::string logPath = ExperimentRunner::GetLaunchLogPath();
1112 printf("Opening launch log: %s\n", logPath.c_str());
1113 #ifdef __linux__
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());
1119 #endif
1120 }
1121 if (ImGui::IsItemHovered()) {
1122 ImGui::SetTooltip("View log of launched PEBL processes");
1123 }
1124
1125 ImGui::EndMenu();
1126 }
1127
1128 if (ImGui::BeginMenu("Help"))
1129 {
1130 if (ImGui::MenuItem("About PEBL Launcher")) {
1131 mShowAbout = true;
1132 }
1133
1134 if (ImGui::MenuItem("PEBL Documentation")) {
1135 #ifdef __linux__
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");
1141 #endif
1142 }
1143
1144 if (ImGui::MenuItem("PEBL Manual (PDF)")) {
1145 // Open local PDF manual
1146 std::string manualName = std::string("PEBLManual") + PEBL_VERSION + ".pdf";
1147 std::string workspacePath = mConfig->GetWorkspacePath();
1148 std::string manualPath;
1149
1150 // Portable mode: manual is at portable root (e.g., PEBL2.4_Portable/PEBLManual2.4.pdf)
1151 // Installed mode: manual is in Documents/pebl-exp.2.4/doc/PEBLManual2.4.pdf
1152 std::vector<std::string> possiblePaths = {
1153 (fs::path(workspacePath) / manualName).string(), // Portable: root
1154 (fs::path(workspacePath) / "doc" / manualName).string(), // Installed: doc subfolder
1155 };
1156
1157 for (const auto& path : possiblePaths) {
1158 if (fs::exists(path)) {
1159 manualPath = path;
1160 break;
1161 }
1162 }
1163
1164 if (manualPath.empty()) {
1165 manualPath = possiblePaths[0];
1166 printf("Warning: Manual not found at expected locations\n");
1167 }
1168
1169 #ifdef __linux__
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());
1175 #endif
1176 }
1177
1178 if (ImGui::MenuItem("Function Reference")) {
1179 #ifdef __linux__
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");
1185 #endif
1186 }
1187
1188 if (ImGui::MenuItem("PEBL Website")) {
1189 #ifdef __linux__
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");
1195 #endif
1196 }
1197
1198 if (ImGui::MenuItem("PEBL Online Hub")) {
1199 #ifdef __linux__
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");
1205 #endif
1206 }
1207
1208 ImGui::Separator();
1209
1210 if (ImGui::MenuItem("Show First-Run Dialog")) {
1211 mShowFirstRunDialog = true;
1212 }
1213
1214 ImGui::EndMenu();
1215 }
1216
1217 ImGui::EndMenuBar();
1218 }
1219}
1220
1221void LauncherUI::RenderFilePanel()
1222{
1223 ImGui::Text("Experiment Directory:");
1224 ImGui::PushItemWidth(-1);
1225 if (ImGui::InputText("##ExperimentDir", mExperimentDir, sizeof(mExperimentDir),
1226 ImGuiInputTextFlags_EnterReturnsTrue)) {
1227 ScanExperimentDirectory(mExperimentDir);
1228 mConfig->SetExperimentDirectory(mExperimentDir);
1229 }
1230 ImGui::PopItemWidth();
1231
1232 ImGui::SameLine();
1233 if (ImGui::Button("Browse...")) {
1234 std::string dir = OpenDirectoryDialog("Select Experiment Directory");
1235 if (!dir.empty()) {
1236 std::strcpy(mExperimentDir, dir.c_str());
1237 ScanExperimentDirectory(mExperimentDir);
1238 mConfig->SetExperimentDirectory(mExperimentDir);
1239 }
1240 }
1241
1242 ImGui::Separator();
1243
1244 // Recent tests section
1245 const std::vector<RecentExperiment>& recent = mConfig->GetRecentExperiments();
1246 if (!recent.empty()) {
1247 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Recent Tests:");
1248
1249 ImGui::BeginChild("RecentList", ImVec2(0, 120), true);
1250 ImGui::SetWindowFontScale(1.3f);
1251
1252 for (const auto& exp : recent) {
1253 // Show just the name, with timestamp as tooltip
1254 if (ImGui::Selectable(exp.name.c_str())) {
1255 // Find this experiment in our main list
1256 for (int i = 0; i < (int)mExperiments.size(); i++) {
1257 if (mExperiments[i].path == exp.path) {
1258 mSelectedExperiment = i;
1259 LoadExperimentInfo(exp.path);
1260 break;
1261 }
1262 }
1263 }
1264
1265 if (ImGui::IsItemHovered()) {
1266 // Format timestamp
1267 char timeBuf[64];
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());
1271 }
1272 }
1273
1274 ImGui::SetWindowFontScale(1.0f);
1275 ImGui::EndChild();
1276
1277 ImGui::Separator();
1278 }
1279
1280 ImGui::Text("Tests (%zu found):", mExperiments.size());
1281
1282 // Filter box
1283 static char filter[256] = "";
1284 ImGui::PushItemWidth(-1);
1285 ImGui::InputTextWithHint("##Filter", "Filter tests...", filter, sizeof(filter));
1286 ImGui::PopItemWidth();
1287
1288 ImGui::Separator();
1289
1290 // Scrollable experiment list with larger font
1291 ImGui::BeginChild("ExperimentList", ImVec2(0, 0), false);
1292
1293 // Increase font scale for better readability
1294 ImGui::SetWindowFontScale(1.3f);
1295
1296 for (int i = 0; i < (int)mExperiments.size(); i++) {
1297 const ExperimentInfo& exp = mExperiments[i];
1298
1299 // Apply filter
1300 if (strlen(filter) > 0 &&
1301 exp.name.find(filter) == std::string::npos) {
1302 continue;
1303 }
1304
1305 bool is_selected = (mSelectedExperiment == i);
1306 if (ImGui::Selectable(exp.name.c_str(), is_selected)) {
1307 mSelectedExperiment = i;
1308 LoadExperimentInfo(exp.path);
1309 }
1310
1311 if (ImGui::IsItemHovered()) {
1312 ImGui::SetTooltip("%s", exp.path.c_str());
1313 }
1314 }
1315
1316 // Reset font scale
1317 ImGui::SetWindowFontScale(1.0f);
1318
1319 ImGui::EndChild();
1320}
1321
1322void LauncherUI::RenderDetailsPanel()
1323{
1324 // Create tabbed interface
1325 if (ImGui::BeginTabBar("DetailsTabs")) {
1326 // Details Tab
1327 if (ImGui::BeginTabItem("Details")) {
1328 RenderDetailsTab();
1329 ImGui::EndTabItem();
1330 }
1331
1332 // Study Tab
1333 if (ImGui::BeginTabItem("Study")) {
1334 RenderStudyTab();
1335 ImGui::EndTabItem();
1336 }
1337
1338 // Chain Tab
1339 if (ImGui::BeginTabItem("Chain")) {
1340 RenderChainTab();
1341 ImGui::EndTabItem();
1342 }
1343
1344 ImGui::EndTabBar();
1345 }
1346}
1347
1348void LauncherUI::RenderDetailsTab()
1349{
1350 if (mSelectedExperiment < 0 || mSelectedExperiment >= (int)mExperiments.size()) {
1351 ImGui::TextWrapped("Select a test from the list on the left to view its details.");
1352 return;
1353 }
1354
1355 const ExperimentInfo& exp = mExperiments[mSelectedExperiment];
1356
1357 // Calculate layout heights
1358 float availHeight = ImGui::GetContentRegionAvail().y;
1359 float topSectionHeight = availHeight * 0.45f; // Top section takes 45% of height
1360 float panelWidth = ImGui::GetContentRegionAvail().x;
1361 float screenshotWidth = panelWidth * 0.5f;
1362
1363 // Top section: Screenshot (left) and Info (right) side-by-side
1364 ImGui::BeginChild("TopSection", ImVec2(0, topSectionHeight), false);
1365
1366 // Left side: Screenshot
1367 ImGui::BeginChild("ScreenshotPanel", ImVec2(screenshotWidth, 0), false, ImGuiWindowFlags_NoScrollbar);
1368
1369 if (mScreenshotTexture) {
1370 // Calculate display size while maintaining aspect ratio
1371 float aspectRatio = (float)mScreenshotHeight / (float)mScreenshotWidth;
1372 float displayWidth = screenshotWidth - 20; // Leave some padding
1373 float displayHeight = displayWidth * aspectRatio;
1374
1375 // Limit height to available space
1376 float maxHeight = topSectionHeight - 20;
1377 if (displayHeight > maxHeight) {
1378 displayHeight = maxHeight;
1379 displayWidth = displayHeight / aspectRatio;
1380 }
1381
1382 ImGui::Image((ImTextureID)(intptr_t)mScreenshotTexture,
1383 ImVec2(displayWidth, displayHeight));
1384 } else {
1385 ImGui::TextDisabled("No screenshot available");
1386 }
1387
1388 ImGui::EndChild();
1389
1390 ImGui::SameLine();
1391
1392 // Right side: Experiment info
1393 ImGui::BeginChild("InfoPanel", ImVec2(0, 0), false);
1394
1395 // Experiment name
1396 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", exp.name.c_str());
1397 ImGui::Separator();
1398 ImGui::Spacing();
1399
1400 // Basic information
1401 ImGui::Text("Path:");
1402 ImGui::TextWrapped("%s", exp.directory.c_str());
1403
1404 if (ImGui::Button("Open Experiment Folder", ImVec2(-1, 0))) {
1405 OpenDirectoryInFileBrowser(exp.directory);
1406 }
1407 if (ImGui::IsItemHovered()) {
1408 ImGui::SetTooltip("Open this experiment's folder in file browser");
1409 }
1410
1411 ImGui::Spacing();
1412
1413 // Parameters
1414 if (exp.hasParams) {
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");
1418 }
1419 } else {
1420 ImGui::TextDisabled("No parameters");
1421 }
1422
1423 // Translations/Languages
1424 if (exp.hasTranslations && !mAvailableLanguages.empty()) {
1425 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "✓ Translations: %zu languages",
1426 mAvailableLanguages.size());
1427
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 += ", ";
1433 }
1434 ImGui::SetTooltip("%s", tooltip.c_str());
1435 }
1436 } else {
1437 ImGui::TextDisabled("No translations");
1438 }
1439
1440 ImGui::Spacing();
1441 ImGui::Separator();
1442 ImGui::Spacing();
1443
1444 // Description
1445 ImGui::Text("Description:");
1446 ImGui::BeginChild("Description", ImVec2(0, 100), true);
1447 if (!exp.description.empty()) {
1448 ImGui::TextWrapped("%s", exp.description.c_str());
1449 } else {
1450 ImGui::TextDisabled("No description available");
1451 }
1452 ImGui::EndChild();
1453
1454 ImGui::EndChild(); // End InfoPanel
1455 ImGui::EndChild(); // End TopSection
1456
1457 ImGui::Spacing();
1458 ImGui::Separator();
1459 ImGui::Spacing();
1460
1461 // Configuration section
1462 ImGui::Text("Configuration:");
1463
1464 // Subject code and language on same line (compact layout)
1465 float itemWidth = ImGui::GetContentRegionAvail().x * 0.45f;
1466
1467 ImGui::Text("Subject Code:");
1468 ImGui::SameLine(150);
1469 ImGui::PushItemWidth(itemWidth);
1470 if (ImGui::InputText("##SubjectCode", mSubjectCode, sizeof(mSubjectCode))) {
1471 mConfig->SetSubjectCode(mSubjectCode);
1472 }
1473 ImGui::PopItemWidth();
1474
1475 // Language selection (dropdown)
1476 if (exp.hasTranslations && !mAvailableLanguages.empty()) {
1477 ImGui::SameLine();
1478 ImGui::Text("Language:");
1479 ImGui::SameLine();
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());
1486 mConfig->SetLanguage(mLanguageCode);
1487 }
1488 if (is_selected) {
1489 ImGui::SetItemDefaultFocus();
1490 }
1491 }
1492 ImGui::EndCombo();
1493 }
1494 ImGui::PopItemWidth();
1495 }
1496
1497 ImGui::Spacing();
1498
1499 // Fullscreen toggle
1500 if (ImGui::Checkbox("Fullscreen Mode", &mFullscreen)) {
1501 mConfig->SetFullscreen(mFullscreen);
1502 }
1503
1504 ImGui::Spacing();
1505
1506 // Parameter editor button (if experiment has parameters)
1507 if (exp.hasParams) {
1508 if (ImGui::Button("Edit Parameters", ImVec2(-1, 0))) {
1509 // Sync schema from scale definition if this is a scale-based test
1510 std::string expScaleCode = fs::path(exp.path).stem().string();
1511 SyncScaleSchema(exp.directory, expScaleCode);
1512
1513 // Load parameters from schema file
1514 mParameters.clear();
1515
1516 fs::path schemaPath = fs::path(exp.directory) / "params" / (fs::path(exp.path).filename().string() + ".schema.json");
1517
1518 if (fs::exists(schemaPath)) {
1519 try {
1520 std::ifstream schemaFile(schemaPath);
1521 if (!schemaFile.is_open()) {
1522 printf("ERROR: Could not open schema file: %s\n", schemaPath.string().c_str());
1523 } else {
1524 // Parse JSON schema file properly
1525 nlohmann::json schemaJson;
1526 schemaFile >> schemaJson;
1527 schemaFile.close();
1528
1529 if (!schemaJson.contains("parameters") || !schemaJson["parameters"].is_array()) {
1530 printf("ERROR: Schema file does not contain 'parameters' array\n");
1531 } else {
1532 // Extract parameters from JSON
1533 for (const auto& paramJson : schemaJson["parameters"]) {
1534 if (!paramJson.contains("name") || !paramJson.contains("default")) {
1535 continue; // Skip invalid entries
1536 }
1537
1538 Parameter param;
1539 param.name = paramJson["name"].get<std::string>();
1540
1541 // Convert default value to 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()) {
1551 param.defaultValue = paramJson["default"].dump();
1552 } else {
1553 param.defaultValue = paramJson["default"].dump();
1554 }
1555
1556 // Extract description
1557 if (paramJson.contains("description")) {
1558 param.description = paramJson["description"].get<std::string>();
1559 }
1560
1561 // Extract options if available
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>());
1566 } else {
1567 param.options.push_back(opt.dump());
1568 }
1569 }
1570 }
1571
1572 param.value = param.defaultValue; // Initialize to default
1573 mParameters.push_back(param);
1574 }
1575 printf("Loaded %zu parameters from schema\n", mParameters.size());
1576 }
1577 }
1578 } catch (const std::exception& e) {
1579 printf("ERROR parsing schema JSON: %s\n", e.what());
1580 }
1581 }
1582
1583 // Set parameter file path
1584 mParameterFile = (fs::path(exp.directory) / (fs::path(exp.path).stem().string() + ".par.json")).string();
1585
1586 // Load existing parameter file if it exists
1587 if (fs::exists(mParameterFile)) {
1588 try {
1589 std::ifstream paramFile(mParameterFile);
1590 if (paramFile.is_open()) {
1591 nlohmann::json paramJson;
1592 paramFile >> paramJson;
1593 paramFile.close();
1594
1595 // Update parameter values from JSON object
1596 for (auto& param : mParameters) {
1597 if (paramJson.contains(param.name)) {
1598 // Convert JSON value to string
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";
1607 } else {
1608 param.value = paramJson[param.name].dump();
1609 }
1610 }
1611 }
1612 printf("Loaded parameter values from: %s\n", mParameterFile.c_str());
1613 }
1614 } catch (const std::exception& e) {
1615 printf("ERROR parsing parameter file JSON: %s\n", e.what());
1616 }
1617 }
1618
1619 mShowParameterEditor = true;
1620 }
1621 ImGui::Spacing();
1622 }
1623
1624 ImGui::Separator();
1625 ImGui::Spacing();
1626
1627 // Add to Study button
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));
1632
1633 if (ImGui::Button("Add to Study", ImVec2(buttonWidth, 40))) {
1634 AddTestToStudy();
1635 }
1636
1637 ImGui::PopStyleColor(3);
1638
1639 ImGui::SameLine();
1640
1641 // Run Test button
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));
1645
1646 if (ImGui::Button("Run Test", ImVec2(buttonWidth, 40))) {
1647 RunTest();
1648 }
1649
1650 ImGui::PopStyleColor(3);
1651
1652 // Status/info text and folder button
1653 ImGui::Spacing();
1654 ImGui::TextDisabled("Path: %s", exp.directory.c_str());
1655 ImGui::SameLine();
1656 if (ImGui::SmallButton("Open Folder")) {
1657 OpenDirectoryInFileBrowser(exp.directory);
1658 }
1659 if (ImGui::IsItemHovered()) {
1660 ImGui::SetTooltip("Open experiment folder in file browser");
1661 }
1662}
1663
1664void LauncherUI::RenderChainTab()
1665{
1666
1667 // Top section: Chain selector and info
1668 ImGui::BeginChild("ChainSelector", ImVec2(0, 150), true);
1669
1670 // Chain selection dropdown
1671 ImGui::Text("Current Chain:");
1672 ImGui::SameLine();
1673
1674 const char* currentChainName = mCurrentChain ? mCurrentChain->GetName().c_str() : "None";
1675 ImGui::PushItemWidth(250);
1676 if (ImGui::BeginCombo("##ChainSelect", currentChainName)) {
1677 // List chains from current study
1678 if (mCurrentStudy) {
1679 auto chainFiles = mCurrentStudy->GetChainFiles();
1680
1681 // Only show "None" option if no chains exist
1682 if (chainFiles.empty()) {
1683 if (ImGui::Selectable("None", !mCurrentChain)) {
1684 mCurrentChain.reset();
1685 }
1686 }
1687
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);
1692
1693 if (ImGui::Selectable(chainName.c_str(), is_selected)) {
1694 // Construct full path to chain file
1695 std::string fullChainPath = mCurrentStudy->GetPath() + "/chains/" + chainFiles[i];
1696 LoadChain(fullChainPath);
1697 printf("Loaded chain from dropdown: %s\n", chainName.c_str());
1698 }
1699 }
1700 } else {
1701 // No study loaded - show "None" option
1702 if (ImGui::Selectable("None", !mCurrentChain)) {
1703 mCurrentChain.reset();
1704 }
1705 }
1706 ImGui::EndCombo();
1707 }
1708 ImGui::PopItemWidth();
1709
1710 ImGui::SameLine();
1711
1712 // New chain button
1713 if (ImGui::Button("New Chain...")) {
1714 CreateNewChain();
1715 }
1716
1717 ImGui::SameLine();
1718
1719 // Save chain button
1720 if (mCurrentChain) {
1721 if (ImGui::Button("Save Chain")) {
1722 SaveCurrentChain();
1723 }
1724
1725 ImGui::SameLine();
1726
1727 // Copy chain button
1728 if (ImGui::Button("Copy Chain...")) {
1729 ImGui::OpenPopup("Copy Chain");
1730 }
1731
1732 ImGui::SameLine();
1733
1734 // Delete chain button
1735 if (ImGui::Button("Delete Chain")) {
1736 ImGui::OpenPopup("Confirm Delete Chain");
1737 }
1738 }
1739
1740 // Copy Chain popup
1741 if (ImGui::BeginPopupModal("Copy Chain", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
1742 ImGui::Text("Create a copy of '%s'", mCurrentChain ? mCurrentChain->GetName().c_str() : "");
1743 ImGui::Spacing();
1744
1745 static char copyName[256] = "";
1746 if (ImGui::IsWindowAppearing()) {
1747 // Pre-fill with original name + "_copy"
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';
1752 }
1753 ImGui::SetKeyboardFocusHere();
1754 }
1755
1756 ImGui::Text("New chain name:");
1757 ImGui::InputText("##CopyChainName", copyName, sizeof(copyName));
1758
1759 ImGui::Spacing();
1760
1761 if (ImGui::Button("Copy", ImVec2(120, 0))) {
1762 if (strlen(copyName) > 0 && mCurrentChain && mCurrentStudy) {
1763 // Create a copy of the chain with the new name
1764 std::string studyPath = mCurrentStudy->GetPath();
1765 std::string newChainPath = (fs::path(studyPath) / "chains" / (std::string(copyName) + ".json")).string();
1766
1767 // Copy the current chain file
1768 std::string oldChainPath = mCurrentChain->GetFilePath();
1769 try {
1770 fs::copy_file(oldChainPath, newChainPath, fs::copy_options::overwrite_existing);
1771
1772 // Load the new chain and update its name
1773 auto newChain = Chain::LoadFromFile(newChainPath);
1774 if (newChain) {
1775 newChain->SetName(copyName);
1776 newChain->Save();
1777
1778 // Select the new chain
1779 mCurrentChain = newChain;
1780 mConfig->SetCurrentChainName(copyName);
1781 printf("Created copy of chain: %s\n", copyName);
1782 }
1783 } catch (const std::exception& e) {
1784 printf("Error copying chain: %s\n", e.what());
1785 }
1786
1787 copyName[0] = '\0';
1788 ImGui::CloseCurrentPopup();
1789 }
1790 }
1791
1792 ImGui::SameLine();
1793
1794 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
1795 copyName[0] = '\0';
1796 ImGui::CloseCurrentPopup();
1797 }
1798
1799 ImGui::EndPopup();
1800 }
1801
1802 // Delete Chain confirmation popup
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() : "");
1805 ImGui::Spacing();
1806 ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f), "This action cannot be undone.");
1807 ImGui::Spacing();
1808
1809 if (ImGui::Button("Delete", ImVec2(120, 0))) {
1810 if (mCurrentChain && mCurrentStudy) {
1811 std::string chainPath = mCurrentChain->GetFilePath();
1812 std::string chainName = mCurrentChain->GetName();
1813 try {
1814 fs::remove(chainPath);
1815 mCurrentChain.reset();
1816 mConfig->SetCurrentChainName("");
1817 printf("Deleted chain: %s\n", chainName.c_str());
1818 } catch (const std::exception& e) {
1819 printf("Error deleting chain: %s\n", e.what());
1820 }
1821 }
1822 ImGui::CloseCurrentPopup();
1823 }
1824
1825 ImGui::SameLine();
1826
1827 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
1828 ImGui::CloseCurrentPopup();
1829 }
1830
1831 ImGui::EndPopup();
1832 }
1833
1834 ImGui::Spacing();
1835
1836 // Chain info
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());
1841
1842 ImGui::Spacing();
1843
1844 // Upload configuration checkbox
1845 bool uploadEnabled = mCurrentChain->GetUploadEnabled();
1846 if (ImGui::Checkbox("Upload data to server", &uploadEnabled)) {
1847 mCurrentChain->SetUploadEnabled(uploadEnabled);
1848
1849 // If enabling upload, ensure study has upload config
1850 if (uploadEnabled && mCurrentStudy) {
1851 bool hasConfig = !mCurrentStudy->GetStudyToken().empty() &&
1852 !mCurrentStudy->GetUploadServerURL().empty();
1853
1854 if (!hasConfig) {
1855 ImGui::OpenPopup("Upload Config Required");
1856 } else {
1857 // Create/update upload.json for all tests in chain
1858 int created = 0;
1859 for (const auto& item : mCurrentChain->GetItems()) {
1860 if (!item.IsPageItem()) {
1861 if (mCurrentStudy->CreateUploadConfigForTest(item.testName)) {
1862 created++;
1863 }
1864 }
1865 }
1866 if (created > 0) {
1867 printf("Created %d upload.json file(s) for chain tests\n", created);
1868 }
1869 }
1870 }
1871
1872 mCurrentChain->Save();
1873 }
1874
1875 // Warning popup if upload config is missing
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.");
1878 ImGui::Spacing();
1879 ImGui::TextWrapped("Please go to Study Settings and enter:");
1880 ImGui::BulletText("Upload Server URL");
1881 ImGui::BulletText("Study Token");
1882 ImGui::Spacing();
1883
1884 if (ImGui::Button("OK", ImVec2(120, 0))) {
1885 // Revert the checkbox
1886 mCurrentChain->SetUploadEnabled(false);
1887 ImGui::CloseCurrentPopup();
1888 }
1889 ImGui::EndPopup();
1890 }
1891
1892 if (uploadEnabled) {
1893 ImGui::SameLine();
1894 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "(Enabled)");
1895 }
1896
1897 ImGui::Spacing();
1898 ImGui::Separator();
1899 ImGui::Spacing();
1900
1901 // LSL configuration checkbox
1902 bool lslEnabled = mCurrentChain->GetLSLEnabled();
1903 if (ImGui::Checkbox("Enable LSL streaming", &lslEnabled)) {
1904 mCurrentChain->SetLSLEnabled(lslEnabled);
1905 mCurrentChain->Save();
1906 }
1907
1908 if (lslEnabled) {
1909 ImGui::SameLine();
1910 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "(Enabled)");
1911 }
1912
1913 // LSL stream name input (shown when LSL is enabled)
1914 if (lslEnabled) {
1915 ImGui::Indent(20.0f);
1916 ImGui::Text("LSL Stream Name:");
1917 ImGui::SameLine();
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();
1927 }
1928
1929 char streamName[256];
1930 std::strncpy(streamName, mCurrentChain->GetLSLStreamName().c_str(), sizeof(streamName) - 1);
1931 streamName[sizeof(streamName) - 1] = '\0';
1932
1933 if (ImGui::InputText("##lsl_stream_name", streamName, sizeof(streamName))) {
1934 mCurrentChain->SetLSLStreamName(std::string(streamName));
1935 mCurrentChain->Save();
1936 }
1937
1938 ImGui::Unindent(20.0f);
1939 }
1940 } else {
1941 ImGui::TextDisabled("No chain loaded. Create a new chain or select an existing one.");
1942 }
1943
1944 ImGui::EndChild();
1945
1946 ImGui::Spacing();
1947
1948 // Add item buttons
1949 if (!mCurrentChain || !mCurrentStudy) {
1950 ImGui::BeginDisabled();
1951 }
1952
1953 float buttonWidth = (ImGui::GetContentRegionAvail().x - 20) / 4.0f;
1954
1955 if (ImGui::Button("Add Instruction", ImVec2(buttonWidth, 0))) {
1956 AddInstructionPage();
1957 }
1958
1959 ImGui::SameLine();
1960
1961 if (ImGui::Button("Add Consent", ImVec2(buttonWidth, 0))) {
1962 AddConsentPage();
1963 }
1964
1965 ImGui::SameLine();
1966
1967 if (ImGui::Button("Add Completion", ImVec2(buttonWidth, 0))) {
1968 AddCompletionPage();
1969 }
1970
1971 ImGui::SameLine();
1972
1973 if (ImGui::Button("Add Test", ImVec2(buttonWidth, 0))) {
1974 AddTestToChain();
1975 }
1976
1977 if (!mCurrentChain || !mCurrentStudy) {
1978 ImGui::EndDisabled();
1979 }
1980
1981 ImGui::Spacing();
1982 ImGui::Separator();
1983 ImGui::Spacing();
1984
1985 // Chain items list
1986 ImGui::Text("Chain Items:");
1987 ImGui::BeginChild("ChainItemsList", ImVec2(0, -80), true);
1988
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.");
1993 } else {
1994 // Display chain items
1995 const auto& items = mCurrentChain->GetItems();
1996 for (size_t i = 0; i < items.size(); i++) {
1997 const ChainItem& item = items[i];
1998
1999 ImGui::PushID((int)i);
2000
2001 // Drag handle — source and target for reordering
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));
2008 ImGui::Text("Move: %s", item.GetDisplayName().c_str());
2009 ImGui::EndDragDropSource();
2010 }
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);
2016 ImGui::PopID();
2017 break;
2018 }
2019 }
2020 ImGui::EndDragDropTarget();
2021 }
2022 ImGui::SameLine();
2023
2024 // Item number
2025 ImGui::Text("%zu.", i + 1);
2026 ImGui::SameLine();
2027
2028 // Type icon/label
2029 const char* typeLabel = "";
2030 ImVec4 typeColor(1.0f, 1.0f, 1.0f, 1.0f);
2031
2032 if (item.type == ItemType::Instruction) {
2033 typeLabel = "[INS]";
2034 typeColor = ImVec4(0.4f, 0.7f, 1.0f, 1.0f);
2035 } else if (item.type == ItemType::Consent) {
2036 typeLabel = "[CON]";
2037 typeColor = ImVec4(0.7f, 0.4f, 1.0f, 1.0f);
2038 } else if (item.type == ItemType::Completion) {
2039 typeLabel = "[CMP]";
2040 typeColor = ImVec4(0.4f, 1.0f, 0.7f, 1.0f);
2041 } else if (item.type == ItemType::Test) {
2042 typeLabel = "[TST]";
2043 typeColor = ImVec4(1.0f, 0.7f, 0.4f, 1.0f);
2044 }
2045
2046 ImGui::TextColored(typeColor, "%s", typeLabel);
2047 ImGui::SameLine();
2048
2049 // Item name/title
2050 std::string displayName = item.GetDisplayName();
2051 ImGui::Text("%s", displayName.c_str());
2052
2053 // Show test details if it's a test item
2054 if (item.type == ItemType::Test) {
2055 ImGui::Indent(40);
2056 ImGui::TextDisabled("Test: %s | Variant: %s",
2057 item.testName.c_str(),
2058 item.paramVariant.empty() ? "default" : item.paramVariant.c_str());
2059
2060 // Randomization group dropdown (inline editing)
2061 ImGui::SameLine();
2062 ImGui::TextDisabled(" | Group:");
2063 ImGui::SameLine();
2064
2065 ImGui::PushItemWidth(80);
2066 const char* groupOptions[] = {"None", "1", "2", "3"};
2067 int currentGroup = item.randomGroup;
2068 if (ImGui::Combo("##randomgroup", &currentGroup, groupOptions, 4)) {
2069 // Update the item's random group
2070 ChainItem* mutableItem = mCurrentChain->GetItem(i);
2071 if (mutableItem) {
2072 mutableItem->randomGroup = currentGroup;
2073 SaveCurrentChain();
2074 printf("Updated randomization group for item %zu to %d\n", i, currentGroup);
2075 }
2076 }
2077 ImGui::PopItemWidth();
2078
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");
2083 }
2084
2085 ImGui::Unindent(40);
2086 }
2087
2088 // Control buttons (on the right)
2089 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 250);
2090
2091 // Up button
2092 if (i == 0) {
2093 ImGui::BeginDisabled();
2094 }
2095 if (ImGui::ArrowButton("##up", ImGuiDir_Up)) {
2096 MoveChainItemUp(i);
2097 ImGui::PopID();
2098 break; // Exit loop after moving to avoid iterator issues
2099 }
2100 if (i == 0) {
2101 ImGui::EndDisabled();
2102 }
2103 if (ImGui::IsItemHovered()) {
2104 ImGui::SetTooltip("Move up");
2105 }
2106
2107 ImGui::SameLine();
2108
2109 // Down button
2110 if (i >= items.size() - 1) {
2111 ImGui::BeginDisabled();
2112 }
2113 if (ImGui::ArrowButton("##down", ImGuiDir_Down)) {
2114 MoveChainItemDown(i);
2115 ImGui::PopID();
2116 break; // Exit loop after moving to avoid iterator issues
2117 }
2118 if (i >= items.size() - 1) {
2119 ImGui::EndDisabled();
2120 }
2121 if (ImGui::IsItemHovered()) {
2122 ImGui::SetTooltip("Move down");
2123 }
2124
2125 ImGui::SameLine();
2126
2127 // Test button
2128 if (ImGui::SmallButton("Test")) {
2129 TestChainItem(i);
2130 }
2131 if (ImGui::IsItemHovered()) {
2132 ImGui::SetTooltip("Test run this item");
2133 }
2134
2135 ImGui::SameLine();
2136
2137 // Edit button
2138 if (ImGui::SmallButton("Edit")) {
2139 EditChainItem(i);
2140 }
2141 if (ImGui::IsItemHovered()) {
2142 ImGui::SetTooltip("Edit item");
2143 }
2144
2145 ImGui::SameLine();
2146
2147 // Remove button
2148 if (ImGui::SmallButton("Remove")) {
2149 RemoveChainItem(i);
2150 ImGui::PopID();
2151 break; // Exit loop after removing to avoid iterator issues
2152 }
2153 if (ImGui::IsItemHovered()) {
2154 ImGui::SetTooltip("Remove from chain");
2155 }
2156
2157 ImGui::Spacing();
2158 ImGui::PopID();
2159 }
2160 }
2161
2162 ImGui::EndChild();
2163
2164 ImGui::Spacing();
2165 ImGui::TextDisabled("Use the 'Test' button to run individual items, or go to the Run tab to execute the full chain.");
2166}
2167
2168void LauncherUI::ShowAboutDialog()
2169{
2170 ImGui::OpenPopup("About PEBL Launcher");
2171
2172 ImVec2 center = ImGui::GetMainViewport()->GetCenter();
2173 ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
2174
2175 if (ImGui::BeginPopupModal("About PEBL Launcher", &mShowAbout, ImGuiWindowFlags_AlwaysAutoResize))
2176 {
2177 ImGui::Text("PEBL Experiment Launcher");
2178 ImGui::Separator();
2179 ImGui::Text("Version: %s (Prototype)", PEBL_VERSION);
2180 ImGui::Text("Built with Dear ImGui and SDL2");
2181 ImGui::Spacing();
2182 ImGui::Text("Copyright (c) 2026 Shane T. Mueller");
2183 ImGui::Text("Licensed under GPL");
2184 ImGui::Spacing();
2185
2186 if (ImGui::Button("Close", ImVec2(120, 0))) {
2187 mShowAbout = false;
2188 ImGui::CloseCurrentPopup();
2189 }
2190
2191 ImGui::EndPopup();
2192 }
2193}
2194
2195void LauncherUI::ShowSettingsDialog()
2196{
2197 ImGui::OpenPopup("Settings");
2198
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);
2202
2203 if (ImGui::BeginPopupModal("Settings", &mShowSettings, 0))
2204 {
2205 ImGui::Text("Configure PEBL Launcher settings");
2206 ImGui::Separator();
2207 ImGui::Spacing();
2208
2209 // Tabbed interface for different settings categories
2210 if (ImGui::BeginTabBar("SettingsTabs", ImGuiTabBarFlags_None))
2211 {
2212 // ============================================================
2213 // GENERAL TAB
2214 // ============================================================
2215 if (ImGui::BeginTabItem("General"))
2216 {
2217 ImGui::Spacing();
2218
2219 // Default subject code
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))) {
2226 mConfig->SetSubjectCode(subjectCode);
2227 }
2228 ImGui::PopItemWidth();
2229
2230 ImGui::Spacing();
2231
2232 // Default language
2233 ImGui::Text("Default Language:");
2234 char language[16];
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))) {
2239 mConfig->SetLanguage(language);
2240 }
2241 ImGui::PopItemWidth();
2242 if (ImGui::IsItemHovered()) {
2243 ImGui::SetTooltip("Two-letter language code (en, de, es, fr, etc.)");
2244 }
2245
2246 ImGui::Spacing();
2247
2248 // Fullscreen mode
2249 bool fullscreen = mConfig->GetFullscreen();
2250 if (ImGui::Checkbox("Run tests in fullscreen mode by default", &fullscreen)) {
2251 mConfig->SetFullscreen(fullscreen);
2252 }
2253
2254 ImGui::Spacing();
2255
2256 // Font size
2257 ImGui::Text("Font Size:");
2258 int fontSize = mConfig->GetFontSize();
2259 ImGui::PushItemWidth(100);
2260 if (ImGui::SliderInt("##FontSize", &fontSize, 10, 24)) {
2261 mConfig->SetFontSize(fontSize);
2262 }
2263 ImGui::PopItemWidth();
2264 if (ImGui::IsItemHovered()) {
2265 ImGui::SetTooltip("Requires restart to take effect");
2266 }
2267
2268 ImGui::EndTabItem();
2269 }
2270
2271 // ============================================================
2272 // FILE PATHS TAB
2273 // ============================================================
2274 if (ImGui::BeginTabItem("File Paths"))
2275 {
2276 ImGui::Spacing();
2277 ImGui::TextWrapped("Configure file system locations for PEBL. Leave blank for auto-detection.");
2278 ImGui::Separator();
2279 ImGui::Spacing();
2280
2281 // Workspace path
2282 ImGui::Text("Workspace Path:");
2283 if (ImGui::IsItemHovered()) {
2284 ImGui::SetTooltip("Main workspace directory (typically Documents/pebl-exp.%s/)", PEBL_VERSION);
2285 }
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))) {
2291 mConfig->SetWorkspacePath(workspacePath);
2292 }
2293 ImGui::PopItemWidth();
2294 ImGui::SameLine();
2295 if (ImGui::Button("Browse##Workspace")) {
2296 std::string path = OpenDirectoryDialog("Select Workspace Directory");
2297 if (!path.empty()) {
2298 mConfig->SetWorkspacePath(path);
2299 }
2300 }
2301
2302 ImGui::Spacing();
2303
2304 // Battery path
2305 ImGui::Text("Battery Path:");
2306 if (ImGui::IsItemHovered()) {
2307 ImGui::SetTooltip("Directory containing battery tests (e.g., /usr/local/share/pebl/battery/)");
2308 }
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))) {
2314 mConfig->SetBatteryPath(batteryPath);
2315 }
2316 ImGui::PopItemWidth();
2317 ImGui::SameLine();
2318 if (ImGui::Button("Browse##Battery")) {
2319 std::string path = OpenDirectoryDialog("Select Battery Directory");
2320 if (!path.empty()) {
2321 mConfig->SetBatteryPath(path);
2322 }
2323 }
2324
2325 ImGui::Spacing();
2326
2327 // PEBL executable path
2328 ImGui::Text("PEBL Executable Path:");
2329 if (ImGui::IsItemHovered()) {
2330 ImGui::SetTooltip("Location of the pebl2 executable (auto-detected on startup)");
2331 }
2332 char peblExePath[512];
2333 std::strncpy(peblExePath, mConfig->GetPeblExecutablePath().c_str(), sizeof(peblExePath) - 1);
2334 peblExePath[sizeof(peblExePath) - 1] = '\0';
2335 ImGui::PushItemWidth(-100);
2336 if (ImGui::InputText("##PeblExePath", peblExePath, sizeof(peblExePath))) {
2337 mConfig->SetPeblExecutablePath(peblExePath);
2338 }
2339 ImGui::PopItemWidth();
2340 ImGui::SameLine();
2341 if (ImGui::Button("Browse##PeblExe")) {
2342 std::string path = OpenFileDialog("Select PEBL Executable");
2343 if (!path.empty()) {
2344 mConfig->SetPeblExecutablePath(path);
2345 }
2346 }
2347
2348 ImGui::Spacing();
2349 ImGui::Separator();
2350 ImGui::Spacing();
2351
2352 ImGui::TextWrapped("Note: Restart the launcher after changing paths for changes to take full effect.");
2353
2354 ImGui::EndTabItem();
2355 }
2356
2357 // ============================================================
2358 // UPLOAD TAB
2359 // ============================================================
2360 if (ImGui::BeginTabItem("Upload"))
2361 {
2362 ImGui::Spacing();
2363 ImGui::TextWrapped("Configure automatic data upload to PEBL Hub or custom server.");
2364 ImGui::Separator();
2365 ImGui::Spacing();
2366
2367 // Auto-upload checkbox
2368 bool autoUpload = mConfig->GetAutoUpload();
2369 if (ImGui::Checkbox("Enable auto-upload after test completion", &autoUpload)) {
2370 mConfig->SetAutoUpload(autoUpload);
2371 }
2372
2373 ImGui::Spacing();
2374
2375 // Upload URL
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))) {
2382 mConfig->SetUploadURL(uploadURL);
2383 }
2384 ImGui::PopItemWidth();
2385 if (ImGui::IsItemHovered()) {
2386 ImGui::SetTooltip("Default: https://peblhub.online/api/upload");
2387 }
2388
2389 ImGui::Spacing();
2390
2391 // Upload token
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)) {
2398 mConfig->SetUploadToken(uploadToken);
2399 }
2400 ImGui::PopItemWidth();
2401 if (ImGui::IsItemHovered()) {
2402 ImGui::SetTooltip("Get your token from peblhub.online/account");
2403 }
2404
2405 ImGui::Spacing();
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.");
2408
2409 ImGui::EndTabItem();
2410 }
2411
2412 ImGui::EndTabBar();
2413 }
2414
2415 ImGui::Spacing();
2416 ImGui::Separator();
2417 ImGui::Spacing();
2418
2419 // Bottom buttons
2420 if (ImGui::Button("Save", ImVec2(120, 0))) {
2421 mConfig->SaveConfig();
2422 mShowSettings = false;
2423 ImGui::CloseCurrentPopup();
2424 }
2425
2426 ImGui::SameLine();
2427
2428 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
2429 // Reload config to revert changes
2430 mConfig->LoadConfig();
2431 mShowSettings = false;
2432 ImGui::CloseCurrentPopup();
2433 }
2434
2435 ImGui::SameLine();
2436 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 120);
2437
2438 if (ImGui::Button("Apply", ImVec2(120, 0))) {
2439 mConfig->SaveConfig();
2440 }
2441
2442 ImGui::EndPopup();
2443 }
2444}
2445
2446void LauncherUI::ShowParameterEditor()
2447{
2448 ImGui::OpenPopup("Parameter Editor");
2449
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);
2453
2454 if (ImGui::BeginPopupModal("Parameter Editor", &mShowParameterEditor, 0))
2455 {
2456 // Header row: description on left, Variants button on right
2457 if (mEditingDefaultParams) {
2458 ImGui::Text("Editing default parameters");
2459 } else {
2460 ImGui::Text("Editing variant: %s", mVariantName);
2461 }
2462 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 80);
2463 if (ImGui::SmallButton("Variants...")) {
2464 mShowVariantNameDialog = true;
2465 }
2466 if (ImGui::IsItemHovered()) {
2467 ImGui::SetTooltip("Create or switch to a named parameter variant\n(e.g., for different input devices or conditions)");
2468 }
2469 if (mEditingDefaultParams) {
2470 ImGui::TextDisabled("Values here override the built-in defaults from the test definition.");
2471 } else {
2472 ImGui::TextDisabled("Variants are alternate parameter sets for different conditions.");
2473 }
2474 ImGui::Separator();
2475 ImGui::Spacing();
2476
2477 // Scrollable parameter list in table format
2478 ImGui::BeginChild("ParameterList", ImVec2(0, -40), true);
2479
2480 if (mParameters.empty()) {
2481 ImGui::TextDisabled("No parameters defined for this experiment");
2482 } else {
2483 // Table header
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();
2490
2491 // Parameter rows
2492 bool shouldFocusFirst = ImGui::IsWindowAppearing();
2493 for (size_t i = 0; i < mParameters.size(); i++) {
2494 Parameter& param = mParameters[i];
2495
2496 ImGui::PushID((int)i);
2497 ImGui::TableNextRow();
2498
2499 // Column 1: Parameter name
2500 ImGui::TableSetColumnIndex(0);
2501 ImGui::TextWrapped("%s", param.name.c_str());
2502
2503 // Column 2: Editable value — use combo/checkbox when options available
2504 ImGui::TableSetColumnIndex(1);
2505 ImGui::PushItemWidth(-1);
2506
2507 if (!param.options.empty()) {
2508 // Combo box for parameters with defined options
2509 // Find current selection index
2510 int currentIdx = -1;
2511 for (size_t j = 0; j < param.options.size(); j++) {
2512 if (param.options[j] == param.value) {
2513 currentIdx = (int)j;
2514 break;
2515 }
2516 }
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)) {
2522 param.value = param.options[j];
2523 }
2524 if (isSelected) {
2525 ImGui::SetItemDefaultFocus();
2526 }
2527 }
2528 ImGui::EndCombo();
2529 }
2530 } else {
2531 // Free text input
2532 char buffer[256];
2533 std::strncpy(buffer, param.value.c_str(), sizeof(buffer) - 1);
2534 buffer[sizeof(buffer) - 1] = '\0';
2535
2536 if (shouldFocusFirst && i == 0) {
2537 ImGui::SetKeyboardFocusHere();
2538 }
2539 if (ImGui::InputText("##value", buffer, sizeof(buffer))) {
2540 param.value = buffer;
2541 }
2542 }
2543
2544 ImGui::PopItemWidth();
2545
2546 // Column 3: Description with default value
2547 ImGui::TableSetColumnIndex(2);
2548 std::string descText = param.description;
2549 if (!param.defaultValue.empty()) {
2550 descText += " (built-in: " + param.defaultValue + ")";
2551 }
2552 // Show options if available
2553 if (!param.options.empty()) {
2554 descText += " Options: ";
2555 for (size_t j = 0; j < param.options.size(); j++) {
2556 if (j > 0) descText += ", ";
2557 descText += param.options[j];
2558 }
2559 }
2560 ImGui::TextWrapped("%s", descText.c_str());
2561
2562 // Column 4: Reset button
2563 ImGui::TableSetColumnIndex(3);
2564 if (ImGui::SmallButton("Reset")) {
2565 param.value = param.defaultValue;
2566 }
2567
2568 ImGui::PopID();
2569 }
2570
2571 ImGui::EndTable();
2572 }
2573 }
2574
2575 ImGui::EndChild();
2576
2577 ImGui::Spacing();
2578
2579 // Buttons
2580 if (ImGui::Button("Save", ImVec2(120, 0))) {
2581 // Save parameters to file
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) {
2589 file << ",";
2590 }
2591 file << std::endl;
2592 }
2593 file << "}" << std::endl;
2594 file.close();
2595 printf("Parameters saved to: %s\n", mParameterFile.c_str());
2596
2597 // Register named variants with the study (skip for default par.json)
2598 if (!mEditingDefaultParams) {
2599 size_t lastSlash = mParameterFile.find_last_of("/\\");
2600 std::string filename = (lastSlash != std::string::npos)
2601 ? mParameterFile.substr(lastSlash + 1)
2602 : mParameterFile;
2603 size_t dotPar = filename.find(".par.json");
2604 if (dotPar != std::string::npos) {
2605 std::string variantName = filename.substr(0, dotPar);
2606
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);
2614 if (test) {
2615 ParameterVariant variant;
2616 variant.file = variantName + ".par.json";
2617 variant.description = "Custom variant";
2618 test->parameterVariants[variantName] = variant;
2619 mCurrentStudy->Save();
2620 printf("Registered variant '%s' for test '%s'\n", variantName.c_str(), testName.c_str());
2621 }
2622 }
2623 }
2624 }
2625 }
2626 }
2627 }
2628 mShowParameterEditor = false;
2629 ImGui::CloseCurrentPopup();
2630 }
2631
2632 ImGui::SameLine();
2633
2634 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
2635 mShowParameterEditor = false;
2636 ImGui::CloseCurrentPopup();
2637 }
2638
2639 ImGui::EndPopup();
2640 }
2641}
2642
2643void LauncherUI::ShowVariantNameDialog()
2644{
2645 ImGui::OpenPopup("Parameter Variants");
2646
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);
2650
2651 if (ImGui::BeginPopupModal("Parameter Variants", &mShowVariantNameDialog, 0))
2652 {
2653 // Get test name and variants
2654 std::string testName = "";
2655 const std::map<std::string, ParameterVariant>* variants = nullptr;
2656
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);
2662 if (test) {
2663 variants = &test->parameterVariants;
2664 }
2665 }
2666 }
2667
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.");
2670 ImGui::Separator();
2671 ImGui::Spacing();
2672
2673 // Edit Default button (prominent)
2674 if (ImGui::Button("Edit Default Parameters", ImVec2(-1, 35))) {
2675 mVariantName[0] = '\0';
2676 LoadParameterEditorForVariant();
2677 mShowVariantNameDialog = false;
2678 ImGui::CloseCurrentPopup();
2679 }
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());
2682 }
2683
2684 ImGui::Spacing();
2685 ImGui::Separator();
2686 ImGui::Spacing();
2687
2688 // Show existing named variants
2689 if (variants && !variants->empty()) {
2690 ImGui::Text("Named Variants:");
2691 ImGui::Spacing();
2692
2693 ImGui::BeginChild("ExistingVariants", ImVec2(0, 120), true);
2694 for (const auto& pair : *variants) {
2695 ImGui::PushID(pair.first.c_str());
2696
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;
2702 ImGui::PopID();
2703 ImGui::CloseCurrentPopup();
2704 break;
2705 }
2706
2707 ImGui::SameLine();
2708 ImGui::TextDisabled("(%s)", pair.second.file.c_str());
2709
2710 ImGui::PopID();
2711 }
2712 ImGui::EndChild();
2713
2714 ImGui::Spacing();
2715 }
2716
2717 // Create new variant section
2718 ImGui::Text("Create New Variant:");
2719 ImGui::Spacing();
2720
2721 ImGui::Text("Name:");
2722 ImGui::SameLine();
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());
2727 }
2728 ImGui::SameLine();
2729 ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - 130);
2730 ImGui::InputText("##variantname", mVariantName, sizeof(mVariantName));
2731 ImGui::PopItemWidth();
2732 ImGui::SameLine();
2733
2734 bool canCreate = strlen(mVariantName) > 0;
2735 if (!canCreate) {
2736 ImGui::BeginDisabled();
2737 }
2738 if (ImGui::Button("Create", ImVec2(120, 0))) {
2739 LoadParameterEditorForVariant();
2740 mShowVariantNameDialog = false;
2741 ImGui::CloseCurrentPopup();
2742 }
2743 if (!canCreate) {
2744 ImGui::EndDisabled();
2745 }
2746
2747 ImGui::Spacing();
2748
2749 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
2750 mShowVariantNameDialog = false;
2751 ImGui::CloseCurrentPopup();
2752 }
2753
2754 ImGui::EndPopup();
2755 }
2756}
2757
2758void LauncherUI::LoadParameterEditorForVariant()
2759{
2760 if (!mCurrentStudy || mEditingTestIndex < 0) {
2761 printf("Error: Invalid state for loading parameter editor\n");
2762 return;
2763 }
2764
2765 const auto& tests = mCurrentStudy->GetTests();
2766 if (mEditingTestIndex >= (int)tests.size()) {
2767 printf("Error: Invalid test index\n");
2768 return;
2769 }
2770
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";
2775
2776 // Sync schema from scale definition if this is a scale-based test
2777 SyncScaleSchema(testPath, test.testName);
2778
2779 printf("Loading parameter schema from: %s\n", schemaPath.c_str());
2780
2781 // Check if schema file exists
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());
2787
2788 // List what files DO exist in params directory
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());
2794 }
2795 } else {
2796 printf("Params directory does not exist: %s\n", paramsDir.c_str());
2797 }
2798 return;
2799 }
2800
2801 // Load schema to get parameter definitions
2802 try {
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());
2806 return;
2807 }
2808
2809 // Parse JSON schema file
2810 mParameters.clear();
2811 nlohmann::json schemaJson;
2812 schemaFile >> schemaJson;
2813 schemaFile.close();
2814
2815 if (!schemaJson.contains("parameters") || !schemaJson["parameters"].is_array()) {
2816 printf("ERROR: Schema file does not contain 'parameters' array\n");
2817 return;
2818 }
2819
2820 // Extract parameters from JSON
2821 for (const auto& paramJson : schemaJson["parameters"]) {
2822 if (!paramJson.contains("name") || !paramJson.contains("default")) {
2823 continue; // Skip invalid entries
2824 }
2825
2826 Parameter param;
2827 param.name = paramJson["name"].get<std::string>();
2828
2829 // Convert default value to 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";
2838 } else {
2839 param.defaultValue = paramJson["default"].dump();
2840 }
2841
2842 if (paramJson.contains("description")) {
2843 param.description = paramJson["description"].get<std::string>();
2844 }
2845
2846 // Extract options if available
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>());
2851 } else {
2852 param.options.push_back(opt.dump());
2853 }
2854 }
2855 }
2856
2857 param.value = param.defaultValue; // Initialize to default
2858 mParameters.push_back(param);
2859 }
2860
2861 printf("Loaded %zu parameters from schema\n", mParameters.size());
2862
2863 // Build parameter file path
2864 // Empty variant name = default parameter file (testname.pbl.par.json)
2865 // Non-empty variant name = named variant (testname-variantname.par.json)
2866 std::string variantFileName;
2867 if (strlen(mVariantName) == 0) {
2868 variantFileName = test.testName + ".pbl.par.json";
2869 mEditingDefaultParams = true;
2870 } else {
2871 variantFileName = test.testName + "-" + std::string(mVariantName) + ".par.json";
2872 mEditingDefaultParams = false;
2873 }
2874 mParameterFile = testPath + "/params/" + variantFileName;
2875 printf("Parameter file: %s\n", mParameterFile.c_str());
2876
2877 // If parameter file already exists, load its values
2878 if (fs::exists(mParameterFile)) {
2879 printf("Loading existing parameter values from file\n");
2880 std::ifstream paramFile(mParameterFile);
2881 if (paramFile.is_open()) {
2882 // Read JSON file (simple key-value format)
2883 std::string paramLine;
2884 while (std::getline(paramFile, paramLine)) {
2885 // Parse: "name": "value"
2886 size_t colonPos = paramLine.find(':');
2887 if (colonPos != std::string::npos) {
2888 // Extract name (between first " and second ")
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);
2893
2894 // Extract value (between third " and fourth ")
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);
2899
2900 // Find and update parameter
2901 for (auto& param : mParameters) {
2902 if (param.name == paramName) {
2903 param.value = paramValue;
2904 break;
2905 }
2906 }
2907 }
2908 }
2909 }
2910 }
2911 paramFile.close();
2912 }
2913 }
2914
2915 // Show parameter editor
2916 mShowParameterEditor = true;
2917
2918 } catch (const std::exception& e) {
2919 printf("Error loading parameter schema: %s\n", e.what());
2920 }
2921}
2922
2923void LauncherUI::ScanExperimentDirectory(const std::string& path)
2924{
2925 mExperiments.clear();
2926 mSelectedExperiment = -1;
2927 FreeScreenshot();
2928
2929 if (!fs::exists(path) || !fs::is_directory(path)) {
2930 return;
2931 }
2932
2933 try {
2934 for (const auto& entry : fs::recursive_directory_iterator(path)) {
2935 if (entry.is_regular_file() && entry.path().extension() == ".pbl") {
2936 // Only include tests that have a .about.txt file
2937 // This filters out helper scripts, stimulus generators, and experimental files
2938 fs::path aboutPath = entry.path().parent_path() / (entry.path().filename().string() + ".about.txt");
2939 if (!fs::exists(aboutPath)) {
2940 continue; // Skip this .pbl file - not a proper battery test
2941 }
2942
2943 ExperimentInfo info;
2944 info.path = entry.path().string();
2945
2946 // Use parent directory name + filename for display
2947 // This helps differentiate tests with same name in different directories
2948 std::string parentDir = entry.path().parent_path().filename().string();
2949 std::string filename = entry.path().stem().string();
2950
2951 if (parentDir != filename) {
2952 // Show as "parentdir/filename" if they differ
2953 info.name = parentDir + "/" + filename;
2954 } else {
2955 // Just show filename if parent directory has same name
2956 info.name = filename;
2957 }
2958
2959 info.directory = entry.path().parent_path().string();
2960 info.description = "";
2961
2962 // Check for parameter schema
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);
2966
2967 // Check for translations
2968 fs::path translationsDir = entry.path().parent_path() / "translations";
2969 info.hasTranslations = fs::exists(translationsDir) && fs::is_directory(translationsDir);
2970
2971 // Check for screenshot (e.g., taskname.pbl.png)
2972 fs::path screenshotPath = entry.path().parent_path() / (entry.path().filename().string() + ".png");
2973 if (fs::exists(screenshotPath)) {
2974 info.hasScreenshot = true;
2975 info.screenshotPath = screenshotPath.string();
2976 } else {
2977 info.hasScreenshot = false;
2978 info.screenshotPath = "";
2979 }
2980
2981 mExperiments.push_back(info);
2982 }
2983 }
2984
2985 // Sort alphabetically (case-insensitive)
2986 std::sort(mExperiments.begin(), mExperiments.end(),
2987 [](const ExperimentInfo& a, const ExperimentInfo& b) {
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;
2993 });
2994
2995 } catch (const fs::filesystem_error& e) {
2996 printf("Error scanning directory: %s\n", e.what());
2997 }
2998}
2999
3000void LauncherUI::ScanTemplates()
3001{
3002 mTemplateNames.clear();
3003 mTemplateFiles.clear();
3004
3005 // Check media/templates directory (new location)
3006 std::string templatesPath = mBatteryPath + "/../media/templates";
3007
3008 if (!fs::exists(templatesPath) || !fs::is_directory(templatesPath)) {
3009 printf("Warning: Templates directory not found: %s\n", templatesPath.c_str());
3010 return;
3011 }
3012
3013 try {
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); // Remove .pbl
3018
3019 // Convert filename to display name (e.g., "simple-rt" -> "Simple RT")
3020 std::string niceName;
3021 bool capitalizeNext = true;
3022 for (char c : displayName) {
3023 if (c == '-' || c == '_') {
3024 niceName += ' ';
3025 capitalizeNext = true;
3026 } else if (capitalizeNext) {
3027 niceName += toupper(c);
3028 capitalizeNext = false;
3029 } else {
3030 niceName += c;
3031 }
3032 }
3033
3034 mTemplateFiles.push_back(filename);
3035 mTemplateNames.push_back(niceName);
3036 }
3037 }
3038
3039 // Sort alphabetically
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]; });
3044
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]);
3050 }
3051 mTemplateNames = sortedNames;
3052 mTemplateFiles = sortedFiles;
3053
3054 printf("Scanned templates: found %zu templates\n", mTemplateFiles.size());
3055
3056 } catch (const fs::filesystem_error& e) {
3057 printf("Error scanning templates directory: %s\n", e.what());
3058 }
3059}
3060
3061void LauncherUI::LoadScreenshot(const std::string& imagePath)
3062{
3063 // Free previous screenshot if any
3064 FreeScreenshot();
3065
3066 if (imagePath.empty() || !fs::exists(imagePath)) {
3067 return;
3068 }
3069
3070 // Load image using SDL_image
3071 SDL_Surface* surface = IMG_Load(imagePath.c_str());
3072 if (!surface) {
3073 printf("Failed to load screenshot: %s\n", IMG_GetError());
3074 return;
3075 }
3076
3077 // Create texture from surface
3078 mScreenshotTexture = SDL_CreateTextureFromSurface(mRenderer, surface);
3079 if (!mScreenshotTexture) {
3080 printf("Failed to create texture: %s\n", SDL_GetError());
3081 SDL_FreeSurface(surface);
3082 return;
3083 }
3084
3085 mScreenshotWidth = surface->w;
3086 mScreenshotHeight = surface->h;
3087
3088 SDL_FreeSurface(surface);
3089}
3090
3091void LauncherUI::FreeScreenshot()
3092{
3093 if (mScreenshotTexture) {
3094 SDL_DestroyTexture(mScreenshotTexture);
3095 mScreenshotTexture = nullptr;
3096 mScreenshotWidth = 0;
3097 mScreenshotHeight = 0;
3098 }
3099}
3100
3101void LauncherUI::FreeStudyTestScreenshot()
3102{
3103 if (mStudyTestScreenshot) {
3104 SDL_DestroyTexture(mStudyTestScreenshot);
3105 mStudyTestScreenshot = nullptr;
3106 mStudyTestScreenshotW = 0;
3107 mStudyTestScreenshotH = 0;
3108 }
3109}
3110
3111void LauncherUI::LoadStudyTestPreview(int testIndex)
3112{
3113 FreeStudyTestScreenshot();
3114 mStudyTestDescription.clear();
3115
3116 if (!mCurrentStudy || testIndex < 0) return;
3117
3118 const auto& tests = mCurrentStudy->GetTests();
3119 if (testIndex >= (int)tests.size()) return;
3120
3121 const std::string& testName = tests[testIndex].testName;
3122 std::string studyPath = mCurrentStudy->GetPath();
3123 std::string testDir = studyPath + "/tests/" + testName;
3124
3125 printf("LoadStudyTestPreview: testName='%s' studyPath='%s' testDir='%s'\n",
3126 testName.c_str(), studyPath.c_str(), testDir.c_str());
3127
3128 // Load about.txt from study's local test directory
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>());
3137 aboutFile.close();
3138 }
3139 }
3140
3141 // If not found locally, try battery source
3142 if (mStudyTestDescription.empty()) {
3143 // Look in battery for matching test
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()) {
3148 mStudyTestDescription = exp.description;
3149 break;
3150 }
3151 }
3152 }
3153
3154 // Load screenshot from study's local test directory
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)) {
3158 // Fall back to battery source
3159 for (const auto& exp : mExperiments) {
3160 fs::path expPath(exp.path);
3161 std::string expName = expPath.stem().string();
3162 if (expName == testName && exp.hasScreenshot) {
3163 screenshotPath = exp.screenshotPath;
3164 break;
3165 }
3166 }
3167 }
3168
3169 if (fs::exists(screenshotPath)) {
3170 SDL_Surface* surface = IMG_Load(screenshotPath.c_str());
3171 if (surface) {
3172 mStudyTestScreenshot = SDL_CreateTextureFromSurface(mRenderer, surface);
3173 if (mStudyTestScreenshot) {
3174 mStudyTestScreenshotW = surface->w;
3175 mStudyTestScreenshotH = surface->h;
3176 }
3177 SDL_FreeSurface(surface);
3178 }
3179 }
3180}
3181
3182void LauncherUI::LoadExperimentInfo(const std::string& scriptPath)
3183{
3184 if (mSelectedExperiment < 0 || mSelectedExperiment >= (int)mExperiments.size()) {
3185 return;
3186 }
3187
3188 ExperimentInfo& exp = mExperiments[mSelectedExperiment];
3189
3190 // Load description from .about.txt file
3191 fs::path aboutPath = fs::path(scriptPath).parent_path() /
3192 (fs::path(scriptPath).filename().string() + ".about.txt");
3193
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>());
3199 exp.description = content;
3200 aboutFile.close();
3201 }
3202 }
3203
3204 // Load screenshot
3205 if (exp.hasScreenshot) {
3206 LoadScreenshot(exp.screenshotPath);
3207 } else {
3208 FreeScreenshot();
3209 }
3210
3211 // Scan for available translations
3212 mAvailableLanguages.clear();
3213 if (exp.hasTranslations) {
3214 fs::path translationsDir = fs::path(scriptPath).parent_path() / "translations";
3215 try {
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;
3221 // Try dash-based format: "taskname.pbl-en"
3222 size_t dashPos = filename.find_last_of('-');
3223 if (dashPos != std::string::npos) {
3224 langCode = filename.substr(dashPos + 1);
3225 } else {
3226 // Try dot-based format: "taskname.en"
3227 size_t dotPos = filename.find_last_of('.');
3228 if (dotPos != std::string::npos) {
3229 langCode = filename.substr(dotPos + 1);
3230 }
3231 }
3232 if (!langCode.empty()) {
3233 langSet.insert(langCode);
3234 }
3235 }
3236 }
3237 mAvailableLanguages.assign(langSet.begin(), langSet.end());
3238
3239 // Sort language codes (already sorted by set, but be explicit)
3240 std::sort(mAvailableLanguages.begin(), mAvailableLanguages.end());
3241
3242 // Default to first available language if current language not in list
3243 if (!mAvailableLanguages.empty()) {
3244 bool found = false;
3245 for (const auto& lang : mAvailableLanguages) {
3246 if (lang == mLanguageCode) {
3247 found = true;
3248 break;
3249 }
3250 }
3251 if (!found) {
3252 std::strcpy(mLanguageCode, mAvailableLanguages[0].c_str());
3253 }
3254 }
3255
3256 } catch (const fs::filesystem_error& e) {
3257 printf("Error scanning translations: %s\n", e.what());
3258 }
3259 }
3260}
3261
3262void LauncherUI::RunTest()
3263{
3264 if (mSelectedExperiment < 0 || mSelectedExperiment >= (int)mExperiments.size()) {
3265 return;
3266 }
3267
3268 // Clean up any previous test
3269 if (mRunningExperiment) {
3270 if (mRunningExperiment->IsRunning()) {
3271 printf("Warning: Previous test still running\n");
3272 return;
3273 }
3274 delete mRunningExperiment;
3275 mRunningExperiment = nullptr;
3276 }
3277
3278 const ExperimentInfo& exp = mExperiments[mSelectedExperiment];
3279
3280 // Build command line arguments
3281 std::vector<std::string> args;
3282
3283 // Subject code
3284 if (strlen(mSubjectCode) > 0) {
3285 args.push_back("-s");
3286 args.push_back(mSubjectCode);
3287 }
3288
3289 // Language
3290 if (strlen(mLanguageCode) > 0 && exp.hasTranslations) {
3291 args.push_back("-v");
3292 args.push_back(std::string("gLanguage=") + mLanguageCode);
3293 }
3294
3295 // Fullscreen
3296 if (mFullscreen) {
3297 args.push_back("--fullscreen");
3298 }
3299
3300 // Create new experiment runner
3301 mRunningExperiment = new ExperimentRunner(mConfig);
3302 bool success = mRunningExperiment->RunExperiment(exp.path, args,
3303 mSubjectCode,
3304 mLanguageCode,
3305 mFullscreen);
3306
3307 if (success) {
3308 // Add to recent experiments list
3309 mConfig->AddRecentExperiment(exp.path, exp.name);
3310 mShowStderr = false; // Start showing stdout
3311 } else {
3312 printf("Failed to run experiment: %s\n", exp.path.c_str());
3313 delete mRunningExperiment;
3314 mRunningExperiment = nullptr;
3315 // TODO: Show error dialog
3316 }
3317}
3318
3319void LauncherUI::OpenDirectoryInFileBrowser(const std::string& path)
3320{
3321 if (path.empty()) {
3322 printf("Cannot open directory: empty path\n");
3323 return;
3324 }
3325 // Create the directory if it doesn't exist yet (e.g. data/ before first test run)
3326 if (!fs::exists(path)) {
3327 try {
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());
3332 return;
3333 }
3334 }
3335
3336#ifdef _WIN32
3337 // Windows: Use explorer - normalize path to use backslashes
3338 std::string winPath = path;
3339 for (char& c : winPath) {
3340 if (c == '/') c = '\\';
3341 }
3342 std::string command = "explorer \"" + winPath + "\"";
3343 printf("Opening directory: %s\n", winPath.c_str());
3344 system(command.c_str());
3345#elif __APPLE__
3346 // macOS: Use open
3347 std::string command = "open \"" + path + "\" &";
3348 system(command.c_str());
3349#else
3350 // Linux: try desktop file managers directly before falling back to xdg-open.
3351 // xdg-open often fails for directories on systems where the MIME association
3352 // for inode/directory is not configured (e.g. headless or minimal desktops).
3353 static const char* kManagers[] = {
3354 "nautilus", "dolphin", "nemo", "thunar", "pcmanfm", nullptr
3355 };
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());
3363 opened = true;
3364 break;
3365 }
3366 }
3367 if (!opened) {
3368 // Last resort: xdg-open
3369 std::string command = "xdg-open \"" + path + "\" &";
3370 int result = system(command.c_str());
3371 if (result != 0) {
3372 printf("Failed to open directory in file browser: %s\n", path.c_str());
3373 }
3374 }
3375#endif
3376}
3377
3378void LauncherUI::OpenFileInTextEditor(const std::string& filePath)
3379{
3380 if (filePath.empty() || !fs::exists(filePath)) {
3381 printf("Cannot open file: %s (file not found)\n", filePath.c_str());
3382 return;
3383 }
3384
3385#ifdef _WIN32
3386 // Windows: Use notepad as fallback, or default text editor via start
3387 std::string command = "start \"\" \"" + filePath + "\"";
3388#elif __APPLE__
3389 // macOS: Use open which will use default text editor
3390 std::string command = "open \"" + filePath + "\"";
3391#else
3392 // Linux: Use xdg-open which respects default application settings
3393 std::string command = "xdg-open \"" + filePath + "\" &";
3394#endif
3395
3396 printf("Opening file in text editor: %s\n", filePath.c_str());
3397 int result = system(command.c_str());
3398 if (result != 0) {
3399 printf("Failed to open file in text editor: %s\n", filePath.c_str());
3400 }
3401}
3402
3403void LauncherUI::LaunchTranslationEditor()
3404{
3405 if (mSelectedExperiment < 0 || mSelectedExperiment >= (int)mExperiments.size()) {
3406 printf("ERROR: No experiment selected for translation editor\n");
3407 return;
3408 }
3409
3410 const ExperimentInfo& exp = mExperiments[mSelectedExperiment];
3411
3412 // Get PEBL executable path from config
3413 std::string peblExec = mConfig->GetPeblExecutablePath();
3414 if (peblExec.empty()) {
3415 peblExec = "pebl2"; // Fallback
3416 }
3417
3418 // Find translatetest.pbl - look in various possible locations
3419 std::string translateScript;
3420 std::string batteryPath = mConfig->GetBatteryPath();
3421 fs::path peblDir = fs::path(peblExec).parent_path().parent_path(); // Go up from bin/
3422 fs::path binDir = fs::path(peblExec).parent_path(); // bin/ directory
3423 std::vector<std::string> possiblePaths = {
3424 (binDir / "translatetest.pbl").string(), // In bin/ alongside executable
3425 (peblDir / "pebl-lib" / "translatetest.pbl").string(), // In pebl-lib/
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(),
3430 };
3431 for (const auto& path : possiblePaths) {
3432 if (fs::exists(path)) {
3433 translateScript = path;
3434 break;
3435 }
3436 }
3437 if (translateScript.empty()) {
3438 printf("ERROR: Could not find translatetest.pbl in any expected location\n");
3439 return;
3440 }
3441
3442 // Build command to launch translatetest.pbl
3443 std::string scriptPath = exp.path;
3444 std::string lang = std::string(mLanguageCode);
3445 if (lang.empty()) {
3446 lang = "en";
3447 }
3448
3449 std::string command = "\"" + peblExec + "\" \"" + translateScript + "\" -v \"" + scriptPath + "\" --language " + lang;
3450
3451 printf("Launching translation editor: %s\n", command.c_str());
3452
3453 // Launch in background
3454#ifdef __linux__
3455 command += " &";
3456 int result = system(command.c_str());
3457 if (result != 0) {
3458 printf("ERROR: Failed to launch translation editor (exit code %d)\n", result);
3459 }
3460#else
3461 system(command.c_str());
3462#endif
3463}
3464
3465void LauncherUI::LaunchDataCombiner(const std::string& workingDirectory)
3466{
3467 // Get PEBL executable path (same logic as ExperimentRunner)
3468 std::string peblExec = "pebl2"; // Default fallback
3469#ifdef __linux__
3470 char exePath[1024];
3471 ssize_t len = readlink("/proc/self/exe", exePath, sizeof(exePath) - 1);
3472 if (len != -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";
3478 }
3479 }
3480#endif
3481
3482 // Build command to launch combinedatafiles.pbl
3483 std::string command;
3484
3485 // If working directory is specified, change to it first
3486 if (!workingDirectory.empty()) {
3487#ifdef __linux__
3488 command = "cd \"" + workingDirectory + "\" && " + peblExec + " combinedatafiles.pbl &";
3489#else
3490 command = "cd \"" + workingDirectory + "\" & " + peblExec + " combinedatafiles.pbl";
3491#endif
3492 printf("Launching data combiner in: %s\n", workingDirectory.c_str());
3493 } else {
3494 command = peblExec + " combinedatafiles.pbl";
3495#ifdef __linux__
3496 command += " &";
3497#endif
3498 printf("Launching data combiner\n");
3499 }
3500
3501 printf("Command: %s\n", command.c_str());
3502
3503 // Launch in background
3504 int result = system(command.c_str());
3505 if (result != 0) {
3506 printf("ERROR: Failed to launch data combiner (exit code %d)\n", result);
3507 }
3508}
3509
3510void LauncherUI::RunChain()
3511{
3512 if (!mCurrentChain || mCurrentChain->GetItems().empty()) {
3513 printf("Cannot run chain: no chain loaded or chain is empty\n");
3514 return;
3515 }
3516
3517 if (mRunningChain) {
3518 printf("Chain already running\n");
3519 return;
3520 }
3521
3522 if (!mCurrentStudy) {
3523 printf("Cannot run chain: no study loaded\n");
3524 return;
3525 }
3526
3527 // Check if subject code is empty
3528 if (strlen(mSubjectCode) == 0) {
3529 printf("ERROR: Subject code is required to run chain\n");
3530 return;
3531 }
3532
3533 // Check for existing subject codes
3534 std::vector<std::string> existingCodes = CheckExistingSubjectCodes();
3535 std::string currentCode(mSubjectCode);
3536
3537 bool codeExists = false;
3538 for (const auto& code : existingCodes) {
3539 if (code == currentCode) {
3540 codeExists = true;
3541 break;
3542 }
3543 }
3544
3545 if (codeExists) {
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");
3550 }
3551
3552 // Show dialog to user
3553 mDuplicateWarningCodes = existingCodes;
3554 mShowDuplicateSubjectWarning = true;
3555 return; // Don't start chain yet - wait for user confirmation
3556 }
3557
3558 // No duplicate - proceed directly
3559 RunChainConfirmed();
3560}
3561
3562void LauncherUI::RunChainConfirmed()
3563{
3564 // Clean up any previous experiment
3565 if (mRunningExperiment) {
3566 if (mRunningExperiment->IsRunning()) {
3567 printf("Warning: Previous experiment still running\n");
3568 return;
3569 }
3570 delete mRunningExperiment;
3571 mRunningExperiment = nullptr;
3572 }
3573
3574 // Clear accumulated output for new chain run
3575 mChainAccumulatedStdout.clear();
3576 mChainAccumulatedStderr.clear();
3577
3578 // Build execution order with randomization groups
3579 // Items with the same randomGroup > 0 are shuffled among themselves,
3580 // regardless of their position in the chain
3581 const auto& items = mCurrentChain->GetItems();
3582 mChainExecutionOrder.clear();
3583 mChainExecutionOrder.reserve(items.size());
3584
3585 // First pass: collect all items by randomization group
3586 std::map<int, std::vector<int>> groupItems; // groupId -> list of item indices
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);
3590 }
3591 }
3592
3593 // Shuffle each group
3594 std::random_device rd;
3595 std::mt19937 g(rd());
3596 std::map<int, size_t> groupNextIndex; // Track which item to use next from each group
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());
3601 }
3602
3603 // Second pass: build execution order
3604 // Non-grouped items stay in place, grouped items are replaced with shuffled order
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;
3608 // Use next item from shuffled group
3609 size_t& nextIdx = groupNextIndex[groupId];
3610 if (nextIdx < groupItems[groupId].size()) {
3611 mChainExecutionOrder.push_back(groupItems[groupId][nextIdx]);
3612 nextIdx++;
3613 }
3614 } else {
3615 // Not in a randomization group - add as-is
3616 mChainExecutionOrder.push_back(i);
3617 }
3618 }
3619
3620 mRunningChain = true;
3621 mCurrentChainItemIndex = 0;
3622 printf("Starting chain execution (%zu items)\n", mCurrentChain->GetItems().size());
3623
3624 // Execute first item
3625 ExecuteChainItem(0);
3626}
3627
3628void LauncherUI::ExecuteChainItem(int index)
3629{
3630 if (!mCurrentChain || index < 0 || index >= (int)mChainExecutionOrder.size()) {
3631 printf("Invalid chain item index: %d\n", index);
3632 mRunningChain = false;
3633 return;
3634 }
3635
3636 // Map logical execution index to actual item index
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(),
3640 item.GetDisplayName().c_str(), itemIndex + 1);
3641
3642 // Create new experiment runner
3643 mRunningExperiment = new ExperimentRunner(mConfig);
3644
3645 if (item.type == ItemType::Test) {
3646 // Execute test item - look up test from study to get correct path
3647 const Test* test = mCurrentStudy->GetTest(item.testName);
3648 if (!test) {
3649 printf("Error: Test '%s' not found in study\n", item.testName.c_str());
3650 delete mRunningExperiment;
3651 mRunningExperiment = nullptr;
3652 mRunningChain = false;
3653 return;
3654 }
3655
3656 std::string studyPath = mCurrentStudy->GetPath();
3657 // Extract basename from test_name for .pbl filename
3658 std::string baseName = fs::path(item.testName).filename().string();
3659 std::string testPath = (fs::path(studyPath) / "tests" / test->testPath / (baseName + ".pbl")).string();
3660
3661 std::vector<std::string> args;
3662
3663 // Note: Language is handled by ExperimentRunner::RunExperiment via the language parameter
3664
3665 // Add parameter file (variant or default)
3666 // Note: PEBL automatically looks in params/ directory, so just pass filename
3667 if (!item.paramVariant.empty()) {
3668 std::string paramFile;
3669
3670 if (item.paramVariant == "default") {
3671 // Use default parameter file: testName.pbl.par.json
3672 paramFile = baseName + ".pbl.par.json";
3673 } else {
3674 // Look up the actual filename from the parameter variant
3675 const ParameterVariant* variant = test->GetVariant(item.paramVariant);
3676 if (variant && !variant->file.empty()) {
3677 paramFile = variant->file;
3678 }
3679 }
3680
3681 if (!paramFile.empty()) {
3682 args.push_back("--pfile");
3683 args.push_back(paramFile);
3684 }
3685 }
3686
3687 // Add upload flag if chain has upload enabled
3688 if (mCurrentChain->GetUploadEnabled()) {
3689 std::string uploadPath = mCurrentStudy->GetUploadConfigPath(item.testName);
3690
3691 // Create/update upload.json for this test
3692 mCurrentStudy->CreateUploadConfigForTest(item.testName);
3693
3694 // Add --upload flag
3695 args.push_back("--upload");
3696 args.push_back(uploadPath);
3697 printf("Upload enabled: %s\n", uploadPath.c_str());
3698 }
3699
3700 // Add LSL flag if chain has LSL enabled
3701 if (mCurrentChain->GetLSLEnabled()) {
3702 std::string streamName = mCurrentChain->GetLSLStreamName();
3703
3704 // Replace placeholders: {test} -> test name, {subject} -> subject ID
3705 size_t pos;
3706 while ((pos = streamName.find("{test}")) != std::string::npos) {
3707 streamName.replace(pos, 6, item.testName);
3708 }
3709 while ((pos = streamName.find("{subject}")) != std::string::npos) {
3710 streamName.replace(pos, 9, mSubjectCode);
3711 }
3712
3713 // Add --lsl flag
3714 args.push_back("--lsl");
3715 args.push_back(streamName);
3716 printf("LSL enabled: stream=%s\n", streamName.c_str());
3717 }
3718
3719 // Add additional arguments from Run tab settings
3720 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3721 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3722
3723 // Debug: print all arguments being passed
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());
3727 }
3728 fflush(stdout);
3729
3730 bool success = mRunningExperiment->RunExperiment(testPath, args,
3731 mSubjectCode,
3732 item.language.empty() ? mLanguageCode : item.language,
3733 mFullscreen);
3734
3735 if (!success) {
3736 printf("Failed to run test: %s\n", item.testName.c_str());
3737 delete mRunningExperiment;
3738 mRunningExperiment = nullptr;
3739 mRunningChain = false;
3740 }
3741
3742 } else {
3743 // Execute page item (instruction/consent/completion)
3744 // Use ChainPage.pbl to display the page
3745 std::string tmpDir = GetWorkspaceTempDirectory(mConfig->GetWorkspacePath());
3746 std::string configFile = item.CreateChainPageConfig(tmpDir);
3747
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;
3753 return;
3754 }
3755
3756 // Run ChainPage.pbl with the config - use absolute path from PEBL install
3757 std::string mediaPath = GetPEBLMediaPath(mConfig->GetPeblExecutablePath());
3758 std::string chainPagePath;
3759 if (!mediaPath.empty()) {
3760#ifdef _WIN32
3761 chainPagePath = mediaPath + "\\apps\\ChainPage\\ChainPage.pbl";
3762#else
3763 chainPagePath = mediaPath + "/apps/ChainPage/ChainPage.pbl";
3764#endif
3765 } else {
3766 // Fallback to relative path (may work if CWD is PEBL root)
3767 chainPagePath = "media/apps/ChainPage/ChainPage.pbl";
3768 printf("Warning: Could not determine PEBL media path, using relative path\n");
3769 }
3770
3771 std::vector<std::string> args;
3772 // -v flag passes positional argument to Start(p)
3773 args.push_back("-v");
3774 args.push_back(configFile);
3775
3776 // Add additional arguments from Run tab settings
3777 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3778 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3779
3780 bool success = mRunningExperiment->RunExperiment(chainPagePath, args,
3781 mSubjectCode,
3782 mLanguageCode,
3783 mFullscreen);
3784
3785 if (!success) {
3786 printf("Failed to run page: %s\n", item.GetDisplayName().c_str());
3787 delete mRunningExperiment;
3788 mRunningExperiment = nullptr;
3789 mRunningChain = false;
3790 }
3791 }
3792}
3793
3794void LauncherUI::TestChainItem(int index)
3795{
3796 if (!mCurrentChain || index < 0 || index >= (int)mCurrentChain->GetItems().size()) {
3797 printf("Invalid chain item index: %d\n", index);
3798 return;
3799 }
3800
3801 if (!mCurrentStudy) {
3802 printf("Cannot test chain item: no study loaded\n");
3803 return;
3804 }
3805
3806 // Clean up any previous experiment
3807 if (mRunningExperiment) {
3808 if (mRunningExperiment->IsRunning()) {
3809 printf("Warning: Previous experiment still running\n");
3810 return;
3811 }
3812 delete mRunningExperiment;
3813 mRunningExperiment = nullptr;
3814 }
3815
3816 const ChainItem& item = mCurrentChain->GetItems()[index];
3817 printf("Test running chain item: %s\n", item.GetDisplayName().c_str());
3818
3819 // Create new experiment runner
3820 mRunningExperiment = new ExperimentRunner(mConfig);
3821
3822 if (item.type == ItemType::Test) {
3823 // Execute test item - look up test from study to get correct path
3824 const Test* test = mCurrentStudy->GetTest(item.testName);
3825 if (!test) {
3826 printf("Error: Test '%s' not found in study\n", item.testName.c_str());
3827 return;
3828 }
3829
3830 std::string studyPath = mCurrentStudy->GetPath();
3831 // Extract basename from test_name for .pbl filename
3832 std::string baseName = fs::path(item.testName).filename().string();
3833 std::string testPath = (fs::path(studyPath) / "tests" / test->testPath / (baseName + ".pbl")).string();
3834
3835 std::vector<std::string> args;
3836
3837 // Note: Language is handled by ExperimentRunner::RunExperiment via the language parameter
3838
3839 // Add parameter variant if not default
3840 if (!item.paramVariant.empty() && item.paramVariant != "default") {
3841 // Look up the actual filename from the parameter variant
3842 const ParameterVariant* variant = test->GetVariant(item.paramVariant);
3843 if (variant && !variant->file.empty()) {
3844 // Just pass params/filename - working dir is set to test directory
3845 std::string paramFile = "params/" + variant->file;
3846 args.push_back("--pfile");
3847 args.push_back(paramFile);
3848 }
3849 }
3850
3851 // Add additional arguments from Run tab settings
3852 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3853 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3854
3855 bool success = mRunningExperiment->RunExperiment(testPath, args,
3856 mSubjectCode,
3857 item.language.empty() ? mLanguageCode : item.language,
3858 mFullscreen);
3859
3860 if (!success) {
3861 printf("Failed to run test: %s\n", item.testName.c_str());
3862 delete mRunningExperiment;
3863 mRunningExperiment = nullptr;
3864 }
3865
3866 } else {
3867 // Execute page item (instruction/consent/completion)
3868 // Use ChainPage.pbl to display the page
3869 std::string tmpDir = GetWorkspaceTempDirectory(mConfig->GetWorkspacePath());
3870 std::string configFile = item.CreateChainPageConfig(tmpDir);
3871
3872 if (configFile.empty()) {
3873 printf("Failed to create page config in: %s\n", tmpDir.c_str());
3874 delete mRunningExperiment;
3875 mRunningExperiment = nullptr;
3876 return;
3877 }
3878
3879 // Run ChainPage.pbl with the config - use absolute path from PEBL install
3880 std::string mediaPath = GetPEBLMediaPath(mConfig->GetPeblExecutablePath());
3881 std::string chainPagePath;
3882 if (!mediaPath.empty()) {
3883#ifdef _WIN32
3884 chainPagePath = mediaPath + "\\apps\\ChainPage\\ChainPage.pbl";
3885#else
3886 chainPagePath = mediaPath + "/apps/ChainPage/ChainPage.pbl";
3887#endif
3888 } else {
3889 // Fallback to relative path (may work if CWD is PEBL root)
3890 chainPagePath = "media/apps/ChainPage/ChainPage.pbl";
3891 printf("Warning: Could not determine PEBL media path, using relative path\n");
3892 }
3893
3894 std::vector<std::string> args;
3895 // -v flag passes positional argument to Start(p)
3896 args.push_back("-v");
3897 args.push_back(configFile);
3898
3899 // Add additional arguments from Run tab settings
3900 std::vector<std::string> additionalArgs = BuildAdditionalArguments();
3901 args.insert(args.end(), additionalArgs.begin(), additionalArgs.end());
3902
3903 bool success = mRunningExperiment->RunExperiment(chainPagePath, args,
3904 mSubjectCode,
3905 mLanguageCode,
3906 mFullscreen);
3907
3908 if (!success) {
3909 printf("Failed to run page: %s\n", item.GetDisplayName().c_str());
3910 delete mRunningExperiment;
3911 mRunningExperiment = nullptr;
3912 }
3913 }
3914}
3915
3916std::string LauncherUI::OpenDirectoryDialog(const std::string& title, const std::string& startDir)
3917{
3918#ifdef _WIN32
3919 // Windows: Use IFileDialog (Vista+) for modern folder picker
3920 std::string result;
3921
3922 // Initialize COM
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));
3928
3929 if (SUCCEEDED(hr)) {
3930 // Set options to pick folders
3931 DWORD dwOptions;
3932 hr = pfd->GetOptions(&dwOptions);
3933 if (SUCCEEDED(hr)) {
3934 hr = pfd->SetOptions(dwOptions | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM);
3935 }
3936
3937 // Set title
3938 if (SUCCEEDED(hr) && !title.empty()) {
3939 std::wstring wtitle(title.begin(), title.end());
3940 pfd->SetTitle(wtitle.c_str());
3941 }
3942
3943 // Set starting directory if provided
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();
3951 }
3952 }
3953
3954 // Show the dialog
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)) {
3963 // Convert wide string to UTF-8
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);
3968 }
3969 CoTaskMemFree(pszPath);
3970 }
3971 psi->Release();
3972 }
3973 }
3974 pfd->Release();
3975 }
3976 CoUninitialize();
3977 }
3978
3979 return result;
3980#elif __APPLE__
3981 // macOS: Use osascript
3982 std::string command = "osascript -e 'POSIX path of (choose folder";
3983 if (!startDir.empty()) {
3984 command += " default location (POSIX file \"" + startDir + "\")";
3985 }
3986 command += " with prompt \"" + title + "\")'";
3987 FILE* pipe = popen(command.c_str(), "r");
3988 if (!pipe) return "";
3989
3990 char buffer[1024];
3991 std::string result;
3992 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
3993 result = buffer;
3994 // Remove trailing newline
3995 if (!result.empty() && result[result.length()-1] == '\n') {
3996 result.erase(result.length()-1);
3997 }
3998 }
3999 pclose(pipe);
4000 return result;
4001#else
4002 // Linux: Use zenity
4003 std::string command = "zenity --file-selection --directory --title=\"" + title + "\"";
4004 if (!startDir.empty()) {
4005 command += " --filename=\"" + startDir + "/\"";
4006 }
4007 command += " 2>/dev/null";
4008 FILE* pipe = popen(command.c_str(), "r");
4009 if (!pipe) return "";
4010
4011 char buffer[1024];
4012 std::string result;
4013 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
4014 result = buffer;
4015 // Remove trailing newline
4016 if (!result.empty() && result[result.length()-1] == '\n') {
4017 result.erase(result.length()-1);
4018 }
4019 }
4020 pclose(pipe);
4021 return result;
4022#endif
4023}
4024
4025std::string LauncherUI::OpenFileDialog(const std::string& title, const std::string& filter, const std::string& initialDir)
4026{
4027#ifdef _WIN32
4028 // Windows: Use GetOpenFileName
4029 char filename[MAX_PATH] = "";
4030
4031 OPENFILENAMEA ofn;
4032 ZeroMemory(&ofn, sizeof(ofn));
4033 ofn.lStructSize = sizeof(ofn);
4034 ofn.hwndOwner = NULL;
4035 ofn.lpstrFile = filename;
4036 ofn.nMaxFile = MAX_PATH;
4037
4038 // Convert filter from "*.json" format to Windows format "JSON Files\0*.json\0All Files\0*.*\0\0"
4039 std::string winFilter;
4040 if (!filter.empty()) {
4041 // Create a description from the filter
4042 std::string desc = filter;
4043 if (desc.substr(0, 2) == "*.") {
4044 desc = desc.substr(2) + " files";
4045 // Capitalize first letter
4046 if (!desc.empty()) desc[0] = toupper(desc[0]);
4047 }
4048 winFilter = desc + '\0' + filter + '\0' + "All Files" + '\0' + "*.*" + '\0';
4049 } else {
4050 winFilter = "All Files\0*.*\0";
4051 }
4052 winFilter += '\0'; // Double null terminator
4053 ofn.lpstrFilter = winFilter.c_str();
4054
4055 // Convert title
4056 ofn.lpstrTitle = title.c_str();
4057
4058 // Set initial directory if provided
4059 if (!initialDir.empty()) {
4060 ofn.lpstrInitialDir = initialDir.c_str();
4061 }
4062
4063 ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
4064
4065 if (GetOpenFileNameA(&ofn)) {
4066 return std::string(filename);
4067 }
4068 return "";
4069#elif __APPLE__
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 "";
4073
4074 char buffer[1024];
4075 std::string result;
4076 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
4077 result = buffer;
4078 if (!result.empty() && result[result.length()-1] == '\n') {
4079 result.erase(result.length()-1);
4080 }
4081 }
4082 pclose(pipe);
4083 return result;
4084#else
4085 // Linux: Use zenity
4086 std::string command = "zenity --file-selection --title=\"" + title + "\"";
4087 if (!filter.empty()) {
4088 command += " --file-filter=\"" + filter + "\"";
4089 }
4090 command += " 2>/dev/null";
4091
4092 FILE* pipe = popen(command.c_str(), "r");
4093 if (!pipe) return "";
4094
4095 char buffer[1024];
4096 std::string result;
4097 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
4098 result = buffer;
4099 if (!result.empty() && result[result.length()-1] == '\n') {
4100 result.erase(result.length()-1);
4101 }
4102 }
4103 pclose(pipe);
4104 return result;
4105#endif
4106}
4107
4108std::string LauncherUI::SaveFileDialog(const std::string& title, const std::string& defaultName)
4109{
4110#ifdef _WIN32
4111 // Windows: Use GetSaveFileName
4112 char filename[MAX_PATH];
4113 strncpy(filename, defaultName.c_str(), MAX_PATH - 1);
4114 filename[MAX_PATH - 1] = '\0';
4115
4116 OPENFILENAMEA ofn;
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;
4125
4126 if (GetSaveFileNameA(&ofn)) {
4127 return std::string(filename);
4128 }
4129 return "";
4130#elif __APPLE__
4131 std::string command = "osascript -e 'POSIX path of (choose file name with prompt \"" + title + "\"";
4132 if (!defaultName.empty()) {
4133 command += " default name \"" + defaultName + "\"";
4134 }
4135 command += ")'";
4136
4137 FILE* pipe = popen(command.c_str(), "r");
4138 if (!pipe) return "";
4139
4140 char buffer[1024];
4141 std::string result;
4142 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
4143 result = buffer;
4144 if (!result.empty() && result[result.length()-1] == '\n') {
4145 result.erase(result.length()-1);
4146 }
4147 }
4148 pclose(pipe);
4149 return result;
4150#else
4151 // Linux: Use zenity
4152 std::string command = "zenity --file-selection --save --title=\"" + title + "\"";
4153 if (!defaultName.empty()) {
4154 command += " --filename=\"" + defaultName + "\"";
4155 }
4156 command += " 2>/dev/null";
4157
4158 FILE* pipe = popen(command.c_str(), "r");
4159 if (!pipe) return "";
4160
4161 char buffer[1024];
4162 std::string result;
4163 if (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
4164 result = buffer;
4165 if (!result.empty() && result[result.length()-1] == '\n') {
4166 result.erase(result.length()-1);
4167 }
4168 }
4169 pclose(pipe);
4170 return result;
4171#endif
4172}
4173
4174// ============================================================================
4175// Study Tab Implementation
4176// ============================================================================
4177
4178void LauncherUI::RenderStudyTab()
4179{
4180 ImGui::Text("Study Management");
4181 ImGui::Separator();
4182 ImGui::Spacing();
4183
4184 // Top section: Study selector and info
4185 ImGui::BeginChild("StudySelector", ImVec2(0, 100), true);
4186
4187 // Study selection dropdown
4188 ImGui::Text("Current Study:");
4189 ImGui::SameLine();
4190
4191 const char* currentStudyName = mCurrentStudy ? mCurrentStudy->GetName().c_str() : "None";
4192 ImGui::PushItemWidth(250);
4193 if (ImGui::BeginCombo("##StudySelect", currentStudyName)) {
4194 // Refresh study list from disk each time the combo is opened
4195 mStudyList = mWorkspace->GetStudyDirectories();
4196
4197 // "None" option
4198 if (ImGui::Selectable("None", !mCurrentStudy)) {
4199 mCurrentStudy.reset();
4200 }
4201
4202 // List studies from workspace
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]);
4208 }
4209 }
4210 ImGui::EndCombo();
4211 }
4212 ImGui::PopItemWidth();
4213
4214 ImGui::SameLine();
4215
4216 // New study button
4217 if (ImGui::Button("New Study...")) {
4218 CreateNewStudy();
4219 }
4220
4221 ImGui::Spacing();
4222
4223 // Study info
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());
4228 } else {
4229 ImGui::TextDisabled("No study loaded. Create a new study or select an existing one.");
4230 }
4231
4232 ImGui::EndChild();
4233
4234 ImGui::Spacing();
4235
4236 // Tests in study section - split view: list on left, preview on right
4237 ImGui::Text("Tests in Study:");
4238
4239 float listWidth = ImGui::GetContentRegionAvail().x * 0.4f;
4240
4241 // Left side: Test list
4242 ImGui::BeginChild("StudyTestsList", ImVec2(listWidth, -40), true);
4243
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.");
4248 } else {
4249 // Display tests in study as selectable items
4250 const auto& tests = mCurrentStudy->GetTests();
4251 for (size_t i = 0; i < tests.size(); i++) {
4252 ImGui::PushID((int)i);
4253
4254 // Display name (prefer displayName, fall back to testName)
4255 std::string displayLabel = tests[i].displayName.empty()
4256 ? tests[i].testName : tests[i].displayName;
4257
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);
4263 }
4264 }
4265
4266 // Show parameter variants as tooltip
4267 if (ImGui::IsItemHovered() && !tests[i].parameterVariants.empty()) {
4268 ImGui::SetTooltip("%zu parameter variants", tests[i].parameterVariants.size());
4269 }
4270
4271 ImGui::PopID();
4272 }
4273 }
4274
4275 ImGui::EndChild();
4276
4277 ImGui::SameLine();
4278
4279 // Right side: Test preview (screenshot + about.txt)
4280 ImGui::BeginChild("StudyTestPreview", ImVec2(0, -40), true);
4281
4282 if (mCurrentStudy && mSelectedStudyTestIndex >= 0 &&
4283 mSelectedStudyTestIndex < (int)mCurrentStudy->GetTests().size()) {
4284
4285 const auto& test = mCurrentStudy->GetTests()[mSelectedStudyTestIndex];
4286
4287 // Test name header
4288 std::string headerName = test.displayName.empty() ? test.testName : test.displayName;
4289 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", headerName.c_str());
4290 ImGui::Separator();
4291 ImGui::Spacing();
4292
4293 // Action buttons
4294 if (ImGui::SmallButton("Edit Params")) {
4295 EditTestParameters(mSelectedStudyTestIndex);
4296 }
4297 ImGui::SameLine();
4298 if (ImGui::SmallButton("Remove from Study")) {
4299 const std::string testName = test.testName;
4300 RemoveTestFromStudy(testName);
4301 // Reset selection if removed test was selected
4302 if (mSelectedStudyTestIndex >= (int)mCurrentStudy->GetTests().size()) {
4303 mSelectedStudyTestIndex = (int)mCurrentStudy->GetTests().size() - 1;
4304 if (mSelectedStudyTestIndex >= 0) {
4305 LoadStudyTestPreview(mSelectedStudyTestIndex);
4306 } else {
4307 FreeStudyTestScreenshot();
4308 mStudyTestDescription.clear();
4309 }
4310 }
4311 }
4312
4313 ImGui::Spacing();
4314 ImGui::Separator();
4315 ImGui::Spacing();
4316
4317 // Screenshot
4318 if (mStudyTestScreenshot) {
4319 float aspectRatio = (float)mStudyTestScreenshotH / (float)mStudyTestScreenshotW;
4320 float displayWidth = ImGui::GetContentRegionAvail().x;
4321 float displayHeight = displayWidth * aspectRatio;
4322
4323 if (displayHeight > 400) {
4324 displayHeight = 400;
4325 displayWidth = displayHeight / aspectRatio;
4326 }
4327
4328 ImGui::Image((ImTextureID)(intptr_t)mStudyTestScreenshot,
4329 ImVec2(displayWidth, displayHeight));
4330 ImGui::Spacing();
4331 }
4332
4333 // Description
4334 if (!mStudyTestDescription.empty()) {
4335 ImGui::TextWrapped("%s", mStudyTestDescription.c_str());
4336 } else {
4337 ImGui::TextDisabled("No description available");
4338 }
4339 } else {
4340 ImGui::TextDisabled("Select a test to view details");
4341 }
4342
4343 ImGui::EndChild();
4344
4345 ImGui::Spacing();
4346
4347 // Bottom buttons
4348 if (!mCurrentStudy) {
4349 ImGui::BeginDisabled();
4350 }
4351
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());
4356 }
4357 }
4358
4359 if (!mCurrentStudy) {
4360 ImGui::EndDisabled();
4361 }
4362}
4363
4364// ============================================================================
4365// Page Editor Dialog Implementation
4366// ============================================================================
4367
4368void LauncherUI::ShowPageEditor()
4369{
4370 ImGui::OpenPopup("Page Editor");
4371
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);
4375
4376 if (ImGui::BeginPopupModal("Page Editor", &mPageEditor.show, 0))
4377 {
4378 const char* pageTypes[] = { "Instruction", "Consent", "Completion" };
4379
4380 ImGui::Text("Page Type:");
4381 ImGui::SameLine();
4382 ImGui::PushItemWidth(150);
4383 ImGui::Combo("##PageType", &mPageEditor.pageType, pageTypes, 3);
4384 ImGui::PopItemWidth();
4385
4386 ImGui::Spacing();
4387
4388 // Title field
4389 ImGui::Text("Title:");
4390 ImGui::PushItemWidth(-1);
4391 if (ImGui::IsWindowAppearing()) {
4392 ImGui::SetKeyboardFocusHere();
4393 }
4394 ImGui::InputText("##Title", mPageEditor.title, sizeof(mPageEditor.title));
4395 ImGui::PopItemWidth();
4396
4397 ImGui::Spacing();
4398
4399 // Content field (large text area)
4400 ImGui::Text("Content:");
4401 ImGui::PushItemWidth(-1);
4402 ImGui::InputTextMultiline("##Content", mPageEditor.content, sizeof(mPageEditor.content),
4403 ImVec2(-1, 300));
4404 ImGui::PopItemWidth();
4405
4406 ImGui::Spacing();
4407 ImGui::Separator();
4408 ImGui::Spacing();
4409
4410 // Buttons
4411 if (ImGui::Button("Save", ImVec2(120, 0))) {
4412 if (!mCurrentChain) {
4413 printf("Error: No chain loaded\n");
4414 } else {
4415 // Create ChainItem based on page type
4416 ChainItem item;
4417 if (mPageEditor.pageType == 0) {
4419 } else if (mPageEditor.pageType == 1) {
4420 item.type = ItemType::Consent;
4421 } else {
4423 }
4424
4425 item.title = mPageEditor.title;
4426 item.content = mPageEditor.content;
4427
4428 if (mPageEditor.editingIndex >= 0) {
4429 // Update existing item
4430 mCurrentChain->RemoveItem(mPageEditor.editingIndex);
4431 mCurrentChain->InsertItem(mPageEditor.editingIndex, item);
4432 printf("Updated page: %s\n", item.title.c_str());
4433 } else {
4434 // Add new item
4435 mCurrentChain->AddItem(item);
4436 printf("Added page: %s\n", item.title.c_str());
4437 }
4438
4439 // Auto-save the chain
4440 SaveCurrentChain();
4441 }
4442
4443 mPageEditor.show = false;
4444 ImGui::CloseCurrentPopup();
4445 }
4446
4447 ImGui::SameLine();
4448
4449 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
4450 mPageEditor.show = false;
4451 ImGui::CloseCurrentPopup();
4452 }
4453
4454 ImGui::EndPopup();
4455 }
4456}
4457
4458void LauncherUI::ShowTestEditor()
4459{
4460 const char* dialogTitle = mTestEditor.editingIndex >= 0 ? "Edit Test" : "Add Test to Chain";
4461 ImGui::OpenPopup(dialogTitle);
4462
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);
4466
4467 if (ImGui::BeginPopupModal(dialogTitle, &mTestEditor.show, 0))
4468 {
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();
4474 }
4475 ImGui::EndPopup();
4476 return;
4477 }
4478
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();
4486 }
4487 ImGui::EndPopup();
4488 return;
4489 }
4490
4491 // In edit mode, just show which test is being edited (read-only)
4492 // In add mode, show test selection list
4493 if (mTestEditor.editingIndex >= 0) {
4494 // Edit mode - show test name as read-only
4495 ImGui::Text("Editing Test:");
4496 ImGui::SameLine();
4497 if (mTestEditor.selectedTestIndex >= 0 && mTestEditor.selectedTestIndex < (int)tests.size()) {
4498 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", tests[mTestEditor.selectedTestIndex].testName.c_str());
4499 } else {
4500 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "(test not found)");
4501 }
4502 ImGui::Spacing();
4503 } else {
4504 // Add mode - show test selection list
4505 ImGui::Text("Select Test:");
4506 ImGui::Spacing();
4507
4508 ImGui::BeginChild("TestList", ImVec2(0, 150), true);
4509 for (size_t i = 0; i < tests.size(); i++) {
4510 bool isSelected = (mTestEditor.selectedTestIndex == (int)i);
4511 if (ImGui::Selectable(tests[i].testName.c_str(), isSelected)) {
4512 mTestEditor.selectedTestIndex = i;
4513 mTestEditor.selectedVariantIndex = 0; // Reset variant selection
4514 }
4515 }
4516 ImGui::EndChild();
4517
4518 ImGui::Spacing();
4519 }
4520
4521 // Parameter variant selection (if test is selected)
4522 if (mTestEditor.selectedTestIndex >= 0 && mTestEditor.selectedTestIndex < (int)tests.size()) {
4523 const Test& selectedTest = tests[mTestEditor.selectedTestIndex];
4524
4525 ImGui::Text("Parameter Variant:");
4526 ImGui::SameLine();
4527 ImGui::PushItemWidth(200);
4528
4529 // Build list of variants
4530 std::vector<std::string> variantNames;
4531 variantNames.push_back("default"); // Always have default option
4532 for (const auto& [name, variant] : selectedTest.parameterVariants) {
4533 variantNames.push_back(name);
4534 }
4535
4536 // Current variant name
4537 const char* currentVariant = mTestEditor.selectedVariantIndex < (int)variantNames.size()
4538 ? variantNames[mTestEditor.selectedVariantIndex].c_str()
4539 : "default";
4540
4541 if (ImGui::BeginCombo("##Variant", currentVariant)) {
4542 for (size_t i = 0; i < variantNames.size(); i++) {
4543 bool isSelected = (mTestEditor.selectedVariantIndex == (int)i);
4544 if (ImGui::Selectable(variantNames[i].c_str(), isSelected)) {
4545 mTestEditor.selectedVariantIndex = i;
4546 }
4547 }
4548 ImGui::EndCombo();
4549 }
4550 ImGui::PopItemWidth();
4551
4552 ImGui::Spacing();
4553
4554 // Language selection (optional) - scan for available language files
4555 ImGui::Text("Language (optional):");
4556 ImGui::SameLine();
4557
4558 // Scan translations directory for available language files
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";
4563
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;
4571
4572 std::string lang;
4573 // Dash format: "name.pbl-lang.json"
4574 size_t dashPos = filename.rfind('-');
4575 if (dashPos != std::string::npos && dotJson > dashPos) {
4576 lang = filename.substr(dashPos + 1, dotJson - dashPos - 1);
4577 } else {
4578 // Dot format: "name.lang.json"
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);
4582 }
4583 }
4584 if (!lang.empty() && lang != "json") {
4585 availableLanguages.push_back(lang);
4586 }
4587 }
4588 }
4589 }
4590
4591 ImGui::PushItemWidth(150);
4592 if (ImGui::BeginCombo("##Language", mTestEditor.language)) {
4593 // Show available languages from translation files
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);
4598 mTestEditor.language[sizeof(mTestEditor.language) - 1] = '\0';
4599 }
4600 }
4601
4602 // Allow custom entry
4603 ImGui::Separator();
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);
4608 mTestEditor.language[sizeof(mTestEditor.language) - 1] = '\0';
4609 ImGui::CloseCurrentPopup();
4610 }
4611
4612 ImGui::EndCombo();
4613 }
4614 ImGui::PopItemWidth();
4615
4616 if (!availableLanguages.empty()) {
4617 ImGui::SameLine();
4618 ImGui::TextDisabled("(%zu available)", availableLanguages.size());
4619 }
4620
4621 // Translation editor button
4622 ImGui::SameLine();
4623 if (ImGui::SmallButton("Edit Translations...")) {
4624 // Set up state and open popup from within this modal's context
4625 // (ImGui requires OpenPopup to be called from the same popup stack level)
4626 std::string testPathStr = studyPath + "/tests/" + selectedTest.testPath;
4627 mTranslationEditor.testIndex = mTestEditor.selectedTestIndex;
4628 std::strncpy(mTranslationEditor.testPath, testPathStr.c_str(), sizeof(mTranslationEditor.testPath) - 1);
4629 mTranslationEditor.testPath[sizeof(mTranslationEditor.testPath) - 1] = '\0';
4630
4631 if (std::strlen(mTestEditor.language) > 0) {
4632 std::strncpy(mTranslationEditor.language, mTestEditor.language, sizeof(mTranslationEditor.language) - 1);
4633 mTranslationEditor.language[sizeof(mTranslationEditor.language) - 1] = '\0';
4634 } else {
4635 mTranslationEditor.language[0] = '\0';
4636 }
4637
4638 mTranslationEditor.show = true;
4639 mTranslationEditor.fromTestEditor = true;
4640 }
4641 if (ImGui::IsItemHovered()) {
4642 ImGui::SetTooltip("Open translation editor for this test");
4643 }
4644
4645 // Render translation editor inline here as a child modal of this popup.
4646 // This is the correct ImGui pattern: OpenPopup and BeginPopupModal must
4647 // be called from the same popup stack level.
4648 if (mTranslationEditor.show && mTranslationEditor.fromTestEditor) {
4649 ShowTranslationEditorDialog();
4650 if (!mTranslationEditor.show) {
4651 mTranslationEditor.fromTestEditor = false;
4652 }
4653 }
4654
4655 ImGui::Spacing();
4656
4657 // Randomization group (optional)
4658 ImGui::Text("Randomization Group:");
4659 ImGui::SameLine();
4660 ImGui::PushItemWidth(100);
4661 const char* groupOptions[] = {"None", "1", "2", "3"};
4662 ImGui::Combo("##RandomGroup", &mTestEditor.randomGroup, groupOptions, 4);
4663 ImGui::PopItemWidth();
4664 ImGui::SameLine();
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");
4669 }
4670 }
4671
4672 ImGui::Spacing();
4673 ImGui::Separator();
4674 ImGui::Spacing();
4675
4676 // Buttons
4677 bool canSave = mTestEditor.selectedTestIndex >= 0;
4678 if (!canSave) {
4679 ImGui::BeginDisabled();
4680 }
4681
4682 if (ImGui::Button("Save", ImVec2(120, 0))) {
4683 if (!mCurrentChain) {
4684 printf("Error: No chain loaded\n");
4685 } else {
4686 const Test& selectedTest = tests[mTestEditor.selectedTestIndex];
4687
4688 // Create ChainItem for test
4690 item.testName = selectedTest.testName;
4691
4692 // Set parameter variant
4693 if (mTestEditor.selectedVariantIndex > 0) {
4694 std::vector<std::string> variantNames;
4695 for (const auto& [name, variant] : selectedTest.parameterVariants) {
4696 variantNames.push_back(name);
4697 }
4698 if (mTestEditor.selectedVariantIndex - 1 < (int)variantNames.size()) {
4699 item.paramVariant = variantNames[mTestEditor.selectedVariantIndex - 1];
4700 }
4701 } else {
4702 item.paramVariant = "default";
4703 }
4704
4705 // Set language if specified
4706 if (std::strlen(mTestEditor.language) > 0) {
4707 item.language = mTestEditor.language;
4708 }
4709
4710 // Set randomization group
4711 item.randomGroup = mTestEditor.randomGroup;
4712
4713 if (mTestEditor.editingIndex >= 0) {
4714 // Update existing item
4715 mCurrentChain->RemoveItem(mTestEditor.editingIndex);
4716 mCurrentChain->InsertItem(mTestEditor.editingIndex, item);
4717 printf("Updated test item: %s\n", item.testName.c_str());
4718 } else {
4719 // Add new item
4720 mCurrentChain->AddItem(item);
4721 printf("Added test to chain: %s\n", item.testName.c_str());
4722 }
4723
4724 // Auto-save the chain
4725 SaveCurrentChain();
4726 }
4727
4728 mTestEditor.show = false;
4729 ImGui::CloseCurrentPopup();
4730 }
4731
4732 if (!canSave) {
4733 ImGui::EndDisabled();
4734 }
4735
4736 ImGui::SameLine();
4737
4738 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
4739 mTestEditor.show = false;
4740 ImGui::CloseCurrentPopup();
4741 }
4742
4743 ImGui::EndPopup();
4744 }
4745}
4746
4747// ============================================================================
4748// Study Management Function Stubs
4749// ============================================================================
4750
4751void LauncherUI::CreateNewStudy()
4752{
4753 // TODO: Show dialog to enter study name and description
4754 printf("CreateNewStudy() - not yet implemented\n");
4755}
4756
4757void LauncherUI::ImportSnapshotFromPath(const std::string& snapshotPath)
4758{
4759 if (!mSnapshots) return;
4760
4761 // Read study_name directly since full validation may fail on platform format
4762 std::string studyName = "imported_study";
4763 std::string studyInfoPath = snapshotPath + "/study-info.json";
4764 std::ifstream infoFile(studyInfoPath);
4765 if (infoFile.is_open()) {
4766 try {
4767 nlohmann::json j;
4768 infoFile >> j;
4769 studyName = j.value("study_name", "imported_study");
4770 } catch (...) {}
4771 infoFile.close();
4772 }
4773
4774 // Import first (copies files), then convert
4775 std::string studiesDir = mWorkspace->GetStudiesPath();
4776 std::string newStudyName = studyName + "_imported";
4777
4778 if (mSnapshots->ImportSnapshot(snapshotPath, studiesDir, newStudyName)) {
4779 std::string newStudyPath = studiesDir + "/" + newStudyName;
4780
4781 // Convert platform format to launcher format (in place on copied study)
4782 if (!mSnapshots->ConvertSnapshotFormat(newStudyPath)) {
4783 printf("Warning: Failed to convert snapshot format, attempting to use as-is\n");
4784 }
4785
4786 printf("Imported snapshot as: %s\n", newStudyName.c_str());
4787
4788 // Load the newly imported study
4789 LoadStudy(newStudyPath);
4790 } else {
4791 printf("Failed to import snapshot\n");
4792 }
4793}
4794
4795void LauncherUI::LoadStudy(const std::string& studyPath)
4796{
4797 printf("LoadStudy(%s)\n", studyPath.c_str());
4798
4799 // Handle both absolute and relative paths
4800 std::string fullPath = studyPath;
4801
4802 // If path doesn't start with / or contain : (Windows drive), assume it's relative to workspace
4803 if (!studyPath.empty() && studyPath[0] != '/' && studyPath.find(':') == std::string::npos) {
4804 // Try prepending workspace studies path
4805 std::string studiesPath = mWorkspace->GetStudiesPath();
4806 fullPath = studiesPath + "/" + studyPath;
4807 printf("Converted to full path: %s\n", fullPath.c_str());
4808 }
4809
4810 mCurrentStudy = Study::LoadFromDirectory(fullPath);
4811 if (mCurrentStudy) {
4812 printf("Study loaded: %s\n", mCurrentStudy->GetName().c_str());
4813
4814 // Reset study test preview
4815 mSelectedStudyTestIndex = -1;
4816 FreeStudyTestScreenshot();
4817 mStudyTestDescription.clear();
4818
4819 // Save selected study to config
4820 mConfig->SetCurrentStudyPath(fullPath);
4821 mConfig->SaveConfig();
4822
4823 // Auto-load a chain: prefer "Main.json", otherwise first alphabetically
4824 std::string mainChainPath = fullPath + "/chains/Main.json";
4825 if (fs::exists(mainChainPath)) {
4826 LoadChain(mainChainPath);
4827 printf("Auto-loaded Main chain\n");
4828 } else {
4829 // No Main chain - load first chain alphabetically
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());
4836 } else {
4837 // No chains - clear current chain
4838 mCurrentChain.reset();
4839 }
4840 }
4841 } else {
4842 printf("Failed to load study from: %s\n", fullPath.c_str());
4843
4844 // Show error message to user
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");
4847 }
4848}
4849
4850void LauncherUI::AddTestToStudy()
4851{
4852 if (!mCurrentStudy) {
4853 printf("Error: No study loaded. Create or load a study first.\n");
4854 return;
4855 }
4856
4857 if (mSelectedExperiment < 0 || mSelectedExperiment >= (int)mExperiments.size()) {
4858 printf("Error: No experiment selected\n");
4859 return;
4860 }
4861
4862 const ExperimentInfo& exp = mExperiments[mSelectedExperiment];
4863
4864 // Copy test from battery to study/tests/
4865 std::string studyPath = mCurrentStudy->GetPath();
4866
4867 // Use the parent directory name as the test folder name
4868 // This avoids nested directories when exp.name contains "/"
4869 fs::path sourceDir(exp.directory);
4870 std::string testFolderName = sourceDir.filename().string();
4871 std::string testDestDir = studyPath + "/tests/" + testFolderName;
4872
4873 try {
4874 // Create test directory
4875 fs::create_directories(testDestDir);
4876
4877 // Copy all files from source directory (not subdirectories)
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());
4883 }
4884 }
4885
4886 // Copy ALL subdirectories (params, translations, sounds, images, etc.)
4887 for (const auto& entry : fs::directory_iterator(sourceDir)) {
4888 if (entry.is_directory()) {
4889 std::string subDirName = entry.path().filename().string();
4890 // Skip data directory - that's for output, not resources
4891 if (subDirName == "data") continue;
4892
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());
4897 }
4898 }
4899
4900 // Add test entry to study
4901 // Use display name that includes parent/filename for disambiguation
4902 Test test;
4903 test.testName = exp.name;
4904 test.testPath = testFolderName; // Relative path within study/tests/
4905 test.included = true;
4906
4907 mCurrentStudy->AddTest(test);
4908
4909 // Scan for parameter variants in the copied params directory
4910 int testIndex = (int)mCurrentStudy->GetTests().size() - 1;
4911 ScanParameterVariants(testIndex);
4912
4913 mCurrentStudy->Save(); // Save study-info.json
4914 printf("Added test to study: %s\n", test.testName.c_str());
4915
4916 // Also add to default "Main" chain if it exists
4917 std::string mainChainPath = studyPath + "/chains/Main.json";
4918 if (fs::exists(mainChainPath)) {
4920 item.testName = test.testName;
4921 item.paramVariant = "default";
4922 item.language = "en";
4923 item.randomGroup = 0;
4924
4925 // If mCurrentChain is the Main chain, add directly to it
4926 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
4927 mCurrentChain->AddItem(item);
4928 mCurrentChain->Save();
4929 printf("Added test to Main chain (current chain)\n");
4930 } else {
4931 // Otherwise load Main chain separately
4932 auto mainChain = Chain::LoadFromFile(mainChainPath);
4933 if (mainChain) {
4934 mainChain->AddItem(item);
4935 mainChain->Save();
4936 printf("Added test to Main chain\n");
4937 }
4938 }
4939 }
4940
4941 } catch (const fs::filesystem_error& e) {
4942 printf("Error copying test files: %s\n", e.what());
4943 }
4944}
4945
4946void LauncherUI::AddTestFromFile(const std::string& filePath)
4947{
4948 if (!mCurrentStudy) {
4949 printf("Error: No study loaded. Create or load a study first.\n");
4950 return;
4951 }
4952
4953 if (!fs::exists(filePath)) {
4954 printf("Error: File does not exist: %s\n", filePath.c_str());
4955 return;
4956 }
4957
4958 // Extract test name from filename (without .pbl extension)
4959 fs::path path(filePath);
4960 std::string testName = path.stem().string();
4961 std::string sourceDir = path.parent_path().string();
4962
4963 // Create test directory in study/tests/
4964 std::string studyPath = mCurrentStudy->GetPath();
4965 std::string testDestDir = studyPath + "/tests/" + testName;
4966
4967 try {
4968 // Create test directory
4969 fs::create_directories(testDestDir);
4970
4971 // Copy all files from source directory (not subdirectories)
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());
4977 }
4978 }
4979
4980 // Copy ALL subdirectories (params, translations, sounds, images, etc.)
4981 for (const auto& entry : fs::directory_iterator(sourceDir)) {
4982 if (entry.is_directory()) {
4983 std::string subDirName = entry.path().filename().string();
4984 // Skip data directory - that's for output, not resources
4985 if (subDirName == "data") continue;
4986
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());
4991 }
4992 }
4993
4994 // Add test to study
4995 Test test;
4996 test.testName = testName;
4997 test.testPath = testName; // Relative path within study/tests/
4998 test.included = true;
4999
5000 mCurrentStudy->AddTest(test);
5001 mCurrentStudy->Save(); // Save study-info.json
5002 printf("Added test from file to study: %s\n", testName.c_str());
5003
5004 // Also add to default "Main" chain if it exists
5005 std::string mainChainPath = studyPath + "/chains/Main.json";
5006 if (fs::exists(mainChainPath)) {
5008 item.testName = testName;
5009 item.paramVariant = "default";
5010 item.language = "en";
5011 item.randomGroup = 0;
5012
5013 // If mCurrentChain is the Main chain, add directly to it
5014 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
5015 mCurrentChain->AddItem(item);
5016 mCurrentChain->Save();
5017 printf("Added test to Main chain (current chain)\n");
5018 } else {
5019 // Otherwise load Main chain separately
5020 auto mainChain = Chain::LoadFromFile(mainChainPath);
5021 if (mainChain) {
5022 mainChain->AddItem(item);
5023 mainChain->Save();
5024 printf("Added test to Main chain\n");
5025 }
5026 }
5027 }
5028
5029 } catch (const fs::filesystem_error& e) {
5030 printf("Error copying test files: %s\n", e.what());
5031 }
5032}
5033
5034void LauncherUI::CreateTestFromTemplate(const std::string& testName, int templateType)
5035{
5036 if (!mCurrentStudy) {
5037 printf("Error: No study loaded. Create or load a study first.\n");
5038 return;
5039 }
5040
5041 // Create test directory in study/tests/
5042 std::string studyPath = mCurrentStudy->GetPath();
5043 std::string testDir = studyPath + "/tests/" + testName;
5044
5045 try {
5046 // Create test directory structure
5047 fs::create_directories(testDir);
5048 fs::create_directories(testDir + "/params");
5049 fs::create_directories(testDir + "/translations");
5050
5051 // Get template filename from dynamic list
5052 std::string templateFilename;
5053 if (templateType >= 0 && templateType < (int)mTemplateFiles.size()) {
5054 templateFilename = mTemplateFiles[templateType];
5055 } else {
5056 printf("Error: Invalid template type: %d\n", templateType);
5057 return;
5058 }
5059
5060 // Find template file in media/templates/
5061 std::string templatePath = mBatteryPath + "/../media/templates/" + templateFilename;
5062
5063 // Try to read template file
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");
5068
5069 // Fallback to minimal template
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");
5074 return;
5075 }
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";
5083 out << " }\n\n";
5084 out << " ## Your code here\n\n";
5085 out << " MessageBox(\"Experiment complete. Thank you!\", gWin)\n";
5086 out << " return(0)\n";
5087 out << "}\n";
5088 out.close();
5089 } else {
5090 // Read entire template file
5091 std::stringstream buffer;
5092 buffer << templateFile.rdbuf();
5093 std::string templateContent = buffer.str();
5094 templateFile.close();
5095
5096 // Write template content to new test file
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");
5101 return;
5102 }
5103
5104 // Replace template name placeholders with actual test name
5105 // (For now, just write the template as-is - user can customize the filename in the .pbl)
5106 out << templateContent;
5107 out.close();
5108 }
5109
5110 printf("Created test from template: %s/%s.pbl\n", testDir.c_str(), testName.c_str());
5111
5112 // Add test to study
5113 Test test;
5114 test.testName = testName;
5115 test.testPath = testName; // Relative path within study/tests/
5116 test.included = true;
5117
5118 mCurrentStudy->AddTest(test);
5119 mCurrentStudy->Save(); // Save study-info.json
5120 printf("Added new test to study: %s\n", testName.c_str());
5121
5122 } catch (const fs::filesystem_error& e) {
5123 printf("Error creating test from template: %s\n", e.what());
5124 }
5125}
5126
5127void LauncherUI::CreateTestFromGenericStudy(const std::string& testName)
5128{
5129 if (!mCurrentStudy) {
5130 printf("Error: No study loaded. Create or load a study first.\n");
5131 return;
5132 }
5133
5134 // Create test directory in study/tests/
5135 std::string studyPath = mCurrentStudy->GetPath();
5136 std::string testDir = studyPath + "/tests/" + testName;
5137
5138 try {
5139 // Source: battery/template/ directory
5140 std::string templateDir = mBatteryPath + "/template";
5141
5142 if (!fs::exists(templateDir)) {
5143 printf("Error: Template directory not found: %s\n", templateDir.c_str());
5144 return;
5145 }
5146
5147 // Copy entire directory structure recursively
5148 fs::create_directories(testDir);
5149
5150 // Copy params/ directory
5151 if (fs::exists(templateDir + "/params")) {
5152 fs::copy(templateDir + "/params", testDir + "/params",
5153 fs::copy_options::recursive | fs::copy_options::overwrite_existing);
5154
5155 // Rename template.pbl.schema.json to testname.pbl.schema.json
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);
5160 }
5161 }
5162
5163 // Copy translations/ directory
5164 if (fs::exists(templateDir + "/translations")) {
5165 fs::copy(templateDir + "/translations", testDir + "/translations",
5166 fs::copy_options::recursive | fs::copy_options::overwrite_existing);
5167
5168 // Rename template.pbl-en.json to testname.pbl-en.json
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);
5173 }
5174 }
5175
5176 // Create data/ directory (empty)
5177 fs::create_directories(testDir + "/data");
5178
5179 // Copy template.pbl to testname.pbl
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);
5184 }
5185
5186 // Copy template.pbl.about.txt to testname.pbl.about.txt
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);
5191 }
5192
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());
5198
5199 // Add test to study
5200 Test test;
5201 test.testName = testName;
5202 test.testPath = testName; // Relative path within study/tests/
5203 test.included = true;
5204
5205 mCurrentStudy->AddTest(test);
5206 mCurrentStudy->Save(); // Save study-info.json
5207 printf("Added new test to study: %s\n", testName.c_str());
5208
5209 } catch (const fs::filesystem_error& e) {
5210 printf("Error creating test from Generic Study Template: %s\n", e.what());
5211 }
5212}
5213
5214void LauncherUI::RemoveTestFromStudy(const std::string& testName)
5215{
5216 if (!mCurrentStudy) {
5217 printf("Error: No study loaded\n");
5218 return;
5219 }
5220
5221 mCurrentStudy->RemoveTest(testName);
5222 mCurrentStudy->Save(); // Save study-info.json
5223 printf("Removed test from study: %s\n", testName.c_str());
5224}
5225
5226bool LauncherUI::SyncScaleSchema(const std::string& testDir, const std::string& scaleCode)
5227{
5228 // Look for scale definition JSON in the test directory.
5229 // Scale tests store definitions in subdirectories like:
5230 // testDir/CODE/CODE.json or testDir/definitions/CODE.json
5231 std::string scaleJsonPath;
5232 std::vector<std::string> candidates = {
5233 testDir + "/" + scaleCode + "/" + scaleCode + ".json",
5234 testDir + "/definitions/" + scaleCode + ".json"
5235 };
5236 for (const auto& path : candidates) {
5237 if (fs::exists(path)) {
5238 scaleJsonPath = path;
5239 break;
5240 }
5241 }
5242
5243 // Also check the scale library (original source with full options)
5244 if (mScaleManager) {
5245 std::string libPath = mScaleManager->GetDefinitionPath(scaleCode);
5246 if (!libPath.empty() && fs::exists(libPath)) {
5247 // Prefer the library source — it has the original options
5248 scaleJsonPath = libPath;
5249 }
5250 }
5251
5252 if (scaleJsonPath.empty()) {
5253 return false; // Not a scale-based test
5254 }
5255
5256 // Parse scale definition to extract parameters
5257 nlohmann::json scaleDef;
5258 try {
5259 std::ifstream scaleFile(scaleJsonPath);
5260 if (!scaleFile.is_open()) return false;
5261 scaleFile >> scaleDef;
5262 scaleFile.close();
5263 } catch (const std::exception& e) {
5264 printf("Error parsing scale JSON %s: %s\n", scaleJsonPath.c_str(), e.what());
5265 return false;
5266 }
5267
5268 printf("Syncing schema from scale definition: %s\n", scaleJsonPath.c_str());
5269
5270 // Build schema JSON from scale parameters
5271 nlohmann::json schemaJson = {
5272 {"test", scaleCode},
5273 {"version", "1.0"},
5274 {"description", scaleCode + " Scale"}
5275 };
5276 nlohmann::json schemaParams = nlohmann::json::array();
5277
5278 // Always include the scale selector (fixed to this scale, hidden from UI)
5279 schemaParams.push_back({
5280 {"name", "scale"},
5281 {"type", "string"},
5282 {"default", scaleCode},
5283 {"description", "OSD scale code (reads definitions/{code}.json)"},
5284 {"hidden", true}
5285 });
5286
5287 // Build par.json defaults
5288 nlohmann::json parDefaults = {{"scale", scaleCode}};
5289
5290 // Extract each parameter from scale definition (if any)
5291 if (scaleDef.contains("parameters"))
5292 for (auto& [pName, pDef] : scaleDef["parameters"].items()) {
5293 nlohmann::json sp;
5294 sp["name"] = pName;
5295
5296 std::string pType = "string";
5297 if (pDef.contains("type")) pType = pDef["type"].get<std::string>();
5298 sp["type"] = pType;
5299
5300 // Default value
5301 if (pDef.contains("default")) {
5302 sp["default"] = pDef["default"];
5303 // Also set in par defaults
5304 parDefaults[pName] = pDef["default"];
5305 }
5306
5307 if (pDef.contains("description")) {
5308 sp["description"] = pDef["description"].get<std::string>();
5309 }
5310
5311 // Options
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});
5316 }
5317
5318 schemaParams.push_back(sp);
5319 }
5320
5321 // Always include shuffle_questions as a standard ScaleRunner parameter
5322 if (!parDefaults.contains("shuffle_questions")) {
5323 schemaParams.push_back({
5324 {"name", "shuffle_questions"},
5325 {"type", "boolean"},
5326 {"default", 0},
5327 {"options", nlohmann::json::array({0, 1})},
5328 {"description", "Randomize item order within randomization groups"}
5329 });
5330 parDefaults["shuffle_questions"] = 0;
5331 }
5332
5333 // Always include show_header as a standard ScaleRunner parameter
5334 if (!parDefaults.contains("show_header")) {
5335 schemaParams.push_back({
5336 {"name", "show_header"},
5337 {"type", "boolean"},
5338 {"default", 1},
5339 {"options", nlohmann::json::array({0, 1})},
5340 {"description", "Display the scale title header above the questionnaire"}
5341 });
5342 parDefaults["show_header"] = 1;
5343 }
5344
5345 schemaJson["parameters"] = schemaParams;
5346
5347 // Write schema file
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);
5353 schemaFile.close();
5354 printf("Updated schema: %s\n", schemaPath.c_str());
5355 }
5356
5357 // Create or update par.json — add missing parameters without overwriting existing values
5358 std::string parPath = testDir + "/params/" + scaleCode + ".pbl.par.json";
5359 nlohmann::json existingParams;
5360 if (fs::exists(parPath)) {
5361 try {
5362 std::ifstream existingFile(parPath);
5363 if (existingFile.is_open()) {
5364 existingFile >> existingParams;
5365 existingFile.close();
5366 }
5367 } catch (...) {
5368 existingParams = nlohmann::json::object();
5369 }
5370 }
5371
5372 // Merge: add defaults for any parameters not already in the file
5373 bool updated = false;
5374 for (auto& [key, val] : parDefaults.items()) {
5375 if (!existingParams.contains(key)) {
5376 existingParams[key] = val;
5377 updated = true;
5378 }
5379 }
5380
5381 if (updated || !fs::exists(parPath)) {
5382 std::ofstream parFile(parPath);
5383 if (parFile.is_open()) {
5384 parFile << existingParams.dump(2);
5385 parFile.close();
5386 printf("%s params: %s\n", updated ? "Updated" : "Created", parPath.c_str());
5387 }
5388 }
5389
5390 return true;
5391}
5392
5393
5394void LauncherUI::EditTestParameters(int testIndex)
5395{
5396 if (!mCurrentStudy) {
5397 printf("Error: No study loaded\n");
5398 return;
5399 }
5400
5401 const auto& tests = mCurrentStudy->GetTests();
5402 if (testIndex < 0 || testIndex >= (int)tests.size()) {
5403 printf("Error: Invalid test index\n");
5404 return;
5405 }
5406
5407 const Test& test = tests[testIndex];
5408 std::string studyPath = mCurrentStudy->GetPath();
5409 std::string testPath = studyPath + "/tests/" + test.testPath;
5410
5411 // Sync schema from scale definition if this is a scale-based test
5412 SyncScaleSchema(testPath, test.testName);
5413
5414 std::string schemaPath = testPath + "/params/" + test.testName + ".pbl.schema.json";
5415
5416 // Check if schema file exists
5417 struct stat st;
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");
5422 return;
5423 }
5424
5425 // Store test index for later use
5426 mEditingTestIndex = testIndex;
5427
5428 // Scan for existing parameter variants in the params directory
5429 ScanParameterVariants(testIndex);
5430
5431 // Load the default parameter set directly (skip variant dialog)
5432 mVariantName[0] = '\0';
5433 LoadParameterEditorForVariant();
5434}
5435
5436void LauncherUI::ScanParameterVariants(int testIndex)
5437{
5438 if (!mCurrentStudy) return;
5439
5440 const auto& tests = mCurrentStudy->GetTests();
5441 if (testIndex < 0 || testIndex >= (int)tests.size()) return;
5442
5443 Test* test = mCurrentStudy->GetTest(tests[testIndex].testName);
5444 if (!test) return;
5445
5446 std::string studyPath = mCurrentStudy->GetPath();
5447 std::string paramsDir = studyPath + "/tests/" + test->testPath + "/params";
5448
5449 // Clear existing variants
5450 test->parameterVariants.clear();
5451
5452 try {
5453 if (!fs::exists(paramsDir) || !fs::is_directory(paramsDir)) {
5454 printf("No params directory found at %s\n", paramsDir.c_str());
5455 return;
5456 }
5457
5458 // Scan for .par.json files
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();
5462
5463 // Look for pattern: testname-variantname.par.json
5464 if (filename.find(".par.json") != std::string::npos) {
5465 // Extract variant name
5466 size_t dashPos = filename.find('-');
5467 size_t parPos = filename.find(".par.json");
5468
5469 if (dashPos != std::string::npos && parPos != std::string::npos && dashPos < parPos) {
5470 std::string variantName = filename.substr(dashPos + 1, parPos - dashPos - 1);
5471
5472 ParameterVariant variant;
5473 variant.file = filename;
5474 variant.description = "Parameter set: " + variantName;
5475
5476 test->parameterVariants[variantName] = variant;
5477 printf("Found parameter variant: %s\n", variantName.c_str());
5478 }
5479 }
5480 }
5481 }
5482
5483 printf("Scanned %zu parameter variants for test %s\n",
5484 test->parameterVariants.size(), test->testName.c_str());
5485
5486 // Save study to persist the scanned variants
5487 mCurrentStudy->Save();
5488
5489 } catch (const fs::filesystem_error& e) {
5490 printf("Error scanning parameter variants: %s\n", e.what());
5491 }
5492}
5493
5494// ============================================================================
5495// Chain Management Function Stubs
5496// ============================================================================
5497
5498void LauncherUI::CreateNewChain()
5499{
5500 if (!mCurrentStudy) {
5501 printf("Error: No study loaded. Create or load a study first.\n");
5502 return;
5503 }
5504
5505 mShowNewChainDialog = true;
5506}
5507
5508void LauncherUI::LoadChain(const std::string& chainPath)
5509{
5510 printf("LoadChain(%s)\n", chainPath.c_str());
5511
5512 mCurrentChain = Chain::LoadFromFile(chainPath);
5513 if (mCurrentChain) {
5514 printf("Chain loaded: %s\n", mCurrentChain->GetName().c_str());
5515
5516 // Save selected chain to config (save just the filename, not full path)
5517 size_t lastSlash = chainPath.find_last_of("/\\");
5518 std::string chainName = (lastSlash != std::string::npos)
5519 ? chainPath.substr(lastSlash + 1)
5520 : chainPath;
5521 mConfig->SetCurrentChainName(chainName);
5522 mConfig->SaveConfig();
5523 } else {
5524 printf("Failed to load chain from: %s\n", chainPath.c_str());
5525 }
5526}
5527
5528void LauncherUI::SaveCurrentChain()
5529{
5530 if (!mCurrentChain) {
5531 printf("Error: No chain loaded\n");
5532 return;
5533 }
5534
5535 if (mCurrentChain->Save()) {
5536 printf("Chain saved: %s\n", mCurrentChain->GetName().c_str());
5537 } else {
5538 printf("Failed to save chain\n");
5539 }
5540}
5541
5542void LauncherUI::AddInstructionPage()
5543{
5544 mPageEditor.show = true;
5545 mPageEditor.editingIndex = -1;
5546 mPageEditor.pageType = 0; // Instruction
5547 mPageEditor.title[0] = '\0';
5548 mPageEditor.content[0] = '\0';
5549}
5550
5551void LauncherUI::AddConsentPage()
5552{
5553 mPageEditor.show = true;
5554 mPageEditor.editingIndex = -1;
5555 mPageEditor.pageType = 1; // Consent
5556 mPageEditor.title[0] = '\0';
5557 mPageEditor.content[0] = '\0';
5558}
5559
5560void LauncherUI::AddCompletionPage()
5561{
5562 mPageEditor.show = true;
5563 mPageEditor.editingIndex = -1;
5564 mPageEditor.pageType = 2; // Completion
5565 mPageEditor.title[0] = '\0';
5566 mPageEditor.content[0] = '\0';
5567}
5568
5569void LauncherUI::AddTestToChain()
5570{
5571 if (!mCurrentChain) {
5572 printf("Error: No chain loaded\n");
5573 return;
5574 }
5575
5576 if (!mCurrentStudy) {
5577 printf("Error: No study loaded. Tests must come from a study.\n");
5578 return;
5579 }
5580
5581 // Open test editor dialog for adding new test
5582 mTestEditor.show = true;
5583 mTestEditor.editingIndex = -1; // -1 means adding new item
5584 mTestEditor.selectedTestIndex = -1;
5585 mTestEditor.selectedVariantIndex = 0;
5586 mTestEditor.language[0] = '\0';
5587}
5588
5589void LauncherUI::RemoveChainItem(int index)
5590{
5591 if (!mCurrentChain) {
5592 printf("Error: No chain loaded\n");
5593 return;
5594 }
5595
5596 mCurrentChain->RemoveItem(index);
5597 SaveCurrentChain();
5598 printf("Removed chain item at index: %d\n", index);
5599}
5600
5601void LauncherUI::MoveChainItemUp(int index)
5602{
5603 if (!mCurrentChain) {
5604 printf("Error: No chain loaded\n");
5605 return;
5606 }
5607
5608 if (index > 0) {
5609 mCurrentChain->MoveItem(index, index - 1);
5610 SaveCurrentChain();
5611 printf("Moved chain item up from index %d to %d\n", index, index - 1);
5612 }
5613}
5614
5615void LauncherUI::MoveChainItemDown(int index)
5616{
5617 if (!mCurrentChain) {
5618 printf("Error: No chain loaded\n");
5619 return;
5620 }
5621
5622 if (index < (int)mCurrentChain->GetItems().size() - 1) {
5623 // Swap with next item (MoveItem doesn't work correctly for moving forward)
5624 ChainItem* item1 = mCurrentChain->GetItem(index);
5625 ChainItem* item2 = mCurrentChain->GetItem(index + 1);
5626 if (item1 && item2) {
5627 ChainItem temp = *item1;
5628 *item1 = *item2;
5629 *item2 = temp;
5630 SaveCurrentChain();
5631 printf("Moved chain item down from index %d to %d\n", index, index + 1);
5632 }
5633 }
5634}
5635
5636void LauncherUI::MoveChainItemTo(int from, int to)
5637{
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);
5646 }
5647 SaveCurrentChain();
5648}
5649
5650void LauncherUI::EditChainItem(int index)
5651{
5652 if (!mCurrentChain) {
5653 printf("Error: No chain loaded\n");
5654 return;
5655 }
5656
5657 const auto& items = mCurrentChain->GetItems();
5658 if (index < 0 || index >= (int)items.size()) {
5659 printf("Error: Invalid chain item index: %d\n", index);
5660 return;
5661 }
5662
5663 const ChainItem& item = items[index];
5664
5665 // Only page items can be edited in the page editor
5666 if (item.type == ItemType::Instruction ||
5667 item.type == ItemType::Consent ||
5668 item.type == ItemType::Completion) {
5669
5670 mPageEditor.show = true;
5671 mPageEditor.editingIndex = index;
5672
5673 if (item.type == ItemType::Instruction) {
5674 mPageEditor.pageType = 0;
5675 } else if (item.type == ItemType::Consent) {
5676 mPageEditor.pageType = 1;
5677 } else {
5678 mPageEditor.pageType = 2;
5679 }
5680
5681 std::strncpy(mPageEditor.title, item.title.c_str(), sizeof(mPageEditor.title) - 1);
5682 std::strncpy(mPageEditor.content, item.content.c_str(), sizeof(mPageEditor.content) - 1);
5683 } else if (item.type == ItemType::Test) {
5684 // Test items - show test editor
5685 if (!mCurrentStudy) {
5686 printf("Error: No study loaded - cannot edit test item\n");
5687 return;
5688 }
5689
5690 // Find test in study's test list
5691 const auto& tests = mCurrentStudy->GetTests();
5692 int testIndex = -1;
5693 for (size_t i = 0; i < tests.size(); i++) {
5694 if (tests[i].testName == item.testName) {
5695 testIndex = i;
5696 break;
5697 }
5698 }
5699
5700 if (testIndex < 0) {
5701 printf("Warning: Test '%s' not found in study\n", item.testName.c_str());
5702 // Still allow editing, but select first test as fallback
5703 testIndex = 0;
5704 }
5705
5706 // Find parameter variant index
5707 int variantIndex = 0; // Default to "default" variant
5708 if (!item.paramVariant.empty() && item.paramVariant != "default") {
5709 const Test& test = tests[testIndex];
5710 int idx = 1; // Start at 1 since 0 is "default"
5711 for (const auto& [name, variant] : test.parameterVariants) {
5712 if (name == item.paramVariant) {
5713 variantIndex = idx;
5714 break;
5715 }
5716 idx++;
5717 }
5718 }
5719
5720 // Set up test editor state
5721 mTestEditor.show = true;
5722 mTestEditor.editingIndex = index;
5723 mTestEditor.selectedTestIndex = testIndex;
5724 mTestEditor.selectedVariantIndex = variantIndex;
5725 std::strncpy(mTestEditor.language, item.language.c_str(), sizeof(mTestEditor.language) - 1);
5726 mTestEditor.language[sizeof(mTestEditor.language) - 1] = '\0';
5727 mTestEditor.randomGroup = item.randomGroup;
5728 }
5729}
5730
5731// ============================================================================
5732// New Study-Centric UI Implementation
5733// ============================================================================
5734
5735void LauncherUI::RenderStudyBar()
5736{
5737 ImGui::Spacing();
5738
5739 // Study selector and control buttons
5740 ImGui::Text("Study:");
5741 ImGui::SameLine();
5742
5743 const char* currentStudyName = mCurrentStudy ? mCurrentStudy->GetName().c_str() : "No study loaded";
5744 ImGui::PushItemWidth(300);
5745 if (ImGui::BeginCombo("##StudySelect", currentStudyName)) {
5746 // List studies from workspace
5747 auto studyDirs = mWorkspace->GetStudyDirectories();
5748 for (size_t i = 0; i < studyDirs.size(); i++) {
5749 // Extract study name from path
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]);
5754 }
5755 }
5756 ImGui::EndCombo();
5757 }
5758 ImGui::PopItemWidth();
5759
5760 ImGui::SameLine();
5761 if (ImGui::Button("New Study")) {
5762 mShowNewStudyDialog = true;
5763 }
5764
5765 ImGui::SameLine();
5766 if (mCurrentStudy) {
5767 if (ImGui::Button("Open Directory")) {
5768 std::string studyPath = mCurrentStudy->GetPath();
5769 OpenDirectoryInFileBrowser(studyPath);
5770 }
5771
5772 ImGui::SameLine();
5773 if (ImGui::Button("Study Settings")) {
5774 mShowStudySettingsDialog = true;
5775 }
5776 }
5777
5778 // Show study info if loaded
5779 if (mCurrentStudy) {
5780 ImGui::SameLine();
5781 ImGui::TextDisabled("| %s | %zu tests | %d chains",
5782 mCurrentStudy->GetName().c_str(),
5783 mCurrentStudy->GetTests().size(),
5784 mCurrentStudy->GetChainCount());
5785 }
5786
5787 ImGui::Separator();
5788 ImGui::Spacing(); // Add spacing after separator
5789}
5790
5791void LauncherUI::RenderTestsTab()
5792{
5793 if (!mCurrentStudy) {
5794 // No study loaded - show full-width battery browser for exploration
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();
5798 ImGui::Spacing();
5799 ImGui::Separator();
5800 ImGui::Spacing();
5801
5802 // Show battery browser full-width (without "Add to Study" button)
5803 RenderBatteryBrowser();
5804 return;
5805 }
5806
5807 // Two-panel layout: Tests in Study (left) | Add Test to Study (right)
5808 float panelWidth = ImGui::GetContentRegionAvail().x * 0.35f;
5809
5810 // Left panel: Tests in Study
5811 ImGui::BeginChild("TestsInStudy", ImVec2(panelWidth, 0), true);
5812 RenderTestsInStudy();
5813 ImGui::EndChild();
5814
5815 ImGui::SameLine();
5816
5817 // Right panel: Add Test to Study
5818 ImGui::BeginChild("AddTestPanel", ImVec2(0, 0), true);
5819 RenderAddTestPanel();
5820 ImGui::EndChild();
5821}
5822
5823void LauncherUI::RenderTestsInStudy()
5824{
5825 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Tests in Study");
5826 ImGui::Separator();
5827 ImGui::Spacing();
5828
5829 const auto& tests = mCurrentStudy->GetTests();
5830
5831 if (tests.empty()) {
5832 ImGui::TextDisabled("No tests in this study yet.\nUse the panel on the right to add tests.");
5833 return;
5834 }
5835
5836 // Scrollable list of tests - reserve space for preview below when a test is selected
5837 float listHeight = (mSelectedStudyTestIndex >= 0) ? ImGui::GetContentRegionAvail().y * 0.4f : -1;
5838 ImGui::BeginChild("TestList", ImVec2(0, listHeight), false);
5839
5840 for (size_t i = 0; i < tests.size(); i++) {
5841 ImGui::PushID((int)i);
5842
5843 ImGui::Text("%zu.", i + 1);
5844 ImGui::SameLine();
5845
5846 // Calculate available width for test name (leaving 50px for menu button on the right)
5847 float availableWidth = ImGui::GetContentRegionAvail().x - 50;
5848 std::string testName = tests[i].testName;
5849
5850 // Add variant count if present
5851 if (!tests[i].parameterVariants.empty()) {
5852 testName += " (" + std::to_string(tests[i].parameterVariants.size()) + " variants)";
5853 }
5854
5855 // Truncate if too long
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();
5860 }
5861 displayName += "...";
5862 }
5863
5864 // Make test name clickable to show preview
5865 bool is_selected = (mSelectedStudyTestIndex == (int)i);
5866 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.7f, 1.0f, 1.0f)); // Blue color
5867 if (ImGui::Selectable(displayName.c_str(), is_selected, ImGuiSelectableFlags_None, ImVec2(availableWidth, 0))) {
5868 mSelectedStudyTestIndex = (int)i;
5869 LoadStudyTestPreview((int)i);
5870 }
5871 ImGui::PopStyleColor();
5872 if (ImGui::IsItemHovered()) {
5873 if (displayName != testName) {
5874 ImGui::SetTooltip("%s\nClick to view test details", testName.c_str());
5875 } else {
5876 ImGui::SetTooltip("Click to view test details");
5877 }
5878 }
5879
5880 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 50);
5881
5882 // Menu button with all options
5883 if (ImGui::SmallButton("...")) {
5884 ImGui::OpenPopup("TestMenu");
5885 }
5886
5887 if (ImGui::BeginPopup("TestMenu")) {
5888 std::string studyPath = mCurrentStudy->GetPath();
5889 fs::path testPath = fs::path(studyPath) / "tests" / tests[i].testPath;
5890 // Extract basename from test_name for .pbl filename
5891 std::string baseName = fs::path(tests[i].testName).filename().string();
5892 std::string pblFile = (testPath / (baseName + ".pbl")).string();
5893
5894 // Quick Launch
5895 if (ImGui::MenuItem("Quick Launch")) {
5896 // Open Quick Launch tab with this test selected
5897 std::ifstream file(pblFile);
5898 if (file.is_open()) {
5899 file.close();
5900
5901 // Set Quick Launch path to this test
5902 std::strncpy(mQuickLaunchPath, pblFile.c_str(), sizeof(mQuickLaunchPath) - 1);
5903 mQuickLaunchPath[sizeof(mQuickLaunchPath) - 1] = '\0';
5904
5905 // Update Quick Launch directory to parent of selected file
5906 mQuickLaunchDirectory = fs::path(pblFile).parent_path().string();
5907
5908 // Switch to Quick Launch tab
5909 mTopLevelTab = 1;
5910
5911 printf("Switched to Quick Launch with test: %s\n", baseName.c_str());
5912 } else {
5913 printf("Error: Could not find test file: %s\n", pblFile.c_str());
5914 }
5915 }
5916
5917 ImGui::Separator();
5918
5919 // Edit code
5920 if (ImGui::MenuItem("Edit Code")) {
5921 std::ifstream file(pblFile);
5922 if (file.is_open()) {
5923 std::stringstream buffer;
5924 buffer << file.rdbuf();
5925 file.close();
5926
5927 mCodeEditorFilePath = pblFile;
5928 mCodeEditor.SetText(buffer.str());
5929 mShowCodeEditor = true;
5930 } else {
5931 printf("Error: Could not open file for editing: %s\n", pblFile.c_str());
5932 }
5933 }
5934
5935 // Edit parameters
5936 if (ImGui::MenuItem("Edit Parameters...")) {
5937 EditTestParameters(i);
5938 }
5939
5940 // Edit translations
5941 if (ImGui::MenuItem("Edit Translations...")) {
5942 // Open translation editor dialog
5943 mTranslationEditor.testIndex = i;
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'; // Start with no language selected
5948 mTranslationEditor.show = true;
5949 }
5950
5951 ImGui::Separator();
5952
5953 // Open test directory
5954 if (ImGui::MenuItem("Open Test Directory")) {
5955 OpenDirectoryInFileBrowser(testPath.string());
5956 }
5957
5958 // Combine data
5959 if (ImGui::MenuItem("Combine Data Files...")) {
5960 std::string dataPath = (testPath / "data").string();
5961
5962 // Create data directory if it doesn't exist
5963 if (!fs::exists(dataPath)) {
5964 fs::create_directories(dataPath);
5965 }
5966
5967 LaunchDataCombiner(dataPath);
5968 }
5969
5970 ImGui::Separator();
5971
5972 // Remove test
5973 if (ImGui::MenuItem("Remove from Study")) {
5974 const std::string testName = tests[i].testName;
5975 RemoveTestFromStudy(testName);
5976 ImGui::EndPopup();
5977 ImGui::PopID();
5978 break;
5979 }
5980
5981 ImGui::EndPopup();
5982 }
5983
5984 ImGui::Spacing();
5985 ImGui::PopID();
5986 }
5987
5988 ImGui::EndChild();
5989
5990 // Preview section for selected study test
5991 if (mSelectedStudyTestIndex >= 0 && mSelectedStudyTestIndex < (int)tests.size()) {
5992 ImGui::Separator();
5993 ImGui::Spacing();
5994
5995 // Screenshot
5996 if (mStudyTestScreenshot) {
5997 float aspectRatio = (float)mStudyTestScreenshotH / (float)mStudyTestScreenshotW;
5998 float displayWidth = ImGui::GetContentRegionAvail().x;
5999 float displayHeight = displayWidth * aspectRatio;
6000
6001 if (displayHeight > 300) {
6002 displayHeight = 300;
6003 displayWidth = displayHeight / aspectRatio;
6004 }
6005
6006 ImGui::Image((ImTextureID)(intptr_t)mStudyTestScreenshot,
6007 ImVec2(displayWidth, displayHeight));
6008 ImGui::Spacing();
6009 }
6010
6011 // Description
6012 if (!mStudyTestDescription.empty()) {
6013 ImGui::TextWrapped("%s", mStudyTestDescription.c_str());
6014 }
6015 }
6016}
6017
6018void LauncherUI::RenderAddTestPanel()
6019{
6020 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Add Test to Study");
6021 ImGui::Separator();
6022 ImGui::Spacing();
6023
6024 // Four sub-tabs: Battery, Scale, File, New
6025 if (ImGui::BeginTabBar("AddTestTabs")) {
6026 if (ImGui::BeginTabItem("Battery")) {
6027 mAddTestSubTab = 0;
6028 RenderBatteryBrowser();
6029 ImGui::EndTabItem();
6030 }
6031
6032 if (ImGui::BeginTabItem("Scale")) {
6033 mAddTestSubTab = 1;
6034 RenderScaleBrowser();
6035 ImGui::EndTabItem();
6036 }
6037
6038 if (ImGui::BeginTabItem("File")) {
6039 mAddTestSubTab = 2;
6040 RenderFileImport();
6041 ImGui::EndTabItem();
6042 }
6043
6044 if (ImGui::BeginTabItem("New")) {
6045 mAddTestSubTab = 3;
6046 RenderNewTestTemplate();
6047 ImGui::EndTabItem();
6048 }
6049
6050 ImGui::EndTabBar();
6051 }
6052}
6053
6054void LauncherUI::RenderBatteryBrowser()
6055{
6056 // This is essentially the old file panel + details panel combined
6057 // Filter box
6058 static char filter[256] = "";
6059 ImGui::PushItemWidth(-1);
6060 ImGui::InputTextWithHint("##Filter", "Filter tests...", filter, sizeof(filter));
6061 ImGui::PopItemWidth();
6062
6063 ImGui::Spacing();
6064
6065 // Split into test list (left) and details (right)
6066 float listWidth = ImGui::GetContentRegionAvail().x * 0.4f;
6067
6068 ImGui::BeginChild("BatteryTestList", ImVec2(listWidth, 0), true);
6069
6070 ImGui::Text("Battery Tests (%zu found):", mExperiments.size());
6071 ImGui::Separator();
6072
6073 // Scrollable test list
6074 for (int i = 0; i < (int)mExperiments.size(); i++) {
6075 const ExperimentInfo& exp = mExperiments[i];
6076
6077 // Apply filter
6078 if (strlen(filter) > 0 &&
6079 exp.name.find(filter) == std::string::npos) {
6080 continue;
6081 }
6082
6083 bool is_selected = (mSelectedExperiment == i);
6084 if (ImGui::Selectable(exp.name.c_str(), is_selected)) {
6085 mSelectedExperiment = i;
6086 LoadExperimentInfo(exp.path);
6087 }
6088
6089 if (ImGui::IsItemHovered()) {
6090 ImGui::SetTooltip("%s", exp.path.c_str());
6091 }
6092 }
6093
6094 // Keyboard navigation - detect arrow keys and load details
6095 if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) {
6096 int newSelection = mSelectedExperiment;
6097
6098 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
6099 // Find next visible item after current selection
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) {
6103 continue;
6104 }
6105 if (foundCurrent) {
6106 newSelection = i;
6107 break;
6108 }
6109 if (i == mSelectedExperiment) {
6110 foundCurrent = true;
6111 }
6112 }
6113 } else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
6114 // Find previous visible item before current selection
6115 for (int i = (int)mExperiments.size() - 1; i >= 0; i--) {
6116 if (strlen(filter) > 0 && mExperiments[i].name.find(filter) == std::string::npos) {
6117 continue;
6118 }
6119 if (i < mSelectedExperiment) {
6120 newSelection = i;
6121 break;
6122 }
6123 }
6124 }
6125
6126 // If selection changed via keyboard, load the details
6127 if (newSelection != mSelectedExperiment && newSelection >= 0 && newSelection < (int)mExperiments.size()) {
6128 mSelectedExperiment = newSelection;
6129 LoadExperimentInfo(mExperiments[newSelection].path);
6130 }
6131 }
6132
6133 ImGui::EndChild();
6134
6135 ImGui::SameLine();
6136
6137 // Right side: Test details
6138 ImGui::BeginChild("BatteryTestDetails", ImVec2(0, 0), true);
6139
6140 if (mSelectedExperiment < 0 || mSelectedExperiment >= (int)mExperiments.size()) {
6141 ImGui::TextDisabled("Select a test to view details");
6142 } else {
6143 const ExperimentInfo& exp = mExperiments[mSelectedExperiment];
6144
6145 // Test name
6146 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", exp.name.c_str());
6147 ImGui::Separator();
6148 ImGui::Spacing();
6149
6150 // Add to Study button (only available when study is loaded)
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));
6155
6156 if (ImGui::Button("Add to Study", ImVec2(-1, 40))) {
6157 AddTestToStudy();
6158 }
6159
6160 ImGui::PopStyleColor(3);
6161 } else {
6162 // No study loaded - show disabled button with tooltip
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");
6168 }
6169 }
6170
6171 ImGui::Spacing();
6172 ImGui::Separator();
6173 ImGui::Spacing();
6174
6175 // Screenshot
6176 if (mScreenshotTexture) {
6177 float aspectRatio = (float)mScreenshotHeight / (float)mScreenshotWidth;
6178 float displayWidth = ImGui::GetContentRegionAvail().x;
6179 float displayHeight = displayWidth * aspectRatio;
6180
6181 // Cap height at a reasonable max (e.g., 400px) but let it fill width
6182 if (displayHeight > 400) {
6183 displayHeight = 400;
6184 displayWidth = displayHeight / aspectRatio;
6185 }
6186
6187 ImGui::Image((ImTextureID)(intptr_t)mScreenshotTexture,
6188 ImVec2(displayWidth, displayHeight));
6189 ImGui::Spacing();
6190 }
6191
6192 // Description
6193 if (!exp.description.empty()) {
6194 ImGui::TextWrapped("%s", exp.description.c_str());
6195 } else {
6196 ImGui::TextDisabled("No description available");
6197 }
6198
6199 ImGui::Spacing();
6200
6201 // Info badges
6202 if (exp.hasParams) {
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");
6206 }
6207 ImGui::SameLine();
6208 }
6209 if (exp.hasTranslations) {
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) {
6217 tooltip += ", ";
6218 }
6219 }
6220 ImGui::SetTooltip("%s", tooltip.c_str());
6221 } else {
6222 ImGui::SetTooltip("This test has translation support");
6223 }
6224 }
6225 }
6226 }
6227
6228 ImGui::EndChild();
6229}
6230
6231void LauncherUI::RenderScaleBrowser()
6232{
6233 // Filter box
6234 static char filter[256] = "";
6235 ImGui::PushItemWidth(-1);
6236 ImGui::InputTextWithHint("##ScaleFilter", "Filter scales...", filter, sizeof(filter));
6237 ImGui::PopItemWidth();
6238
6239 ImGui::Spacing();
6240
6241 // Ensure scale list is loaded
6242 if (mScaleList.empty()) {
6243 mScaleList = mScaleManager->GetAvailableScales();
6244 }
6245
6246 // Split into scale list (left) and details (right)
6247 float listWidth = ImGui::GetContentRegionAvail().x * 0.4f;
6248
6249 ImGui::BeginChild("ScaleList", ImVec2(listWidth, 0), true);
6250
6251 ImGui::Text("Available Scales (%zu found):", mScaleList.size());
6252 ImGui::Separator();
6253
6254 // Scrollable scale list
6255 for (int i = 0; i < (int)mScaleList.size(); i++) {
6256 const std::string& scaleName = mScaleList[i];
6257
6258 // Apply filter
6259 if (strlen(filter) > 0 &&
6260 scaleName.find(filter) == std::string::npos) {
6261 continue;
6262 }
6263
6264 bool is_selected = (mSelectedScaleIndex == i);
6265 if (ImGui::Selectable(scaleName.c_str(), is_selected)) {
6266 mSelectedScaleIndex = i;
6267 }
6268 }
6269
6270 // Keyboard navigation
6271 if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) {
6272 int newSelection = mSelectedScaleIndex;
6273
6274 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
6275 // Find next visible item after current selection
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) {
6279 continue;
6280 }
6281 if (foundCurrent) {
6282 newSelection = i;
6283 break;
6284 }
6285 if (i == mSelectedScaleIndex) {
6286 foundCurrent = true;
6287 }
6288 }
6289 } else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
6290 // Find previous visible item before current selection
6291 for (int i = (int)mScaleList.size() - 1; i >= 0; i--) {
6292 if (strlen(filter) > 0 && mScaleList[i].find(filter) == std::string::npos) {
6293 continue;
6294 }
6295 if (i < mSelectedScaleIndex) {
6296 newSelection = i;
6297 break;
6298 }
6299 }
6300 }
6301
6302 // Update selection if changed
6303 if (newSelection != mSelectedScaleIndex && newSelection >= 0 && newSelection < (int)mScaleList.size()) {
6304 mSelectedScaleIndex = newSelection;
6305 }
6306 }
6307
6308 ImGui::EndChild();
6309
6310 ImGui::SameLine();
6311
6312 // Right side: Scale details
6313 ImGui::BeginChild("ScaleDetails", ImVec2(0, 0), true);
6314
6315 if (mSelectedScaleIndex < 0 || mSelectedScaleIndex >= (int)mScaleList.size()) {
6316 ImGui::TextDisabled("Select a scale to view details");
6317 } else {
6318 const std::string& scaleCode = mScaleList[mSelectedScaleIndex];
6319 auto metadata = mScaleManager->GetScaleMetadata(scaleCode);
6320
6321 // Scale name
6322 if (!metadata.name.empty()) {
6323 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", metadata.name.c_str());
6324 } else {
6325 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", scaleCode.c_str());
6326 }
6327 ImGui::Separator();
6328 ImGui::Spacing();
6329
6330 // Load screenshot if selection changed
6331 if (mSelectedScaleIndex != mScaleBrowserScreenshotForIndex) {
6332 if (mScaleBrowserScreenshot) {
6333 SDL_DestroyTexture(mScaleBrowserScreenshot);
6334 mScaleBrowserScreenshot = nullptr;
6335 mScaleBrowserScreenshotW = 0;
6336 mScaleBrowserScreenshotH = 0;
6337 }
6338
6339 // Get definition path and derive parent directory
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();
6343
6344 if (fs::exists(screenshotPath)) {
6345 SDL_Surface* surface = IMG_Load(screenshotPath.c_str());
6346 if (surface) {
6347 mScaleBrowserScreenshot = SDL_CreateTextureFromSurface(mRenderer, surface);
6348 if (mScaleBrowserScreenshot) {
6349 mScaleBrowserScreenshotW = surface->w;
6350 mScaleBrowserScreenshotH = surface->h;
6351 }
6352 SDL_FreeSurface(surface);
6353 }
6354 }
6355
6356 mScaleBrowserScreenshotForIndex = mSelectedScaleIndex;
6357 }
6358
6359 // Display screenshot
6360 if (mScaleBrowserScreenshot) {
6361 float aspectRatio = (float)mScaleBrowserScreenshotH / (float)mScaleBrowserScreenshotW;
6362 float displayWidth = ImGui::GetContentRegionAvail().x;
6363 float displayHeight = displayWidth * aspectRatio;
6364
6365 if (displayHeight > 300.0f) {
6366 displayHeight = 300.0f;
6367 displayWidth = displayHeight / aspectRatio;
6368 }
6369
6370 ImGui::Image((ImTextureID)(intptr_t)mScaleBrowserScreenshot,
6371 ImVec2(displayWidth, displayHeight));
6372 ImGui::Spacing();
6373 }
6374
6375 // Add to Study button (only available when study is loaded)
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));
6380
6381 if (ImGui::Button("Add to Study", ImVec2(-1, 40))) {
6382 // Load the scale and add it to the study
6383 auto scale = mScaleManager->LoadScale(scaleCode);
6384 if (!scale) {
6385 printf("Failed to load scale '%s'\n", scaleCode.c_str());
6386 } else {
6387 std::string studyPath = mCurrentStudy->GetPath();
6388 std::string testDir = studyPath + "/tests/" + scaleCode;
6389
6390 try {
6391 // Create test directory structure
6392 fs::create_directories(testDir);
6393 fs::create_directories(testDir + "/" + scaleCode);
6394 fs::create_directories(testDir + "/params");
6395
6396 // Copy ScaleRunner.pbl and rename to scalecode.pbl
6397 std::string scaleRunnerSource = mBatteryPath + "/../media/apps/scales/ScaleRunner.pbl";
6398 std::string scaleRunnerDest = testDir + "/" + scaleCode + ".pbl";
6399
6400 if (!fs::exists(scaleRunnerSource)) {
6401 printf("Error: ScaleRunner.pbl not found at: %s\n", scaleRunnerSource.c_str());
6402 } else {
6403 fs::copy_file(scaleRunnerSource, scaleRunnerDest, fs::copy_options::overwrite_existing);
6404 printf("Copied ScaleRunner.pbl to %s\n", scaleRunnerDest.c_str());
6405
6406 // Export scale as OSD bundle: {code}/{code}.osd
6407 std::string osdDir = testDir + "/" + scaleCode;
6408
6409 if (!scale->ExportToOSD(osdDir)) {
6410 printf("Error: Failed to export scale OSD\n");
6411 } else {
6412 printf("Exported scale OSD to %s\n", osdDir.c_str());
6413
6414 // Generate schema and default params from scale definition
6415 SyncScaleSchema(testDir, scaleCode);
6416
6417 // Add test to study
6418 Test test;
6419 test.testName = scaleCode;
6420 test.displayName = metadata.name.empty() ? scaleCode : metadata.name;
6421 test.testPath = scaleCode;
6422 test.included = true;
6423
6424 mCurrentStudy->AddTest(test);
6425 mCurrentStudy->Save();
6426
6427 // Add to Main chain if it exists
6428 std::string mainChainPath = studyPath + "/chains/Main.json";
6429 if (fs::exists(mainChainPath)) {
6431 item.testName = scaleCode;
6432 item.paramVariant = "default";
6433 item.language = "en";
6434 item.randomGroup = 0;
6435
6436 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
6437 mCurrentChain->AddItem(item);
6438 mCurrentChain->Save();
6439 printf("Added test to Main chain (current chain)\n");
6440 } else {
6441 auto mainChain = Chain::LoadFromFile(mainChainPath);
6442 if (mainChain) {
6443 mainChain->AddItem(item);
6444 mainChain->Save();
6445 printf("Added test to Main chain\n");
6446 }
6447 }
6448 }
6449
6450 printf("Added scale '%s' to study\n", scaleCode.c_str());
6451 }
6452 }
6453 } catch (const fs::filesystem_error& e) {
6454 printf("Error adding scale to study: %s\n", e.what());
6455 }
6456 }
6457 }
6458
6459 ImGui::PopStyleColor(3);
6460 } else {
6461 // No study loaded - show disabled button with tooltip
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");
6467 }
6468 }
6469
6470 ImGui::Spacing();
6471 ImGui::Separator();
6472 ImGui::Spacing();
6473
6474 // Scale description
6475 if (!metadata.description.empty()) {
6476 ImGui::TextWrapped("%s", metadata.description.c_str());
6477 ImGui::Spacing();
6478 }
6479
6480 // Scale info
6481 ImGui::Text("Code: %s", scaleCode.c_str());
6482
6483 if (!metadata.author.empty()) {
6484 ImGui::Text("Author: %s", metadata.author.c_str());
6485 }
6486
6487 ImGui::Text("Questions: %d", metadata.questionCount);
6488
6489 if (!metadata.availableLanguages.empty()) {
6490 ImGui::Text("Languages: ");
6491 ImGui::SameLine();
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) {
6495 ImGui::SameLine();
6496 ImGui::Text(",");
6497 ImGui::SameLine();
6498 }
6499 }
6500 }
6501 }
6502
6503 ImGui::EndChild();
6504}
6505
6506void LauncherUI::RenderFileImport()
6507{
6508 ImGui::TextWrapped("Import a test from a .pbl file on your computer.");
6509 ImGui::Spacing();
6510 ImGui::Separator();
6511 ImGui::Spacing();
6512
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();
6518
6519 ImGui::SameLine();
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);
6524 }
6525 }
6526
6527 ImGui::Spacing();
6528 ImGui::Separator();
6529 ImGui::Spacing();
6530
6531 if (strlen(filePath) > 0 && fs::exists(filePath)) {
6532 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "File found: %s", filePath);
6533
6534 ImGui::Spacing();
6535
6536 if (ImGui::Button("Add to Study", ImVec2(200, 40))) {
6537 AddTestFromFile(filePath);
6538 filePath[0] = '\0'; // Clear the input after adding
6539 }
6540 } else if (strlen(filePath) > 0) {
6541 ImGui::TextColored(ImVec4(0.8f, 0.2f, 0.2f, 1.0f), "File not found");
6542 }
6543}
6544
6545void LauncherUI::RenderNewTestTemplate()
6546{
6547 ImGui::TextWrapped("Create a new test from a template.");
6548 ImGui::Spacing();
6549 ImGui::Separator();
6550 ImGui::Spacing();
6551
6552 // Generic Study Template option (complete directory structure)
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.");
6555 ImGui::Spacing();
6556
6557 static char genericTestName[128] = "";
6558 ImGui::Text("Test Name:");
6559 ImGui::InputText("##GenericTestName", genericTestName, sizeof(genericTestName));
6560
6561 if (strlen(genericTestName) > 0) {
6562 if (ImGui::Button("Create from Generic Study Template", ImVec2(300, 40))) {
6563 CreateTestFromGenericStudy(genericTestName);
6564 genericTestName[0] = '\0'; // Clear the input after creating
6565 }
6566 }
6567
6568 ImGui::Spacing();
6569 ImGui::Separator();
6570 ImGui::Spacing();
6571
6572 // Individual test templates (simple .pbl files)
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.");
6575 ImGui::Spacing();
6576
6577 if (mTemplateNames.empty()) {
6578 ImGui::TextColored(ImVec4(0.8f, 0.4f, 0.2f, 1.0f),
6579 "No templates found. Check media/templates/ directory.");
6580 return;
6581 }
6582
6583 static int selectedTemplate = 0;
6584
6585 // Convert vector<string> to vector<const char*> for ImGui::Combo
6586 std::vector<const char*> templateCStrings;
6587 for (const auto& name : mTemplateNames) {
6588 templateCStrings.push_back(name.c_str());
6589 }
6590
6591 ImGui::Text("Template:");
6592 ImGui::Combo("##Template", &selectedTemplate, templateCStrings.data(), (int)templateCStrings.size());
6593
6594 ImGui::Spacing();
6595
6596 static char testName[128] = "";
6597 ImGui::Text("Test Name:");
6598 ImGui::InputText("##TestName", testName, sizeof(testName));
6599
6600 ImGui::Spacing();
6601 ImGui::Separator();
6602 ImGui::Spacing();
6603
6604 if (strlen(testName) > 0) {
6605 if (ImGui::Button("Create Test", ImVec2(200, 40))) {
6606 CreateTestFromTemplate(testName, selectedTemplate);
6607 testName[0] = '\0'; // Clear the input after creating
6608 }
6609 }
6610}
6611
6612void LauncherUI::RenderChainsTab()
6613{
6614 // Use existing RenderChainTab implementation but require study
6615 if (!mCurrentStudy) {
6616 ImGui::TextWrapped("No study loaded. Chains are associated with studies.");
6617 return;
6618 }
6619
6620 RenderChainTab();
6621}
6622
6623void LauncherUI::RenderRunTab()
6624{
6625 if (!mCurrentStudy) {
6626 ImGui::TextWrapped("No study loaded. Load or create a study to run tests.");
6627 return;
6628 }
6629 ImGui::Separator();
6630 ImGui::Spacing();
6631
6632 // Participant code - two-part editable field
6633 if (mCurrentStudy && mCurrentChain) {
6634 // Initialize study code if not set
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';
6639 }
6640
6641 // Get current counter
6642 int counter = mCurrentChain->GetParticipantCounter();
6643 char counterStr[16];
6644 snprintf(counterStr, sizeof(counterStr), "%d", counter);
6645
6646 ImGui::Text("Participant Code:");
6647 ImGui::SameLine();
6648
6649 // Study code prefix (editable)
6650 ImGui::PushItemWidth(80);
6651 if (ImGui::InputText("##StudyCodePrefix", mStudyCode, sizeof(mStudyCode))) {
6652 // Study code edited - no need to save yet, just updates the display
6653 }
6654 ImGui::PopItemWidth();
6655
6656 ImGui::SameLine();
6657 ImGui::Text("_");
6658 ImGui::SameLine();
6659
6660 // Counter number (editable with validation)
6661 ImGui::PushItemWidth(80);
6662 static char counterBuffer[16] = "";
6663 static bool counterBufferInitialized = false;
6664
6665 // Initialize counter buffer on first render or when chain changes
6666 if (!counterBufferInitialized || strcmp(counterBuffer, counterStr) != 0) {
6667 strncpy(counterBuffer, counterStr, sizeof(counterBuffer) - 1);
6668 counterBuffer[sizeof(counterBuffer) - 1] = '\0';
6669 counterBufferInitialized = true;
6670 }
6671
6672 if (ImGui::InputText("##CounterNumber", counterBuffer, sizeof(counterBuffer), ImGuiInputTextFlags_CharsDecimal)) {
6673 // Validate and update counter
6674 if (strlen(counterBuffer) > 0) {
6675 int newCounter = atoi(counterBuffer);
6676 if (newCounter < 1) newCounter = 1;
6677 mCurrentChain->SetParticipantCounter(newCounter);
6678 mCurrentChain->Save();
6679 // Update buffer to reflect validated value
6680 snprintf(counterBuffer, sizeof(counterBuffer), "%d", newCounter);
6681 }
6682 }
6683 ImGui::PopItemWidth();
6684
6685 ImGui::SameLine();
6686 ImGui::Text("=");
6687 ImGui::SameLine();
6688
6689 // Combined result (read-only display)
6690 std::string participantCode = std::string(mStudyCode) + "_" + counterBuffer;
6691 ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%s", participantCode.c_str());
6692
6693 // Update internal fields for compatibility
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';
6698 } else {
6699 // Fallback: no study/chain loaded
6700 ImGui::Text("Participant Code:");
6701 ImGui::SameLine();
6702 ImGui::PushItemWidth(200);
6703 ImGui::InputText("##ParticipantCodeFallback", mSubjectCode, sizeof(mSubjectCode));
6704 ImGui::PopItemWidth();
6705 }
6706
6707 // Check if subject code already exists and show warning (cached to avoid scanning every frame)
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;
6713
6714 // Refresh cache only when study/chain changes
6715 std::string currentStudyPath = mCurrentStudy ? mCurrentStudy->GetPath() : "";
6716 std::string currentChainName = mCurrentChain ? mCurrentChain->GetName() : "";
6717
6718 if (!cacheInitialized || lastStudyPath != currentStudyPath || lastChainName != currentChainName) {
6719 cachedExistingCodes = CheckExistingSubjectCodes();
6720 lastStudyPath = currentStudyPath;
6721 lastChainName = currentChainName;
6722 cacheInitialized = true;
6723 }
6724
6725 std::string currentCode(mSubjectCode);
6726 bool codeExists = false;
6727 for (const auto& code : cachedExistingCodes) {
6728 if (code == currentCode) {
6729 codeExists = true;
6730 break;
6731 }
6732 }
6733
6734 if (codeExists) {
6735 ImGui::SameLine();
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!");
6739 }
6740 }
6741
6742 // Show existing codes if any
6743 if (!cachedExistingCodes.empty()) {
6744 ImGui::Indent(20);
6745 ImGui::TextDisabled("Existing codes:");
6746 ImGui::SameLine();
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];
6751 }
6752 if (cachedExistingCodes.size() > 10) {
6753 codesList += "... (" + std::to_string(cachedExistingCodes.size()) + " total)";
6754 }
6755 ImGui::TextDisabled("%s", codesList.c_str());
6756 ImGui::Unindent(20);
6757 }
6758 }
6759
6760 ImGui::Spacing();
6761
6762 // Two-column layout for settings
6763 float columnWidth = ImGui::GetContentRegionAvail().x * 0.5f;
6764
6765 // Left column
6766 ImGui::BeginChild("SettingsLeft", ImVec2(columnWidth - 5, 85), false);
6767
6768 // Language
6769 ImGui::Text("Language:");
6770 ImGui::SameLine();
6771 ImGui::PushItemWidth(60);
6772 ImGui::InputText("##Language", mLanguageCode, sizeof(mLanguageCode));
6773 ImGui::PopItemWidth();
6774 ImGui::SameLine();
6775 ImGui::TextDisabled("(en, es, de, fr...)");
6776
6777 // Fullscreen
6778 ImGui::Checkbox("Fullscreen Mode", &mFullscreen);
6779
6780 // VSync
6781 ImGui::Checkbox("Enable VSync", &mVSync);
6782 if (ImGui::IsItemHovered()) {
6783 ImGui::SetTooltip("Synchronize with monitor refresh rate");
6784 }
6785
6786 ImGui::EndChild();
6787
6788 ImGui::SameLine();
6789
6790 // Right column
6791 ImGui::BeginChild("SettingsRight", ImVec2(0, 85), false);
6792
6793 // Screen Resolution
6794 ImGui::Text("Resolution:");
6795 ImGui::SameLine();
6796 ImGui::PushItemWidth(150);
6797 const char* resolutions[] = {
6798 "Auto (Current)",
6799 "1920x1080 (Full HD)",
6800 "1680x1050",
6801 "1440x900",
6802 "1366x768",
6803 "1280x1024",
6804 "1280x800",
6805 "1280x720 (HD)",
6806 "1024x768",
6807 "800x600"
6808 };
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;
6814 }
6815 }
6816 ImGui::EndCombo();
6817 }
6818 ImGui::PopItemWidth();
6819
6820 // Advanced Options (compact)
6821 if (ImGui::TreeNode("Advanced")) {
6822 ImGui::Text("Driver:");
6823 ImGui::SameLine();
6824 ImGui::PushItemWidth(100);
6825 ImGui::InputText("##Driver", mGraphicsDriver, sizeof(mGraphicsDriver));
6826 ImGui::PopItemWidth();
6827
6828 ImGui::Text("Args:");
6829 ImGui::SameLine();
6830 ImGui::PushItemWidth(100);
6831 ImGui::InputText("##CustomArgs", mCustomArguments, sizeof(mCustomArguments));
6832 ImGui::PopItemWidth();
6833
6834 ImGui::TreePop();
6835 }
6836
6837 ImGui::EndChild();
6838
6839 ImGui::Separator();
6840 ImGui::Spacing();
6841
6842 // Chain selector
6843 ImGui::Text("Select Chain:");
6844
6845 // List chains from study
6846 auto chainFiles = mCurrentStudy->GetChainFiles();
6847 if (chainFiles.empty()) {
6848 ImGui::TextDisabled("No chains defined. Create a chain in the Chains tab.");
6849 } else {
6850 for (size_t i = 0; i < chainFiles.size(); i++) {
6851 std::string chainName = fs::path(chainFiles[i]).stem().string();
6852
6853 // Highlight selected chain
6854 bool isSelected = (mCurrentChain &&
6855 fs::path(mCurrentChain->GetFilePath()).stem().string() == chainName);
6856 if (isSelected) {
6857 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.5f, 0.8f, 1.0f));
6858 }
6859
6860 if (ImGui::Button(chainName.c_str(), ImVec2(200, 0))) {
6861 // Construct full path to chain file
6862 std::string fullChainPath = mCurrentStudy->GetPath() + "/chains/" + chainFiles[i];
6863 LoadChain(fullChainPath);
6864 printf("Loaded chain: %s\n", chainName.c_str());
6865 }
6866
6867 if (isSelected) {
6868 ImGui::PopStyleColor();
6869 }
6870 }
6871 }
6872
6873 ImGui::Spacing();
6874 ImGui::Separator();
6875 ImGui::Spacing();
6876
6877 // Run button
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));
6881
6882 bool canRun = mCurrentChain && !mCurrentChain->GetItems().empty() && !mRunningChain;
6883 if (!canRun) {
6884 ImGui::BeginDisabled();
6885 }
6886
6887 const char* buttonLabel = mRunningChain ? "Running..." : "Run Selected Chain";
6888 if (ImGui::Button(buttonLabel, ImVec2(-1, 50))) {
6889 RunChain();
6890 }
6891
6892 if (!canRun) {
6893 ImGui::EndDisabled();
6894 }
6895
6896 ImGui::PopStyleColor(3);
6897}
6898
6899void LauncherUI::RenderQuickLaunchTab()
6900{
6901 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Quick Launch");
6902 ImGui::Separator();
6903 ImGui::Spacing();
6904
6905 // Two-column layout for top section: Instructions + Directory (left) | Recent tests (right)
6906 float topLeftWidth = ImGui::GetContentRegionAvail().x * 0.5f;
6907
6908 // Left column: Instructions + Directory (compact - 4 lines)
6909 ImGui::BeginChild("InstructionsColumn", ImVec2(topLeftWidth, 100), false);
6910
6911 ImGui::Text("Browse and run .pbl scripts.");
6912 ImGui::Spacing();
6913 ImGui::Separator();
6914 ImGui::Spacing();
6915
6916 // Current directory display (read-only)
6917 ImGui::Text("Directory:");
6918 ImGui::PushItemWidth(-100); // Leave room for browse button
6919 ImGui::InputText("##QuickLaunchDir", &mQuickLaunchDirectory[0], 512, ImGuiInputTextFlags_ReadOnly);
6920 ImGui::PopItemWidth();
6921 ImGui::SameLine();
6922 if (ImGui::Button("Browse...", ImVec2(90, 0))) {
6923 std::string dir = OpenDirectoryDialog("Select Directory for Quick Launch");
6924 if (!dir.empty()) {
6925 mQuickLaunchDirectory = dir;
6926 mQuickLaunchSelectedFile = -1;
6927 mQuickLaunchPath[0] = '\0';
6928 }
6929 }
6930
6931 ImGui::EndChild();
6932
6933 ImGui::SameLine();
6934
6935 // Right column: Recent tests (compact - 4 lines)
6936 ImGui::BeginChild("RecentTestsColumn", ImVec2(0, 100), false);
6937
6938 const std::vector<RecentExperiment>& recent = mConfig->GetRecentExperiments();
6939 if (!recent.empty()) {
6940 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Recent Tests:");
6941 ImGui::Spacing();
6942
6943 ImGui::BeginChild("RecentList", ImVec2(0, 0), true);
6944
6945 for (size_t i = 0; i < recent.size(); i++) {
6946 const auto& exp = recent[i];
6947 // Use index as unique ID to handle duplicate names
6948 ImGui::PushID(static_cast<int>(i));
6949
6950 // Show just the name, with timestamp as tooltip
6951 if (ImGui::Selectable(exp.name.c_str())) {
6952 // Set the quick launch path to this experiment
6953 std::strncpy(mQuickLaunchPath, exp.path.c_str(), sizeof(mQuickLaunchPath) - 1);
6954 mQuickLaunchPath[sizeof(mQuickLaunchPath) - 1] = '\0';
6955
6956 // Update directory to parent of selected file
6957 fs::path filePath(exp.path);
6958 mQuickLaunchDirectory = filePath.parent_path().string();
6959 }
6960
6961 if (ImGui::IsItemHovered()) {
6962 // Format timestamp
6963 char timeBuf[64];
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());
6967 }
6968
6969 ImGui::PopID();
6970 }
6971
6972 ImGui::EndChild();
6973 } else {
6974 ImGui::TextDisabled("No recent tests");
6975 }
6976
6977 ImGui::EndChild();
6978
6979 ImGui::Spacing();
6980 ImGui::Separator();
6981 ImGui::Spacing();
6982
6983 // File list (left) and configuration (right)
6984 float leftWidth = ImGui::GetContentRegionAvail().x * 0.5f;
6985
6986 // Left: File browser (compact)
6987 ImGui::BeginChild("QuickLaunchFiles", ImVec2(leftWidth, 170), true);
6988 ImGui::TextColored(ImVec4(0.7f, 0.7f, 1.0f, 1.0f), "PEBL Scripts");
6989 ImGui::Separator();
6990
6991 // Scan current directory for directories and .pbl files
6992 std::vector<std::string> directories;
6993 std::vector<std::string> pblFiles;
6994
6995 try {
6996 // Always add ".." for parent directory navigation
6997 directories.push_back("..");
6998
6999 for (const auto& entry : fs::directory_iterator(mQuickLaunchDirectory)) {
7000 std::string name = entry.path().filename().string();
7001
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);
7006 }
7007 }
7008
7009 std::sort(directories.begin(), directories.end());
7010 std::sort(pblFiles.begin(), pblFiles.end());
7011 } catch (const fs::filesystem_error&) {
7012 // Directory doesn't exist or can't be read
7013 }
7014
7015 // If mQuickLaunchPath is set, find its index in the file list
7016 static std::string lastProcessedPath;
7017 if (strlen(mQuickLaunchPath) > 0 && std::string(mQuickLaunchPath) != lastProcessedPath) {
7018 std::string targetFile = mQuickLaunchPath;
7019 lastProcessedPath = targetFile; // Remember we processed this path
7020
7021 // Extract just the filename from the full path
7022 targetFile = fs::path(targetFile).filename().string();
7023 // Find index in pblFiles
7024 for (int i = 0; i < (int)pblFiles.size(); i++) {
7025 if (pblFiles[i] == targetFile) {
7026 mQuickLaunchSelectedFile = i;
7027 break;
7028 }
7029 }
7030 }
7031
7032 // Display directories first with folder icon
7033 int dirIndex = 0;
7034 for (const auto& dir : directories) {
7035 std::string displayName = (dir == "..") ? "[UP] .." : "[DIR] " + dir;
7036 bool isParentDir = (dir == "..");
7037
7038 if (ImGui::Selectable(displayName.c_str(), false, 0, ImVec2(ImGui::GetContentRegionAvail().x - 60, 0))) {
7039 // Navigate into directory
7040 if (isParentDir) {
7041 // Go up one level using fs::path for cross-platform support
7042 fs::path currentPath(mQuickLaunchDirectory);
7043 fs::path parentPath = currentPath.parent_path();
7044 if (!parentPath.empty() && parentPath != currentPath) {
7045 mQuickLaunchDirectory = parentPath.string();
7046 }
7047 } else {
7048 // Navigate into subdirectory using fs::path
7049 mQuickLaunchDirectory = (fs::path(mQuickLaunchDirectory) / dir).string();
7050 }
7051 mQuickLaunchSelectedFile = -1;
7052 mQuickLaunchPath[0] = '\0';
7053 }
7054
7055 // Add Open button for actual directories (not "..")
7056 if (!isParentDir) {
7057 ImGui::SameLine();
7058 ImGui::PushID(1000 + dirIndex); // Use offset to avoid ID collision with files
7059 if (ImGui::SmallButton("Open")) {
7060 std::string fullPath = (fs::path(mQuickLaunchDirectory) / dir).string();
7061 OpenDirectoryInFileBrowser(fullPath);
7062 }
7063 ImGui::PopID();
7064 if (ImGui::IsItemHovered()) {
7065 ImGui::SetTooltip("Open directory in file browser");
7066 }
7067 }
7068
7069 dirIndex++;
7070 }
7071
7072 // Display .pbl files
7073 int fileIndex = 0;
7074 for (const auto& file : pblFiles) {
7075 bool is_selected = (mQuickLaunchSelectedFile == fileIndex);
7076
7077 // Make filename selectable
7078 if (ImGui::Selectable(file.c_str(), is_selected, 0, ImVec2(ImGui::GetContentRegionAvail().x - 60, 0))) {
7079 // Set as selected and update path
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';
7084 }
7085
7086 // Add Edit button on the same line
7087 ImGui::SameLine();
7088 ImGui::PushID(fileIndex);
7089 if (ImGui::SmallButton("Edit")) {
7090 std::string fullPath = (fs::path(mQuickLaunchDirectory) / file).string();
7091
7092 // Open in code editor
7093 std::ifstream fileStream(fullPath);
7094 if (fileStream.is_open()) {
7095 std::stringstream buffer;
7096 buffer << fileStream.rdbuf();
7097 fileStream.close();
7098
7099 mCodeEditorFilePath = fullPath;
7100 mCodeEditor.SetText(buffer.str());
7101 mShowCodeEditor = true;
7102 } else {
7103 printf("Error: Could not open file for editing: %s\n", fullPath.c_str());
7104 }
7105 }
7106 ImGui::PopID();
7107 if (ImGui::IsItemHovered()) {
7108 ImGui::SetTooltip("Open file in code editor");
7109 }
7110
7111 fileIndex++;
7112 }
7113
7114 if (directories.empty() && pblFiles.empty()) {
7115 ImGui::TextDisabled("Empty directory");
7116 }
7117
7118 ImGui::EndChild();
7119
7120 ImGui::SameLine();
7121
7122 // Right: Configuration (compact)
7123 ImGui::BeginChild("QuickLaunchConfig", ImVec2(0, 170), true);
7124 ImGui::TextColored(ImVec4(0.7f, 0.7f, 1.0f, 1.0f), "Configuration");
7125 ImGui::Separator();
7126 ImGui::Spacing();
7127
7128 ImGui::Text("Subject Code:");
7129 ImGui::PushItemWidth(-1);
7130 ImGui::InputText("##QLSubject", mSubjectCode, sizeof(mSubjectCode));
7131 ImGui::PopItemWidth();
7132
7133 ImGui::Spacing();
7134
7135 ImGui::Text("Language:");
7136 ImGui::PushItemWidth(-1);
7137 ImGui::InputText("##QLLanguage", mLanguageCode, sizeof(mLanguageCode));
7138 ImGui::PopItemWidth();
7139
7140 ImGui::Spacing();
7141
7142 ImGui::Text("Parameter File (optional):");
7143 ImGui::PushItemWidth(-80);
7144 ImGui::InputText("##QLParams", mQuickLaunchParamFile, sizeof(mQuickLaunchParamFile));
7145 ImGui::PopItemWidth();
7146 ImGui::SameLine();
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';
7152 }
7153 }
7154
7155 ImGui::Spacing();
7156
7157 ImGui::Checkbox("Fullscreen", &mFullscreen);
7158
7159 ImGui::EndChild();
7160
7161 ImGui::Spacing();
7162
7163 // Run button
7164 bool canRun = (mQuickLaunchPath[0] != '\0');
7165 if (!canRun) {
7166 ImGui::BeginDisabled();
7167 }
7168
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));
7172
7173 if (ImGui::Button("Run Script", ImVec2(-1, 50))) {
7174 // Build extra arguments (subject code and language are handled by RunExperiment)
7175 std::vector<std::string> args;
7176 if (strlen(mQuickLaunchParamFile) > 0) {
7177 args.push_back("--pfile");
7178 args.push_back(mQuickLaunchParamFile);
7179 }
7180
7181 // Clean up any previous experiment
7182 if (mRunningExperiment) {
7183 delete mRunningExperiment;
7184 }
7185
7186 // Run the experiment
7187 mRunningExperiment = new ExperimentRunner(mConfig);
7188 bool success = mRunningExperiment->RunExperiment(mQuickLaunchPath, args,
7189 mSubjectCode, mLanguageCode,
7190 mFullscreen);
7191 if (success) {
7192 // Add to recent experiments list
7193 std::string scriptPath = mQuickLaunchPath;
7194 std::string scriptName = fs::path(scriptPath).filename().string();
7195 mConfig->AddRecentExperiment(scriptPath, scriptName);
7196 mShowStderr = false; // Start showing stdout
7197 } else {
7198 printf("Failed to run: %s\n", mQuickLaunchPath);
7199 }
7200 }
7201
7202 ImGui::PopStyleColor(3);
7203
7204 if (!canRun) {
7205 ImGui::EndDisabled();
7206 }
7207}
7208
7209void LauncherUI::RenderOutputPanel()
7210{
7211 ImGui::Separator();
7212
7213 // Header bar - always visible
7214 // Expand/collapse toggle with arrow indicator
7215 const char* toggleLabel = mOutputExpanded ? "v Output" : "> Output";
7216 if (ImGui::Button(toggleLabel, ImVec2(100, 0))) {
7217 mOutputExpanded = !mOutputExpanded;
7218 }
7219 if (ImGui::IsItemHovered()) {
7220 ImGui::SetTooltip(mOutputExpanded ? "Collapse output panel" : "Expand output panel");
7221 }
7222
7223 if (!mOutputExpanded) {
7224 // Collapsed - just show a brief status on the same line
7225 ImGui::SameLine();
7226 if (mRunningExperiment && mRunningExperiment->IsRunning()) {
7227 ImGui::TextDisabled("(running...)");
7228 } else if (mRunningExperiment || !mChainAccumulatedStdout.empty() || !mChainAccumulatedStderr.empty()) {
7229 ImGui::TextDisabled("(click to expand)");
7230 }
7231 return;
7232 }
7233
7234 // Expanded - show stdout/stderr toggle and controls
7235 ImGui::SameLine();
7236 if (ImGui::RadioButton("stdout##bottom", !mShowStderr)) {
7237 mShowStderr = false;
7238 }
7239 ImGui::SameLine();
7240 if (ImGui::RadioButton("stderr##bottom", mShowStderr)) {
7241 mShowStderr = true;
7242 }
7243
7244 // "Open in Editor" button
7245 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 135);
7246 if (ImGui::Button("Open in Editor##bottom", ImVec2(130, 0))) {
7247 std::string output;
7248 if (mRunningExperiment) {
7249 if (mRunningChain) {
7250 output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7251 const std::string& currentOutput = mShowStderr ? mRunningExperiment->GetStderr() :
7252 mRunningExperiment->GetStdout();
7253 output += currentOutput;
7254 } else {
7255 output = mShowStderr ? mRunningExperiment->GetStderr() :
7256 mRunningExperiment->GetStdout();
7257 }
7258 } else if (!mChainAccumulatedStdout.empty() || !mChainAccumulatedStderr.empty()) {
7259 output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7260 }
7261
7262 if (!output.empty()) {
7263 mCodeEditor.SetText(output);
7264 mCodeEditorFilePath = "";
7265 mShowCodeEditor = true;
7266 }
7267 }
7268
7269 // Scrollable output window - fills remaining space
7270 ImGui::BeginChild("BottomOutputPanel", ImVec2(0, 0), true, ImGuiWindowFlags_HorizontalScrollbar);
7271
7272 if (mRunningExperiment) {
7273 std::string output;
7274
7275 // If running a chain, show accumulated output from all items plus current item
7276 if (mRunningChain) {
7277 output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7278 const std::string& currentOutput = mShowStderr ? mRunningExperiment->GetStderr() :
7279 mRunningExperiment->GetStdout();
7280 output += currentOutput;
7281 } else {
7282 output = mShowStderr ? mRunningExperiment->GetStderr() :
7283 mRunningExperiment->GetStdout();
7284 }
7285
7286 if (!output.empty()) {
7287 ImGui::InputTextMultiline("##bottomoutput",
7288 const_cast<char*>(output.c_str()),
7289 output.size() + 1,
7290 ImVec2(-1, -1),
7291 ImGuiInputTextFlags_ReadOnly);
7292 } else if (mRunningExperiment->IsRunning()) {
7293 ImGui::TextDisabled("Waiting for output...");
7294 } else {
7295 ImGui::TextDisabled("No output captured");
7296 }
7297 } else if (!mChainAccumulatedStdout.empty() || !mChainAccumulatedStderr.empty()) {
7298 // Chain completed - show final accumulated output
7299 const std::string& output = mShowStderr ? mChainAccumulatedStderr : mChainAccumulatedStdout;
7300 ImGui::InputTextMultiline("##bottomoutput",
7301 const_cast<char*>(output.c_str()),
7302 output.size() + 1,
7303 ImVec2(-1, -1),
7304 ImGuiInputTextFlags_ReadOnly);
7305 } else {
7306 ImGui::TextDisabled("Run a test or chain to see output here");
7307 }
7308
7309 ImGui::EndChild();
7310}
7311
7312void LauncherUI::ShowNewStudyDialog()
7313{
7314 ImGui::OpenPopup("New Study");
7315
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);
7319
7320 if (ImGui::BeginPopupModal("New Study", &mShowNewStudyDialog, 0))
7321 {
7322 ImGui::Text("Create a new study");
7323 ImGui::Separator();
7324 ImGui::Spacing();
7325
7326 ImGui::Text("Study Name:");
7327 ImGui::PushItemWidth(-1);
7328 if (ImGui::IsWindowAppearing()) {
7329 ImGui::SetKeyboardFocusHere();
7330 }
7331 ImGui::InputText("##StudyName", mNewStudyName, sizeof(mNewStudyName));
7332 ImGui::PopItemWidth();
7333
7334 ImGui::Spacing();
7335
7336 ImGui::Text("Description:");
7337 ImGui::PushItemWidth(-1);
7338 ImGui::InputTextMultiline("##StudyDesc", mNewStudyDescription, sizeof(mNewStudyDescription),
7339 ImVec2(-1, 100));
7340 ImGui::PopItemWidth();
7341
7342 ImGui::Spacing();
7343
7344 ImGui::Text("Author:");
7345 ImGui::PushItemWidth(-1);
7346 ImGui::InputText("##StudyAuthor", mNewStudyAuthor, sizeof(mNewStudyAuthor));
7347 ImGui::PopItemWidth();
7348
7349 ImGui::Spacing();
7350 ImGui::Separator();
7351 ImGui::Spacing();
7352
7353 if (ImGui::Button("Create", ImVec2(120, 0))) {
7354 if (strlen(mNewStudyName) > 0) {
7355 // Create new study
7356 std::string studyPath = mWorkspace->GetStudiesPath() + "/" + mNewStudyName;
7357 mCurrentStudy = Study::CreateNew(studyPath, mNewStudyName, mNewStudyAuthor);
7358
7359 if (mCurrentStudy) {
7360 mCurrentStudy->SetDescription(mNewStudyDescription);
7361 mCurrentStudy->Save();
7362 printf("Created new study: %s\n", mNewStudyName);
7363
7364 // Save selected study to config
7365 mConfig->SetCurrentStudyPath(studyPath);
7366 mConfig->SaveConfig();
7367
7368 // Auto-load Main chain (created by Study::CreateNew)
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");
7373 }
7374 }
7375
7376 // Clear form
7377 mNewStudyName[0] = '\0';
7378 mNewStudyDescription[0] = '\0';
7379 mNewStudyAuthor[0] = '\0';
7380
7381 mShowNewStudyDialog = false;
7382 ImGui::CloseCurrentPopup();
7383 }
7384 }
7385
7386 ImGui::SameLine();
7387
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();
7394 }
7395
7396 ImGui::EndPopup();
7397 }
7398}
7399
7400void LauncherUI::ShowNewChainDialog()
7401{
7402 ImGui::OpenPopup("New Chain");
7403
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);
7407
7408 if (ImGui::BeginPopupModal("New Chain", &mShowNewChainDialog, 0))
7409 {
7410 ImGui::Text("Create a new chain");
7411 ImGui::Separator();
7412 ImGui::Spacing();
7413
7414 ImGui::Text("Chain Name:");
7415 ImGui::PushItemWidth(-1);
7416 if (ImGui::IsWindowAppearing()) {
7417 ImGui::SetKeyboardFocusHere();
7418 }
7419 ImGui::InputText("##ChainName", mNewChainName, sizeof(mNewChainName));
7420 ImGui::PopItemWidth();
7421
7422 ImGui::Spacing();
7423
7424 ImGui::Text("Description (optional):");
7425 ImGui::PushItemWidth(-1);
7426 ImGui::InputText("##ChainDesc", mNewChainDescription, sizeof(mNewChainDescription));
7427 ImGui::PopItemWidth();
7428
7429 ImGui::Spacing();
7430 ImGui::Separator();
7431 ImGui::Spacing();
7432
7433 if (ImGui::Button("Create", ImVec2(120, 0))) {
7434 if (strlen(mNewChainName) > 0) {
7435 // Create chain file path
7436 std::string studyPath = mCurrentStudy->GetPath();
7437 std::string chainPath = studyPath + "/chains/" + std::string(mNewChainName) + ".json";
7438
7439 // Create new chain
7440 mCurrentChain = Chain::CreateNew(chainPath, mNewChainName, mNewChainDescription);
7441
7442 if (mCurrentChain) {
7443 mCurrentChain->Save();
7444 printf("Created new chain: %s\n", mNewChainName);
7445
7446 // Save selected chain to config
7447 std::string chainFileName = std::string(mNewChainName) + ".json";
7448 mConfig->SetCurrentChainName(chainFileName);
7449 mConfig->SaveConfig();
7450 }
7451
7452 // Clear form
7453 mNewChainName[0] = '\0';
7454 mNewChainDescription[0] = '\0';
7455
7456 mShowNewChainDialog = false;
7457 ImGui::CloseCurrentPopup();
7458 }
7459 }
7460
7461 ImGui::SameLine();
7462
7463 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
7464 mNewChainName[0] = '\0';
7465 mNewChainDescription[0] = '\0';
7466 mShowNewChainDialog = false;
7467 ImGui::CloseCurrentPopup();
7468 }
7469
7470 ImGui::EndPopup();
7471 }
7472}
7473
7474void LauncherUI::ShowStudySettingsDialog()
7475{
7476 if (!mCurrentStudy) {
7477 mShowStudySettingsDialog = false;
7478 return;
7479 }
7480
7481 ImGui::OpenPopup("Study Settings");
7482
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);
7486
7487 if (ImGui::BeginPopupModal("Study Settings", &mShowStudySettingsDialog, 0))
7488 {
7489 ImGui::Text("Study: %s", mCurrentStudy->GetName().c_str());
7490 ImGui::Separator();
7491 ImGui::Spacing();
7492
7493 // Study metadata editing
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;
7500
7501 if (!initialized) {
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);
7507 initialized = true;
7508 }
7509
7510 ImGui::Text("Name:");
7511 ImGui::PushItemWidth(-1);
7512 if (ImGui::IsWindowAppearing()) {
7513 ImGui::SetKeyboardFocusHere();
7514 }
7515 ImGui::InputText("##Name", nameBuffer, sizeof(nameBuffer));
7516 ImGui::PopItemWidth();
7517
7518 ImGui::Spacing();
7519
7520 ImGui::Text("Description:");
7521 ImGui::PushItemWidth(-1);
7522 ImGui::InputTextMultiline("##Desc", descBuffer, sizeof(descBuffer), ImVec2(-1, 150));
7523 ImGui::PopItemWidth();
7524
7525 ImGui::Spacing();
7526
7527 ImGui::Text("Author:");
7528 ImGui::PushItemWidth(-1);
7529 ImGui::InputText("##Author", authorBuffer, sizeof(authorBuffer));
7530 ImGui::PopItemWidth();
7531
7532 ImGui::Spacing();
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());
7536
7537 ImGui::Spacing();
7538 ImGui::Separator();
7539 ImGui::Spacing();
7540
7541 // Upload configuration
7542 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Data Upload Configuration");
7543 ImGui::Spacing();
7544 ImGui::TextWrapped("Configure automatic data upload to PEBLOnlinePlatform or compatible server:");
7545 ImGui::Spacing();
7546
7547 ImGui::Text("Upload Server URL:");
7548 ImGui::SameLine();
7549 if (ImGui::SmallButton("?##ServerHelp")) {
7550 ImGui::SetTooltip("Server URL (e.g., https://peblhub.online or http://localhost:8080)");
7551 }
7552 ImGui::PushItemWidth(-1);
7553 ImGui::InputText("##UploadServer", uploadServerBuffer, sizeof(uploadServerBuffer));
7554 ImGui::PopItemWidth();
7555
7556 ImGui::Spacing();
7557
7558 ImGui::Text("Study Token:");
7559 ImGui::SameLine();
7560 if (ImGui::SmallButton("?##TokenHelp")) {
7561 ImGui::SetTooltip("Study token from PEBLOnlinePlatform (e.g., STUDY_ABC123...)");
7562 }
7563 ImGui::PushItemWidth(-1);
7564 ImGui::InputText("##StudyToken", studyTokenBuffer, sizeof(studyTokenBuffer));
7565 ImGui::PopItemWidth();
7566
7567 ImGui::Spacing();
7568
7569 // Button to load from upload.json file
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;
7576 try {
7577 file >> uploadConfig;
7578
7579 // Extract server URL from host, port, and page
7580 std::string host = uploadConfig.value("host", "");
7581 int port = uploadConfig.value("port", 443);
7582
7583 // Construct server URL
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);
7588 }
7589
7590 // Get token
7591 std::string token = uploadConfig.value("token", "");
7592
7593 // Populate fields
7594 std::strncpy(uploadServerBuffer, serverUrl.c_str(), sizeof(uploadServerBuffer) - 1);
7595 uploadServerBuffer[sizeof(uploadServerBuffer) - 1] = '\0';
7596
7597 std::strncpy(studyTokenBuffer, token.c_str(), sizeof(studyTokenBuffer) - 1);
7598 studyTokenBuffer[sizeof(studyTokenBuffer) - 1] = '\0';
7599
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());
7603 }
7604 file.close();
7605 } else {
7606 printf("Failed to open upload.json file\n");
7607 }
7608 }
7609 }
7610
7611 ImGui::Spacing();
7612 ImGui::Separator();
7613 ImGui::Spacing();
7614
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();
7622
7623 initialized = false;
7624 mShowStudySettingsDialog = false;
7625 ImGui::CloseCurrentPopup();
7626 }
7627
7628 ImGui::SameLine();
7629
7630 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
7631 initialized = false;
7632 mShowStudySettingsDialog = false;
7633 ImGui::CloseCurrentPopup();
7634 }
7635
7636 ImGui::EndPopup();
7637 }
7638}
7639
7640void LauncherUI::ShowFirstRunDialog()
7641{
7642 ImGui::OpenPopup("Welcome to PEBL!");
7643
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);
7647
7648 if (ImGui::BeginPopupModal("Welcome to PEBL!", nullptr, 0))
7649 {
7650 ImGui::TextWrapped("Welcome! This appears to be your first time running PEBL %s.", PEBL_VERSION);
7651 ImGui::Spacing();
7652 ImGui::Separator();
7653 ImGui::Spacing();
7654
7655 ImGui::TextWrapped("PEBL will create a workspace directory at:");
7656 ImGui::Spacing();
7657 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), " %s", mWorkspace->GetWorkspacePath().c_str());
7658 ImGui::Spacing();
7659
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");
7667
7668 ImGui::Spacing();
7669 ImGui::Separator();
7670 ImGui::Spacing();
7671
7672 ImGui::TextWrapped("Resources will be copied from the installation. This may take a minute on first run.");
7673
7674 ImGui::Spacing();
7675 ImGui::Separator();
7676 ImGui::Spacing();
7677
7678 // Center the button
7679 float buttonWidth = 200.0f;
7680 float windowWidth = ImGui::GetContentRegionAvail().x;
7681 ImGui::SetCursorPosX((windowWidth - buttonWidth) * 0.5f);
7682
7683 if (ImGui::Button("Continue", ImVec2(buttonWidth, 40))) {
7684 // Initialize workspace (creates directories)
7685 if (!mWorkspace->Initialize()) {
7686 printf("ERROR: Failed to initialize workspace\n");
7687 } else {
7688 // Find installation path using BinReloc (AppImage-compatible)
7689 std::string installPath;
7690
7691 #ifdef ENABLE_BINRELOC
7692 // Use BinReloc to find installation prefix
7693 BrInitError error;
7694 if (br_init(&error) != 0) {
7695 char* prefix = br_find_prefix(PREFIX);
7696 if (prefix) {
7697 installPath = std::string(prefix);
7698 free(prefix);
7699 printf("BinReloc found installation at: %s\n", installPath.c_str());
7700 }
7701 }
7702 #endif
7703
7704 #ifndef _WIN32
7705 // Fallback 1: Try to find pebl2 executable via /proc/self/exe (Linux only)
7706 if (installPath.empty()) {
7707 char exePath[1024];
7708 ssize_t len = readlink("/proc/self/exe", exePath, sizeof(exePath) - 1);
7709 if (len != -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);
7715 // If we're in bin/, go up one level
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);
7720 }
7721 }
7722 printf("/proc/self/exe derived installation at: %s\n", installPath.c_str());
7723 }
7724 }
7725 }
7726 #endif
7727
7728 // Fallback 2: Use battery path from config
7729 if (installPath.empty()) {
7730 std::string batteryPath = mConfig->GetBatteryPath();
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());
7736 }
7737 }
7738 }
7739
7740 // Copy resources if we found installation
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);
7745 } else {
7746 printf("WARNING: Could not determine installation path - resources not copied\n");
7747 }
7748 }
7749
7750 mShowFirstRunDialog = false;
7751 ImGui::CloseCurrentPopup();
7752 }
7753
7754 ImGui::EndPopup();
7755 }
7756}
7757
7758void LauncherUI::ShowGettingStartedDialog()
7759{
7760 ImGui::OpenPopup("Create a Study to Get Started");
7761
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);
7765
7766 if (ImGui::BeginPopupModal("Create a Study to Get Started", nullptr, ImGuiWindowFlags_NoResize))
7767 {
7768 ImGui::Spacing();
7769 ImGui::TextWrapped("Welcome! To begin using PEBL, you need to create a study.");
7770 ImGui::Spacing();
7771 ImGui::Separator();
7772 ImGui::Spacing();
7773
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");
7778 ImGui::Spacing();
7779 ImGui::Separator();
7780 ImGui::Spacing();
7781
7782 // Center the buttons
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);
7788
7789 // New Study button (green)
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));
7793
7794 if (ImGui::Button("New Study", ImVec2(buttonWidth, 40))) {
7795 mShowGettingStartedDialog = false;
7796 mShowNewStudyDialog = true;
7797 ImGui::CloseCurrentPopup();
7798 }
7799
7800 ImGui::PopStyleColor(3);
7801
7802 ImGui::SameLine(0, spacing);
7803
7804 // Browse Tests button
7805 if (ImGui::Button("Browse Tests", ImVec2(buttonWidth, 40))) {
7806 mShowGettingStartedDialog = false;
7807 mTopLevelTab = 0; // Switch to Manage Studies tab
7808 ImGui::CloseCurrentPopup();
7809 }
7810
7811 if (ImGui::IsItemHovered()) {
7812 ImGui::SetTooltip("Explore available battery tests before creating a study");
7813 }
7814
7815 ImGui::EndPopup();
7816 }
7817}
7818
7819void LauncherUI::ShowDuplicateSubjectWarning()
7820{
7821 ImGui::OpenPopup("Duplicate Subject Code");
7822
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);
7826
7827 if (ImGui::BeginPopupModal("Duplicate Subject Code", &mShowDuplicateSubjectWarning, 0))
7828 {
7829 ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f), "âš  Warning: Subject Code Already Used");
7830 ImGui::Separator();
7831 ImGui::Spacing();
7832
7833 ImGui::TextWrapped("The subject code '%s' has already been used in this study.", mSubjectCode);
7834 ImGui::Spacing();
7835 ImGui::TextWrapped("Running the chain again with this code may overwrite existing data files!");
7836 ImGui::Spacing();
7837 ImGui::Separator();
7838 ImGui::Spacing();
7839
7840 // Show existing codes in a scrollable region
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());
7846 } else {
7847 ImGui::Text("• %s", code.c_str());
7848 }
7849 }
7850 ImGui::EndChild();
7851
7852 ImGui::Spacing();
7853 ImGui::Separator();
7854 ImGui::Spacing();
7855
7856 // Buttons
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();
7863 // Actually run the chain now
7864 RunChainConfirmed();
7865 }
7866 ImGui::PopStyleColor(3);
7867
7868 ImGui::SameLine();
7869
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();
7876 }
7877 ImGui::PopStyleColor(3);
7878
7879 ImGui::EndPopup();
7880 }
7881}
7882
7883void LauncherUI::ShowEditParticipantCodeDialog()
7884{
7885 ImGui::OpenPopup("Edit Participant Code");
7886
7887 // Center dialog
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);
7891
7892 if (ImGui::BeginPopupModal("Edit Participant Code", &mShowEditParticipantCodeDialog, 0))
7893 {
7894 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Edit Participant Code Components");
7895 ImGui::Separator();
7896 ImGui::Spacing();
7897
7898 ImGui::TextWrapped("The participant code is generated from: STUDYCODE_COUNTER");
7899 ImGui::TextWrapped("Edit the study code (4 characters) and counter separately below.");
7900 ImGui::Spacing();
7901
7902 // Study code (4 characters)
7903 ImGui::Text("Study Code (4 chars):");
7904 ImGui::SameLine();
7905 ImGui::PushItemWidth(100);
7906 if (ImGui::IsWindowAppearing()) {
7907 ImGui::SetKeyboardFocusHere();
7908 }
7909 ImGui::InputText("##StudyCode", mStudyCode, sizeof(mStudyCode));
7910 ImGui::PopItemWidth();
7911
7912 ImGui::Spacing();
7913
7914 // Participant counter
7915 if (mCurrentChain) {
7916 int counter = mCurrentChain->GetParticipantCounter();
7917 ImGui::Text("Counter:");
7918 ImGui::SameLine();
7919 ImGui::PushItemWidth(100);
7920 if (ImGui::InputInt("##Counter", &counter)) {
7921 if (counter < 1) counter = 1;
7922 mCurrentChain->SetParticipantCounter(counter);
7923 mCurrentChain->Save();
7924 }
7925 ImGui::PopItemWidth();
7926
7927 ImGui::Spacing();
7928
7929 // Preview
7930 std::string preview = std::string(mStudyCode) + "_" + std::to_string(counter);
7931 ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "Preview:");
7932 ImGui::SameLine();
7933 ImGui::Text("%s", preview.c_str());
7934 }
7935
7936 ImGui::Spacing();
7937 ImGui::Separator();
7938 ImGui::Spacing();
7939
7940 // Buttons
7941 if (ImGui::Button("Done", ImVec2(120, 0))) {
7942 mShowEditParticipantCodeDialog = false;
7943 ImGui::CloseCurrentPopup();
7944 }
7945
7946 ImGui::EndPopup();
7947 }
7948}
7949
7950void LauncherUI::ShowCodeEditor()
7951{
7952 ImGui::SetNextWindowSize(ImVec2(1200, 800), ImGuiCond_FirstUseEver);
7953
7954 bool open = true;
7955 if (ImGui::Begin("Code Editor", &open, ImGuiWindowFlags_MenuBar))
7956 {
7957 // Menu bar with file operations
7958 if (ImGui::BeginMenuBar())
7959 {
7960 if (ImGui::BeginMenu("File"))
7961 {
7962 if (ImGui::MenuItem("Save", "Ctrl+S")) {
7963 // Save file
7964 std::string text = mCodeEditor.GetText();
7965 std::ofstream outFile(mCodeEditorFilePath);
7966 if (outFile.is_open()) {
7967 outFile << text;
7968 outFile.close();
7969 printf("Saved file: %s\n", mCodeEditorFilePath.c_str());
7970 } else {
7971 printf("Error: Could not save file: %s\n", mCodeEditorFilePath.c_str());
7972 }
7973 }
7974
7975 if (ImGui::MenuItem("Open in External Editor")) {
7976 // Use external editor setting from config
7977 std::string editorCmd = mConfig->GetExternalEditor();
7978 std::string command;
7979
7980#ifdef _WIN32
7981 if (editorCmd == "start") {
7982 command = "start \"\" \"" + mCodeEditorFilePath + "\"";
7983 } else {
7984 command = editorCmd + " \"" + mCodeEditorFilePath + "\"";
7985 }
7986#else
7987 command = editorCmd + " \"" + mCodeEditorFilePath + "\" &";
7988#endif
7989
7990 printf("Opening in external editor: %s\n", command.c_str());
7991 int result = system(command.c_str());
7992 if (result != 0) {
7993 printf("Warning: External editor command may have failed\n");
7994 }
7995 }
7996
7997 ImGui::Separator();
7998
7999 if (ImGui::MenuItem("Close")) {
8000 open = false;
8001 }
8002
8003 ImGui::EndMenu();
8004 }
8005
8006 if (ImGui::BeginMenu("Edit"))
8007 {
8008 bool ro = mCodeEditor.IsReadOnly();
8009 if (ImGui::MenuItem("Read-only mode", nullptr, &ro))
8010 mCodeEditor.SetReadOnly(ro);
8011 ImGui::Separator();
8012
8013 if (ImGui::MenuItem("Undo", "Ctrl+Z", nullptr, !ro && mCodeEditor.CanUndo()))
8014 mCodeEditor.Undo();
8015 if (ImGui::MenuItem("Redo", "Ctrl+Y", nullptr, !ro && mCodeEditor.CanRedo()))
8016 mCodeEditor.Redo();
8017
8018 ImGui::Separator();
8019
8020 if (ImGui::MenuItem("Copy", "Ctrl+C", nullptr, mCodeEditor.HasSelection()))
8021 mCodeEditor.Copy();
8022 if (ImGui::MenuItem("Cut", "Ctrl+X", nullptr, !ro && mCodeEditor.HasSelection()))
8023 mCodeEditor.Cut();
8024 if (ImGui::MenuItem("Delete", "Del", nullptr, !ro && mCodeEditor.HasSelection()))
8025 mCodeEditor.Delete();
8026 if (ImGui::MenuItem("Paste", "Ctrl+V", nullptr, !ro && ImGui::GetClipboardText() != nullptr))
8027 mCodeEditor.Paste();
8028
8029 ImGui::Separator();
8030
8031 if (ImGui::MenuItem("Select all", nullptr, nullptr))
8033
8034 ImGui::EndMenu();
8035 }
8036
8037 if (ImGui::BeginMenu("View"))
8038 {
8039 if (ImGui::MenuItem("Dark palette"))
8041 if (ImGui::MenuItem("Light palette"))
8043 if (ImGui::MenuItem("Retro blue palette"))
8045 ImGui::EndMenu();
8046 }
8047 ImGui::EndMenuBar();
8048 }
8049
8050 // Show filename and stats
8051 ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "File:");
8052 ImGui::SameLine();
8053 ImGui::Text("%s", mCodeEditorFilePath.c_str());
8054
8055 ImGui::SameLine(ImGui::GetWindowWidth() - 300);
8056 auto cpos = mCodeEditor.GetCursorPosition();
8057 ImGui::Text("%d lines | Ln %d, Col %d", mCodeEditor.GetTotalLines(), cpos.mLine + 1, cpos.mColumn + 1);
8058
8059 // Render the text editor
8060 mCodeEditor.Render("##TextEditor");
8061
8062 ImGui::End();
8063 }
8064
8065 if (!open) {
8066 mShowCodeEditor = false;
8067 }
8068}
8069
8070void LauncherUI::ShowTranslationEditorDialog()
8071{
8072 ImGui::OpenPopup("Translation Editor");
8073
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);
8077
8078 if (ImGui::BeginPopupModal("Translation Editor", &mTranslationEditor.show, ImGuiWindowFlags_NoScrollbar))
8079 {
8080 // Build file paths — scale mode uses the scale directory directly;
8081 // test mode uses the test's translations/ subdirectory.
8082 std::string baseName;
8083 std::string translationsDir;
8084 bool isScale = mTranslationEditor.scaleMode;
8085
8086 if (mTranslationEditor.scaleMode) {
8087 // Scale Builder mode: translations live in the scale directory itself
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();
8093 mTranslationEditor.ClearScaleMode();
8094 ImGui::CloseCurrentPopup();
8095 }
8096 ImGui::EndPopup();
8097 return;
8098 }
8099 baseName = mTranslationEditor.scaleCode;
8100 translationsDir = mTranslationEditor.scaleDir;
8101
8102 // Header
8103 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Scale:");
8104 ImGui::SameLine();
8105 ImGui::Text("%s", mTranslationEditor.scaleCode);
8106 } else {
8107 // Test mode (regular battery test)
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();
8114 }
8115 ImGui::EndPopup();
8116 return;
8117 }
8118
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();
8126 }
8127 ImGui::EndPopup();
8128 return;
8129 }
8130
8131 const Test& test = tests[mTranslationEditor.testIndex];
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");
8135
8136 // Header
8137 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Test:");
8138 ImGui::SameLine();
8139 ImGui::Text("%s", test.testName.c_str());
8140 }
8141
8142 // English file: scale builder and OSD-scale tests use {name}.en.json directly;
8143 // traditional tests use {name}.pbl-en.json.
8144 std::string englishFile;
8145 if (isScale) {
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);
8149 } else {
8150 englishFile = (fs::path(translationsDir) / (baseName + ".pbl-en.json")).string();
8151 }
8152
8153 // Scan for available language files
8154 std::vector<std::string> availableLanguages;
8155 availableLanguages.push_back("en"); // English is always available as base
8156
8157 if (fs::exists(translationsDir) && fs::is_directory(translationsDir)) {
8158 std::set<std::string> langSet;
8159 // Scale-code prefix filter: in scale mode only list files for this scale
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();
8165 // In scale mode, skip files that don't belong to this scale
8166 if (!scalePrefix.empty() && filename.find(scalePrefix) != 0) {
8167 continue;
8168 }
8169 std::string lang;
8170 // Try dash-based format: "name.pbl-lang.json"
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) {
8176 // Try dot-based format: "name.lang.json"
8177 size_t lastDot = filename.rfind('.', dotPos - 1);
8178 if (lastDot != std::string::npos) {
8179 lang = filename.substr(lastDot + 1, dotPos - lastDot - 1);
8180 }
8181 }
8182 if (!lang.empty() && lang != "en") {
8183 langSet.insert(lang);
8184 }
8185 }
8186 }
8187 for (const auto& l : langSet) {
8188 availableLanguages.push_back(l);
8189 }
8190 }
8191
8192 // Language selector
8193 ImGui::SameLine(0, 20);
8194 ImGui::Text("Language:");
8195 ImGui::SameLine();
8196 ImGui::PushItemWidth(100);
8197
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';
8205 }
8206 }
8207 // New language option
8208 ImGui::Separator();
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';
8216 newLang[0] = '\0';
8217 ImGui::CloseCurrentPopup();
8218 }
8219 }
8220 ImGui::PopItemWidth();
8221 ImGui::EndCombo();
8222 }
8223 ImGui::PopItemWidth();
8224
8225 // Detect language change and reload
8226 if (strcmp(prevLanguage, mTranslationEditor.language) != 0) {
8227 mTranslationEditor.dataLoaded = false;
8228 mTranslationEditor.selectedKeyIndex = -1;
8229 std::strncpy(prevLanguage, mTranslationEditor.language, sizeof(prevLanguage) - 1);
8230 prevLanguage[sizeof(prevLanguage) - 1] = '\0';
8231 }
8232
8233 // Load translation data if not loaded
8234 if (!mTranslationEditor.dataLoaded && mTranslationEditor.language[0] != '\0') {
8235 mTranslationEditor.Clear();
8236
8237 // First load English as the base
8238 if (fs::exists(englishFile)) {
8239 try {
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>();
8245 mTranslationEditor.targetValues[key] = ""; // Initialize empty
8246 }
8247 } catch (const std::exception& e) {
8248 printf("Error loading English translation file: %s\n", e.what());
8249 }
8250 }
8251
8252 // Then load target language if it exists and is not English
8253 if (std::string(mTranslationEditor.language) != "en") {
8254 std::string targetFile;
8255 if (isScale) {
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;
8259 } else {
8260 targetFile = (fs::path(translationsDir) / (baseName + ".pbl-" + mTranslationEditor.language + ".json")).string();
8261 }
8262 if (fs::exists(targetFile)) {
8263 try {
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>();
8268 // Add any keys that weren't in English file
8269 if (mTranslationEditor.englishValues.find(key) == mTranslationEditor.englishValues.end()) {
8270 mTranslationEditor.keys.push_back(key);
8271 mTranslationEditor.englishValues[key] = "";
8272 }
8273 }
8274 } catch (const std::exception& e) {
8275 printf("Error loading target translation file: %s\n", e.what());
8276 }
8277 }
8278 } else {
8279 // For English, target == english
8280 mTranslationEditor.targetValues = mTranslationEditor.englishValues;
8281 }
8282
8283 mTranslationEditor.dataLoaded = true;
8284 mTranslationEditor.dirty = false;
8285 if (!mTranslationEditor.keys.empty()) {
8286 mTranslationEditor.selectedKeyIndex = 0;
8287 }
8288 }
8289
8290 // Show dirty indicator
8291 if (mTranslationEditor.dirty) {
8292 ImGui::SameLine(0, 20);
8293 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "(unsaved changes)");
8294 }
8295
8296 ImGui::Spacing();
8297 ImGui::Separator();
8298
8299 // Main content area
8300 if (!mTranslationEditor.language[0]) {
8301 ImGui::Spacing();
8302 ImGui::TextWrapped("Select a language to edit translations.");
8303 } else if (mTranslationEditor.keys.empty()) {
8304 ImGui::Spacing();
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);
8308 if (isScale) {
8309 ImGui::Text("%s.en.json", baseName.c_str());
8310 } else {
8311 ImGui::Text("%s.pbl-en.json", baseName.c_str());
8312 }
8313 ImGui::SameLine(0, 0);
8314 ImGui::TextWrapped(" first.");
8315 } else {
8316 // Two-panel layout: key list on left, edit boxes on right
8317 float contentHeight = ImGui::GetContentRegionAvail().y - 40; // Leave room for buttons
8318
8319 // Left panel - key list
8320 ImGui::BeginChild("KeyList", ImVec2(168, contentHeight), true);
8321 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Keys");
8322 ImGui::Separator();
8323 for (size_t i = 0; i < mTranslationEditor.keys.size(); i++) {
8324 const std::string& key = mTranslationEditor.keys[i];
8325 bool isSelected = (mTranslationEditor.selectedKeyIndex == (int)i);
8326
8327 if (ImGui::Selectable(key.c_str(), isSelected)) {
8328 mTranslationEditor.selectedKeyIndex = (int)i;
8329 }
8330 }
8331 ImGui::EndChild();
8332
8333 ImGui::SameLine();
8334
8335 // Right panel - edit boxes
8336 ImGui::BeginChild("EditPanel", ImVec2(0, contentHeight), true);
8337
8338 if (mTranslationEditor.selectedKeyIndex >= 0 && mTranslationEditor.selectedKeyIndex < (int)mTranslationEditor.keys.size()) {
8339 const std::string& selectedKey = mTranslationEditor.keys[mTranslationEditor.selectedKeyIndex];
8340 std::string& englishVal = mTranslationEditor.englishValues[selectedKey];
8341 std::string& targetVal = mTranslationEditor.targetValues[selectedKey];
8342
8343 // Show key name
8344 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Key: %s", selectedKey.c_str());
8345 ImGui::Spacing();
8346
8347 // Calculate available height for text boxes
8348 float availHeight = ImGui::GetContentRegionAvail().y;
8349 float boxHeight = (availHeight - 60) / 2;
8350
8351 // Original value on top (read-only reference with word wrap)
8352 ImGui::Text("Original (reference):");
8353
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();
8361
8362 ImGui::Spacing();
8363
8364 // Editable value on bottom
8365 ImGui::Text("%s (editing):", mTranslationEditor.language);
8366
8367 static char editBuf[8192];
8368 std::strncpy(editBuf, targetVal.c_str(), sizeof(editBuf) - 1);
8369 editBuf[sizeof(editBuf) - 1] = '\0';
8370
8371 if (ImGui::InputTextMultiline("##target", editBuf, sizeof(editBuf), ImVec2(-1, boxHeight), ImGuiInputTextFlags_WordWrap)) {
8372 targetVal = editBuf;
8373 mTranslationEditor.dirty = true;
8374 }
8375 } else {
8376 ImGui::TextDisabled("Select a key from the list to edit");
8377 }
8378
8379 ImGui::EndChild();
8380 }
8381
8382 ImGui::Spacing();
8383
8384 // Buttons
8385 bool canSave = mTranslationEditor.dirty && !mTranslationEditor.keys.empty();
8386 if (!canSave) {
8387 ImGui::BeginDisabled();
8388 }
8389
8390 if (ImGui::Button("Save", ImVec2(100, 0))) {
8391 // Create translations directory if needed
8392 try {
8393 fs::create_directories(translationsDir);
8394 } catch (...) {}
8395
8396 // Build JSON and save
8397 nlohmann::json j;
8398 for (const auto& key : mTranslationEditor.keys) {
8399 j[key] = mTranslationEditor.targetValues[key];
8400 }
8401
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();
8407 } else {
8408 targetFile = (fs::path(translationsDir) / (baseName + ".pbl-" + mTranslationEditor.language + ".json")).string();
8409 }
8410
8411 try {
8412 std::ofstream f(targetFile);
8413 f << j.dump(4); // Pretty print with 4-space indent
8414 f.close();
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());
8419 }
8420 }
8421
8422 if (!canSave) {
8423 ImGui::EndDisabled();
8424 }
8425
8426 ImGui::SameLine();
8427
8428 if (ImGui::Button("Close", ImVec2(100, 0))) {
8429 if (mTranslationEditor.dirty) {
8430 // TODO: Could add a confirmation dialog here
8431 }
8432 mTranslationEditor.show = false;
8433 mTranslationEditor.Clear();
8434 prevLanguage[0] = '\0'; // Reset for next time
8435 ImGui::CloseCurrentPopup();
8436 }
8437
8438 // Add key button
8439 ImGui::SameLine(0, 20);
8440 if (ImGui::Button("+ Add Key", ImVec2(100, 0))) {
8441 ImGui::OpenPopup("Add Key");
8442 }
8443
8444 if (ImGui::BeginPopup("Add Key")) {
8445 static char newKey[64] = "";
8446 ImGui::Text("Key name:");
8447 if (ImGui::IsWindowAppearing()) {
8448 ImGui::SetKeyboardFocusHere();
8449 }
8450 if (ImGui::InputText("##newkey", newKey, sizeof(newKey), ImGuiInputTextFlags_EnterReturnsTrue)) {
8451 if (strlen(newKey) > 0) {
8452 std::string key(newKey);
8453 // Convert to uppercase
8454 for (char& c : key) c = toupper(c);
8455 // Check if key already exists
8456 if (mTranslationEditor.englishValues.find(key) == mTranslationEditor.englishValues.end()) {
8457 mTranslationEditor.keys.push_back(key);
8458 mTranslationEditor.englishValues[key] = "";
8459 mTranslationEditor.targetValues[key] = "";
8460 mTranslationEditor.dirty = true;
8461 // Select the new key
8462 mTranslationEditor.selectedKeyIndex = (int)mTranslationEditor.keys.size() - 1;
8463 }
8464 newKey[0] = '\0';
8465 ImGui::CloseCurrentPopup();
8466 }
8467 }
8468 ImGui::EndPopup();
8469 }
8470
8471 ImGui::EndPopup();
8472 }
8473}
8474
8475std::vector<std::string> LauncherUI::CheckExistingSubjectCodes()
8476{
8477 std::vector<std::string> existingCodes;
8478
8479 if (!mCurrentStudy || !mCurrentChain) {
8480 return existingCodes;
8481 }
8482
8483 std::string studyPath = mCurrentStudy->GetPath();
8484 const auto& chainItems = mCurrentChain->GetItems();
8485
8486 // Collect unique list of tests in the chain
8487 std::vector<std::string> testsInChain;
8488 for (const auto& item : chainItems) {
8489 if (item.type == ItemType::Test) {
8490 // Check if not already in list
8491 bool found = false;
8492 for (const auto& testName : testsInChain) {
8493 if (testName == item.testName) {
8494 found = true;
8495 break;
8496 }
8497 }
8498 if (!found) {
8499 testsInChain.push_back(item.testName);
8500 }
8501 }
8502 }
8503
8504 // For each test, scan its data directory for subdirectories (subject codes)
8505 for (const auto& testName : testsInChain) {
8506 std::string dataDir = studyPath + "/tests/" + testName + "/data";
8507
8508 try {
8509 if (!fs::exists(dataDir) || !fs::is_directory(dataDir)) {
8510 continue; // data directory doesn't exist yet
8511 }
8512
8513 for (const auto& entry : fs::directory_iterator(dataDir)) {
8514 if (!entry.is_directory()) continue;
8515
8516 std::string name = entry.path().filename().string();
8517
8518 // Check if we've already added this code
8519 bool found = false;
8520 for (const auto& code : existingCodes) {
8521 if (code == name) {
8522 found = true;
8523 break;
8524 }
8525 }
8526 if (!found) {
8527 existingCodes.push_back(name);
8528 }
8529 }
8530 } catch (const fs::filesystem_error&) {
8531 // Directory doesn't exist or can't be read
8532 }
8533 }
8534
8535 return existingCodes;
8536}
8537
8538std::vector<std::string> LauncherUI::BuildAdditionalArguments()
8539{
8540 std::vector<std::string> args;
8541
8542 // Screen resolution
8543 if (mScreenResolution > 0) {
8544 const char* resolutionStrings[] = {
8545 "", // Auto
8546 "1920x1080",
8547 "1680x1050",
8548 "1440x900",
8549 "1366x768",
8550 "1280x1024",
8551 "1280x800",
8552 "1280x720",
8553 "1024x768",
8554 "800x600"
8555 };
8556 args.push_back("--display");
8557 args.push_back(resolutionStrings[mScreenResolution]);
8558 }
8559
8560 // VSync
8561 if (mVSync) {
8562 args.push_back("--vsyncon");
8563 }
8564
8565 // Graphics driver
8566 if (strlen(mGraphicsDriver) > 0) {
8567 args.push_back("--driver");
8568 args.push_back(mGraphicsDriver);
8569 }
8570
8571 // Custom arguments - parse space-separated
8572 if (strlen(mCustomArguments) > 0) {
8573 std::string custom(mCustomArguments);
8574 std::istringstream iss(custom);
8575 std::string arg;
8576 while (iss >> arg) {
8577 args.push_back(arg);
8578 }
8579 }
8580
8581 return args;
8582}
8583
8584// ============================================================================
8585// Scale Builder Implementation
8586// ============================================================================
8587
8588void LauncherUI::ShowScaleBuilder()
8589{
8590 if (!mScaleManager) {
8591 return;
8592 }
8593
8594 // Main layout: left panel (scale list) + right panel (editor)
8595 ImGui::BeginChild("ScaleLeftPanel", ImVec2(250, 0), true);
8596 RenderScaleList();
8597 ImGui::EndChild();
8598
8599 ImGui::SameLine();
8600
8601 ImGui::BeginChild("ScaleRightPanel", ImVec2(0, 0), true);
8602 if (mCurrentScale) {
8603 // Tab bar for different editor sections
8604 if (ImGui::BeginTabBar("ScaleEditorTabs"))
8605 {
8606 if (ImGui::BeginTabItem("Scale Info"))
8607 {
8608 RenderScaleInfoEditor();
8609 ImGui::EndTabItem();
8610 }
8611
8612 if (ImGui::BeginTabItem("Questions"))
8613 {
8614 RenderQuestionsEditor();
8615 ImGui::EndTabItem();
8616 }
8617
8618 if (ImGui::BeginTabItem("Dimensions & Scoring"))
8619 {
8620 RenderScoringEditor();
8621 ImGui::EndTabItem();
8622 }
8623
8624 if (ImGui::BeginTabItem("Translations"))
8625 {
8626 RenderTranslationsEditor();
8627 ImGui::EndTabItem();
8628 }
8629
8630 if (ImGui::BeginTabItem("Sections"))
8631 {
8632 RenderSectionsTab();
8633 ImGui::EndTabItem();
8634 }
8635
8636 if (ImGui::BeginTabItem("Parameters"))
8637 {
8638 RenderParametersEditor();
8639 ImGui::EndTabItem();
8640 }
8641
8642 ImGui::EndTabBar();
8643 }
8644 } else {
8645 ImGui::TextWrapped("Select a scale from the list or create a new one to begin editing.");
8646 ImGui::Spacing();
8647 if (ImGui::Button("Create New Scale")) {
8648 mCurrentScale = ScaleDefinition::CreateNew("newscale");
8649 mScaleTransLanguage[0] = '\0';
8650 mScaleTransSelectedKey = -1;
8651 }
8652 }
8653 ImGui::EndChild();
8654}
8655
8656void LauncherUI::RenderScaleList()
8657{
8658 ImGui::Text("Available Scales");
8659 ImGui::Separator();
8660
8661 // Row 1: New Scale | Save Scale
8662 float buttonWidth = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2.0f;
8663
8664 // New Scale button (default style)
8665 if (ImGui::Button("New Scale", ImVec2(buttonWidth, 0))) {
8666 mCurrentScale = ScaleDefinition::CreateNew("newscale");
8667 mSelectedScaleIndex = -1;
8668 mScaleTransLanguage[0] = '\0';
8669 mScaleTransSelectedKey = -1;
8670 }
8671 if (ImGui::IsItemHovered()) {
8672 ImGui::SetTooltip("Create a new scale definition (Ctrl+N)");
8673 }
8674
8675 ImGui::SameLine();
8676
8677 // Save Scale button (blue)
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));
8681
8682 bool canSaveScale = (mCurrentScale != nullptr);
8683 if (!canSaveScale) {
8684 ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f);
8685 }
8686
8687 if (ImGui::Button("Save Scale", ImVec2(buttonWidth, 0))) {
8688 if (canSaveScale) {
8689 if (mScaleManager->SaveScale(mCurrentScale)) {
8690 printf("Scale saved successfully\n");
8691 mScaleList = mScaleManager->GetAvailableScales();
8692 mLooseOSDEntries = mScaleManager->GetLooseOSDEntries();
8693 } else {
8694 printf("Error: Failed to save scale\n");
8695 }
8696 }
8697 }
8698
8699 if (!canSaveScale) {
8700 ImGui::PopStyleVar();
8701 }
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)");
8706 }
8707
8708 ImGui::PopStyleColor(3);
8709
8710 // Row 2: Test Scale | Create Study
8711 // Test Scale button (orange)
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));
8715
8716 bool canTestScale = (mCurrentScale != nullptr);
8717 if (!canTestScale) {
8718 ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f);
8719 }
8720
8721 if (ImGui::Button("Test Scale", ImVec2(buttonWidth, 0))) {
8722 if (canTestScale) {
8723 TestCurrentScale();
8724 }
8725 }
8726
8727 if (!canTestScale) {
8728 ImGui::PopStyleVar();
8729 }
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)");
8734 }
8735
8736 ImGui::PopStyleColor(3);
8737
8738 ImGui::SameLine();
8739
8740 // Add to Study button (green)
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));
8744
8745 bool canAddToStudy = (mCurrentScale != nullptr && mWorkspace != nullptr);
8746 if (!canAddToStudy) {
8747 ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f);
8748 }
8749
8750 if (ImGui::Button("Add to Study", ImVec2(buttonWidth, 0))) {
8751 if (canAddToStudy) {
8752 mCreateStudyDialog.show = true;
8753 mCreateStudyDialog.needScaleSelection = false;
8754 mCreateStudyDialog.addToExisting = false;
8755 mCreateStudyDialog.selectedStudyIndex = -1;
8756 std::strncpy(mCreateStudyDialog.studyName, mCurrentScale->GetScaleInfo().code.c_str(),
8757 sizeof(mCreateStudyDialog.studyName) - 1);
8758 mCreateStudyDialog.studyName[sizeof(mCreateStudyDialog.studyName) - 1] = '\0';
8759 mCreateStudyDialog.errorMessage[0] = '\0';
8760 mCreateStudyDialog.confirmOverwrite = false;
8761 // Refresh study list for the dropdown
8762 mStudyList = mWorkspace->GetStudyDirectories();
8763 }
8764 }
8765
8766 if (!canAddToStudy) {
8767 ImGui::PopStyleVar();
8768 }
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");
8773 }
8774
8775 ImGui::PopStyleColor(3);
8776
8777 // Browse OpenScales button (teal)
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));
8781
8782 if (ImGui::Button("Browse OpenScales", ImVec2(-1, 0))) {
8783 if (mWorkspace) {
8784 std::string scalesDir = mWorkspace->GetWorkspacePath() + "/scales";
8785 mOpenScalesBrowser.SetOnDownload([this](const std::string& code) {
8786 // Refresh scale list after download
8787 mScaleList.clear();
8788 if (mScaleManager) {
8789 mScaleList = mScaleManager->GetAvailableScales();
8790 }
8791 printf("Downloaded scale %s from OpenScales\n", code.c_str());
8792 });
8793 mOpenScalesBrowser.Show(scalesDir);
8794 }
8795 }
8796 if (ImGui::IsItemHovered()) {
8797 ImGui::SetTooltip("Browse and download scales from the OpenScales repository (openscales.net)");
8798 }
8799
8800 ImGui::PopStyleColor(3);
8801
8802 // Row 3: Open Test Data (always reserve space to prevent scrollbox bounce)
8803 {
8804 std::string testDataDir;
8805 bool hasTestDir = false;
8806 if (mCurrentScale && mWorkspace) {
8807 testDataDir = mWorkspace->GetWorkspacePath() + "/temp/scale-test-"
8808 + mCurrentScale->GetScaleInfo().code + "/data";
8809 // Show button if the parent temp directory exists (test was run at least once)
8810 std::string tempDir = mWorkspace->GetWorkspacePath() + "/temp/scale-test-"
8811 + mCurrentScale->GetScaleInfo().code;
8812 hasTestDir = fs::exists(tempDir);
8813 }
8814
8815 if (hasTestDir) {
8816 if (ImGui::Button("Open Test Data", ImVec2(-1, 0))) {
8817 OpenDirectoryInFileBrowser(testDataDir);
8818 }
8819 if (ImGui::IsItemHovered()) {
8820 ImGui::SetTooltip("Open test output directory: %s", testDataDir.c_str());
8821 }
8822 } else {
8823 // Reserve the same height as a button to prevent layout shift
8824 ImGui::Dummy(ImVec2(-1, ImGui::GetFrameHeight()));
8825 }
8826 }
8827
8828 ImGui::Spacing();
8829
8830 // Load scale list if empty
8831 if (mScaleList.empty()) {
8832 mScaleList = mScaleManager->GetAvailableScales();
8833 }
8834
8835 // Scan for loose .osd files on first display (and after installs)
8836 if (!mLooseOSDEntriesLoaded) {
8837 mLooseOSDEntries = mScaleManager->GetLooseOSDEntries();
8838 mLooseOSDEntriesLoaded = true;
8839 }
8840
8841 // Scrollable list in its own child so the buttons above stay fixed
8842 ImGui::BeginChild("ScaleListScroll", ImVec2(0, 0), false);
8843
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;
8848 // Load the selected scale
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());
8854 } else {
8855 printf("Error: Failed to load scale: %s\n", mScaleList[i].c_str());
8856 }
8857 }
8858 }
8859
8860 // Show loose .osd files that need to be installed
8861 if (!mLooseOSDEntries.empty()) {
8862 ImGui::Spacing();
8863 ImGui::Separator();
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';
8872 }
8873 ImGui::PopStyleColor();
8874 if (ImGui::IsItemHovered()) {
8875 ImGui::SetTooltip("Click to install into scales/%s/", looseEntry.code.c_str());
8876 }
8877 }
8878 }
8879
8880 ImGui::EndChild();
8881
8882 // Install OSD dialog
8883 if (mInstallOSDDialog.show) {
8884 ImGui::OpenPopup("Install OSD Scale");
8885 mInstallOSDDialog.show = false;
8886 }
8887 if (ImGui::BeginPopupModal("Install OSD Scale", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
8888 ImGui::TextWrapped("Install \"%s\"?", mInstallOSDDialog.entry.name.c_str());
8889 ImGui::Spacing();
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());
8894 ImGui::Spacing();
8895 if (mInstallOSDDialog.errorMessage[0] != '\0') {
8896 ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", mInstallOSDDialog.errorMessage);
8897 ImGui::Spacing();
8898 }
8899 if (ImGui::Button("Install", ImVec2(120, 0))) {
8900 auto scale = mScaleManager->InstallLooseOSD(mInstallOSDDialog.entry.path);
8901 if (scale) {
8902 // Refresh lists and auto-select the newly installed scale
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;
8913 break;
8914 }
8915 }
8916 ImGui::CloseCurrentPopup();
8917 } else {
8918 std::snprintf(mInstallOSDDialog.errorMessage,
8919 sizeof(mInstallOSDDialog.errorMessage),
8920 "Installation failed. Check that the file is a valid .osd bundle.");
8921 }
8922 }
8923 ImGui::SameLine();
8924 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
8925 ImGui::CloseCurrentPopup();
8926 }
8927 ImGui::EndPopup();
8928 }
8929}
8930
8931void LauncherUI::RenderScaleInfoEditor()
8932{
8933 if (!mCurrentScale) return;
8934
8935 ImGui::Text("Basic Information");
8936 ImGui::Separator();
8937 ImGui::Spacing();
8938
8939 auto& info = mCurrentScale->GetScaleInfo();
8940
8941 // Scale name
8942 char name[256];
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))) {
8948 info.name = name;
8949 mCurrentScale->SetDirty(true);
8950 }
8951
8952 // Scale code
8953 char code[64];
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))) {
8959 info.code = code;
8960 mCurrentScale->SetDirty(true);
8961 }
8962 if (ImGui::IsItemHovered()) {
8963 ImGui::SetTooltip("Short identifier (e.g., 'grit', 'crt')");
8964 }
8965
8966 // Abbreviation
8967 char abbrev[64];
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);
8975 }
8976
8977 // Description
8978 char desc[1024];
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)) {
8984 info.description = desc;
8985 mCurrentScale->SetDirty(true);
8986 }
8987
8988 ImGui::Spacing();
8989 ImGui::Separator();
8990 ImGui::Text("Publication Info");
8991 ImGui::Spacing();
8992
8993 // Citation
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);
9002 }
9003
9004 // License
9005 char license[256];
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);
9013 }
9014 if (ImGui::IsItemHovered()) {
9015 ImGui::SetTooltip("Short label: CC BY 4.0, Public Domain, free to use, etc.");
9016 }
9017
9018 // License explanation
9019 char licExpl[1024];
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);
9027 }
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.");
9030 }
9031
9032 // License URL
9033 char licUrl[512];
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);
9041 }
9042 if (ImGui::IsItemHovered()) {
9043 ImGui::SetTooltip("URL documenting the license terms (e.g., CC deed, author's download page).");
9044 }
9045
9046 // Version
9047 char version[32];
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);
9055 }
9056
9057 // URL
9058 char url[512];
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))) {
9064 info.url = url;
9065 mCurrentScale->SetDirty(true);
9066 }
9067
9068 // Domain
9069 char domain[128];
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);
9077 }
9078 if (ImGui::IsItemHovered()) {
9079 ImGui::SetTooltip("Subject domain for classification (e.g., Mood, Substance Use, Personality, Work, Education).");
9080 }
9081
9082 ImGui::Spacing();
9083 ImGui::Separator();
9084 ImGui::Text("Participant Display (English)");
9085 ImGui::Spacing();
9086
9087 // Display Title (what participants see - optional override of scale name)
9088 std::string displayTitle = mCurrentScale->GetTranslation("en", "display_title");
9089 if (displayTitle.empty()) {
9090 displayTitle = info.name; // Default to scale name
9091 }
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);
9100 }
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.");
9103 }
9104
9105 // Instructions (question_head)
9106 std::string instructions = mCurrentScale->GetTranslation("en", "question_head");
9107 char instr[512];
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);
9115 }
9116 if (ImGui::IsItemHovered()) {
9117 ImGui::SetTooltip("Instructions shown before each question");
9118 }
9119
9120 // Debrief
9121 std::string debrief = mCurrentScale->GetTranslation("en", "debrief");
9122 char debr[512];
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);
9130 }
9131 if (ImGui::IsItemHovered()) {
9132 ImGui::SetTooltip("Message shown after completing the scale");
9133 }
9134
9135 ImGui::Spacing();
9136 ImGui::Separator();
9137 ImGui::Text("Likert Scale Defaults");
9138 ImGui::Spacing();
9139
9140 auto& likert = mCurrentScale->GetLikertOptions();
9141
9142 // Default number of points
9143 int points = likert.points;
9144 if (ImGui::InputInt("Default Points", &points)) {
9145 if (points >= 2 && points <= 10) {
9146 likert.points = points;
9147
9148 // Auto-populate labels if points increased beyond current label count
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));
9157 }
9158 }
9159
9160 mCurrentScale->SetDirty(true);
9161 }
9162 }
9163 if (ImGui::IsItemHovered()) {
9164 ImGui::SetTooltip("Default number of response options for Likert questions");
9165 }
9166
9167 // Default min/max values
9168 int minVal = likert.min;
9169 if (ImGui::InputInt("Default Min Value", &minVal)) {
9170 likert.min = minVal;
9171 mCurrentScale->SetDirty(true);
9172 }
9173 if (ImGui::IsItemHovered()) {
9174 ImGui::SetTooltip("Default minimum value (-1 = auto: binary scales use 0, regular scales use 1)");
9175 }
9176
9177 int maxVal = likert.max;
9178 if (ImGui::InputInt("Default Max Value", &maxVal)) {
9179 likert.max = maxVal;
9180 mCurrentScale->SetDirty(true);
9181 }
9182 if (ImGui::IsItemHovered()) {
9183 ImGui::SetTooltip("Default maximum value (-1 = auto: points-1 for binary, points for regular)");
9184 }
9185
9186 ImGui::Spacing();
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.");
9190 }
9191
9192 // Display response options as a table
9193 std::vector<std::string>& labelKeys = likert.labels;
9194
9195 // Calculate what the values would be for these labels
9196 // Binary scales (points=2): reverse order (max to min)
9197 // Regular scales: natural order (min to max)
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;
9201
9202 for (int i = 1; i <= points; i++) {
9203 int value;
9204 if (points == 2) {
9205 // Binary: reverse order
9206 value = actualMax - (i - 1);
9207 } else {
9208 // Regular: natural order
9209 value = actualMin + (i - 1);
9210 }
9211 values.push_back(value);
9212 }
9213
9214 // Table header
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();
9221
9222 // Track if we need to remove any labels
9223 int removeIndex = -1;
9224
9225 // Display rows
9226 for (size_t i = 0; i < labelKeys.size() && i < values.size(); i++) {
9227 ImGui::TableNextRow();
9228 ImGui::PushID(static_cast<int>(i));
9229
9230 // Column 1: Value (computed, read-only)
9231 ImGui::TableSetColumnIndex(0);
9232 ImGui::Text("%d", values[i]);
9233
9234 // Column 2: Label Key (editable)
9235 ImGui::TableSetColumnIndex(1);
9236 char keyBuffer[64];
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))) {
9241 // Update the key (need to update both the vector and translations)
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);
9248 }
9249
9250 // Column 3: Label Text (editable)
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);
9260 }
9261
9262 // Column 4: Actions
9263 ImGui::TableSetColumnIndex(3);
9264 if (ImGui::SmallButton("Remove")) {
9265 removeIndex = static_cast<int>(i);
9266 }
9267
9268 ImGui::PopID();
9269 }
9270
9271 ImGui::EndTable();
9272
9273 // Remove label if requested
9274 if (removeIndex >= 0) {
9275 labelKeys.erase(labelKeys.begin() + removeIndex);
9276 // Update points to match (since we removed an option)
9277 likert.points = static_cast<int>(labelKeys.size());
9278 mCurrentScale->SetDirty(true);
9279 }
9280 }
9281
9282 // Add new option button
9283 if (ImGui::Button("Add Response Option")) {
9284 // Generate new label key using scale code
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));
9290 // Update points to match
9291 likert.points = static_cast<int>(labelKeys.size());
9292 mCurrentScale->SetDirty(true);
9293 }
9294
9295 if (ImGui::IsItemHovered()) {
9296 ImGui::SetTooltip("Add a new response option (will update Points automatically)");
9297 }
9298
9299 // Default Required setting
9300 ImGui::Spacing();
9301 ImGui::Separator();
9302 ImGui::Text("Required Questions");
9303 ImGui::Spacing();
9304
9305 {
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);
9312 }
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.");
9318 }
9319 }
9320}
9321
9322void LauncherUI::RenderQuestionsEditor()
9323{
9324 if (!mCurrentScale) return;
9325
9326 ImGui::Text("Questions");
9327 ImGui::SameLine();
9328 if (ImGui::Button("Add Question")) {
9329 mQuestionEditor.show = true;
9330 mQuestionEditor.editingIndex = -1;
9331 mQuestionEditor.isSection = false;
9332 // Auto-generate first unused q{N} ID
9333 {
9334 const auto& qs = mCurrentScale->GetQuestions();
9335 std::set<std::string> used;
9336 for (const auto& q : qs) used.insert(q.id);
9337 int nextNum = 1;
9338 while (used.count("q" + std::to_string(nextNum))) nextNum++;
9339 snprintf(mQuestionEditor.id, sizeof(mQuestionEditor.id), "q%d", nextNum);
9340 }
9341 mQuestionEditor.textKey[0] = '\0';
9342 mQuestionEditor.questionText[0] = '\0';
9343 mQuestionEditor.questionType = 0; // Default to likert
9344 mQuestionEditor.randomGroup = 1; // Default: all in same shuffle pool
9345 mQuestionEditor.requiredState = -1; // Default: use type/scale default
9346 mQuestionEditor.hasVisibleWhen = false;
9347 mQuestionEditor.visibleWhenLogic = 0;
9348 mQuestionEditor.visibleWhenIsComplex = false;
9349 mQuestionEditor.visibleWhenConditions.clear();
9350 mQuestionEditor.hasGate = false;
9351 mQuestionEditor.gateRequiredValue[0] = '\0';
9352 mQuestionEditor.gateOperator = 0;
9353 mQuestionEditor.gateValue = 0.0;
9354 mQuestionEditor.gateTerminateMessageKey[0] = '\0';
9355 mQuestionEditor.gateTerminateMessageText[0] = '\0';
9356 mQuestionEditor.questionHead[0] = '\0';
9357 mQuestionEditor.answerAlias[0] = '\0';
9358 }
9359 ImGui::SameLine();
9360 if (ImGui::Button("Batch Import...")) {
9361 mBatchImport.show = true;
9362 mBatchImport.questionText[0] = '\0';
9363 // Pre-fill with scale code and advance startNumber past existing IDs
9364 if (mCurrentScale) {
9365 std::strncpy(mBatchImport.idPrefix, mCurrentScale->GetScaleInfo().code.c_str(), sizeof(mBatchImport.idPrefix) - 1);
9366 mBatchImport.idPrefix[sizeof(mBatchImport.idPrefix) - 1] = '\0';
9367 std::string prefix = mBatchImport.idPrefix;
9368 int maxNum = 0;
9369 for (const auto& q : mCurrentScale->GetQuestions()) {
9370 if (q.id.size() > prefix.size() && q.id.substr(0, prefix.size()) == prefix) {
9371 try {
9372 int n = std::stoi(q.id.substr(prefix.size()));
9373 if (n > maxNum) maxNum = n;
9374 } catch (...) {}
9375 }
9376 }
9377 mBatchImport.startNumber = maxNum + 1;
9378 }
9379 }
9380 ImGui::SameLine();
9381 if (ImGui::Button("Add Section")) {
9382 mQuestionEditor = QuestionEditorState{};
9383 mQuestionEditor.show = true;
9384 mQuestionEditor.editingIndex = -1;
9385 mQuestionEditor.isSection = true;
9386 // Find first unused sec_{N} ID
9387 {
9388 const auto& qs = mCurrentScale->GetQuestions();
9389 std::set<std::string> used;
9390 for (const auto& q : qs) used.insert(q.id);
9391 int nextNum = 1;
9392 while (used.count("sec_" + std::to_string(nextNum))) nextNum++;
9393 std::snprintf(mQuestionEditor.id, sizeof(mQuestionEditor.id), "sec_%d", nextNum);
9394 }
9395 }
9396
9397 ImGui::Separator();
9398 ImGui::Spacing();
9399
9400 auto& questions = mCurrentScale->GetQuestions();
9401 ImGui::Text("Total questions: %zu", questions.size());
9402 ImGui::Spacing();
9403
9404 // Question list
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();
9416
9417 // ── Virtual "Start" row (implicit section 0, always first, non-moveable) ──
9418 {
9419 // Determine whether the first real item is a section marker.
9420 // If not, the implicit section covers everything before the first marker.
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)));
9426
9427 // Drag handle — disabled (not moveable)
9428 ImGui::TableNextColumn();
9429 ImGui::TextDisabled(" "); // no drag handle
9430
9431 // ID
9432 ImGui::TableNextColumn();
9433 ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), "(start)");
9434
9435 // Type
9436 ImGui::TableNextColumn();
9437 ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), "[section]");
9438
9439 // Req, Rand, Cond — blank
9440 ImGui::TableNextColumn();
9441 ImGui::TableNextColumn();
9442 ImGui::TableNextColumn();
9443
9444 // Text — show note about implicit section
9445 ImGui::TableNextColumn();
9446 if (hasLeadingSection) {
9447 ImGui::TextDisabled("(implicit start — first section marker overrides)");
9448 } else {
9449 ImGui::TextDisabled("Implicit start section (revisable by default)");
9450 }
9451
9452 // Order — empty (cannot be moved)
9453 ImGui::TableNextColumn();
9454
9455 // Edit column — Edit button opens section editor for the implicit start
9456 ImGui::TableNextColumn();
9457 if (!hasLeadingSection) {
9458 if (ImGui::SmallButton("Edit##start")) {
9459 auto& e = mQuestionEditor;
9460 e = QuestionEditorState{};
9461 e.show = true;
9462 e.isSection = true;
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;
9468 }
9469 if (ImGui::IsItemHovered())
9470 ImGui::SetTooltip("Add an explicit section marker at the start\n"
9471 "to control Back button behavior, etc.");
9472 } else {
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.");
9477 }
9478
9479 ImGui::PopID();
9480 }
9481
9482 for (size_t i = 0; i < questions.size(); i++) {
9483 auto& q = questions[i];
9484 ImGui::TableNextRow();
9485 ImGui::PushID((int)i);
9486
9487 // Section marker — render as a distinct blue-tinted row
9488 if (q.type == "section") {
9489 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
9490 ImGui::GetColorU32(ImVec4(0.15f, 0.25f, 0.45f, 0.85f)));
9491
9492 // Drag handle column
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();
9502 }
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);
9509 }
9510 }
9511 ImGui::EndDragDropTarget();
9512 }
9513
9514 // ID column
9515 ImGui::TableNextColumn();
9516 ImGui::TextUnformatted(q.id.c_str());
9517
9518 // Type column
9519 ImGui::TableNextColumn();
9520 ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 1.0f), "[section]");
9521
9522 // Req column — blank
9523 ImGui::TableNextColumn();
9524
9525 // Rand column — blank
9526 ImGui::TableNextColumn();
9527
9528 // Cond column — show "S" if has visible_when; "NR" if not revisable
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");
9534 }
9535 if (!q.revisable) {
9536 ImGui::SameLine();
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");
9540 }
9541
9542 // Question Text column — show translated title if set
9543 ImGui::TableNextColumn();
9544 if (!q.text_key.empty() && mCurrentScale) {
9545 std::string title = mCurrentScale->GetTranslation("en", q.text_key);
9546 if (!title.empty())
9547 ImGui::TextUnformatted(title.c_str());
9548 }
9549
9550 // Order column — move up/down
9551 ImGui::TableNextColumn();
9552 {
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);
9559 }
9560 if (!canMoveUp) ImGui::EndDisabled();
9561 ImGui::SameLine();
9562 if (!canMoveDown) ImGui::BeginDisabled();
9563 if (ImGui::SmallButton("v##sdn")) {
9564 mCurrentScale->MoveQuestion((int)i, (int)i + 1);
9565 mCurrentScale->SetDirty(true);
9566 }
9567 if (!canMoveDown) ImGui::EndDisabled();
9568 }
9569
9570 // Edit / Delete column
9571 ImGui::TableNextColumn();
9572 if (ImGui::SmallButton("Edit##s")) {
9573 auto& e = mQuestionEditor;
9574 e = QuestionEditorState{};
9575 e.show = true;
9576 e.editingIndex = (int)i;
9577 e.isSection = true;
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';
9584 }
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) {
9590 EditorCondition ec;
9591 ec.sourceType = (c.source_type == "item") ? 1 : 0;
9592 std::strncpy(ec.sourceName, c.source_name.c_str(), sizeof(ec.sourceName) - 1);
9593 ec.sourceName[sizeof(ec.sourceName) - 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;
9601 else ec.op = 0;
9602 if (c.is_list) {
9603 std::string joined;
9604 for (size_t vi = 0; vi < c.values.size(); vi++) {
9605 if (vi) joined += ",";
9606 joined += c.values[vi];
9607 }
9608 std::strncpy(ec.value, joined.c_str(), sizeof(ec.value) - 1);
9609 } else {
9610 std::strncpy(ec.value, c.value.c_str(), sizeof(ec.value) - 1);
9611 }
9612 ec.value[sizeof(ec.value) - 1] = '\0';
9613 e.visibleWhenConditions.push_back(ec);
9614 }
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];
9621 }
9622 std::strncpy(e.sectionRandomizeFixed, fixedStr.c_str(), sizeof(e.sectionRandomizeFixed) - 1);
9623 e.sectionRandomizeFixed[sizeof(e.sectionRandomizeFixed) - 1] = '\0';
9624 }
9625 ImGui::SameLine();
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");
9632 }
9633 ImGui::PopStyleColor(3);
9634
9635 ImGui::PopID();
9636 continue;
9637 }
9638
9639 // Drag handle column
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();
9649 }
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);
9656 }
9657 }
9658 ImGui::EndDragDropTarget();
9659 }
9660
9661 // ID column
9662 ImGui::TableNextColumn();
9663 ImGui::Text("%s", q.id.c_str());
9664
9665 // Type column
9666 ImGui::TableNextColumn();
9667 ImGui::Text("%s", q.type.c_str());
9668
9669 // Req (required) column - clickable toggle: default -> required -> optional -> default
9670 ImGui::TableNextColumn();
9671 {
9672 bool isDisplayOnly = (q.type == "inst" || q.type == "image");
9673 if (!isDisplayOnly) {
9674 // Resolve display symbol and tooltip
9675 std::string symbol;
9676 std::string tooltipText;
9677 if (q.required_state == 1) {
9678 symbol = "+";
9679 tooltipText = "Required (explicit)\nClick to toggle";
9680 } else if (q.required_state == 0) {
9681 symbol = "-";
9682 tooltipText = "Optional (explicit)\nClick to toggle";
9683 } else {
9684 // Resolve effective from scale/type default
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";
9693 } else {
9694 effectiveRequired = (q.type != "short" && q.type != "long");
9695 tooltipText = effectiveRequired ? "Required (type default)\nClick to toggle" : "Optional (type default)\nClick to toggle";
9696 }
9697 symbol = effectiveRequired ? "(+)" : "(-)";
9698 }
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))) {
9705 // Cycle: -1 (default) -> 1 (required) -> 0 (optional) -> -1 (default)
9706 if (q.required_state == -1) {
9707 q.required_state = 1;
9708 } else if (q.required_state == 1) {
9709 q.required_state = 0;
9710 } else {
9711 q.required_state = -1;
9712 }
9713 mCurrentScale->SetDirty(true);
9714 }
9715 ImGui::PopID();
9716 if (ImGui::IsItemHovered()) {
9717 ImGui::SetTooltip("%s", tooltipText.c_str());
9718 }
9719 }
9720 }
9721
9722 // Rand (randomization group) column
9723 ImGui::TableNextColumn();
9724 ImGui::PushItemWidth(40);
9725 int rg = q.random_group;
9726 if (ImGui::InputInt("##rg", &rg, 0, 0)) {
9727 if (rg < 0) rg = 0;
9728 q.random_group = rg;
9729 mCurrentScale->SetDirty(true);
9730 }
9731 ImGui::PopItemWidth();
9732 if (ImGui::IsItemHovered()) {
9733 ImGui::SetTooltip("Randomization group\n0 = fixed position\n1+ = shuffle within group");
9734 }
9735
9736 // Cond (condition indicator) column
9737 ImGui::TableNextColumn();
9738 {
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;
9744 else hasQ = true;
9745 }
9746 // Check dimension-level visible_when
9747 if (!q.dimension.empty()) {
9748 for (const auto& dim : mCurrentScale->GetDimensions()) {
9749 if (dim.id == q.dimension && dim.has_visible_when) {
9750 hasD = true;
9751 break;
9752 }
9753 }
9754 }
9755 if (q.has_visible_when && q.visible_when_is_complex) {
9756 condLabel = "*";
9757 condTooltip = "Complex nested condition (edit in code)";
9758 } else {
9759 if (hasP) condLabel += "P";
9760 if (hasQ) condLabel += "Q";
9761 if (hasD) condLabel += "D";
9762 }
9763 if (!condLabel.empty()) {
9764 ImGui::TextUnformatted(condLabel.c_str());
9765 if (ImGui::IsItemHovered()) {
9766 if (condTooltip.empty()) {
9767 condTooltip = "";
9768 if (hasP) condTooltip += "P = parameter condition\n";
9769 if (hasQ) condTooltip += "Q = question condition\n";
9770 if (hasD) condTooltip += "D = dimension condition";
9771 }
9772 ImGui::SetTooltip("%s", condTooltip.c_str());
9773 }
9774 }
9775 }
9776
9777 // Question Text column (truncated with tooltip)
9778 ImGui::TableNextColumn();
9779 std::string questionText = mCurrentScale->GetTranslation("en", q.text_key);
9780 if (questionText.empty()) {
9781 questionText = "[" + q.text_key + "]";
9782 }
9783
9784 // Truncate to 60 characters
9785 std::string displayText = questionText;
9786 if (displayText.length() > 60) {
9787 displayText = displayText.substr(0, 57) + "...";
9788 }
9789
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();
9797 }
9798
9799 // Order (move up/down) column
9800 ImGui::TableNextColumn();
9801 {
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);
9808 }
9809 if (!canMoveUp) ImGui::EndDisabled();
9810 ImGui::SameLine();
9811 if (!canMoveDown) ImGui::BeginDisabled();
9812 if (ImGui::SmallButton("v##dn")) {
9813 mCurrentScale->MoveQuestion((int)i, (int)i + 1);
9814 mCurrentScale->SetDirty(true);
9815 }
9816 if (!canMoveDown) ImGui::EndDisabled();
9817 }
9818
9819 // Edit / Delete column
9820 ImGui::TableNextColumn();
9821 if (ImGui::SmallButton("Edit##q")) {
9822 printf("Edit question %s\n", q.id.c_str());
9823 // Open question editor with current values
9824 mQuestionEditor.show = true;
9825 mQuestionEditor.editingIndex = (int)i;
9826 mQuestionEditor.isSection = false;
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';
9831
9832 // Load question text from translation (English)
9833 std::string qText = mCurrentScale->GetTranslation("en", q.text_key);
9834 std::strncpy(mQuestionEditor.questionText, qText.c_str(), sizeof(mQuestionEditor.questionText) - 1);
9835 mQuestionEditor.questionText[sizeof(mQuestionEditor.questionText) - 1] = '\0';
9836
9837 // Set question type index
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]) {
9841 mQuestionEditor.questionType = ti;
9842 break;
9843 }
9844 }
9845
9846 // Load randomization group and required state
9847 mQuestionEditor.randomGroup = q.random_group;
9848 mQuestionEditor.requiredState = q.required_state;
9849
9850 // Load validation — per-constraint fields
9851 {
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';
9855 };
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));
9877 }
9878
9879 // Load answer alias (S3 answer piping)
9880 strncpy(mQuestionEditor.questionHead, q.question_head.c_str(), sizeof(mQuestionEditor.questionHead) - 1);
9881 mQuestionEditor.questionHead[sizeof(mQuestionEditor.questionHead) - 1] = '\0';
9882 strncpy(mQuestionEditor.answerAlias, q.answer_alias.c_str(), sizeof(mQuestionEditor.answerAlias) - 1);
9883 mQuestionEditor.answerAlias[sizeof(mQuestionEditor.answerAlias) - 1] = '\0';
9884
9885 // Load gate (blocking)
9886 mQuestionEditor.hasGate = q.has_gate;
9887 strncpy(mQuestionEditor.gateRequiredValue, q.gate_required_value.c_str(), sizeof(mQuestionEditor.gateRequiredValue) - 1);
9888 mQuestionEditor.gateRequiredValue[sizeof(mQuestionEditor.gateRequiredValue) - 1] = '\0';
9889 // Load operator form (short questions)
9890 {
9891 const char* opNames[] = { "greater_than", "less_than", "equals", "not_equals" };
9892 mQuestionEditor.gateOperator = 0;
9893 for (int oi = 0; oi < 4; ++oi) {
9894 if (q.gate_operator == opNames[oi]) { mQuestionEditor.gateOperator = oi; break; }
9895 }
9896 }
9897 mQuestionEditor.gateValue = q.gate_value;
9898 strncpy(mQuestionEditor.gateTerminateMessageKey, q.gate_terminate_message_key.c_str(), sizeof(mQuestionEditor.gateTerminateMessageKey) - 1);
9899 mQuestionEditor.gateTerminateMessageKey[sizeof(mQuestionEditor.gateTerminateMessageKey) - 1] = '\0';
9900 {
9901 std::string msgText = (mCurrentScale && !q.gate_terminate_message_key.empty())
9902 ? mCurrentScale->GetTranslation("en", q.gate_terminate_message_key) : "";
9903 strncpy(mQuestionEditor.gateTerminateMessageText, msgText.c_str(), sizeof(mQuestionEditor.gateTerminateMessageText) - 1);
9904 mQuestionEditor.gateTerminateMessageText[sizeof(mQuestionEditor.gateTerminateMessageText) - 1] = '\0';
9905 }
9906
9907 // Load conditional display
9908 mQuestionEditor.hasVisibleWhen = q.has_visible_when;
9909 mQuestionEditor.visibleWhenLogic = (q.visible_when_logic == "any") ? 1 : 0;
9910 mQuestionEditor.visibleWhenIsComplex = q.visible_when_is_complex;
9911 mQuestionEditor.visibleWhenConditions.clear();
9912 for (const auto& c : q.visible_when_simple) {
9913 EditorCondition ec;
9914 ec.sourceType = (c.source_type == "item") ? 1 : 0;
9915 strncpy(ec.sourceName, c.source_name.c_str(), sizeof(ec.sourceName) - 1);
9916 ec.sourceName[sizeof(ec.sourceName) - 1] = '\0';
9917 // Map operator string to index
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;
9925 else ec.op = 0; // equals
9926 if (c.is_list) {
9927 std::string joined;
9928 for (size_t vi = 0; vi < c.values.size(); vi++) {
9929 if (vi) joined += ",";
9930 joined += c.values[vi];
9931 }
9932 strncpy(ec.value, joined.c_str(), sizeof(ec.value) - 1);
9933 } else {
9934 strncpy(ec.value, c.value.c_str(), sizeof(ec.value) - 1);
9935 }
9936 ec.value[sizeof(ec.value) - 1] = '\0';
9937 mQuestionEditor.visibleWhenConditions.push_back(ec);
9938 }
9939
9940 // Load Likert-specific fields
9941 mQuestionEditor.likertPoints = q.likert_points;
9942 mQuestionEditor.likertMin = q.likert_min;
9943 mQuestionEditor.likertMax = q.likert_max;
9944 mQuestionEditor.likertReverse = q.likert_reverse;
9945 mQuestionEditor.randomizeOptions = q.randomize_options;
9946
9947 // Load VAS-specific fields
9948 mQuestionEditor.vasMinValue = q.min_value;
9949 mQuestionEditor.vasMaxValue = q.max_value;
9950 std::strncpy(mQuestionEditor.vasLeftLabel, q.left_label.c_str(), sizeof(mQuestionEditor.vasLeftLabel) - 1);
9951 mQuestionEditor.vasLeftLabel[sizeof(mQuestionEditor.vasLeftLabel) - 1] = '\0';
9952 std::strncpy(mQuestionEditor.vasRightLabel, q.right_label.c_str(), sizeof(mQuestionEditor.vasRightLabel) - 1);
9953 mQuestionEditor.vasRightLabel[sizeof(mQuestionEditor.vasRightLabel) - 1] = '\0';
9954 mQuestionEditor.vasOrientationIdx = (q.vas_orientation == "vertical") ? 1 : 0;
9955 mQuestionEditor.vasAnchors.clear();
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';
9961 mQuestionEditor.vasAnchors.push_back(ae);
9962 }
9963
9964 // Load Multi/multicheck-specific fields (join options with newlines)
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];
9969 }
9970 std::strncpy(mQuestionEditor.multiOptions, optionsText.c_str(), sizeof(mQuestionEditor.multiOptions) - 1);
9971 mQuestionEditor.multiOptions[sizeof(mQuestionEditor.multiOptions) - 1] = '\0';
9972
9973 // Load Grid-specific fields
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];
9978 }
9979 std::strncpy(mQuestionEditor.gridColumns, columnsText.c_str(), sizeof(mQuestionEditor.gridColumns) - 1);
9980 mQuestionEditor.gridColumns[sizeof(mQuestionEditor.gridColumns) - 1] = '\0';
9981
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];
9986 }
9987 std::strncpy(mQuestionEditor.gridRows, rowsText.c_str(), sizeof(mQuestionEditor.gridRows) - 1);
9988 mQuestionEditor.gridRows[sizeof(mQuestionEditor.gridRows) - 1] = '\0';
9989
9990 // Load Image-specific fields
9991 std::strncpy(mQuestionEditor.imagePath, q.image.c_str(), sizeof(mQuestionEditor.imagePath) - 1);
9992 mQuestionEditor.imagePath[sizeof(mQuestionEditor.imagePath) - 1] = '\0';
9993 }
9994 ImGui::SameLine();
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");
10001 }
10002 ImGui::PopStyleColor(3);
10003
10004 ImGui::PopID();
10005 }
10006
10007 // Deletion confirmation modal — rendered outside the loop so index is stable
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());
10013 ImGui::Spacing();
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.");
10016 ImGui::Spacing();
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();
10025 }
10026 ImGui::PopStyleColor(3);
10027 ImGui::SameLine();
10028 if (ImGui::Button("Cancel", ImVec2(100, 0))) {
10029 mDeleteConfirmIndex = -1;
10030 ImGui::CloseCurrentPopup();
10031 }
10032 } else {
10033 mDeleteConfirmIndex = -1;
10034 ImGui::CloseCurrentPopup();
10035 }
10036 ImGui::EndPopup();
10037 }
10038
10039 ImGui::EndTable();
10040 }
10041}
10042
10043void LauncherUI::RenderScoringEditor()
10044{
10045 if (!mCurrentScale) return;
10046
10047 auto& dimensions = mCurrentScale->GetDimensions();
10048 auto& scoring = mCurrentScale->GetScoring();
10049 auto& questions = mCurrentScale->GetQuestions();
10050
10051 if (dimensions.empty()) {
10052 ImGui::TextWrapped("No dimensions defined yet. Add a dimension to configure scoring.");
10053 ImGui::Spacing();
10054 if (ImGui::Button("Add Dimension")) {
10055 mDimensionEditor.show = true;
10056 mDimensionEditor.editingIndex = -1;
10057 mDimensionEditor.id[0] = '\0';
10058 mDimensionEditor.name[0] = '\0';
10059 mDimensionEditor.abbreviation[0] = '\0';
10060 mDimensionEditor.description[0] = '\0';
10061 mDimensionEditor.selectable = false;
10062 mDimensionEditor.defaultEnabled = true;
10063 mDimensionEditor.enabledParam[0] = '\0';
10064 mDimensionEditor.hasVisibleWhen = false;
10065 mDimensionEditor.visibleWhenLogic = 0;
10066 mDimensionEditor.visibleWhenConditions.clear();
10067 }
10068 return;
10069 }
10070
10071 // Two-column layout: Dimensions list on left, scoring on right
10072 ImGui::BeginChild("DimensionList", ImVec2(200, 0), true);
10073
10074 if (ImGui::Button("Add", ImVec2(-1, 0))) {
10075 mDimensionEditor.show = true;
10076 mDimensionEditor.editingIndex = -1;
10077 mDimensionEditor.id[0] = '\0';
10078 mDimensionEditor.name[0] = '\0';
10079 mDimensionEditor.abbreviation[0] = '\0';
10080 mDimensionEditor.description[0] = '\0';
10081 mDimensionEditor.selectable = false;
10082 mDimensionEditor.defaultEnabled = true;
10083 mDimensionEditor.enabledParam[0] = '\0';
10084 mDimensionEditor.hasVisibleWhen = false;
10085 mDimensionEditor.visibleWhenLogic = 0;
10086 mDimensionEditor.visibleWhenConditions.clear();
10087 }
10088 ImGui::Separator();
10089
10090 // List all dimensions
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);
10095 }
10096 }
10097
10098 ImGui::EndChild();
10099
10100 ImGui::SameLine();
10101
10102 // Right panel: dimension info + scoring for selected dimension
10103 ImGui::BeginChild("ItemSelection", ImVec2(0, 0), true);
10104
10105 if (mSelectedDimensionIndex >= 0 && mSelectedDimensionIndex < static_cast<int>(dimensions.size())) {
10106 const auto& selectedDim = dimensions[mSelectedDimensionIndex];
10107
10108 // Dimension info header
10109 ImGui::Text("%s", selectedDim.name.c_str());
10110 ImGui::SameLine();
10111 ImGui::TextDisabled("(%s)", selectedDim.id.c_str());
10112 ImGui::SameLine();
10113
10114 // Edit button
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);
10122
10123 // Load selectable/enable param
10124 mDimensionEditor.selectable = selectedDim.selectable;
10125 mDimensionEditor.defaultEnabled = selectedDim.default_enabled;
10126 strncpy(mDimensionEditor.enabledParam, selectedDim.enabled_param.c_str(), sizeof(mDimensionEditor.enabledParam) - 1);
10127 mDimensionEditor.enabledParam[sizeof(mDimensionEditor.enabledParam) - 1] = '\0';
10128
10129 // Load conditional display
10130 mDimensionEditor.hasVisibleWhen = selectedDim.has_visible_when;
10131 mDimensionEditor.visibleWhenLogic = (selectedDim.visible_when_logic == "any") ? 1 : 0;
10132 mDimensionEditor.visibleWhenConditions.clear();
10133 for (const auto& c : selectedDim.visible_when) {
10134 EditorCondition ec;
10135 ec.sourceType = (c.source_type == "item") ? 1 : 0;
10136 strncpy(ec.sourceName, c.source_name.c_str(), sizeof(ec.sourceName) - 1);
10137 ec.sourceName[sizeof(ec.sourceName) - 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;
10143 else ec.op = 0;
10144 if (c.is_list) {
10145 std::string joined;
10146 for (size_t vi = 0; vi < c.values.size(); vi++) {
10147 if (vi) joined += ",";
10148 joined += c.values[vi];
10149 }
10150 strncpy(ec.value, joined.c_str(), sizeof(ec.value) - 1);
10151 } else {
10152 strncpy(ec.value, c.value.c_str(), sizeof(ec.value) - 1);
10153 }
10154 ec.value[sizeof(ec.value) - 1] = '\0';
10155 mDimensionEditor.visibleWhenConditions.push_back(ec);
10156 }
10157 }
10158 ImGui::SameLine();
10159
10160 // Delete button
10161 if (ImGui::SmallButton("Delete##dim")) {
10162 // Remove scoring for this dimension
10163 scoring.erase(selectedDim.id);
10164 // Remove the dimension itself
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;
10169 }
10170 ImGui::EndChild();
10171 return;
10172 }
10173
10174 // Show abbreviation and description if present
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()) {
10179 ImGui::SameLine();
10180 ImGui::TextDisabled(" | %s", selectedDim.description.c_str());
10181 }
10182 } else {
10183 ImGui::TextDisabled("%s", selectedDim.description.c_str());
10184 }
10185 }
10186
10187 ImGui::Separator();
10188 ImGui::Spacing();
10189
10190 // Get or create scoring for this dimension
10191 if (scoring.find(selectedDim.id) == scoring.end()) {
10192 DimensionScoring newScoring;
10193 newScoring.method = "mean_coded";
10194 newScoring.description = "";
10195 scoring[selectedDim.id] = newScoring;
10196 }
10197
10198 auto& dimScoring = scoring[selectedDim.id];
10199
10200 // Scoring method dropdown
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]) {
10206 currentMethod = i;
10207 break;
10208 }
10209 }
10210
10211 if (ImGui::Combo("Scoring Method", &currentMethod, methodOptions, methodCount)) {
10212 dimScoring.method = methodOptions[currentMethod];
10213 mCurrentScale->SetDirty(true);
10214 }
10215
10216 bool isSumCorrect = (dimScoring.method == "sum_correct");
10217 bool isWeighted = (dimScoring.method == "weighted_sum" || dimScoring.method == "weighted_mean");
10218
10219 ImGui::Spacing();
10220 if (isSumCorrect) {
10221 ImGui::Text("Select items and set correct answers:");
10222 ImGui::SameLine();
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();
10233 }
10234 } else if (isWeighted) {
10235 ImGui::Text("Select items, set coding and weights:");
10236 } else {
10237 ImGui::Text("Select items and set coding:");
10238 }
10239
10240 // Norms and Transform buttons — compact, on the same visual line
10241 {
10242 // Transform button
10243 std::string transformBtn = dimScoring.transform.empty()
10244 ? "Transform..."
10245 : ("Transform (" + std::to_string(dimScoring.transform.size()) + ")");
10246
10247 // Norms button
10248 std::string normsBtn = dimScoring.norms.empty()
10249 ? "Norms..."
10250 : ("Norms (" + std::to_string(dimScoring.norms.size()) + ")");
10251
10252 // Value Map button
10253 std::string vmapBtn = dimScoring.value_map.empty()
10254 ? "Value Map..."
10255 : ("Value Map (" + std::to_string(dimScoring.value_map.size()) + ")");
10256
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);
10262
10263 if (ImGui::SmallButton(vmapBtn.c_str())) {
10264 ImGui::OpenPopup("ValueMapEditor");
10265 }
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.");
10272 }
10273 ImGui::SameLine();
10274 if (ImGui::SmallButton(transformBtn.c_str())) {
10275 ImGui::OpenPopup("TransformEditor");
10276 }
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).");
10280 }
10281 ImGui::SameLine();
10282 if (ImGui::SmallButton(normsBtn.c_str())) {
10283 mNormsEditor.show = true;
10284 mNormsEditor.dimensionId = selectedDim.id;
10285 mNormsEditor.dimensionName = selectedDim.name;
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);
10294 }
10295 }
10296
10297 // Transform editor popup
10298 if (ImGui::BeginPopup("TransformEditor")) {
10299 ImGui::Text("Score Transform Steps");
10300 ImGui::SameLine();
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"
10306 " 2. divide 40\n"
10307 " 3. multiply 100");
10308 }
10309 ImGui::Separator();
10310
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];
10315 ImGui::PushID(ti);
10316
10317 // Step number
10318 ImGui::Text("%d.", ti + 1);
10319 ImGui::SameLine();
10320
10321 // Op dropdown
10322 int opIdx = 0;
10323 for (int oi = 0; oi < 4; oi++) {
10324 if (step.op == ops[oi]) { opIdx = oi; break; }
10325 }
10326 ImGui::PushItemWidth(100);
10327 if (ImGui::Combo("##op", &opIdx, ops, 4)) {
10328 step.op = ops[opIdx];
10329 mCurrentScale->SetDirty(true);
10330 }
10331 ImGui::PopItemWidth();
10332 ImGui::SameLine();
10333
10334 // Value
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);
10340 }
10341 ImGui::PopItemWidth();
10342 ImGui::SameLine();
10343
10344 // Remove button
10345 if (ImGui::SmallButton("X##rm")) {
10346 removeIdx = ti;
10347 }
10348
10349 ImGui::PopID();
10350 }
10351
10352 if (removeIdx >= 0) {
10353 dimScoring.transform.erase(dimScoring.transform.begin() + removeIdx);
10354 mCurrentScale->SetDirty(true);
10355 }
10356
10357 if (ImGui::Button("+ Add Step")) {
10358 dimScoring.transform.push_back(TransformStep("add", 0.0));
10359 mCurrentScale->SetDirty(true);
10360 }
10361
10362 if (!dimScoring.transform.empty()) {
10363 ImGui::SameLine();
10364 if (ImGui::Button("Clear All")) {
10365 dimScoring.transform.clear();
10366 mCurrentScale->SetDirty(true);
10367 }
10368 }
10369
10370 ImGui::EndPopup();
10371 }
10372
10373 // Value Map editor popup
10374 if (ImGui::BeginPopup("ValueMapEditor")) {
10375 ImGui::Text("Value Map (Response Recoding)");
10376 ImGui::SameLine();
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).");
10384 }
10385 ImGui::Separator();
10386
10387 std::string removeKey;
10388 for (auto& [vmKey, vmArr] : dimScoring.value_map) {
10389 ImGui::PushID(vmKey.c_str());
10390
10391 // Key label
10392 ImGui::Text("%s:", vmKey.c_str());
10393 ImGui::SameLine();
10394
10395 // Convert array to comma-separated string for editing
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);
10402 }
10403 char buf[256];
10404 strncpy(buf, arrStr.c_str(), sizeof(buf) - 1);
10405 buf[sizeof(buf) - 1] = '\0';
10406
10407 ImGui::PushItemWidth(250);
10408 if (ImGui::InputText("##vm", buf, sizeof(buf))) {
10409 // Parse comma-separated values back
10410 vmArr.clear();
10411 std::string s(buf);
10412 std::stringstream ss(s);
10413 std::string token;
10414 while (std::getline(ss, token, ',')) {
10415 try {
10416 vmArr.push_back(std::stod(token));
10417 } catch (...) {
10418 vmArr.push_back(0.0);
10419 }
10420 }
10421 mCurrentScale->SetDirty(true);
10422 }
10423 ImGui::PopItemWidth();
10424
10425 ImGui::SameLine();
10426 if (ImGui::SmallButton("X##vmrm")) {
10427 removeKey = vmKey;
10428 }
10429
10430 ImGui::PopID();
10431 }
10432
10433 if (!removeKey.empty()) {
10434 dimScoring.value_map.erase(removeKey);
10435 mCurrentScale->SetDirty(true);
10436 }
10437
10438 ImGui::Spacing();
10439
10440 // Add new entry
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();
10446 ImGui::SameLine();
10447 ImGui::PushItemWidth(200);
10448 ImGui::InputText("Values##vmv", newVMValues, sizeof(newVMValues));
10449 ImGui::PopItemWidth();
10450 ImGui::SameLine();
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);
10457 std::string token;
10458 while (std::getline(ss, token, ',')) {
10459 try { vals.push_back(std::stod(token)); }
10460 catch (...) { vals.push_back(0.0); }
10461 }
10462 if (!vals.empty()) {
10463 dimScoring.value_map[key] = vals;
10464 mCurrentScale->SetDirty(true);
10465 }
10466 }
10467 }
10468
10469 if (!dimScoring.value_map.empty()) {
10470 ImGui::SameLine();
10471 if (ImGui::SmallButton("Clear All##vm")) {
10472 dimScoring.value_map.clear();
10473 mCurrentScale->SetDirty(true);
10474 }
10475 }
10476
10477 ImGui::EndPopup();
10478 }
10479 }
10480
10481 ImGui::Separator();
10482
10483 // Items table
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);
10495 } else {
10496 ImGui::TableSetupColumn("Question Text", ImGuiTableColumnFlags_WidthStretch);
10497 ImGui::TableSetupColumn("Coding", ImGuiTableColumnFlags_WidthFixed, 120);
10498 }
10499 ImGui::TableHeadersRow();
10500
10501 for (const auto& question : questions) {
10502 ImGui::TableNextRow();
10503 ImGui::PushID(question.id.c_str());
10504
10505 auto itemIt = std::find(dimScoring.items.begin(), dimScoring.items.end(), question.id);
10506 bool isIncluded = (itemIt != dimScoring.items.end());
10507
10508 // Checkbox column
10509 ImGui::TableSetColumnIndex(0);
10510 if (ImGui::Checkbox("##include", &isIncluded)) {
10511 if (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;
10515 }
10516 if (isWeighted && dimScoring.weights.find(question.id) == dimScoring.weights.end()) {
10517 dimScoring.weights[question.id] = 1.0;
10518 }
10519 } else {
10520 dimScoring.items.erase(itemIt);
10521 dimScoring.item_coding.erase(question.id);
10522 dimScoring.correct_answers.erase(question.id);
10523 }
10524 mCurrentScale->SetDirty(true);
10525 }
10526
10527 // ID column
10528 ImGui::TableSetColumnIndex(1);
10529 ImGui::Text("%s", question.id.c_str());
10530
10531 // Question text column
10532 ImGui::TableSetColumnIndex(2);
10533 std::string questionText = mCurrentScale->GetTranslation("en", question.text_key);
10534 if (questionText.empty()) {
10535 questionText = "[" + question.text_key + "]";
10536 }
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) + "...";
10541 }
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();
10549 }
10550
10551 // Last column: Coding or Correct Answers
10552 ImGui::TableSetColumnIndex(3);
10553 if (isSumCorrect) {
10554 if (isIncluded) {
10555 auto caIt = dimScoring.correct_answers.find(question.id);
10556 int answerCount = (caIt != dimScoring.correct_answers.end()) ? (int)caIt->second.size() : 0;
10557
10558 std::string btnLabel;
10559 if (answerCount == 0) {
10560 btnLabel = "[click to add]##ca";
10561 } else {
10562 btnLabel = std::to_string(answerCount) + " answer" + (answerCount != 1 ? "s" : "") + "##ca";
10563 }
10564
10565 if (ImGui::SmallButton(btnLabel.c_str())) {
10566 mCorrectAnswersEditor.show = true;
10567 mCorrectAnswersEditor.questionId = question.id;
10568 mCorrectAnswersEditor.dimensionId = selectedDim.id;
10569 mCorrectAnswersEditor.questionType = question.type;
10570
10571 std::string qText = mCurrentScale->GetTranslation("en", question.text_key);
10572 if (qText.empty()) qText = "[" + question.text_key + "]";
10573 mCorrectAnswersEditor.questionText = qText;
10574
10575 mCorrectAnswersEditor.answers.clear();
10576 mCorrectAnswersEditor.caseSensitive.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));
10581 mCorrectAnswersEditor.caseSensitive.push_back(true);
10582 } else {
10583 mCorrectAnswersEditor.answers.push_back(raw);
10584 mCorrectAnswersEditor.caseSensitive.push_back(false);
10585 }
10586 }
10587 }
10588 }
10589
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());
10595 }
10596 ImGui::PopTextWrapPos();
10597 ImGui::EndTooltip();
10598 }
10599 } else {
10600 ImGui::TextDisabled("--");
10601 }
10602 } else {
10603 if (isIncluded) {
10604 int currentCoding = 1;
10605 auto codingIt = dimScoring.item_coding.find(question.id);
10606 if (codingIt != dimScoring.item_coding.end()) {
10607 currentCoding = codingIt->second;
10608 }
10609
10610 const char* codingOptions[] = { "Normal (1)", "Reverse (-1)", "Not Scored (0)" };
10611 int codingIndex = (currentCoding == 1) ? 0 : (currentCoding == -1) ? 1 : 2;
10612
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;
10618 }
10619 mCurrentScale->SetDirty(true);
10620 }
10621 } else {
10622 ImGui::TextDisabled("--");
10623 }
10624 }
10625
10626 // Weight column (only for weighted methods)
10627 if (isWeighted) {
10628 ImGui::TableSetColumnIndex(4);
10629 if (isIncluded) {
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);
10636 }
10637 } else {
10638 ImGui::TextDisabled("--");
10639 }
10640 }
10641
10642 ImGui::PopID();
10643 }
10644
10645 ImGui::EndTable();
10646 }
10647
10648 ImGui::Spacing();
10649 ImGui::Text("Items in dimension: %zu", dimScoring.items.size());
10650
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;
10657 weightSum += w;
10658 if (w <= 0.0) hasZeroOrNeg = true;
10659 }
10660 ImGui::SameLine();
10661 ImGui::Text(" Weight sum: %.4f", weightSum);
10662 if (dimScoring.method == "weighted_mean") {
10663 ImGui::SameLine();
10664 ImGui::TextDisabled("(denominator)");
10665 }
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();
10670 }
10671 }
10672
10673 } else {
10674 ImGui::Text("Select a dimension from the list");
10675 }
10676
10677 ImGui::EndChild();
10678
10679 // ── Computed Variables section ────────────────────────────────
10680 ImGui::Spacing();
10681 ImGui::Separator();
10682 ImGui::Spacing();
10683
10684 auto& computed = mCurrentScale->GetComputed();
10685
10686 bool showComputed = ImGui::CollapsingHeader(
10687 computed.empty() ? "Computed Variables" : ("Computed Variables (" + std::to_string(computed.size()) + ")").c_str(),
10688 ImGuiTreeNodeFlags_DefaultOpen * 0); // collapsed by default
10689
10690 if (showComputed) {
10691 ImGui::TextDisabled("Derived values from expressions referencing score.*, answer.*, computed.*");
10692 ImGui::Spacing();
10693
10694 std::string removeKey;
10695 for (auto& [key, cv] : computed) {
10696 ImGui::PushID(key.c_str());
10697
10698 // Name + type on one line
10699 ImGui::Text("%s", key.c_str());
10700 ImGui::SameLine();
10701 ImGui::TextDisabled("(%s)", cv.type.c_str());
10702 ImGui::SameLine();
10703 if (ImGui::SmallButton("X##rm")) {
10704 removeKey = key;
10705 }
10706
10707 // Expression
10708 char expr[512];
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);
10715 }
10716 if (ImGui::IsItemHovered()) {
10717 ImGui::SetTooltip("Expression using score.*, answer.*, computed.* references.\n"
10718 "Examples:\n"
10719 " score.PHQ_total >= 10\n"
10720 " answer.weight / (answer.height * answer.height)\n"
10721 " computed.met_vigorous + computed.met_moderate");
10722 }
10723
10724 ImGui::Spacing();
10725 ImGui::PopID();
10726 }
10727
10728 if (!removeKey.empty()) {
10729 computed.erase(removeKey);
10730 mCurrentScale->SetDirty(true);
10731 }
10732
10733 // Add new computed variable
10734 static char newComputedName[128] = "";
10735 static int newComputedType = 0;
10736 ImGui::PushItemWidth(150);
10737 ImGui::InputText("##newCVName", newComputedName, sizeof(newComputedName));
10738 ImGui::PopItemWidth();
10739 ImGui::SameLine();
10740 const char* cvTypes[] = { "number", "boolean" };
10741 ImGui::PushItemWidth(80);
10742 ImGui::Combo("##newCVType", &newComputedType, cvTypes, 2);
10743 ImGui::PopItemWidth();
10744 ImGui::SameLine();
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';
10752 }
10753 }
10754 }
10755}
10756
10757void LauncherUI::RenderTranslationsEditor()
10758{
10759 if (!mCurrentScale) return;
10760
10761 auto& translations = mCurrentScale->GetTranslations();
10762
10763 // Collect available languages from the translations map
10764 std::vector<std::string> availableLanguages;
10765 availableLanguages.push_back("en"); // English always first
10766 for (const auto& [lang, _] : translations) {
10767 if (lang != "en") {
10768 availableLanguages.push_back(lang);
10769 }
10770 }
10771
10772 // Ensure English translations exist
10773 if (translations.find("en") == translations.end()) {
10774 translations["en"] = {};
10775 }
10776
10777 // Collect all keys from English translations
10778 std::vector<std::string> allKeys;
10779 for (const auto& [key, _] : translations["en"]) {
10780 allKeys.push_back(key);
10781 }
10782 // Sort keys for consistent display
10783 std::sort(allKeys.begin(), allKeys.end());
10784
10785 // Header: language selector
10786 ImGui::Text("Language:");
10787 ImGui::SameLine();
10788 ImGui::PushItemWidth(100);
10789
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;
10797 }
10798 }
10799 // New language option
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) {
10806 // Create the new language entry with empty values copied from English keys
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] = "";
10813 }
10814 mCurrentScale->SetDirty(true);
10815 }
10816 std::strncpy(mScaleTransLanguage, langCode.c_str(), sizeof(mScaleTransLanguage) - 1);
10817 mScaleTransLanguage[sizeof(mScaleTransLanguage) - 1] = '\0';
10818 newScaleLang[0] = '\0';
10819 ImGui::CloseCurrentPopup();
10820 }
10821 }
10822 ImGui::PopItemWidth();
10823 ImGui::EndCombo();
10824 }
10825 ImGui::PopItemWidth();
10826
10827 ImGui::SameLine(0, 20);
10828 ImGui::TextDisabled("%zu keys, %zu languages", allKeys.size(), availableLanguages.size());
10829
10830 // "Launch Translation Editor" — saves scale to disk, then opens the translation editor dialog
10831 ImGui::SameLine(0, 20);
10832 bool canLaunch = (mCurrentScale != nullptr) && (mScaleManager != nullptr);
10833 if (!canLaunch) ImGui::BeginDisabled();
10834 if (ImGui::Button("Launch Translation Editor")) {
10835 // Save scale so translation files exist on disk
10836 mScaleManager->SaveScale(mCurrentScale);
10837
10838 // Open the translation editor dialog in scale mode
10839 mTranslationEditor.scaleMode = true;
10840 std::string code = mCurrentScale->GetScaleInfo().code;
10841 std::strncpy(mTranslationEditor.scaleCode, code.c_str(), sizeof(mTranslationEditor.scaleCode) - 1);
10842 mTranslationEditor.scaleCode[sizeof(mTranslationEditor.scaleCode) - 1] = '\0';
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';
10846 mTranslationEditor.testIndex = -1; // Not used in scale mode
10847
10848 // Pre-fill language from current inline selector
10849 if (mScaleTransLanguage[0]) {
10850 std::strncpy(mTranslationEditor.language, mScaleTransLanguage, sizeof(mTranslationEditor.language) - 1);
10851 mTranslationEditor.language[sizeof(mTranslationEditor.language) - 1] = '\0';
10852 } else {
10853 std::strncpy(mTranslationEditor.language, "en", sizeof(mTranslationEditor.language) - 1);
10854 }
10855 mTranslationEditor.dataLoaded = false;
10856 mTranslationEditor.show = true;
10857 }
10858 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
10859 ImGui::SetTooltip("Save scale and open the full translation editor dialog");
10860 }
10861 if (!canLaunch) ImGui::EndDisabled();
10862
10863 ImGui::Separator();
10864
10865 if (!mScaleTransLanguage[0]) {
10866 ImGui::Spacing();
10867 ImGui::TextWrapped("Select a language to view or edit translations. English (en) is the base language.");
10868 return;
10869 }
10870
10871 std::string currentLang(mScaleTransLanguage);
10872 bool isEnglish = (currentLang == "en");
10873
10874 // Ensure target language map exists
10875 if (translations.find(currentLang) == translations.end()) {
10876 translations[currentLang] = {};
10877 }
10878 auto& targetMap = translations[currentLang];
10879 auto& englishMap = translations["en"];
10880
10881 if (allKeys.empty()) {
10882 ImGui::Spacing();
10883 ImGui::TextWrapped("No translation keys defined yet. Keys are created automatically when you add questions in the Questions tab.");
10884 return;
10885 }
10886
10887 // Two-panel layout
10888 float contentHeight = ImGui::GetContentRegionAvail().y;
10889
10890 // Left panel - key list
10891 ImGui::BeginChild("ScaleTransKeyList", ImVec2(180, contentHeight), true);
10892 ImGui::TextDisabled("Keys");
10893 ImGui::Separator();
10894
10895 for (size_t i = 0; i < allKeys.size(); i++) {
10896 const std::string& key = allKeys[i];
10897 bool isSelected = (mScaleTransSelectedKey == (int)i);
10898
10899 // Show indicator if target value is empty (untranslated)
10900 bool untranslated = false;
10901 if (!isEnglish) {
10902 auto it = targetMap.find(key);
10903 untranslated = (it == targetMap.end() || it->second.empty());
10904 }
10905
10906 if (untranslated) {
10907 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.0f, 1.0f));
10908 }
10909
10910 if (ImGui::Selectable(key.c_str(), isSelected)) {
10911 mScaleTransSelectedKey = (int)i;
10912 }
10913
10914 if (untranslated) {
10915 ImGui::PopStyleColor();
10916 }
10917 }
10918 ImGui::EndChild();
10919
10920 ImGui::SameLine();
10921
10922 // Right panel - edit area
10923 ImGui::BeginChild("ScaleTransEditPanel", ImVec2(0, contentHeight), true);
10924
10925 if (mScaleTransSelectedKey >= 0 && mScaleTransSelectedKey < (int)allKeys.size()) {
10926 const std::string& selectedKey = allKeys[mScaleTransSelectedKey];
10927
10928 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Key: %s", selectedKey.c_str());
10929 ImGui::Spacing();
10930
10931 float availHeight = ImGui::GetContentRegionAvail().y;
10932
10933 if (isEnglish) {
10934 // Editing English: single editable box
10935 ImGui::Text("English (editing):");
10936
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';
10941
10942 if (ImGui::InputTextMultiline("##scaletrans_edit", editBuf, sizeof(editBuf),
10943 ImVec2(-1, availHeight - 30), ImGuiInputTextFlags_WordWrap)) {
10944 val = editBuf;
10945 mCurrentScale->SetDirty(true);
10946 }
10947 } else {
10948 // Editing another language: English reference on top, target below
10949 float boxHeight = (availHeight - 60) / 2;
10950
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] : "";
10954 char refBuf[8192];
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();
10960
10961 ImGui::Spacing();
10962
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';
10968
10969 if (ImGui::InputTextMultiline("##scaletrans_target", editBuf, sizeof(editBuf),
10970 ImVec2(-1, boxHeight), ImGuiInputTextFlags_WordWrap)) {
10971 targetVal = editBuf;
10972 mCurrentScale->SetDirty(true);
10973 }
10974 }
10975 } else {
10976 ImGui::TextDisabled("Select a key from the list to edit");
10977 }
10978
10979 ImGui::EndChild();
10980}
10981
10982void LauncherUI::RenderSectionsTab()
10983{
10984 if (!mCurrentScale) return;
10985
10986 auto& raw = mCurrentScale->GetRawDefinition();
10987 const auto& questions = mCurrentScale->GetQuestions();
10988
10989 // Collect section IDs from question list (in order)
10990 std::vector<std::string> sectionIds;
10991 for (const auto& q : questions)
10992 if (q.type == "section")
10993 sectionIds.push_back(q.id);
10994
10995 // ── Left panel: randomization ────────────────────────────────────────
10996 ImGui::BeginChild("SectionsRandomPanel", ImVec2(320, 0), true);
10997 ImGui::Text("Section Order Randomization (S4)");
10998 ImGui::Separator();
10999 ImGui::Spacing();
11000
11001 // Top-level toggle
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()}};
11006 } else {
11007 raw.erase("randomize_sections");
11008 }
11009 mCurrentScale->SetDirty(true);
11010 }
11011
11012 if (randomizeEnabled && raw.contains("randomize_sections")) {
11013 auto& rs = raw["randomize_sections"];
11014
11015 // Build fixed set for fast lookup
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>());
11020
11021 ImGui::Spacing();
11022 ImGui::TextDisabled("Fixed sections are not shuffled:");
11023 ImGui::Spacing();
11024
11025 if (sectionIds.empty()) {
11026 ImGui::TextDisabled("(No sections defined — add sections in the Questions tab)");
11027 } else {
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)) {
11032 if (isFixed)
11033 fixedSet.insert(sid);
11034 else
11035 fixedSet.erase(sid);
11036 // Rebuild fixed array
11037 rs["fixed"] = nlohmann::json::array();
11038 for (const auto& f : fixedSet)
11039 rs["fixed"].push_back(f);
11040 mCurrentScale->SetDirty(true);
11041 }
11042 ImGui::SameLine();
11043 ImGui::TextUnformatted(sid.c_str());
11044 }
11045 }
11046 }
11047 ImGui::EndChild();
11048
11049 ImGui::SameLine();
11050
11051 // ── Right panel: branch groups ───────────────────────────────────────
11052 ImGui::BeginChild("SectionsBranchPanel", ImVec2(0, 0), true);
11053 ImGui::Text("Branch Groups (A1)");
11054 ImGui::Separator();
11055 ImGui::Spacing();
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.");
11060 ImGui::Spacing();
11061
11062 // Ensure branches key exists as array if missing
11063 bool hasBranches = raw.contains("branches") && raw["branches"].is_array();
11064
11065 if (ImGui::Button("Add Branch Group")) {
11066 if (!hasBranches) {
11067 raw["branches"] = nlohmann::json::array();
11068 hasBranches = true;
11069 }
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);
11077 }
11078
11079 if (hasBranches) {
11080 auto& branches = raw["branches"];
11081 int numGroups = (int)branches.size();
11082
11083 // Left sub-column: group list
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;
11090 }
11091 ImGui::EndChild();
11092 ImGui::SameLine();
11093
11094 // Right sub-column: group editor
11095 ImGui::BeginChild("BranchGroupEditor", ImVec2(0, 0), true);
11096 if (mSelectedBranchGroupIndex >= 0 && mSelectedBranchGroupIndex < numGroups) {
11097 auto& grp = branches[mSelectedBranchGroupIndex];
11098
11099 // Group ID
11100 char gidBuf[64];
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); }
11108
11109 // Method
11110 ImGui::Text("Method:"); ImGui::SameLine();
11111 const char* methods[] = {"random", "balanced", "parameter"};
11112 int methodIdx = 0;
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); }
11119
11120 ImGui::Spacing();
11121 ImGui::Separator();
11122 ImGui::Text("Arms:");
11123
11124 // Arms
11125 if (!grp.contains("arms") || !grp["arms"].is_array())
11126 grp["arms"] = nlohmann::json::array();
11127 auto& arms = grp["arms"];
11128
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);
11135 }
11136
11137 int armToDelete = -1;
11138 for (int ai = 0; ai < (int)arms.size(); ++ai) {
11139 auto& arm = arms[ai];
11140 ImGui::PushID(ai);
11141
11142 // Arm ID input
11143 char armIdBuf[64];
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); }
11150
11151 ImGui::SameLine();
11152
11153 // Section checklist popup
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)");
11168 } else {
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);
11174 // Rebuild sections array preserving order from sectionIds
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);
11179 }
11180 }
11181 }
11182 ImGui::EndPopup();
11183 }
11184
11185 ImGui::SameLine();
11186 if (ImGui::SmallButton("Del")) armToDelete = ai;
11187
11188 ImGui::PopID();
11189 }
11190 if (armToDelete >= 0) {
11191 arms.erase(arms.begin() + armToDelete);
11192 mCurrentScale->SetDirty(true);
11193 }
11194
11195 ImGui::Spacing();
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);
11202 }
11203 ImGui::PopStyleColor();
11204 } else {
11205 ImGui::TextDisabled("Select a branch group from the list.");
11206 }
11207 ImGui::EndChild();
11208 }
11209
11210 ImGui::EndChild(); // SectionsBranchPanel
11211}
11212
11213void LauncherUI::RenderParametersEditor()
11214{
11215 if (!mCurrentScale) return;
11216
11217 auto& params = mCurrentScale->GetParameters();
11218
11219 // Base parameters are managed automatically by the runner — don't expose them here.
11220 // shuffle_questions and show_header appear in Scale Info; scale is internal.
11221 static const std::set<std::string> baseNames = {"scale", "shuffle_questions", "show_header"};
11222
11223 ImGui::Text("Scale Parameters");
11224 ImGui::Separator();
11225 ImGui::Spacing();
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.");
11232 ImGui::Spacing();
11233
11234 // ── Parameter table ──────────────────────────────────────────────────────
11235 static const char* kTypes[] = {"string", "integer", "float", "boolean", "choice"};
11236
11237 // Collect custom parameters (exclude base names), preserving map order
11238 std::vector<std::string> customKeys;
11239 for (const auto& [k, _] : params)
11240 if (!baseNames.count(k)) customKeys.push_back(k);
11241
11242 if (customKeys.empty()) {
11243 ImGui::TextDisabled("No custom parameters defined. Use 'Add Parameter' below.");
11244 ImGui::Spacing();
11245 } else {
11246 if (ImGui::BeginTable("ParamsTable", 5,
11247 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
11248 ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp,
11249 ImVec2(0, 200)))
11250 {
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();
11258
11259 std::string toDelete;
11260
11261 for (const auto& key : customKeys) {
11262 auto& p = params[key];
11263 ImGui::TableNextRow();
11264 ImGui::PushID(key.c_str());
11265
11266 // Name (read-only — renaming would invalidate any {param_name} references)
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());
11271
11272 // Type dropdown
11273 ImGui::TableSetColumnIndex(1);
11274 int typeIdx = 0;
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];
11280 // Clear options only when switching away from boolean/choice
11281 if (p.type != "boolean" && p.type != "choice") p.options.clear();
11282 if (p.type == "boolean") p.options = {"0", "1"};
11283 mCurrentScale->SetDirty(true);
11284 }
11285
11286 // Default value
11287 ImGui::TableSetColumnIndex(2);
11288 if (p.type == "boolean") {
11289 // Render as checkbox
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);
11294 }
11295 } else {
11296 char defBuf[256];
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);
11303 }
11304 }
11305
11306 // Description
11307 ImGui::TableSetColumnIndex(3);
11308 char descBuf[512];
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);
11315 }
11316
11317 // Delete button
11318 ImGui::TableSetColumnIndex(4);
11319 if (ImGui::SmallButton("Del")) toDelete = key;
11320
11321 ImGui::PopID();
11322 }
11323
11324 ImGui::EndTable();
11325
11326 if (!toDelete.empty()) {
11327 params.erase(toDelete);
11328 mCurrentScale->SetDirty(true);
11329 }
11330 }
11331 }
11332
11333 // ── Base parameter overrides ─────────────────────────────────────────────
11334 ImGui::Spacing();
11335 ImGui::Text("Standard Parameter Defaults");
11336 ImGui::Separator();
11337 ImGui::Spacing();
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.");
11341 ImGui::Spacing();
11342
11343 // shuffle_questions
11344 {
11345 auto it = params.find("shuffle_questions");
11346 bool inOsd = (it != params.end());
11347 bool shuffleOn = inOsd && (it->second.defaultValue == "1");
11348
11349 // Three-state: "use system default (0)" or "override to 1"
11350 // Checkbox means "override to 1"; unchecked+no OSD entry = use system default
11351 bool overrideOn = inOsd;
11352 if (ImGui::Checkbox("Randomize questions by default (shuffle_questions = 1)", &overrideOn)) {
11353 if (overrideOn) {
11354 ScaleParameter sp("boolean", "1",
11355 "Randomize item order within randomization groups (recommended for this scale)");
11356 sp.options = {"0", "1"};
11357 params["shuffle_questions"] = sp;
11358 } else {
11359 params.erase("shuffle_questions");
11360 }
11361 mCurrentScale->SetDirty(true);
11362 }
11363 if (ImGui::IsItemHovered())
11364 ImGui::SetTooltip(
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).");
11367 (void)shuffleOn;
11368 }
11369
11370 ImGui::Spacing();
11371
11372 // show_header
11373 {
11374 auto it = params.find("show_header");
11375 bool inOsd = (it != params.end());
11376
11377 // Checkbox means "override to 0 (hide header)"
11378 bool hideHeader = inOsd && (it->second.defaultValue == "0");
11379 if (ImGui::Checkbox("Hide scale title header by default (show_header = 0)", &hideHeader)) {
11380 if (hideHeader) {
11381 ScaleParameter sp("boolean", "0",
11382 "Hide scale title — recommended when the title would reveal the scale's purpose");
11383 sp.options = {"0", "1"};
11384 params["show_header"] = sp;
11385 } else {
11386 params.erase("show_header");
11387 }
11388 mCurrentScale->SetDirty(true);
11389 }
11390 if (ImGui::IsItemHovered())
11391 ImGui::SetTooltip(
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).");
11395 }
11396
11397 // ── Choice parameter options editor ─────────────────────────────────────
11398 {
11399 bool hasChoiceParams = false;
11400 for (const auto& key : customKeys)
11401 if (params[key].type == "choice") { hasChoiceParams = true; break; }
11402
11403 if (hasChoiceParams) {
11404 ImGui::Spacing();
11405 ImGui::Text("Choice Parameter Options");
11406 ImGui::Separator();
11407 ImGui::Spacing();
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.");
11412 ImGui::Spacing();
11413
11414 for (const auto& key : customKeys) {
11415 auto& p = params[key];
11416 if (p.type != "choice") continue;
11417
11418 ImGui::PushID(key.c_str());
11419 ImGui::Text("%s options:", key.c_str());
11420 ImGui::SameLine();
11421
11422 // Build comma-sep string from options vector for editing
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];
11429 }
11430 sOptBufs[key] = joined;
11431 }
11432 auto& buf = sOptBufs[key];
11433 char cbuf[512];
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))) {
11438 buf = cbuf;
11439 // Parse comma-sep back to vector
11440 p.options.clear();
11441 std::string s = cbuf;
11442 std::stringstream ss(s);
11443 std::string token;
11444 while (std::getline(ss, token, ',')) {
11445 // Trim leading/trailing spaces
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));
11450 }
11451 mCurrentScale->SetDirty(true);
11452 }
11453 ImGui::SameLine();
11454 ImGui::TextDisabled("(comma-separated)");
11455 ImGui::PopID();
11456 }
11457 }
11458 }
11459
11460 // ── Add new parameter ────────────────────────────────────────────────────
11461 ImGui::Spacing();
11462 ImGui::Separator();
11463 ImGui::Text("Add Parameter");
11464 ImGui::Spacing();
11465
11466 static char sNewName[64] = "";
11467 static char sNewDefault[256] = "";
11468 static char sNewDesc[512] = "";
11469 static int sNewTypeIdx = 0; // index into kTypes
11470
11471 // Labels above their fields using BeginGroup/EndGroup
11472 ImGui::BeginGroup();
11473 ImGui::Text("Name");
11474 ImGui::SetNextItemWidth(130.0f);
11475 ImGui::InputText("##add_name", sNewName, sizeof(sNewName));
11476 ImGui::EndGroup();
11477
11478 ImGui::SameLine();
11479
11480 ImGui::BeginGroup();
11481 ImGui::Text("Type");
11482 ImGui::SetNextItemWidth(90.0f);
11483 ImGui::Combo("##add_type", &sNewTypeIdx, kTypes, 5);
11484 ImGui::EndGroup();
11485
11486 ImGui::SameLine();
11487
11488 ImGui::BeginGroup();
11489 ImGui::Text("Default");
11490 ImGui::SetNextItemWidth(120.0f);
11491 ImGui::InputText("##add_default", sNewDefault, sizeof(sNewDefault));
11492 ImGui::EndGroup();
11493
11494 ImGui::SameLine();
11495
11496 ImGui::BeginGroup();
11497 ImGui::Text("Description");
11498 ImGui::SetNextItemWidth(220.0f);
11499 ImGui::InputText("##add_desc", sNewDesc, sizeof(sNewDesc));
11500 ImGui::EndGroup();
11501
11502 ImGui::SameLine();
11503
11504 ImGui::BeginGroup();
11505 ImGui::Text(" "); // spacer to align button baseline with fields
11506 bool nameOk = sNewName[0] != '\0' && !baseNames.count(sNewName) && !params.count(sNewName);
11507 if (!nameOk) ImGui::BeginDisabled();
11508 if (ImGui::Button("Add")) {
11509 ScaleParameter sp;
11510 sp.type = kTypes[sNewTypeIdx];
11511 sp.defaultValue = sNewDefault;
11512 sp.description = sNewDesc;
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';
11519 sNewTypeIdx = 0;
11520 }
11521 if (!nameOk) ImGui::EndDisabled();
11522 ImGui::EndGroup();
11523
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);
11531 }
11532}
11533
11534void LauncherUI::ShowQuestionEditor()
11535{
11536 if (!mCurrentScale) {
11537 mQuestionEditor.show = false;
11538 return;
11539 }
11540
11541 // Branch for section editor — uses its own simplified form
11542 if (mQuestionEditor.isSection) {
11543 RenderSectionEditorForm();
11544 return;
11545 }
11546
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));
11549
11550 const char* title = (mQuestionEditor.editingIndex >= 0) ? "Edit Question" : "Add Question";
11551 if (!ImGui::Begin(title, &mQuestionEditor.show, ImGuiWindowFlags_NoCollapse))
11552 {
11553 ImGui::End();
11554 return;
11555 }
11556
11557 ImGui::Text("Question Details");
11558 ImGui::Separator();
11559 ImGui::Spacing();
11560
11561 // Question ID (also used as translation key — lowercase letters, digits, underscores only)
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.");
11567 }
11568
11569 // Question Text (multiline input)
11570 ImGui::Text("Question Text:");
11571 ImGui::InputTextMultiline("##QuestionText", mQuestionEditor.questionText, sizeof(mQuestionEditor.questionText),
11572 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 4));
11573 if (ImGui::IsItemHovered()) {
11574 ImGui::SetTooltip("The actual question text (English). Quotes are allowed.");
11575 }
11576
11577 // Question Type
11578 const char* questionTypes[] = { "likert", "multi", "short", "long", "vas", "inst", "multicheck", "grid", "image", "imageresponse" };
11579 int prevType = mQuestionEditor.questionType;
11580 ImGui::Combo("Type", &mQuestionEditor.questionType, questionTypes, IM_ARRAYSIZE(questionTypes));
11581 // Auto-set randomization group to 0 when type changes to inst
11582 if (mQuestionEditor.questionType != prevType && mQuestionEditor.questionType == 5) {
11583 mQuestionEditor.randomGroup = 0;
11584 }
11585
11586 // Randomization Group
11587 ImGui::InputInt("Randomization Group", &mQuestionEditor.randomGroup, 1, 1);
11588 if (mQuestionEditor.randomGroup < 0) mQuestionEditor.randomGroup = 0;
11589 if (ImGui::IsItemHovered()) {
11590 ImGui::SetTooltip("0 = fixed position\n1+ = shuffle within group when randomization is enabled");
11591 }
11592
11593 // Required
11594 {
11595 // Build label for default option based on type
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)";
11602 } else {
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)";
11608 } else {
11609 bool typeRequired = (currentType != "short" && currentType != "long");
11610 defaultLabel += typeRequired ? "required, type default)" : "optional, type default)";
11611 }
11612 }
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);
11617 }
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.");
11622 }
11623 }
11624
11625 // Input Validation section — per-constraint, only for short, long, multicheck
11626 {
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) {
11632 ImGui::Spacing();
11633 ImGui::Separator();
11634 ImGui::Text("Input Validation");
11635 ImGui::Spacing();
11636 ImGui::TextDisabled("Enable individual constraints below. Each can have its own error message.");
11637 ImGui::Spacing();
11638
11639 // Helper macro-like lambda for one constraint row
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);
11644 if (enabled) {
11645 ImGui::SameLine();
11646 ImGui::SetNextItemWidth(80);
11647 std::string intId = std::string("##v") + label;
11648 ImGui::InputInt(intId.c_str(), &val);
11649 ImGui::SameLine();
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)");
11654 }
11655 };
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);
11660 if (enabled) {
11661 ImGui::SameLine();
11662 ImGui::SetNextItemWidth(100);
11663 std::string dblId = std::string("##v") + label;
11664 ImGui::InputDouble(dblId.c_str(), &val, 1.0, 10.0, "%.2f");
11665 ImGui::SameLine();
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)");
11670 }
11671 };
11672
11673 auto& e = mQuestionEditor;
11674 if (isText) {
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");
11678 ImGui::Spacing();
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");
11682 }
11683 if (isShort) {
11684 ImGui::Spacing();
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");
11688 ImGui::Spacing();
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 | ()");
11694 ImGui::SameLine();
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)");
11698 }
11699 }
11700 if (isMulti) {
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");
11703 }
11704 }
11705 }
11706
11707 // Conditional Display section
11708 ImGui::Spacing();
11709 ImGui::Separator();
11710 ImGui::Text("Conditional Display");
11711 ImGui::Spacing();
11712
11713 RenderVisibleWhenEditor(mQuestionEditor);
11714
11715 // Likert-specific fields (only show if type is likert)
11716 if (mQuestionEditor.questionType == 0) { // likert
11717 ImGui::Spacing();
11718 ImGui::Separator();
11719 ImGui::Text("Likert Options");
11720 ImGui::Spacing();
11721
11722 ImGui::InputInt("Number of Points", &mQuestionEditor.likertPoints);
11723 if (ImGui::IsItemHovered()) {
11724 ImGui::SetTooltip("Number of response options (-1 = use scale default)");
11725 }
11726 if (mQuestionEditor.likertPoints < -1) mQuestionEditor.likertPoints = -1;
11727 if (mQuestionEditor.likertPoints == 0 || mQuestionEditor.likertPoints == 1) mQuestionEditor.likertPoints = -1;
11728 if (mQuestionEditor.likertPoints > 10) mQuestionEditor.likertPoints = 10;
11729
11730 ImGui::InputInt("Min Value", &mQuestionEditor.likertMin);
11731 if (ImGui::IsItemHovered()) {
11732 ImGui::SetTooltip("Minimum value (-1 = use scale default)");
11733 }
11734
11735 ImGui::InputInt("Max Value", &mQuestionEditor.likertMax);
11736 if (ImGui::IsItemHovered()) {
11737 ImGui::SetTooltip("Maximum value (-1 = use scale default)");
11738 }
11739
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).");
11746 }
11747
11748 // Response Options Selection
11749 ImGui::Spacing();
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)");
11753 }
11754
11755 // Get scale-level response options
11756 auto& scaleLikert = mCurrentScale->GetLikertOptions();
11757 auto& scaleLabels = scaleLikert.labels;
11758
11759 // Get current question's labels (for editing mode)
11760 std::vector<std::string> currentLabels;
11761 if (mQuestionEditor.editingIndex >= 0) {
11762 auto& questions = mCurrentScale->GetQuestions();
11763 if (mQuestionEditor.editingIndex < (int)questions.size()) {
11764 currentLabels = questions[mQuestionEditor.editingIndex].likert_labels;
11765 }
11766 }
11767
11768 // Display checkboxes for each scale-level option
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.");
11772 } else {
11773 // Initialize selected flags if needed
11774 if (mQuestionEditor.selectedResponseOptions.size() != scaleLabels.size()) {
11775 mQuestionEditor.selectedResponseOptions.resize(scaleLabels.size(), false);
11776 // Initialize from current question's labels
11777 for (size_t i = 0; i < scaleLabels.size(); i++) {
11778 mQuestionEditor.selectedResponseOptions[i] = (std::find(currentLabels.begin(), currentLabels.end(), scaleLabels[i]) != currentLabels.end());
11779 }
11780 }
11781
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;
11785 // std::vector<bool> uses proxy references, need a temp variable
11786 bool selected = mQuestionEditor.selectedResponseOptions[i];
11787 if (ImGui::Checkbox(displayText.c_str(), &selected)) {
11788 mQuestionEditor.selectedResponseOptions[i] = selected;
11789 }
11790 }
11791
11792 ImGui::Text("(Leave all unchecked to use all scale-level options)");
11793 }
11794 }
11795
11796 // VAS-specific fields (only show if type is vas)
11797 if (mQuestionEditor.questionType == 4) { // vas
11798 ImGui::Spacing();
11799 ImGui::Separator();
11800 ImGui::Text("VAS (Visual Analog Scale) Options");
11801 ImGui::Spacing();
11802
11803 ImGui::InputInt("Min Value", &mQuestionEditor.vasMinValue);
11804 if (ImGui::IsItemHovered()) {
11805 ImGui::SetTooltip("Minimum value for the scale (e.g., 0)");
11806 }
11807
11808 ImGui::InputInt("Max Value", &mQuestionEditor.vasMaxValue);
11809 if (ImGui::IsItemHovered()) {
11810 ImGui::SetTooltip("Maximum value for the scale (e.g., 100)");
11811 }
11812
11813 ImGui::InputText("Top Label", mQuestionEditor.vasLeftLabel, sizeof(mQuestionEditor.vasLeftLabel));
11814 if (ImGui::IsItemHovered()) {
11815 ImGui::SetTooltip("Text for top of vertical scale (e.g., 'Extremely')");
11816 }
11817
11818 ImGui::InputText("Bottom Label", mQuestionEditor.vasRightLabel, sizeof(mQuestionEditor.vasRightLabel));
11819 if (ImGui::IsItemHovered()) {
11820 ImGui::SetTooltip("Text for bottom of vertical scale (e.g., 'Not at all')");
11821 }
11822
11823 // Orientation
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.");
11828 }
11829
11830 // Named anchors
11831 ImGui::Spacing();
11832 ImGui::Text("Named Anchors");
11833 ImGui::SameLine();
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.");
11837 }
11838
11839 int removeAnchorIdx = -1;
11840 for (int ai = 0; ai < (int)mQuestionEditor.vasAnchors.size(); ai++) {
11841 auto& anc = mQuestionEditor.vasAnchors[ai];
11842 ImGui::PushID(ai);
11843
11844 ImGui::PushItemWidth(80);
11845 ImGui::InputFloat("##ancVal", &anc.value, 0, 0, "%.4g");
11846 ImGui::PopItemWidth();
11847 ImGui::SameLine();
11848
11849 ImGui::PushItemWidth(200);
11850 ImGui::InputText("##ancLabel", anc.label, sizeof(anc.label));
11851 ImGui::PopItemWidth();
11852 ImGui::SameLine();
11853
11854 if (ImGui::SmallButton("X##anc")) {
11855 removeAnchorIdx = ai;
11856 }
11857
11858 ImGui::PopID();
11859 }
11860
11861 if (removeAnchorIdx >= 0) {
11862 mQuestionEditor.vasAnchors.erase(mQuestionEditor.vasAnchors.begin() + removeAnchorIdx);
11863 }
11864
11865 if (ImGui::SmallButton("+ Add Anchor")) {
11867 mQuestionEditor.vasAnchors.push_back(ae);
11868 }
11869 }
11870
11871 // Multi/multicheck-specific fields (only show if type is multi or multicheck)
11872 if (mQuestionEditor.questionType == 1 || mQuestionEditor.questionType == 6) { // multi or multicheck
11873 ImGui::Spacing();
11874 ImGui::Separator();
11875 const char* typeLabel = (mQuestionEditor.questionType == 1) ? "Multiple Choice" : "Multiple Check";
11876 ImGui::Text("%s Options", typeLabel);
11877 ImGui::Spacing();
11878
11879 ImGui::Text("Options (one per line):");
11880 ImGui::InputTextMultiline("##MultiOptions", mQuestionEditor.multiOptions, sizeof(mQuestionEditor.multiOptions),
11881 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 6));
11882 if (ImGui::IsItemHovered()) {
11883 ImGui::SetTooltip("Enter one option per line. These will become answer choices.");
11884 }
11885
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.");
11889 }
11890 }
11891
11892 // Grid-specific fields (only show if type is grid)
11893 if (mQuestionEditor.questionType == 7) { // grid
11894 ImGui::Spacing();
11895 ImGui::Separator();
11896 ImGui::Text("Grid Question Options");
11897 ImGui::Spacing();
11898
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')");
11904 }
11905
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");
11911 }
11912 }
11913
11914 // Image-specific fields (only show if type is image or imageresponse)
11915 if (mQuestionEditor.questionType == 8 || mQuestionEditor.questionType == 9) { // image or imageresponse
11916 ImGui::Spacing();
11917 ImGui::Separator();
11918 const char* typeLabel = (mQuestionEditor.questionType == 8) ? "Image Display" : "Image Response";
11919 ImGui::Text("%s Options", typeLabel);
11920 ImGui::Spacing();
11921
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)");
11925 }
11926 }
11927
11928 // ── Per-item Question Head override ─────────────────────────────────────
11929 {
11930 int qt = mQuestionEditor.questionType;
11931 // Applies to scored types: likert, multi, multicheck, vas, grid
11932 if (qt == 0 || qt == 1 || qt == 4 || qt == 6 || qt == 7) {
11933 ImGui::Spacing();
11934 ImGui::Separator();
11935 ImGui::Spacing();
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.");
11938 ImGui::InputText("##questionHead", mQuestionEditor.questionHead, sizeof(mQuestionEditor.questionHead));
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...').");
11943 }
11944 }
11945 }
11946
11947 // ── Answer Alias (S3 answer piping) ──────────────────────────────────────
11948 {
11949 ImGui::Spacing();
11950 ImGui::Separator();
11951 ImGui::Spacing();
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}");
11957 }
11958
11959 // ── Gate (blocking) ──────────────────────────────────────────────────────
11960 // Only relevant for multi/binary questions
11961 {
11962 int qt = mQuestionEditor.questionType;
11963 bool isGateable = (qt == 1 || qt == 2); // multi (exact match) or short (numeric threshold)
11964 ImGui::Spacing();
11965 ImGui::Separator();
11966 ImGui::Spacing();
11967 ImGui::Text("Gate (Blocking)");
11968 if (!isGateable) {
11969 ImGui::TextDisabled("Available for multi and short questions only.");
11970 } else {
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.");
11975 }
11976 if (mQuestionEditor.hasGate) {
11977 if (qt == 1) {
11978 // multi: exact-match required value
11979 ImGui::InputText("Required value", mQuestionEditor.gateRequiredValue, sizeof(mQuestionEditor.gateRequiredValue));
11980 if (ImGui::IsItemHovered()) {
11981 ImGui::SetTooltip("The option value that allows the participant to continue.\n"
11982 "Any other selection terminates the scale.");
11983 }
11984 } else {
11985 // short: numeric operator + threshold
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");
11991 }
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.");
11995 ImGui::InputTextMultiline("##GateMsg", mQuestionEditor.gateTerminateMessageText,
11996 sizeof(mQuestionEditor.gateTerminateMessageText),
11997 ImVec2(-FLT_MIN, ImGui::GetTextLineHeight() * 3));
11998 }
11999 }
12000 }
12001
12002 ImGui::Spacing();
12003 ImGui::Separator();
12004 ImGui::Spacing();
12005
12006 // Buttons
12007 const char* buttonLabel = (mQuestionEditor.editingIndex >= 0) ? "Save" : "Add Question";
12008 if (ImGui::Button(buttonLabel, ImVec2(120, 0))) {
12009 // Validate
12010 if (strlen(mQuestionEditor.id) == 0) {
12011 printf("Error: Question ID cannot be empty\n");
12012 } else if (mQuestionEditor.editingIndex >= 0) {
12013 // Update existing question
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;
12018 questions[mQuestionEditor.editingIndex].type = questionTypes[mQuestionEditor.questionType];
12019 questions[mQuestionEditor.editingIndex].random_group = mQuestionEditor.randomGroup;
12020 questions[mQuestionEditor.editingIndex].required_state = mQuestionEditor.requiredState;
12021
12022 // Update validation (C9) — per-constraint fields
12023 {
12024 auto& val = questions[mQuestionEditor.editingIndex].validation;
12025 std::string qid = mQuestionEditor.id;
12026 auto& e = mQuestionEditor;
12027
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);
12032 return key;
12033 }
12034 return "";
12035 };
12036
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;
12048
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") : "";
12058 }
12059
12060 // Update question head override
12061 questions[mQuestionEditor.editingIndex].question_head = mQuestionEditor.questionHead;
12062
12063 // Update answer alias (S3)
12064 questions[mQuestionEditor.editingIndex].answer_alias = mQuestionEditor.answerAlias;
12065
12066 // Update gate (blocking) — multi (exact match) or short (numeric operator)
12067 {
12068 auto& q2 = questions[mQuestionEditor.editingIndex];
12069 bool gateAllowed = (mQuestionEditor.questionType == 1 || mQuestionEditor.questionType == 2);
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) {
12073 // short: numeric operator form
12074 q2.gate_required_value = "";
12075 q2.gate_operator = opNames[mQuestionEditor.gateOperator];
12076 q2.gate_value = mQuestionEditor.gateValue;
12077 } else {
12078 q2.gate_required_value = q2.has_gate ? mQuestionEditor.gateRequiredValue : "";
12079 q2.gate_operator = "";
12080 q2.gate_value = 0.0;
12081 }
12082 if (q2.has_gate && mCurrentScale && mQuestionEditor.gateTerminateMessageText[0]) {
12083 std::string autoQid = questions[mQuestionEditor.editingIndex].id;
12084 std::string gateKey = mQuestionEditor.gateTerminateMessageKey[0]
12085 ? mQuestionEditor.gateTerminateMessageKey : (autoQid + "_gate_msg");
12086 mCurrentScale->AddTranslation("en", gateKey, mQuestionEditor.gateTerminateMessageText);
12087 q2.gate_terminate_message_key = gateKey;
12088 } else {
12089 q2.gate_terminate_message_key = q2.has_gate
12090 ? std::string(mQuestionEditor.gateTerminateMessageKey) : "";
12091 }
12092 }
12093
12094 // Update conditional display
12095 if (!mQuestionEditor.visibleWhenIsComplex) {
12096 questions[mQuestionEditor.editingIndex].has_visible_when = mQuestionEditor.hasVisibleWhen;
12097 questions[mQuestionEditor.editingIndex].visible_when_logic = (mQuestionEditor.visibleWhenLogic == 1) ? "any" : "all";
12098 questions[mQuestionEditor.editingIndex].visible_when_simple.clear();
12099 if (mQuestionEditor.hasVisibleWhen) {
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) {
12103 c.source_type = (ec.sourceType == 1) ? "item" : "parameter";
12104 c.source_name = ec.sourceName;
12105 c.op = opNames[ec.op < 8 ? ec.op : 0];
12106 if (ec.op == 4 || ec.op == 5) {
12107 c.is_list = true;
12108 std::istringstream ss(ec.value);
12109 std::string token;
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));
12115 }
12116 } else {
12117 c.is_list = false;
12118 c.value = ec.value;
12119 }
12120 questions[mQuestionEditor.editingIndex].visible_when_simple.push_back(c);
12121 }
12122 }
12123 questions[mQuestionEditor.editingIndex].visible_when_is_complex = false;
12124 }
12125
12126 // Update Likert-specific fields if type is likert
12127 if (mQuestionEditor.questionType == 0) { // likert
12128 questions[mQuestionEditor.editingIndex].likert_points = mQuestionEditor.likertPoints;
12129 questions[mQuestionEditor.editingIndex].likert_min = mQuestionEditor.likertMin;
12130 questions[mQuestionEditor.editingIndex].likert_max = mQuestionEditor.likertMax;
12131 questions[mQuestionEditor.editingIndex].likert_reverse = mQuestionEditor.likertReverse;
12132 }
12133 // Randomize options applies to multi/multicheck
12134 if (mQuestionEditor.questionType == 1 || mQuestionEditor.questionType == 6) {
12135 questions[mQuestionEditor.editingIndex].randomize_options = mQuestionEditor.randomizeOptions;
12136
12137 // Save selected response options
12138 auto& scaleLikert = mCurrentScale->GetLikertOptions();
12139 auto& scaleLabels = scaleLikert.labels;
12140 questions[mQuestionEditor.editingIndex].likert_labels.clear();
12141 bool anySelected = false;
12142 for (size_t i = 0; i < scaleLabels.size() && i < mQuestionEditor.selectedResponseOptions.size(); i++) {
12143 if (mQuestionEditor.selectedResponseOptions[i]) {
12144 questions[mQuestionEditor.editingIndex].likert_labels.push_back(scaleLabels[i]);
12145 anySelected = true;
12146 }
12147 }
12148 // If none selected, clear the labels array to use scale defaults
12149 if (!anySelected) {
12150 questions[mQuestionEditor.editingIndex].likert_labels.clear();
12151 }
12152 }
12153
12154 // Update VAS-specific fields if type is vas
12155 if (mQuestionEditor.questionType == 4) { // vas
12156 questions[mQuestionEditor.editingIndex].min_value = mQuestionEditor.vasMinValue;
12157 questions[mQuestionEditor.editingIndex].max_value = mQuestionEditor.vasMaxValue;
12158 questions[mQuestionEditor.editingIndex].left_label = mQuestionEditor.vasLeftLabel;
12159 questions[mQuestionEditor.editingIndex].right_label = mQuestionEditor.vasRightLabel;
12160 questions[mQuestionEditor.editingIndex].vas_orientation = (mQuestionEditor.vasOrientationIdx == 1) ? "vertical" : "";
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);
12167 }
12168 }
12169
12170 // Update Multi/multicheck-specific fields if type is multi or multicheck
12171 if (mQuestionEditor.questionType == 1 || mQuestionEditor.questionType == 6) { // multi or multicheck
12172 // Parse multiline options (split by newlines)
12173 questions[mQuestionEditor.editingIndex].options.clear();
12174 std::string optionsStr = mQuestionEditor.multiOptions;
12175 std::istringstream iss(optionsStr);
12176 std::string line;
12177 while (std::getline(iss, line)) {
12178 // Trim whitespace and skip empty lines
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);
12183 }
12184 }
12185 }
12186
12187 // Update Grid-specific fields if type is grid
12188 if (mQuestionEditor.questionType == 7) { // grid
12189 // Parse column headers
12190 questions[mQuestionEditor.editingIndex].columns.clear();
12191 std::string columnsStr = mQuestionEditor.gridColumns;
12192 std::istringstream colIss(columnsStr);
12193 std::string line;
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);
12199 }
12200 }
12201
12202 // Parse row labels
12203 questions[mQuestionEditor.editingIndex].rows.clear();
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);
12211 }
12212 }
12213 }
12214
12215 // Update Image-specific fields if type is image or imageresponse
12216 if (mQuestionEditor.questionType == 8 || mQuestionEditor.questionType == 9) { // image or imageresponse
12217 questions[mQuestionEditor.editingIndex].image = mQuestionEditor.imagePath;
12218 }
12219
12220 // Update question text in translation (English)
12221 std::string textKey = questions[mQuestionEditor.editingIndex].text_key;
12222 std::string questionText = mQuestionEditor.questionText;
12223 mCurrentScale->AddTranslation("en", textKey, questionText);
12224
12225 printf("Updated question: %s (type: %s)\n", questions[mQuestionEditor.editingIndex].id.c_str(), questions[mQuestionEditor.editingIndex].type.c_str());
12226 mCurrentScale->SetDirty(true);
12227 }
12228 mQuestionEditor.show = false;
12229 } else {
12230 // Check for duplicate ID before creating
12231 auto& existingQs = mCurrentScale->GetQuestions();
12232 bool isDuplicate = false;
12233 for (const auto& eq : existingQs) {
12234 if (eq.id == mQuestionEditor.id) { isDuplicate = true; break; }
12235 }
12236 if (isDuplicate) {
12237 printf("Error: Question ID '%s' already exists\n", mQuestionEditor.id);
12238 } else {
12239 // Create new question
12240 ScaleQuestion newQuestion;
12241 newQuestion.id = mQuestionEditor.id;
12242 newQuestion.text_key = mQuestionEditor.id;
12243 newQuestion.type = questionTypes[mQuestionEditor.questionType];
12244 newQuestion.random_group = mQuestionEditor.randomGroup;
12245 newQuestion.required_state = mQuestionEditor.requiredState;
12246
12247 // Set validation (C9) — per-constraint fields
12248 {
12249 auto& val = newQuestion.validation;
12250 std::string qid = mQuestionEditor.id;
12251 auto& e = mQuestionEditor;
12252
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);
12257 return key;
12258 }
12259 return "";
12260 };
12261
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;
12273
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") : "";
12283 }
12284
12285 // Set question head override
12286 newQuestion.question_head = mQuestionEditor.questionHead;
12287
12288 // Set answer alias (S3)
12289 newQuestion.answer_alias = mQuestionEditor.answerAlias;
12290
12291 // Set gate (blocking) — multi (exact match) or short (numeric operator)
12292 {
12293 bool gateAllowed = (mQuestionEditor.questionType == 1 || mQuestionEditor.questionType == 2);
12294 newQuestion.has_gate = mQuestionEditor.hasGate && gateAllowed;
12295 const char* opNames[] = { "greater_than", "less_than", "equals", "not_equals" };
12296 if (newQuestion.has_gate && mQuestionEditor.questionType == 2) {
12297 newQuestion.gate_required_value = "";
12298 newQuestion.gate_operator = opNames[mQuestionEditor.gateOperator];
12299 newQuestion.gate_value = mQuestionEditor.gateValue;
12300 } else {
12301 newQuestion.gate_required_value = newQuestion.has_gate ? mQuestionEditor.gateRequiredValue : "";
12302 newQuestion.gate_operator = "";
12303 newQuestion.gate_value = 0.0;
12304 }
12305 if (newQuestion.has_gate && mCurrentScale && mQuestionEditor.gateTerminateMessageText[0]) {
12306 std::string autoQid = mQuestionEditor.id;
12307 std::string gateKey = mQuestionEditor.gateTerminateMessageKey[0]
12308 ? mQuestionEditor.gateTerminateMessageKey : (autoQid + "_gate_msg");
12309 mCurrentScale->AddTranslation("en", gateKey, mQuestionEditor.gateTerminateMessageText);
12310 newQuestion.gate_terminate_message_key = gateKey;
12311 } else {
12312 newQuestion.gate_terminate_message_key = "";
12313 }
12314 }
12315
12316 // Set conditional display
12317 newQuestion.has_visible_when = mQuestionEditor.hasVisibleWhen;
12318 newQuestion.visible_when_logic = (mQuestionEditor.visibleWhenLogic == 1) ? "any" : "all";
12319 if (mQuestionEditor.hasVisibleWhen) {
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) {
12323 c.source_type = (ec.sourceType == 1) ? "item" : "parameter";
12324 c.source_name = ec.sourceName;
12325 c.op = opNames[ec.op < 8 ? ec.op : 0];
12326 if (ec.op == 4 || ec.op == 5) {
12327 c.is_list = true;
12328 std::istringstream ss(ec.value);
12329 std::string token;
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));
12335 }
12336 } else {
12337 c.is_list = false;
12338 c.value = ec.value;
12339 }
12340 newQuestion.visible_when_simple.push_back(c);
12341 }
12342 }
12343
12344 // Set Likert-specific fields if type is likert
12345 if (mQuestionEditor.questionType == 0) { // likert
12346 newQuestion.likert_points = mQuestionEditor.likertPoints;
12347 newQuestion.likert_min = mQuestionEditor.likertMin;
12348 newQuestion.likert_max = mQuestionEditor.likertMax;
12349 newQuestion.likert_reverse = mQuestionEditor.likertReverse;
12350
12351 // Save selected response options
12352 auto& scaleLikert = mCurrentScale->GetLikertOptions();
12353 auto& scaleLabels = scaleLikert.labels;
12354 bool anySelected = false;
12355 for (size_t i = 0; i < scaleLabels.size() && i < mQuestionEditor.selectedResponseOptions.size(); i++) {
12356 if (mQuestionEditor.selectedResponseOptions[i]) {
12357 newQuestion.likert_labels.push_back(scaleLabels[i]);
12358 anySelected = true;
12359 }
12360 }
12361 // If none selected, leave labels empty to use scale defaults
12362 if (!anySelected) {
12363 newQuestion.likert_labels.clear();
12364 }
12365 }
12366
12367 // Set VAS-specific fields if type is vas
12368 if (mQuestionEditor.questionType == 4) { // vas
12369 newQuestion.min_value = mQuestionEditor.vasMinValue;
12370 newQuestion.max_value = mQuestionEditor.vasMaxValue;
12371 newQuestion.left_label = mQuestionEditor.vasLeftLabel;
12372 newQuestion.right_label = mQuestionEditor.vasRightLabel;
12373 newQuestion.vas_orientation = (mQuestionEditor.vasOrientationIdx == 1) ? "vertical" : "";
12374 for (const auto& ae : mQuestionEditor.vasAnchors) {
12376 va.value = (double)ae.value;
12377 va.label = ae.label;
12378 newQuestion.vas_anchors.push_back(va);
12379 }
12380 }
12381
12382 // Set Multi/multicheck-specific fields if type is multi or multicheck
12383 if (mQuestionEditor.questionType == 1 || mQuestionEditor.questionType == 6) { // multi or multicheck
12384 newQuestion.randomize_options = mQuestionEditor.randomizeOptions;
12385 // Parse multiline options (split by newlines)
12386 std::string optionsStr = mQuestionEditor.multiOptions;
12387 std::istringstream iss(optionsStr);
12388 std::string line;
12389 while (std::getline(iss, line)) {
12390 // Trim whitespace and skip empty lines
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);
12395 }
12396 }
12397 }
12398
12399 // Set Grid-specific fields if type is grid
12400 if (mQuestionEditor.questionType == 7) { // grid
12401 // Parse column headers
12402 std::string columnsStr = mQuestionEditor.gridColumns;
12403 std::istringstream colIss(columnsStr);
12404 std::string line;
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);
12410 }
12411 }
12412
12413 // Parse row labels
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);
12421 }
12422 }
12423 }
12424
12425 // Set Image-specific fields if type is image or imageresponse
12426 if (mQuestionEditor.questionType == 8 || mQuestionEditor.questionType == 9) { // image or imageresponse
12427 newQuestion.image = mQuestionEditor.imagePath;
12428 }
12429
12430 // Add to scale
12431 mCurrentScale->GetQuestions().push_back(newQuestion);
12432
12433 // Add question text to translation (English)
12434 std::string textKey = newQuestion.text_key;
12435 std::string questionText = mQuestionEditor.questionText;
12436 mCurrentScale->AddTranslation("en", textKey, questionText);
12437
12438 printf("Added question: %s (type: %s)\n", newQuestion.id.c_str(), newQuestion.type.c_str());
12439 mCurrentScale->SetDirty(true);
12440
12441 mQuestionEditor.show = false;
12442 } // end duplicate-ID check else
12443 }
12444 }
12445 ImGui::SameLine();
12446 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
12447 mQuestionEditor.show = false;
12448 }
12449
12450 ImGui::End();
12451}
12452
12453void LauncherUI::RenderVisibleWhenEditor(QuestionEditorState& e)
12454{
12455 if (e.visibleWhenIsComplex) {
12456 ImGui::TextWrapped("This item has nested conditional logic.");
12457 ImGui::TextWrapped("Conditions are preserved — use code editor to modify.");
12458 return;
12459 }
12460
12461 ImGui::Checkbox("Show conditionally", &e.hasVisibleWhen);
12462 if (ImGui::IsItemHovered())
12463 ImGui::SetTooltip("When checked, this item is only shown when conditions are met");
12464
12465 if (e.hasVisibleWhen) {
12466 const char* logicItems[] = { "AND (all must match)", "OR (any must match)" };
12467 ImGui::Combo("Combine with", &e.visibleWhenLogic, logicItems, IM_ARRAYSIZE(logicItems));
12468
12469 int removeIndex = -1;
12470 for (int ci = 0; ci < (int)e.visibleWhenConditions.size(); ci++) {
12471 auto& cond = e.visibleWhenConditions[ci];
12472 ImGui::PushID(ci);
12473
12474 const char* sourceTypes[] = { "Parameter", "Item" };
12475 ImGui::PushItemWidth(90);
12476 ImGui::Combo("##src", &cond.sourceType, sourceTypes, IM_ARRAYSIZE(sourceTypes));
12477 ImGui::PopItemWidth();
12478 ImGui::SameLine();
12479
12480 ImGui::PushItemWidth(100);
12481 ImGui::InputText("##name", cond.sourceName, sizeof(cond.sourceName));
12482 ImGui::PopItemWidth();
12483 ImGui::SameLine();
12484
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();
12489 ImGui::SameLine();
12490
12491 if (cond.op < 6) { // is_answered/is_not_answered don't need a value
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();
12497 }
12498 ImGui::SameLine();
12499
12500 if (ImGui::SmallButton("X"))
12501 removeIndex = ci;
12502
12503 ImGui::PopID();
12504 }
12505 if (removeIndex >= 0)
12506 e.visibleWhenConditions.erase(e.visibleWhenConditions.begin() + removeIndex);
12507
12508 if (ImGui::SmallButton("+ Add Condition")) {
12509 EditorCondition ec;
12510 e.visibleWhenConditions.push_back(ec);
12511 e.randomGroup = 0;
12512 }
12513 }
12514}
12515
12516void LauncherUI::RenderSectionEditorForm()
12517{
12518 auto& e = mQuestionEditor;
12519 if (!e.show) return;
12520
12521 const char* title = (e.editingIndex >= 0) ? "Edit Section"
12522 : e.isVirtualStart ? "Edit Start Section"
12523 : "Add 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));
12528
12529 if (!ImGui::Begin(title, &e.show, ImGuiWindowFlags_NoCollapse)) {
12530 ImGui::End();
12531 return;
12532 }
12533
12534 ImGui::Text("Section Marker");
12535 ImGui::Separator();
12536 ImGui::Spacing();
12537
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.");
12542
12543 ImGui::Text("Title (optional):");
12544 ImGui::SetNextItemWidth(-FLT_MIN);
12545 ImGui::InputText("##sec_title", e.questionText, sizeof(e.questionText));
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.");
12549
12550 ImGui::Spacing();
12551 ImGui::Separator();
12552 ImGui::Text("Conditional Display");
12553 ImGui::Spacing();
12554
12555 RenderVisibleWhenEditor(e);
12556
12557 ImGui::Spacing();
12558 ImGui::Separator();
12559 ImGui::Text("Navigation");
12560 ImGui::Spacing();
12561
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.");
12567
12568 ImGui::Spacing();
12569
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.");
12575 if (e.sectionRandomize) {
12576 ImGui::SetNextItemWidth(-FLT_MIN);
12577 ImGui::InputText("##fixedIds", e.sectionRandomizeFixed, sizeof(e.sectionRandomizeFixed));
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)");
12582 }
12583
12584 ImGui::Spacing();
12585 ImGui::Separator();
12586 ImGui::Spacing();
12587
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))) {
12592 ScaleQuestion sec;
12593 sec.id = e.id;
12594 sec.type = "section";
12595 sec.text_key = e.id; // convention: key == id
12597 sec.visible_when_logic = (e.visibleWhenLogic == 1) ? "any" : "all";
12598 sec.visible_when_is_complex = false;
12601 sec.section_randomize_fixed.clear();
12603 std::string fixedStr = e.sectionRandomizeFixed;
12604 std::istringstream ss(fixedStr);
12605 std::string token;
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)
12610 sec.section_randomize_fixed.push_back(token.substr(start, end - start + 1));
12611 }
12612 }
12613 if (e.hasVisibleWhen) {
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) {
12617 c.source_type = (ec.sourceType == 1) ? "item" : "parameter";
12618 c.source_name = ec.sourceName;
12619 c.op = opNames[ec.op < 8 ? ec.op : 0];
12620 if (ec.op == 4 || ec.op == 5) {
12621 c.is_list = true;
12622 std::istringstream ss(ec.value);
12623 std::string token;
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));
12629 }
12630 } else {
12631 c.is_list = false;
12632 c.value = ec.value;
12633 }
12634 sec.visible_when_simple.push_back(c);
12635 }
12636 }
12637 if (e.questionText[0] && mCurrentScale)
12638 mCurrentScale->AddTranslation("en", sec.id, e.questionText);
12639
12640 if (e.editingIndex < 0) {
12641 if (e.isVirtualStart)
12642 mCurrentScale->InsertQuestion(0, sec);
12643 else
12644 mCurrentScale->AddQuestion(sec);
12645 } else {
12646 auto& questions = mCurrentScale->GetQuestions();
12647 if (e.editingIndex < (int)questions.size()) {
12648 questions[e.editingIndex] = sec;
12649 mCurrentScale->SetDirty(true);
12650 }
12651 }
12652 e.show = false;
12653 }
12654 if (!canSave) ImGui::EndDisabled();
12655 ImGui::SameLine();
12656 if (ImGui::Button("Cancel", ImVec2(80, 0)))
12657 e.show = false;
12658
12659 ImGui::End();
12660}
12661
12662void LauncherUI::ShowBatchImportDialog()
12663{
12664 if (!mCurrentScale) {
12665 mBatchImport.show = false;
12666 return;
12667 }
12668
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));
12671
12672 if (!ImGui::Begin("Batch Import Questions", &mBatchImport.show, ImGuiWindowFlags_NoCollapse))
12673 {
12674 ImGui::End();
12675 return;
12676 }
12677
12678 ImGui::TextWrapped("Paste your questions below, one per line. Each line will become a separate question.");
12679 ImGui::Spacing();
12680 ImGui::Separator();
12681 ImGui::Spacing();
12682
12683 // Common settings
12684 ImGui::Text("Common Settings for All Questions");
12685 ImGui::Spacing();
12686
12687 // ID Prefix
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, ...)");
12691 }
12692
12693 // Start Number
12694 ImGui::InputInt("Start Number", &mBatchImport.startNumber);
12695
12696 // Question Type
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))) {
12699 // Reset likert config when type changes
12700 if (mBatchImport.questionType == 0) { // likert
12701 mBatchImport.likertPreset = 0;
12702 mBatchImport.likertPoints = 5;
12703 }
12704 }
12705
12706 // Likert-specific configuration (only show if type is likert)
12707 if (mBatchImport.questionType == 0) { // likert
12708 ImGui::Spacing();
12709 ImGui::Text("Likert Response Options:");
12710 ImGui::Indent();
12711
12712 // Preset dropdown
12713 const char* likertPresets[] = {
12714 "Custom",
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)"
12720 };
12721
12722 if (ImGui::Combo("Preset", &mBatchImport.likertPreset, likertPresets, IM_ARRAYSIZE(likertPresets))) {
12723 // Apply preset values
12724 switch (mBatchImport.likertPreset) {
12725 case 1: // TRUE/FALSE
12726 mBatchImport.likertPoints = 2;
12727 std::strncpy(mBatchImport.likertLabels, "TRUE|FALSE", sizeof(mBatchImport.likertLabels) - 1);
12728 break;
12729 case 2: // Agree 5-point
12730 mBatchImport.likertPoints = 5;
12731 std::strncpy(mBatchImport.likertLabels, "Strongly Disagree|Disagree|Neutral|Agree|Strongly Agree", sizeof(mBatchImport.likertLabels) - 1);
12732 break;
12733 case 3: // Agree 7-point
12734 mBatchImport.likertPoints = 7;
12735 std::strncpy(mBatchImport.likertLabels, "Strongly Disagree|Disagree|Somewhat Disagree|Neutral|Somewhat Agree|Agree|Strongly Agree", sizeof(mBatchImport.likertLabels) - 1);
12736 break;
12737 case 4: // Never/Always 5-point
12738 mBatchImport.likertPoints = 5;
12739 std::strncpy(mBatchImport.likertLabels, "Never|Rarely|Sometimes|Often|Always", sizeof(mBatchImport.likertLabels) - 1);
12740 break;
12741 case 5: // Not at all/Extremely 5-point
12742 mBatchImport.likertPoints = 5;
12743 std::strncpy(mBatchImport.likertLabels, "Not at all|A little|Moderately|Quite a bit|Extremely", sizeof(mBatchImport.likertLabels) - 1);
12744 break;
12745 default: // Custom
12746 mBatchImport.likertPoints = 5;
12747 mBatchImport.likertLabels[0] = '\0';
12748 break;
12749 }
12750 mBatchImport.likertLabels[sizeof(mBatchImport.likertLabels) - 1] = '\0';
12751 }
12752
12753 // Manual configuration
12754 ImGui::InputInt("Number of Points", &mBatchImport.likertPoints);
12755 if (mBatchImport.likertPoints < 2) mBatchImport.likertPoints = 2;
12756 if (mBatchImport.likertPoints > 10) mBatchImport.likertPoints = 10;
12757
12758 ImGui::InputInt("Min Value", &mBatchImport.likertMin);
12759 if (ImGui::IsItemHovered()) {
12760 ImGui::SetTooltip("Minimum value for responses (-1 = use default based on points)");
12761 }
12762
12763 ImGui::InputInt("Max Value", &mBatchImport.likertMax);
12764 if (ImGui::IsItemHovered()) {
12765 ImGui::SetTooltip("Maximum value for responses (-1 = use default based on points)");
12766 }
12767
12768 ImGui::InputText("Response Labels", mBatchImport.likertLabels, sizeof(mBatchImport.likertLabels));
12769 if (ImGui::IsItemHovered()) {
12770 ImGui::SetTooltip("Pipe-separated labels (e.g., 'True|False' or 'Strongly Disagree|...|Strongly Agree')");
12771 }
12772
12773 ImGui::Unindent();
12774 ImGui::Spacing();
12775 }
12776
12777 ImGui::Spacing();
12778 ImGui::Separator();
12779 ImGui::Spacing();
12780
12781 // Question text input
12782 ImGui::Text("Question Text (one per line):");
12783 ImGui::InputTextMultiline("##QuestionText", mBatchImport.questionText, sizeof(mBatchImport.questionText), ImVec2(-1, 250));
12784
12785 ImGui::Spacing();
12786 ImGui::Separator();
12787 ImGui::Spacing();
12788
12789 // Preview count
12790 int lineCount = 0;
12791 for (int i = 0; mBatchImport.questionText[i] != '\0'; i++) {
12792 if (mBatchImport.questionText[i] == '\n') lineCount++;
12793 }
12794 if (strlen(mBatchImport.questionText) > 0 && mBatchImport.questionText[strlen(mBatchImport.questionText)-1] != '\n') {
12795 lineCount++; // Count last line if it doesn't end with newline
12796 }
12797 ImGui::Text("Questions to import: %d", lineCount);
12798
12799 ImGui::Spacing();
12800
12801 // Buttons
12802 if (ImGui::Button("Import Questions", ImVec2(150, 0))) {
12803 if (strlen(mBatchImport.idPrefix) == 0) {
12804 printf("Error: ID Prefix cannot be empty\n");
12805 } else {
12806 // Parse questions line by line
12807 std::vector<std::string> lines;
12808 std::string text(mBatchImport.questionText);
12809 size_t start = 0;
12810 size_t end = text.find('\n');
12811
12812 while (end != std::string::npos) {
12813 std::string line = text.substr(start, end - start);
12814 // Trim whitespace
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);
12819 }
12820 start = end + 1;
12821 end = text.find('\n', start);
12822 }
12823
12824 // Handle last line
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);
12831 }
12832 }
12833
12834 // Parse likert labels if this is a likert type
12835 std::vector<std::string> likertLabelsList;
12836 if (mBatchImport.questionType == 0 && strlen(mBatchImport.likertLabels) > 0) {
12837 std::string labelsStr(mBatchImport.likertLabels);
12838 size_t start = 0;
12839 size_t end = labelsStr.find('|');
12840 while (end != std::string::npos) {
12841 likertLabelsList.push_back(labelsStr.substr(start, end - start));
12842 start = end + 1;
12843 end = labelsStr.find('|', start);
12844 }
12845 // Handle last label
12846 if (start < labelsStr.length()) {
12847 likertLabelsList.push_back(labelsStr.substr(start));
12848 }
12849 }
12850
12851 // Create questions
12852 int questionNumber = mBatchImport.startNumber;
12853
12854 // Prepare label keys if labels are provided (will be set per-question)
12855 std::vector<std::string> labelKeys;
12856 if (mBatchImport.questionType == 0 && !likertLabelsList.empty()) {
12857 // Create translation keys for response labels
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);
12861 // Add label to translations
12862 mCurrentScale->GetTranslations()["en"][labelKey] = likertLabelsList[i];
12863 }
12864
12865 // Also update scale-level defaults (for questions that don't override)
12866 mCurrentScale->GetLikertOptions().points = mBatchImport.likertPoints;
12867 mCurrentScale->GetLikertOptions().labels = labelKeys;
12868 mCurrentScale->GetLikertOptions().min = mBatchImport.likertMin;
12869 mCurrentScale->GetLikertOptions().max = mBatchImport.likertMax;
12870 }
12871
12872 // Pre-build set of existing IDs to detect duplicates
12873 std::set<std::string> existingIds;
12874 for (const auto& q : mCurrentScale->GetQuestions()) existingIds.insert(q.id);
12875
12876 for (const auto& line : lines) {
12877 ScaleQuestion newQuestion;
12878 // Format question number with 3-digit zero padding (e.g., 001, 002, ..., 010, 011)
12879 char numStr[16];
12880 snprintf(numStr, sizeof(numStr), "%03d", questionNumber);
12881 newQuestion.id = std::string(mBatchImport.idPrefix) + numStr;
12882 newQuestion.text_key = newQuestion.id;
12883 newQuestion.type = questionTypes[mBatchImport.questionType];
12884
12885 // Skip if ID already exists
12886 if (existingIds.count(newQuestion.id)) {
12887 printf("Warning: Skipping duplicate question ID '%s'\n", newQuestion.id.c_str());
12888 questionNumber++;
12889 continue;
12890 }
12891 existingIds.insert(newQuestion.id); // Track newly added ID
12892
12893 // Apply likert-specific settings
12894 if (mBatchImport.questionType == 0) { // likert
12895 newQuestion.likert_points = mBatchImport.likertPoints;
12896 newQuestion.likert_min = mBatchImport.likertMin;
12897 newQuestion.likert_max = mBatchImport.likertMax;
12898 // Set labels at question level
12899 newQuestion.likert_labels = labelKeys;
12900 }
12901
12902 mCurrentScale->GetQuestions().push_back(newQuestion);
12903
12904 // Also add to translations (English)
12905 mCurrentScale->GetTranslations()["en"][newQuestion.text_key] = line;
12906
12907 questionNumber++;
12908 }
12909
12910 printf("Batch imported %zu questions\n", lines.size());
12911 mBatchImport.show = false;
12912 }
12913 }
12914 ImGui::SameLine();
12915 if (ImGui::Button("Cancel", ImVec2(150, 0))) {
12916 mBatchImport.show = false;
12917 }
12918
12919 ImGui::End();
12920}
12921
12922void LauncherUI::ShowDimensionEditor()
12923{
12924 if (!mCurrentScale) {
12925 mDimensionEditor.show = false;
12926 return;
12927 }
12928
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));
12931
12932 bool isEditing = (mDimensionEditor.editingIndex >= 0);
12933 const char* windowTitle = isEditing ? "Edit Dimension" : "Add Dimension";
12934
12935 if (!ImGui::Begin(windowTitle, &mDimensionEditor.show, ImGuiWindowFlags_NoCollapse))
12936 {
12937 ImGui::End();
12938 return;
12939 }
12940
12941 ImGui::Text("Dimension Details");
12942 ImGui::Separator();
12943 ImGui::Spacing();
12944
12945 // Dimension ID (read-only when editing to avoid breaking scoring references)
12946 if (isEditing) {
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)");
12950 }
12951 } else {
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')");
12955 }
12956 }
12957
12958 // Name
12959 ImGui::InputText("Name", mDimensionEditor.name, sizeof(mDimensionEditor.name));
12960 if (ImGui::IsItemHovered()) {
12961 ImGui::SetTooltip("Full name (e.g., 'Checking Behaviors')");
12962 }
12963
12964 // Abbreviation
12965 ImGui::InputText("Abbreviation", mDimensionEditor.abbreviation, sizeof(mDimensionEditor.abbreviation));
12966 if (ImGui::IsItemHovered()) {
12967 ImGui::SetTooltip("Short abbreviation (e.g., 'CHK')");
12968 }
12969
12970 // Description
12971 ImGui::Spacing();
12972 ImGui::Text("Description:");
12973 ImGui::InputTextMultiline("##DimDescription", mDimensionEditor.description, sizeof(mDimensionEditor.description), ImVec2(-1, 100));
12974
12975 // Parameter-driven enable/disable
12976 ImGui::Spacing();
12977 ImGui::Separator();
12978 ImGui::Text("Dimension Selection");
12979 ImGui::Spacing();
12980
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.");
12984 }
12985
12986 if (mDimensionEditor.selectable) {
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.");
12992 }
12993 }
12994
12995 // Conditional Display section
12996 ImGui::Spacing();
12997 ImGui::Separator();
12998 ImGui::Text("Conditional Display");
12999 ImGui::Spacing();
13000
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.");
13004 }
13005
13006 if (mDimensionEditor.hasVisibleWhen) {
13007 const char* logicItems[] = { "AND (all must match)", "OR (any must match)" };
13008 ImGui::Combo("Combine with##dim", &mDimensionEditor.visibleWhenLogic, logicItems, IM_ARRAYSIZE(logicItems));
13009
13010 int removeIndex = -1;
13011 for (int ci = 0; ci < (int)mDimensionEditor.visibleWhenConditions.size(); ci++) {
13012 auto& cond = mDimensionEditor.visibleWhenConditions[ci];
13013 ImGui::PushID(ci);
13014
13015 const char* sourceTypes[] = { "Parameter", "Item" };
13016 ImGui::PushItemWidth(90);
13017 ImGui::Combo("##src", &cond.sourceType, sourceTypes, IM_ARRAYSIZE(sourceTypes));
13018 ImGui::PopItemWidth();
13019 ImGui::SameLine();
13020
13021 ImGui::PushItemWidth(100);
13022 ImGui::InputText("##name", cond.sourceName, sizeof(cond.sourceName));
13023 ImGui::PopItemWidth();
13024 ImGui::SameLine();
13025
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();
13030 ImGui::SameLine();
13031
13032 ImGui::PushItemWidth(100);
13033 ImGui::InputText("##val", cond.value, sizeof(cond.value));
13034 ImGui::PopItemWidth();
13035 ImGui::SameLine();
13036
13037 if (ImGui::SmallButton("X")) {
13038 removeIndex = ci;
13039 }
13040
13041 ImGui::PopID();
13042 }
13043 if (removeIndex >= 0) {
13044 mDimensionEditor.visibleWhenConditions.erase(
13045 mDimensionEditor.visibleWhenConditions.begin() + removeIndex);
13046 }
13047
13048 if (ImGui::SmallButton("+ Add Condition##dim")) {
13049 EditorCondition ec;
13050 mDimensionEditor.visibleWhenConditions.push_back(ec);
13051 }
13052 }
13053
13054 ImGui::Spacing();
13055 ImGui::Separator();
13056 ImGui::Spacing();
13057
13058 // Helper lambda to save visible_when from editor to dimension
13059 auto saveDimVisibleWhen = [this](ScaleDimension& dim) {
13060 dim.has_visible_when = mDimensionEditor.hasVisibleWhen;
13061 dim.visible_when_logic = (mDimensionEditor.visibleWhenLogic == 1) ? "any" : "all";
13062 dim.visible_when.clear();
13063 if (mDimensionEditor.hasVisibleWhen) {
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) {
13067 c.source_type = (ec.sourceType == 1) ? "item" : "parameter";
13068 c.source_name = ec.sourceName;
13069 c.op = opNames[ec.op < 8 ? ec.op : 0];
13070 if (ec.op == 4 || ec.op == 5) {
13071 c.is_list = true;
13072 std::istringstream ss(ec.value);
13073 std::string token;
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));
13079 }
13080 } else {
13081 c.is_list = false;
13082 c.value = ec.value;
13083 }
13084 dim.visible_when.push_back(c);
13085 }
13086 }
13087 };
13088
13089 // Buttons
13090 const char* okLabel = isEditing ? "Save" : "Create";
13091 if (ImGui::Button(okLabel, ImVec2(120, 0))) {
13092 // Validate
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) {
13098 // Update existing dimension
13099 auto& dim = mCurrentScale->GetDimensions()[mDimensionEditor.editingIndex];
13100 dim.name = mDimensionEditor.name;
13101 dim.abbreviation = mDimensionEditor.abbreviation;
13102 dim.description = mDimensionEditor.description;
13103 dim.selectable = mDimensionEditor.selectable;
13104 dim.default_enabled = mDimensionEditor.defaultEnabled;
13105 dim.enabled_param = mDimensionEditor.selectable ? mDimensionEditor.enabledParam : "";
13106 saveDimVisibleWhen(dim);
13107 mCurrentScale->SetDirty(true);
13108 mDimensionEditor.show = false;
13109 } else {
13110 // Create new dimension
13111 ScaleDimension newDimension;
13112 newDimension.id = mDimensionEditor.id;
13113 newDimension.name = mDimensionEditor.name;
13114 newDimension.abbreviation = mDimensionEditor.abbreviation;
13115 newDimension.description = mDimensionEditor.description;
13116 newDimension.selectable = mDimensionEditor.selectable;
13117 newDimension.default_enabled = mDimensionEditor.defaultEnabled;
13118 newDimension.enabled_param = mDimensionEditor.selectable ? mDimensionEditor.enabledParam : "";
13119 saveDimVisibleWhen(newDimension);
13120
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());
13124
13125 mDimensionEditor.show = false;
13126 }
13127 }
13128 ImGui::SameLine();
13129 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
13130 mDimensionEditor.show = false;
13131 }
13132
13133 ImGui::End();
13134}
13135
13136void LauncherUI::ShowCorrectAnswersEditor()
13137{
13138 if (!mCurrentScale) {
13139 mCorrectAnswersEditor.show = false;
13140 return;
13141 }
13142
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));
13146
13147 std::string title = "Correct Answers: " + mCorrectAnswersEditor.questionId;
13148 if (!ImGui::Begin(title.c_str(), &mCorrectAnswersEditor.show, ImGuiWindowFlags_NoCollapse))
13149 {
13150 ImGui::End();
13151 return;
13152 }
13153
13154 // Compact header: question context + instructions side by side
13155 ImGui::TextWrapped("Q: %s", mCorrectAnswersEditor.questionText.c_str());
13156 ImGui::SameLine();
13157 ImGui::TextDisabled("(%s)", mCorrectAnswersEditor.questionType.c_str());
13158
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).");
13165 } else {
13166 ImGui::TextDisabled("Response is correct if it matches ANY entry below. "
13167 "Use * for any chars, ? for single char. Case-insensitive by default.");
13168 }
13169
13170 ImGui::Separator();
13171
13172 // Answer list header
13173 float matchCaseWidth = 80.0f;
13174 float removeWidth = 60.0f;
13175 float spacing = ImGui::GetStyle().ItemSpacing.x;
13176
13177 // Column labels
13178 ImGui::Text("Answer / Pattern");
13179 ImGui::SameLine(ImGui::GetContentRegionAvail().x - matchCaseWidth - removeWidth - spacing);
13180 ImGui::Text("Aa");
13181 if (ImGui::IsItemHovered()) {
13182 ImGui::SetTooltip("Match case: when checked, comparison is case-sensitive");
13183 }
13184
13185 // Scrollable answer list
13186 int removeIndex = -1;
13187
13188 ImGui::BeginChild("AnswerList", ImVec2(0, -35), true);
13189 for (size_t i = 0; i < mCorrectAnswersEditor.answers.size(); i++) {
13190 ImGui::PushID((int)i);
13191
13192 // Input field
13193 char buf[512];
13194 std::strncpy(buf, mCorrectAnswersEditor.answers[i].c_str(), sizeof(buf) - 1);
13195 buf[sizeof(buf) - 1] = '\0';
13196
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;
13201 }
13202
13203 // Match case checkbox
13204 ImGui::SameLine();
13205 // Ensure caseSensitive vector is in sync
13206 while (mCorrectAnswersEditor.caseSensitive.size() <= i) {
13207 mCorrectAnswersEditor.caseSensitive.push_back(false);
13208 }
13209 bool cs = mCorrectAnswersEditor.caseSensitive[i];
13210 if (ImGui::Checkbox("Match##cs", &cs)) {
13211 mCorrectAnswersEditor.caseSensitive[i] = cs;
13212 }
13213
13214 // Remove button
13215 ImGui::SameLine();
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;
13219 }
13220 ImGui::PopStyleColor();
13221
13222 ImGui::PopID();
13223 }
13224 ImGui::EndChild();
13225
13226 // Remove the marked item (outside the loop)
13227 if (removeIndex >= 0) {
13228 mCorrectAnswersEditor.answers.erase(mCorrectAnswersEditor.answers.begin() + removeIndex);
13229 if (removeIndex < static_cast<int>(mCorrectAnswersEditor.caseSensitive.size())) {
13230 mCorrectAnswersEditor.caseSensitive.erase(mCorrectAnswersEditor.caseSensitive.begin() + removeIndex);
13231 }
13232 }
13233
13234 // Bottom buttons
13235 if (ImGui::Button("+ Add")) {
13236 mCorrectAnswersEditor.answers.push_back("");
13237 mCorrectAnswersEditor.caseSensitive.push_back(false);
13238 }
13239
13240 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 160);
13241
13242 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.6f, 0.2f, 1.0f));
13243 if (ImGui::Button("OK", ImVec2(70, 0))) {
13244 // Save answers back to scoring data, reconstructing (?c) prefix
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()) {
13252 bool cs = (i < mCorrectAnswersEditor.caseSensitive.size()) && mCorrectAnswersEditor.caseSensitive[i];
13253 if (cs) {
13254 cleaned.push_back("(?c)" + ans);
13255 } else {
13256 cleaned.push_back(ans);
13257 }
13258 }
13259 }
13260 if (cleaned.empty()) {
13261 it->second.correct_answers.erase(mCorrectAnswersEditor.questionId);
13262 } else {
13263 it->second.correct_answers[mCorrectAnswersEditor.questionId] = cleaned;
13264 }
13265 mCurrentScale->SetDirty(true);
13266 }
13267 mCorrectAnswersEditor.show = false;
13268 }
13269 ImGui::PopStyleColor();
13270
13271 ImGui::SameLine();
13272 if (ImGui::Button("Cancel", ImVec2(70, 0))) {
13273 mCorrectAnswersEditor.show = false;
13274 }
13275
13276 ImGui::End();
13277}
13278
13279void LauncherUI::ShowNormsEditor()
13280{
13281 if (!mCurrentScale) {
13282 mNormsEditor.show = false;
13283 return;
13284 }
13285
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));
13289
13290 std::string title = "Norms Editor — " + mNormsEditor.dimensionName;
13291 if (!ImGui::Begin(title.c_str(), &mNormsEditor.show, ImGuiWindowFlags_NoCollapse))
13292 {
13293 ImGui::End();
13294 return;
13295 }
13296
13297 ImGui::TextDisabled("Set score ranges and interpretation labels for the report.");
13298 ImGui::Separator();
13299
13300 int removeIndex = -1;
13301
13302 ImGui::BeginChild("ThresholdList", ImVec2(0, -40), true);
13303 if (ImGui::BeginTable("NormsTable", 4, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp))
13304 {
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();
13310
13311 for (size_t i = 0; i < mNormsEditor.rows.size(); i++) {
13312 ImGui::PushID((int)i);
13313 auto& row = mNormsEditor.rows[i];
13314
13315 ImGui::TableNextRow();
13316 ImGui::TableSetColumnIndex(0);
13317 ImGui::SetNextItemWidth(-1);
13318 ImGui::InputFloat("##min", &row.minVal, 0, 0, "%.1f");
13319
13320 ImGui::TableSetColumnIndex(1);
13321 ImGui::SetNextItemWidth(-1);
13322 ImGui::InputFloat("##max", &row.maxVal, 0, 0, "%.1f");
13323
13324 ImGui::TableSetColumnIndex(2);
13325 ImGui::SetNextItemWidth(-1);
13326 ImGui::InputText("##label", row.label, sizeof(row.label));
13327
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;
13332 }
13333 ImGui::PopStyleColor();
13334
13335 ImGui::PopID();
13336 }
13337 ImGui::EndTable();
13338 }
13339 ImGui::EndChild();
13340
13341 if (removeIndex >= 0) {
13342 mNormsEditor.rows.erase(mNormsEditor.rows.begin() + removeIndex);
13343 }
13344
13345 if (ImGui::Button("+ Add Threshold")) {
13347 if (!mNormsEditor.rows.empty()) {
13348 float prevMax = mNormsEditor.rows.back().maxVal;
13349 te.minVal = prevMax + 1.0f;
13350 te.maxVal = te.minVal + 5.0f;
13351 }
13352 mNormsEditor.rows.push_back(te);
13353 }
13354
13355 ImGui::SameLine(ImGui::GetContentRegionAvail().x - 160);
13356
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) {
13364 NormThreshold nt;
13365 nt.min = (double)row.minVal;
13366 nt.max = (double)row.maxVal;
13367 nt.label = row.label;
13368 it->second.norms.push_back(nt);
13369 }
13370 mCurrentScale->SetDirty(true);
13371 }
13372 mNormsEditor.show = false;
13373 }
13374 ImGui::PopStyleColor();
13375
13376 ImGui::SameLine();
13377 if (ImGui::Button("Cancel", ImVec2(70, 0))) {
13378 mNormsEditor.show = false;
13379 }
13380
13381 ImGui::End();
13382}
13383
13384void LauncherUI::ShowCreateStudyFromScaleDialog()
13385{
13386 // If in scale-selection mode, we need mWorkspace but not mCurrentScale
13387 // If not in scale-selection mode, we need both
13388 if (!mWorkspace) {
13389 mCreateStudyDialog.show = false;
13390 return;
13391 }
13392
13393 if (!mCreateStudyDialog.needScaleSelection && !mCurrentScale) {
13394 mCreateStudyDialog.show = false;
13395 return;
13396 }
13397
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));
13400
13401 if (!ImGui::Begin("Add Scale to Study", &mCreateStudyDialog.show, ImGuiWindowFlags_NoCollapse))
13402 {
13403 ImGui::End();
13404 return;
13405 }
13406
13407 ImGui::Text("Add this scale as a test in a study.");
13408 ImGui::Spacing();
13409 ImGui::Separator();
13410 ImGui::Spacing();
13411
13412 // Scale selection (if needed - when opened from Study Bar)
13413 if (mCreateStudyDialog.needScaleSelection) {
13414 ImGui::Text("Select Scale:");
13415 const char* currentScaleName = (mCreateStudyDialog.selectedScaleIndex >= 0 &&
13416 mCreateStudyDialog.selectedScaleIndex < (int)mScaleList.size())
13417 ? mScaleList[mCreateStudyDialog.selectedScaleIndex].c_str()
13418 : "Select a scale...";
13419
13420 if (ImGui::BeginCombo("##ScaleSelect", currentScaleName)) {
13421 for (size_t i = 0; i < mScaleList.size(); i++) {
13422 bool is_selected = (mCreateStudyDialog.selectedScaleIndex == (int)i);
13423 if (ImGui::Selectable(mScaleList[i].c_str(), is_selected)) {
13424 mCreateStudyDialog.selectedScaleIndex = i;
13425 std::strncpy(mCreateStudyDialog.studyName, mScaleList[i].c_str(),
13426 sizeof(mCreateStudyDialog.studyName) - 1);
13427 mCreateStudyDialog.studyName[sizeof(mCreateStudyDialog.studyName) - 1] = '\0';
13428 mCreateStudyDialog.confirmOverwrite = false;
13429 mCreateStudyDialog.errorMessage[0] = '\0';
13430 }
13431 if (is_selected) {
13432 ImGui::SetItemDefaultFocus();
13433 }
13434 }
13435 ImGui::EndCombo();
13436 }
13437 ImGui::Spacing();
13438 }
13439
13440 // Mode selector: Create New Study vs Add to Existing Study
13441 if (ImGui::RadioButton("Create new study", !mCreateStudyDialog.addToExisting)) {
13442 mCreateStudyDialog.addToExisting = false;
13443 mCreateStudyDialog.errorMessage[0] = '\0';
13444 mCreateStudyDialog.confirmOverwrite = false;
13445 }
13446 ImGui::SameLine();
13447 if (ImGui::RadioButton("Add to existing study", mCreateStudyDialog.addToExisting)) {
13448 mCreateStudyDialog.addToExisting = true;
13449 mCreateStudyDialog.errorMessage[0] = '\0';
13450 mCreateStudyDialog.confirmOverwrite = false;
13451 }
13452
13453 ImGui::Spacing();
13454
13455 if (mCreateStudyDialog.addToExisting) {
13456 // Existing study dropdown
13457 ImGui::Text("Select Study:");
13458 const char* currentStudyName = (mCreateStudyDialog.selectedStudyIndex >= 0 &&
13459 mCreateStudyDialog.selectedStudyIndex < (int)mStudyList.size())
13460 ? mStudyList[mCreateStudyDialog.selectedStudyIndex].c_str()
13461 : "Select a study...";
13462
13463 if (ImGui::BeginCombo("##ExistingStudySelect", currentStudyName)) {
13464 for (size_t i = 0; i < mStudyList.size(); i++) {
13465 bool is_selected = (mCreateStudyDialog.selectedStudyIndex == (int)i);
13466 if (ImGui::Selectable(mStudyList[i].c_str(), is_selected)) {
13467 mCreateStudyDialog.selectedStudyIndex = (int)i;
13468 mCreateStudyDialog.errorMessage[0] = '\0';
13469 }
13470 if (is_selected) {
13471 ImGui::SetItemDefaultFocus();
13472 }
13473 }
13474 ImGui::EndCombo();
13475 }
13476 } else {
13477 // New study name input
13478 ImGui::Text("Study Name:");
13479 if (ImGui::InputText("##StudyName", mCreateStudyDialog.studyName, sizeof(mCreateStudyDialog.studyName))) {
13480 mCreateStudyDialog.confirmOverwrite = false;
13481 mCreateStudyDialog.errorMessage[0] = '\0';
13482 }
13483 if (ImGui::IsItemHovered()) {
13484 ImGui::SetTooltip("The name of the study directory to create in my_studies/");
13485 }
13486 }
13487
13488 ImGui::Spacing();
13489
13490 // Show error message if any
13491 if (strlen(mCreateStudyDialog.errorMessage) > 0) {
13492 ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.2f, 0.2f, 1.0f));
13493 ImGui::TextWrapped("%s", mCreateStudyDialog.errorMessage);
13494 ImGui::PopStyleColor();
13495 ImGui::Spacing();
13496 }
13497
13498 ImGui::Separator();
13499 ImGui::Spacing();
13500
13501 // Action button
13502 const char* buttonText = mCreateStudyDialog.addToExisting ? "Add" :
13503 (mCreateStudyDialog.confirmOverwrite ? "Update" : "Create");
13504 if (ImGui::Button(buttonText, ImVec2(120, 0))) {
13505 // Load the scale if needed
13506 std::shared_ptr<ScaleDefinition> scaleToUse = mCurrentScale;
13507 if (mCreateStudyDialog.needScaleSelection) {
13508 if (mCreateStudyDialog.selectedScaleIndex < 0) {
13509 std::strncpy(mCreateStudyDialog.errorMessage, "Please select a scale first.",
13510 sizeof(mCreateStudyDialog.errorMessage) - 1);
13511 scaleToUse = nullptr;
13512 } else {
13513 std::string scaleCode = mScaleList[mCreateStudyDialog.selectedScaleIndex];
13514 scaleToUse = mScaleManager->LoadScale(scaleCode);
13515 if (!scaleToUse) {
13516 std::strncpy(mCreateStudyDialog.errorMessage, "Failed to load selected scale.",
13517 sizeof(mCreateStudyDialog.errorMessage) - 1);
13518 }
13519 }
13520 }
13521
13522 if (scaleToUse) {
13523 if (mCreateStudyDialog.addToExisting) {
13524 // Add to existing study
13525 if (mCreateStudyDialog.selectedStudyIndex < 0 ||
13526 mCreateStudyDialog.selectedStudyIndex >= (int)mStudyList.size()) {
13527 std::strncpy(mCreateStudyDialog.errorMessage, "Please select a study.",
13528 sizeof(mCreateStudyDialog.errorMessage) - 1);
13529 } else {
13530 std::string studyDir = mStudyList[mCreateStudyDialog.selectedStudyIndex];
13531 std::string studyPath = mWorkspace->GetStudiesPath() + "/" + studyDir;
13532
13533 if (mScaleManager->AddScaleToStudy(scaleToUse, studyPath)) {
13534 printf("Scale '%s' added to study '%s'\n",
13535 scaleToUse->GetScaleInfo().code.c_str(), studyDir.c_str());
13536
13537 // Also add to "Main" chain if it exists
13538 std::string mainChainPath = studyPath + "/chains/Main.json";
13539 if (fs::exists(mainChainPath)) {
13541 item.testName = scaleToUse->GetScaleInfo().code;
13542 item.paramVariant = "default";
13543 item.language = "en";
13544 item.randomGroup = 0;
13545
13546 if (mCurrentChain && mCurrentChain->GetFilePath() == mainChainPath) {
13547 mCurrentChain->AddItem(item);
13548 mCurrentChain->Save();
13549 printf("Added scale to Main chain (current chain)\n");
13550 } else {
13551 auto mainChain = Chain::LoadFromFile(mainChainPath);
13552 if (mainChain) {
13553 mainChain->AddItem(item);
13554 mainChain->Save();
13555 printf("Added scale to Main chain\n");
13556 }
13557 }
13558 }
13559
13560 mCreateStudyDialog.show = false;
13561 mStudyList = mWorkspace->GetStudyDirectories();
13562
13563 // Reload current study so the new test appears in the listing
13564 if (mCurrentStudy && mCurrentStudy->GetPath() == studyPath) {
13565 LoadStudy(studyPath);
13566 }
13567 } else {
13568 std::strncpy(mCreateStudyDialog.errorMessage,
13569 "Failed to add scale to study. Check console for details.",
13570 sizeof(mCreateStudyDialog.errorMessage) - 1);
13571 }
13572 }
13573 } else {
13574 // Create new study
13575 if (strlen(mCreateStudyDialog.studyName) == 0) {
13576 std::strncpy(mCreateStudyDialog.errorMessage, "Study name cannot be empty.",
13577 sizeof(mCreateStudyDialog.errorMessage) - 1);
13578 } else {
13579 std::string studyPath = mWorkspace->GetStudiesPath() + "/" + std::string(mCreateStudyDialog.studyName);
13580 if (fs::exists(studyPath) && !mCreateStudyDialog.confirmOverwrite) {
13581 std::string warnMsg = "Study '" + std::string(mCreateStudyDialog.studyName) + "' already exists. Click Update to overwrite it.";
13582 std::strncpy(mCreateStudyDialog.errorMessage, warnMsg.c_str(),
13583 sizeof(mCreateStudyDialog.errorMessage) - 1);
13584 mCreateStudyDialog.errorMessage[sizeof(mCreateStudyDialog.errorMessage) - 1] = '\0';
13585 mCreateStudyDialog.confirmOverwrite = true;
13586 } else {
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;
13591 mCreateStudyDialog.confirmOverwrite = false;
13592 mStudyList = mWorkspace->GetStudyDirectories();
13593 } else {
13594 std::strncpy(mCreateStudyDialog.errorMessage,
13595 "Failed to create study. Check console for details.",
13596 sizeof(mCreateStudyDialog.errorMessage) - 1);
13597 }
13598 }
13599 }
13600 }
13601 }
13602 }
13603 ImGui::SameLine();
13604 if (ImGui::Button("Cancel", ImVec2(120, 0))) {
13605 mCreateStudyDialog.show = false;
13606 mCreateStudyDialog.confirmOverwrite = false;
13607 }
13608
13609 ImGui::End();
13610}
13611
13612void LauncherUI::TestCurrentScale()
13613{
13614 if (!mCurrentScale) {
13615 printf("Error: No scale loaded to test\n");
13616 return;
13617 }
13618
13619 if (!mWorkspace) {
13620 printf("Error: No workspace loaded\n");
13621 return;
13622 }
13623
13624 printf("Testing scale: %s\n", mCurrentScale->GetScaleInfo().code.c_str());
13625
13626 try {
13627 std::string scaleCode = mCurrentScale->GetScaleInfo().code;
13628
13629 // Create temp directory in workspace: workspace/temp/scale-test-{code}/
13630 std::string tempDir = mWorkspace->GetWorkspacePath() + "/temp/scale-test-" + scaleCode;
13631
13632 // Remove old temp directory if it exists
13633 if (fs::exists(tempDir)) {
13634 fs::remove_all(tempDir);
13635 }
13636
13637 // Create temp directory structure:
13638 // {tempDir}/
13639 // {scaleCode}.pbl <- ScaleRunner.pbl (renamed)
13640 // {scaleCode}/ <- {scaleCode}.osd (OSD bundle, for ScaleRunner)
13641 // definitions/ <- {scaleCode}.json (for SyncScaleSchema params generation)
13642 // translations/ <- {scaleCode}.{lang}.json
13643 // params/ <- parameter file
13644 // data/ <- output
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");
13651
13652 printf("Created temp test directory: %s\n", tempDir.c_str());
13653
13654 // Copy ScaleRunner.pbl to temp directory
13655 std::string scaleRunnerSource = mBatteryPath + "/../media/apps/scales/ScaleRunner.pbl";
13656 std::string scaleRunnerDest = tempDir + "/" + scaleCode + ".pbl";
13657
13658 if (!fs::exists(scaleRunnerSource)) {
13659 printf("Error: ScaleRunner.pbl not found at: %s\n", scaleRunnerSource.c_str());
13660 return;
13661 }
13662
13663 fs::copy_file(scaleRunnerSource, scaleRunnerDest, fs::copy_options::overwrite_existing);
13664 printf("Copied ScaleRunner.pbl\n");
13665
13666 // Export OSD bundle (used by ScaleRunner at runtime)
13667 if (!mCurrentScale->ExportToOSD(tempDir + "/" + scaleCode)) {
13668 printf("Warning: Failed to export OSD bundle (non-fatal)\n");
13669 } else {
13670 printf("Exported OSD bundle\n");
13671 }
13672
13673 // Export split JSON (used by SyncScaleSchema for params generation)
13674 if (!mCurrentScale->ExportToJSON(tempDir + "/definitions", tempDir + "/translations")) {
13675 printf("Error: Failed to export scale JSON files\n");
13676 return;
13677 }
13678
13679 printf("Exported scale definition and translations\n");
13680
13681 // Generate schema and default params from scale definition
13682 SyncScaleSchema(tempDir, scaleCode);
13683
13684 printf("Preparing to test scale...\n");
13685
13686 // Create experiment runner
13687 if (mRunningExperiment) {
13688 if (mRunningExperiment->IsRunning()) {
13689 printf("Warning: Previous test still running\n");
13690 return;
13691 }
13692 delete mRunningExperiment;
13693 mRunningExperiment = nullptr;
13694 }
13695
13696 // Run the test using --pfile to pass the parameter file
13697 // (PEBL prepends "params/" to the --pfile argument)
13698 mRunningExperiment = new ExperimentRunner(mConfig);
13699 std::vector<std::string> args = {
13700 "--pfile", scaleCode + ".pbl.par.json",
13701 "--windowed"
13702 };
13703
13704 bool success = mRunningExperiment->RunExperiment(scaleRunnerDest, args,
13705 ("TEST_" + scaleCode).c_str(),
13706 "en", false);
13707
13708 if (success) {
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; // Start showing stdout
13713 } else {
13714 printf("Failed to run scale test\n");
13715 delete mRunningExperiment;
13716 mRunningExperiment = nullptr;
13717 }
13718
13719 } catch (const std::exception& e) {
13720 printf("Exception while testing scale: %s\n", e.what());
13721 }
13722}
13723
13724void LauncherUI::ShowSnapshotCreatedDialog()
13725{
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);
13729
13730 if (!ImGui::Begin("Snapshot Created", &mShowSnapshotCreated, ImGuiWindowFlags_NoCollapse))
13731 {
13732 ImGui::End();
13733 return;
13734 }
13735
13736 ImGui::TextColored(ImVec4(0.2f, 0.8f, 0.2f, 1.0f), "✓ Snapshot created successfully!");
13737 ImGui::Separator();
13738 ImGui::Spacing();
13739
13740 // Show snapshot info
13741 ImGui::Text("Snapshot Name:");
13742 ImGui::SameLine();
13743 ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "%s", mLastSnapshotName);
13744
13745 ImGui::Spacing();
13746
13747 ImGui::Text("Location:");
13748 ImGui::TextWrapped("%s", mLastSnapshotPath);
13749
13750 ImGui::Spacing();
13751 ImGui::Separator();
13752 ImGui::Spacing();
13753
13754 ImGui::TextWrapped("This snapshot excludes data/ directories and is ready to upload to PEBLHub or share with others.");
13755 ImGui::Spacing();
13756
13757 // Buttons
13758 if (ImGui::Button("Open in File Manager", ImVec2(200, 0))) {
13759 OpenDirectoryInFileBrowser(std::string(mLastSnapshotPath));
13760 }
13761 if (ImGui::IsItemHovered()) {
13762 ImGui::SetTooltip("Open the snapshot directory in your file manager");
13763 }
13764
13765 ImGui::SameLine();
13766
13767 // Create ZIP button
13768 if (ImGui::Button("Create ZIP", ImVec2(150, 0))) {
13769 // Create ZIP file from snapshot
13770 std::string zipPath = std::string(mLastSnapshotPath) + ".zip";
13771 printf("Creating ZIP file: %s\n", zipPath.c_str());
13772
13773 // Use zip command (cross-platform via shell)
13774#ifdef _WIN32
13775 // Windows: Use PowerShell Compress-Archive
13776 std::string command = "powershell -Command \"Compress-Archive -Path '" +
13777 std::string(mLastSnapshotPath) + "' -DestinationPath '" +
13778 zipPath + "' -Force\"";
13779#else
13780 // Linux/Mac: Use zip command
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() + "\"";
13784#endif
13785
13786 printf("Running: %s\n", command.c_str());
13787 int result = system(command.c_str());
13788
13789 if (result == 0) {
13790 printf("ZIP file created: %s\n", zipPath.c_str());
13791 // Show success message
13792 ImGui::OpenPopup("ZIP Created");
13793 } else {
13794 printf("Failed to create ZIP file (exit code: %d)\n", result);
13795 ImGui::OpenPopup("ZIP Failed");
13796 }
13797 }
13798 if (ImGui::IsItemHovered()) {
13799 ImGui::SetTooltip("Create a ZIP file from this snapshot for easy sharing/upload");
13800 }
13801
13802 ImGui::SameLine();
13803
13804 if (ImGui::Button("Close", ImVec2(100, 0))) {
13805 mShowSnapshotCreated = false;
13806 }
13807
13808 // Success/failure popups for ZIP creation
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);
13812 ImGui::Spacing();
13813 if (ImGui::Button("OK", ImVec2(120, 0))) {
13814 ImGui::CloseCurrentPopup();
13815 }
13816 ImGui::EndPopup();
13817 }
13818
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);
13823 ImGui::Spacing();
13824 if (ImGui::Button("OK", ImVec2(120, 0))) {
13825 ImGui::CloseCurrentPopup();
13826 }
13827 ImGui::EndPopup();
13828 }
13829
13830 ImGui::End();
13831}
char * br_find_prefix(const char *default_prefix)
Definition BinReloc.cpp:433
#define NULL
Definition BinReloc.cpp:317
int br_init(BrInitError *error)
Definition BinReloc.cpp:338
BrInitError
Definition BinReloc.h:22
#define PREFIX
#define PEBL_VERSION
static std::shared_ptr< Chain > LoadFromFile(const std::string &path)
Definition Chain.cpp:127
static std::shared_ptr< Chain > CreateNew(const std::string &path, const std::string &name, const std::string &description="")
Definition Chain.cpp:142
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)
int GetExitCode() const
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)
int GetFontSize() const
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="")
Definition Study.cpp:129
static std::shared_ptr< Study > LoadFromDirectory(const std::string &path)
Definition Study.cpp:100
static const Palette & GetLightPalette()
static const Palette & GetDarkPalette()
static const Palette & GetRetroBluePalette()
Coordinates GetCursorPosition() const
Definition TextEditor.h:218
void SetShowWhitespaces(bool aValue)
Definition TextEditor.h:230
bool HasSelection() const
void SetReadOnly(bool aValue)
std::string GetText() const
bool IsReadOnly() const
Definition TextEditor.h:211
int GetTotalLines() const
Definition TextEditor.h:207
void Render(const char *aTitle, const ImVec2 &aSize=ImVec2(), bool aBorder=false)
void Undo(int aSteps=1)
void SetPalette(const Palette &aValue)
void SetText(const std::string &aText)
void Redo(int aSteps=1)
bool CanRedo() const
void SetTabSize(int aValue)
bool CanUndo() const
void SetSelection(const Coordinates &aStart, const Coordinates &aEnd, SelectionMode aMode=SelectionMode::Normal)
void SetLanguageDefinition(const LanguageDefinition &aLanguageDef)
static Result ExtractAll(const std::string &zipPath, const std::string &destPath)
char idPrefix[64]
Definition LauncherUI.h:258
char likertLabels[512]
Definition LauncherUI.h:269
char questionText[8192]
Definition LauncherUI.h:257
std::string testName
Definition Chain.h:33
std::string CreateChainPageConfig(const std::string &tempDir) const
Definition Chain.cpp:47
std::string title
Definition Chain.h:29
std::string GetDisplayName() const
Definition Chain.cpp:106
std::string content
Definition Chain.h:30
std::string paramVariant
Definition Chain.h:34
ItemType type
Definition Chain.h:26
std::string language
Definition Chain.h:35
int randomGroup
Definition Chain.h:36
std::vector< std::string > answers
Definition LauncherUI.h:300
std::vector< bool > caseSensitive
Definition LauncherUI.h:301
std::vector< EditorCondition > visibleWhenConditions
Definition LauncherUI.h:241
std::string description
char sourceName[64]
Definition LauncherUI.h:106
char value[256]
Definition LauncherUI.h:108
bool hasTranslations
Definition LauncherUI.h:31
std::string name
Definition LauncherUI.h:26
std::string description
Definition LauncherUI.h:28
std::string path
Definition LauncherUI.h:25
std::string directory
Definition LauncherUI.h:27
std::string screenshotPath
Definition LauncherUI.h:29
std::vector< ThresholdEdit > rows
Definition LauncherUI.h:290
std::string dimensionId
Definition LauncherUI.h:283
std::string dimensionName
Definition LauncherUI.h:284
char content[4096]
Definition LauncherUI.h:42
char title[256]
Definition LauncherUI.h:41
std::string file
Definition Study.h:16
std::string description
Definition Study.h:15
std::string name
Definition LauncherUI.h:326
std::vector< std::string > options
Definition LauncherUI.h:330
std::string value
Definition LauncherUI.h:327
std::string description
Definition LauncherUI.h:329
std::string defaultValue
Definition LauncherUI.h:328
char gridRows[4096]
Definition LauncherUI.h:145
char gateTerminateMessageText[512]
Definition LauncherUI.h:169
std::vector< bool > selectedResponseOptions
Definition LauncherUI.h:129
char sectionRandomizeFixed[512]
Definition LauncherUI.h:174
char multiOptions[4096]
Definition LauncherUI.h:141
char questionText[2048]
Definition LauncherUI.h:120
char gridColumns[2048]
Definition LauncherUI.h:144
char gateRequiredValue[64]
Definition LauncherUI.h:165
char gateTerminateMessageKey[64]
Definition LauncherUI.h:168
char vasRightLabel[256]
Definition LauncherUI.h:135
std::vector< AnchorEdit > vasAnchors
Definition LauncherUI.h:138
char vasLeftLabel[256]
Definition LauncherUI.h:134
std::vector< EditorCondition > visibleWhenConditions
Definition LauncherUI.h:157
char questionHead[256]
Definition LauncherUI.h:160
std::string name
std::string abbreviation
std::string enabled_param
std::string description
std::string description
std::string defaultValue
std::string type
std::vector< std::string > options
std::string vas_orientation
std::string gate_terminate_message_key
std::string right_label
std::string image
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 type
std::string text_key
std::string answer_alias
std::string gate_required_value
std::string left_label
std::vector< std::string > section_randomize_fixed
std::vector< std::string > columns
std::vector< std::string > likert_labels
std::string visible_when_logic
std::string question_head
std::string gate_operator
int selectedVariantIndex
Definition LauncherUI.h:51
char language[16]
Definition LauncherUI.h:52
Definition Study.h:24
std::string displayName
Definition Study.h:26
std::string testPath
Definition Study.h:27
std::string testName
Definition Study.h:25
std::map< std::string, ParameterVariant > parameterVariants
Definition Study.h:29
const ParameterVariant * GetVariant(const std::string &variantName) const
Definition Study.cpp:80
bool included
Definition Study.h:28
std::map< std::string, std::string > targetValues
Definition LauncherUI.h:74
std::vector< std::string > keys
Definition LauncherUI.h:72
std::map< std::string, std::string > englishValues
Definition LauncherUI.h:73
std::vector< std::string > values
int count
Definition test.cpp:12