diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index 919f6f2..ea5d5b2 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -40,6 +40,20 @@ ParseResult parseArguments(const std::vector& arguments) { result.options.deviceId = arguments[++index]; continue; } + if (argument == "--profile" || argument == "--theme") { + if (index + 1 >= arguments.size() || arguments[index + 1].empty() || + arguments[index + 1][0] == '-') { + return {false, {}, argument + " requires a non-empty name or ID."}; + } + std::string& selector = argument == "--profile" + ? result.options.profileSelector + : result.options.themeSelector; + if (!selector.empty()) { + return {false, {}, argument + " may only be specified once."}; + } + selector = arguments[++index]; + continue; + } return {false, {}, "Unknown argument: " + argument}; } @@ -48,7 +62,7 @@ ParseResult parseArguments(const std::vector& arguments) { std::string usageText() { return - "Usage: prism-tui [--output ]\n" + "Usage: prism-tui [--output ] [--profile ] [--theme ]\n" " prism-tui --list-outputs\n" " prism-tui --help\n" " prism-tui --version\n\n" @@ -57,6 +71,8 @@ std::string usageText() { " --list-outputs List available system output devices.\n" " --device Alias for --output.\n" " --list-devices Alias for --list-outputs.\n" + " --profile Start with a saved profile (name or ID).\n" + " --theme Start with a Prism .iro theme (name or ID).\n" " -h, --help Show this help.\n" " -V, --version Show the Prism TUI version.\n\n" "Controls:\n" diff --git a/tui/src/cli.h b/tui/src/cli.h index 0dd179b..e99803f 100644 --- a/tui/src/cli.h +++ b/tui/src/cli.h @@ -15,6 +15,8 @@ enum class Command { struct Options { Command command = Command::Run; std::string deviceId; + std::string profileSelector; + std::string themeSelector; }; struct ParseResult { diff --git a/tui/src/main.cpp b/tui/src/main.cpp index af23bdf..07f08e6 100644 --- a/tui/src/main.cpp +++ b/tui/src/main.cpp @@ -72,7 +72,9 @@ int run(const std::vector& arguments) { std::move(capture), started, parsed.options.deviceId, - outputDevices); + outputDevices, + parsed.options.profileSelector, + parsed.options.themeSelector); } } // namespace diff --git a/tui/src/profile_library.cpp b/tui/src/profile_library.cpp index 2106dc7..4472ea8 100644 --- a/tui/src/profile_library.cpp +++ b/tui/src/profile_library.cpp @@ -251,6 +251,24 @@ const TuiProfile* TuiProfileLibrary::find(const std::string& id) const { return managed ? &managed->profile : nullptr; } +const TuiProfile* TuiProfileLibrary::findSelector( + const std::string& nameOrId) const { + if (const auto* exactId = find(nameOrId)) return exactId; + const auto exactName = std::find_if( + managed_.begin(), managed_.end(), [&](const auto& entry) { + return entry.profile.name == nameOrId; + }); + if (exactName != managed_.end()) return &exactName->profile; + + const std::string expected = lowercaseAscii(trimName(nameOrId)); + const auto insensitive = std::find_if( + managed_.begin(), managed_.end(), [&](const auto& entry) { + return lowercaseAscii(entry.profile.id) == expected || + lowercaseAscii(entry.profile.name) == expected; + }); + return insensitive == managed_.end() ? nullptr : &insensitive->profile; +} + TuiProfileLibrary::ManagedProfile* TuiProfileLibrary::findManaged( const std::string& id) { const auto found = std::find_if( @@ -275,12 +293,19 @@ bool TuiProfileLibrary::writeActiveState(std::string* error) const { } bool TuiProfileLibrary::activate(const std::string& id, std::string* error) { + if (!selectForSession(id, error)) return false; + return writeActiveState(error); +} + +bool TuiProfileLibrary::selectForSession( + const std::string& id, + std::string* error) { if (!findManaged(id)) { if (error) *error = "profile was not found"; return false; } activeProfileId_ = id; - return writeActiveState(error); + return true; } bool TuiProfileLibrary::nameIsAvailable( diff --git a/tui/src/profile_library.h b/tui/src/profile_library.h index 1b664ef..8ecd2a5 100644 --- a/tui/src/profile_library.h +++ b/tui/src/profile_library.h @@ -27,8 +27,11 @@ public: const std::vector& profiles() const { return profiles_; } const std::string& activeProfileId() const { return activeProfileId_; } const TuiProfile* find(const std::string& id) const; + const TuiProfile* findSelector(const std::string& nameOrId) const; bool activate(const std::string& id, std::string* error = nullptr); + bool selectForSession(const std::string& id, + std::string* error = nullptr); bool saveNew(const std::string& name, const TuiSettings& settings, std::string* createdId = nullptr, diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index b9e7518..926683c 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -2359,12 +2360,13 @@ bool stdinAndStdoutAreTerminals() { int runInteractive(std::unique_ptr capture, const Prism::Capture::StartResult& started, std::string requestedDeviceId, - std::vector outputDevices) { + std::vector outputDevices, + std::string startupProfileSelector, + std::string startupThemeSelector) { using namespace ftxui; signalRequested = 0; SignalHandlerGuard signalHandlerGuard; - ScreenInteractive screen = ScreenInteractive::Fullscreen(); SnapshotStore frameStore; SnapshotStore settingsStore; SnapshotStore outputSwitchRequestStore; @@ -2382,22 +2384,56 @@ int runInteractive(std::unique_ptr capture, } else if (!themeLoadWarning.empty()) { interfaceState.settingsStatus = themeLoadWarning; } - if (!themeLibrary.find(interfaceState.settings.themeId)) { - interfaceState.settings.themeId = "Default"; - } - interfaceState.theme = themeLibrary.resolve(interfaceState.settings.themeId); TuiProfileLibrary profileLibrary( defaultProfileDirectory(), defaultProfileStatePath()); std::string profileLoadError; - if (!profileLibrary.load(&profileLoadError)) { + const bool profilesLoaded = profileLibrary.load(&profileLoadError); + if (!profilesLoaded) { interfaceState.profileStatus = "Could not load profiles: " + profileLoadError; interfaceState.profileStatusError = true; } + if (!startupProfileSelector.empty()) { + if (!profilesLoaded) { + std::cerr << "prism-tui: could not load profiles: " + << profileLoadError << '\n'; + return 1; + } + const TuiProfile* profile = + profileLibrary.findSelector(startupProfileSelector); + if (!profile) { + std::cerr << "prism-tui: profile not found: " + << startupProfileSelector << '\n'; + return 2; + } + interfaceState.settings = applyProfileSettings( + profile->settings, interfaceState.settings); + std::string selectionError; + if (!profileLibrary.selectForSession(profile->id, &selectionError)) { + std::cerr << "prism-tui: could not select profile: " + << selectionError << '\n'; + return 1; + } + } + if (!startupThemeSelector.empty()) { + const TuiTheme* theme = themeLibrary.findSelector(startupThemeSelector); + if (!theme) { + std::cerr << "prism-tui: theme not found: " + << startupThemeSelector << '\n'; + return 2; + } + interfaceState.settings.themeId = theme->id; + } + if (!themeLibrary.find(interfaceState.settings.themeId)) { + interfaceState.settings.themeId = "Default"; + } + interfaceState.theme = themeLibrary.resolve(interfaceState.settings.themeId); interfaceState.profiles = profileLibrary.profiles(); interfaceState.activeProfileId = profileLibrary.activeProfileId(); interfaceState.profileDirty = calculateUnsavedProfileChanges(interfaceState); + + ScreenInteractive screen = ScreenInteractive::Fullscreen(); settingsStore.publish(interfaceState.settings); DisplayFrame initial; initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f); diff --git a/tui/src/tui_runtime.h b/tui/src/tui_runtime.h index 3addbdd..2fbaabe 100644 --- a/tui/src/tui_runtime.h +++ b/tui/src/tui_runtime.h @@ -12,6 +12,8 @@ bool stdinAndStdoutAreTerminals(); int runInteractive(std::unique_ptr capture, const Prism::Capture::StartResult& started, std::string requestedDeviceId, - std::vector outputDevices); + std::vector outputDevices, + std::string startupProfileSelector = {}, + std::string startupThemeSelector = {}); } // namespace Prism::Tui diff --git a/tui/src/tui_theme.cpp b/tui/src/tui_theme.cpp index 24fe2d5..7ba38ca 100644 --- a/tui/src/tui_theme.cpp +++ b/tui/src/tui_theme.cpp @@ -826,6 +826,24 @@ const TuiTheme* IroThemeLibrary::find(const std::string& id) const { return found == themes_.end() ? nullptr : &*found; } +const TuiTheme* IroThemeLibrary::findSelector( + const std::string& nameOrId) const { + if (const auto* exactId = find(nameOrId)) return exactId; + const auto exactName = std::find_if( + themes_.begin(), themes_.end(), [&](const TuiTheme& theme) { + return theme.name == nameOrId; + }); + if (exactName != themes_.end()) return &*exactName; + + const std::string expected = normalizeKey(nameOrId); + const auto insensitive = std::find_if( + themes_.begin(), themes_.end(), [&](const TuiTheme& theme) { + return normalizeKey(theme.id) == expected || + normalizeKey(theme.name) == expected; + }); + return insensitive == themes_.end() ? nullptr : &*insensitive; +} + const TuiTheme& IroThemeLibrary::resolve(const std::string& id) const { if (const auto* theme = find(id)) return *theme; if (!themes_.empty()) return themes_.front(); diff --git a/tui/src/tui_theme.h b/tui/src/tui_theme.h index 2fe18ca..be6b5d2 100644 --- a/tui/src/tui_theme.h +++ b/tui/src/tui_theme.h @@ -92,6 +92,7 @@ public: bool load(std::string* warning = nullptr); const std::vector& themes() const { return themes_; } const TuiTheme* find(const std::string& id) const; + const TuiTheme* findSelector(const std::string& nameOrId) const; const TuiTheme& resolve(const std::string& id) const; std::string adjacentId(const std::string& id, int direction) const; const std::filesystem::path& directory() const { return directory_; } diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index 0538b70..88503f2 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -139,6 +139,15 @@ void testCli() { "the output alias should retain its output ID"); require(Prism::Tui::parseArguments({"--list-outputs"}).options.command == Prism::Tui::Command::ListDevices, "the output-list alias should parse"); + auto startup = Prism::Tui::parseArguments({ + "--profile", "Studio Wide", + "--theme", "Alpha Centauri", + "--output", "output-id", + }); + require(startup.ok && startup.options.profileSelector == "Studio Wide" && + startup.options.themeSelector == "Alpha Centauri" && + startup.options.deviceId == "output-id", + "profile, theme, and output startup selections should combine"); require(!Prism::Tui::parseArguments({"--device"}).ok, "missing device ID should fail"); require(!Prism::Tui::parseArguments({"--device", "--help"}).ok, "an option should not be accepted as a device ID"); @@ -147,6 +156,14 @@ void testCli() { "duplicate device options should fail"); require(!Prism::Tui::parseArguments({"--device", "fake", "--output", "fake"}).ok, "mixed output aliases should still be rejected as duplicates"); + require(!Prism::Tui::parseArguments({"--profile"}).ok && + !Prism::Tui::parseArguments({"--theme", "--output"}).ok, + "startup selectors should require a value"); + require(!Prism::Tui::parseArguments({ + "--profile", "one", "--profile", "two"}).ok && + !Prism::Tui::parseArguments({ + "--theme", "one", "--theme", "two"}).ok, + "startup selectors should reject duplicate values"); require(!Prism::Tui::parseArguments({"--help", "--version"}).ok, "exclusive commands should not combine"); require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos, @@ -158,6 +175,9 @@ void testCli() { require(Prism::Tui::usageText().find("--list-outputs") != std::string::npos && Prism::Tui::usageText().find("Choose the system output") != std::string::npos, "help should describe output aliases and the in-app picker"); + require(Prism::Tui::usageText().find("--profile ") != std::string::npos && + Prism::Tui::usageText().find("--theme ") != std::string::npos, + "help should describe profile and theme startup selection"); } void testOutputSwitching() { @@ -529,6 +549,11 @@ line = 22, 23, 24 require(library.find("Redshift")->spectrumLine == Prism::Tui::ThemeColor{1, 2, 3}, "managed .iro files should override bundled themes with the same filename stem"); + require(library.findSelector("alpha-centauri") && + library.findSelector("alpha-centauri")->id == "Alpha Centauri" && + library.findSelector("TEST THEME") && + !library.findSelector("Not A Theme"), + "theme startup selectors should accept visible names and normalized IDs"); const std::string nextTheme = library.adjacentId("Default", 1); require(!warning.empty() && nextTheme != "Default" && library.find(nextTheme) && library.adjacentId(nextTheme, -1) == "Default", @@ -688,6 +713,21 @@ void testProfileLibrary() { Prism::Tui::profileSettingsEqual( library.find(profileId)->settings, settings), "saving a profile should activate and preserve its scoped settings"); + require(library.findSelector("studio wide") && + library.findSelector("studio wide")->id == profileId && + library.findSelector(profileId) && + !library.findSelector("Missing Profile"), + "profile startup selectors should accept names and internal IDs"); + require(library.activate(Prism::Tui::kDefaultTuiProfileId, &error) && + library.selectForSession(profileId, &error) && + library.activeProfileId() == profileId, + "startup profile selection should update the active session"); + Prism::Tui::TuiProfileLibrary sessionReload(profilesPath, statePath); + require(sessionReload.load(&error) && + sessionReload.activeProfileId() == Prism::Tui::kDefaultTuiProfileId, + "startup profile selection should not rewrite the persisted active profile"); + require(library.activate(profileId, &error), + "profile fixtures should restore their persisted active selection"); bool foundProfileFile = false; for (const auto& entry : std::filesystem::directory_iterator(profilesPath)) {