hypertwist/UnrealHyperTwist/Source/UnrealHyperTwist/Private/HyperTwistUX/HyperTwistSettingsPanelWidget.cpp
2026-07-23 17:23:24 +00:00

2951 lines
90 KiB
C++

#include "HyperTwistUX/HyperTwistSettingsPanelWidget.h"
#include "Blueprint/WidgetTree.h"
#include "Components/Border.h"
#include "Components/Button.h"
#include "Components/ButtonSlot.h"
#include "Components/CheckBox.h"
#include "Components/ComboBoxString.h"
#include "Components/EditableTextBox.h"
#include "Components/HorizontalBox.h"
#include "Components/HorizontalBoxSlot.h"
#include "Components/ScrollBox.h"
#include "Components/SizeBox.h"
#include "Components/Slider.h"
#include "Components/Spacer.h"
#include "Components/TextBlock.h"
#include "Components/UniformGridPanel.h"
#include "Components/UniformGridSlot.h"
#include "Components/VerticalBox.h"
#include "Components/VerticalBoxSlot.h"
#include "Components/WidgetSwitcher.h"
#include "Input/Reply.h"
#include "InputCoreTypes.h"
namespace HyperTwistSettingsPanelWidgetInternal
{
constexpr int32 ControlsPageIndex = 0;
constexpr int32 AudioPageIndex = 1;
constexpr int32 GraphicsPageIndex = 2;
constexpr int32 AccessibilityPageIndex = 3;
constexpr int32 SpeechPageIndex = 4;
struct FProviderOption
{
const TCHAR* Id;
const TCHAR* Label;
const TCHAR* DefaultEndpoint;
const TCHAR* DefaultModel;
};
const TArray<FProviderOption>& GetSpeechProviderOptions()
{
static const TArray<FProviderOption> Options = {
{TEXT("local-whisper-cpp"), TEXT("On-device Whisper"), TEXT("http://127.0.0.1:8766"), TEXT("local-whisper/base-q5_1")},
{TEXT("local-freestyle"), TEXT("Local Freestyle bridge"), TEXT("http://127.0.0.1:4649"), TEXT("local-whisper/base-q5_1")},
{TEXT("openai"), TEXT("OpenAI via speech bridge"), TEXT("http://127.0.0.1:8766"), TEXT("openai/gpt-4o-transcribe")},
{TEXT("groq"), TEXT("Groq via speech bridge"), TEXT("http://127.0.0.1:8766"), TEXT("groq/whisper-large-v3-turbo")},
{TEXT("deepgram"), TEXT("Deepgram via speech bridge"), TEXT("http://127.0.0.1:8766"), TEXT("deepgram/nova-3")},
{TEXT("elevenlabs"), TEXT("ElevenLabs via speech bridge"), TEXT("http://127.0.0.1:8766"), TEXT("elevenlabs/scribe_v2_realtime")}
};
return Options;
}
const TArray<FProviderOption>& GetVoiceProviderOptions()
{
static const TArray<FProviderOption> Options = {
{TEXT("local-piper"), TEXT("On-device Piper"), TEXT("http://127.0.0.1:8766"), TEXT("piper-medium")},
{TEXT("openai"), TEXT("OpenAI via voice bridge"), TEXT("http://127.0.0.1:8766"), TEXT("gpt-4o-mini-tts")},
{TEXT("elevenlabs"), TEXT("ElevenLabs via voice bridge"), TEXT("http://127.0.0.1:8766"), TEXT("eleven_multilingual_v2")},
{TEXT("custom-compatible"), TEXT("Custom compatible service"), TEXT(""), TEXT("")}
};
return Options;
}
const TArray<FProviderOption>& GetCoachProviderOptions()
{
static const TArray<FProviderOption> Options = {
{TEXT("local-disabled"), TEXT("Built-in guide only"), TEXT(""), TEXT("")},
{TEXT("local-openai-compatible"), TEXT("Local OpenAI-compatible"), TEXT("http://127.0.0.1:11434/v1"), TEXT("")},
{TEXT("openai"), TEXT("OpenAI"), TEXT("https://api.openai.com/v1/responses"), TEXT("gpt-5.5")},
{TEXT("groq"), TEXT("Groq"), TEXT("https://api.groq.com/openai/v1"), TEXT("")},
{TEXT("custom-openai-compatible"), TEXT("Custom OpenAI-compatible"), TEXT(""), TEXT("")}
};
return Options;
}
const FProviderOption* FindProviderById(
const TArray<FProviderOption>& Options,
const FString& Id
)
{
return Options.FindByPredicate(
[&Id](const FProviderOption& Option)
{
return Id.Equals(Option.Id, ESearchCase::IgnoreCase);
});
}
const FProviderOption* FindProviderByLabel(
const TArray<FProviderOption>& Options,
const FString& Label
)
{
return Options.FindByPredicate(
[&Label](const FProviderOption& Option)
{
return Label.Equals(Option.Label, ESearchCase::IgnoreCase);
});
}
TArray<FString> BuildProviderLabels(const TArray<FProviderOption>& Options)
{
TArray<FString> Labels;
Labels.Reserve(Options.Num());
for (const FProviderOption& Option : Options)
{
Labels.Add(Option.Label);
}
return Labels;
}
FString ProviderLabel(
const TArray<FProviderOption>& Options,
const FString& Id
)
{
if (const FProviderOption* Option = FindProviderById(Options, Id))
{
return Option->Label;
}
return Options.Num() > 0 ? FString(Options[0].Label) : FString();
}
FString SpeechLanguageLabel(const FString& LanguageCode)
{
if (LanguageCode.Equals(TEXT("en"), ESearchCase::IgnoreCase)) return TEXT("English");
if (LanguageCode.Equals(TEXT("de"), ESearchCase::IgnoreCase)) return TEXT("German");
if (LanguageCode.Equals(TEXT("fr"), ESearchCase::IgnoreCase)) return TEXT("French");
if (LanguageCode.Equals(TEXT("es"), ESearchCase::IgnoreCase)) return TEXT("Spanish");
if (LanguageCode.Equals(TEXT("it"), ESearchCase::IgnoreCase)) return TEXT("Italian");
if (LanguageCode.Equals(TEXT("pt"), ESearchCase::IgnoreCase)) return TEXT("Portuguese");
if (LanguageCode.Equals(TEXT("ja"), ESearchCase::IgnoreCase)) return TEXT("Japanese");
if (LanguageCode.Equals(TEXT("ko"), ESearchCase::IgnoreCase)) return TEXT("Korean");
if (LanguageCode.Equals(TEXT("zh"), ESearchCase::IgnoreCase)) return TEXT("Chinese");
return TEXT("Auto-detect");
}
FString SpeechLanguageCode(const FString& LanguageLabel)
{
if (LanguageLabel == TEXT("English")) return TEXT("en");
if (LanguageLabel == TEXT("German")) return TEXT("de");
if (LanguageLabel == TEXT("French")) return TEXT("fr");
if (LanguageLabel == TEXT("Spanish")) return TEXT("es");
if (LanguageLabel == TEXT("Italian")) return TEXT("it");
if (LanguageLabel == TEXT("Portuguese")) return TEXT("pt");
if (LanguageLabel == TEXT("Japanese")) return TEXT("ja");
if (LanguageLabel == TEXT("Korean")) return TEXT("ko");
if (LanguageLabel == TEXT("Chinese")) return TEXT("zh");
return TEXT("auto");
}
FString PercentLabel(const float Value)
{
return FString::Printf(TEXT("%d%%"), FMath::RoundToInt(Value * 100.0f));
}
FString DecimalLabel(const float Value)
{
return FString::Printf(TEXT("%.2fx"), Value);
}
void SetText(UTextBlock* TextBlock, const FString& Text)
{
if (TextBlock != nullptr)
{
TextBlock->SetText(FText::FromString(Text));
}
}
FString QualityLabel(const EHyperTwistGraphicsQuality Quality)
{
switch (Quality)
{
case EHyperTwistGraphicsQuality::Low:
return TEXT("Low");
case EHyperTwistGraphicsQuality::Medium:
return TEXT("Medium");
case EHyperTwistGraphicsQuality::Epic:
return TEXT("Epic");
default:
return TEXT("High");
}
}
FString WindowModeLabel(const EHyperTwistWindowMode WindowMode)
{
switch (WindowMode)
{
case EHyperTwistWindowMode::Fullscreen:
return TEXT("Fullscreen");
case EHyperTwistWindowMode::Windowed:
return TEXT("Windowed");
default:
return TEXT("Borderless");
}
}
}
void UHyperTwistKeyBindingRowWidget::ConfigureBinding(
const FName InActionId,
const FString& InLabel,
const FString& InCategory,
const FKey InKey
)
{
ActionId = InActionId;
DisplayLabel = InLabel;
CategoryLabel = InCategory;
BoundKey = InKey;
EnsureWidgetTreeBuilt();
if (BindingLabel != nullptr)
{
BindingLabel->SetText(FText::FromString(DisplayLabel));
}
UpdateBoundKey(BoundKey);
}
void UHyperTwistKeyBindingRowWidget::UpdateBoundKey(
const FKey InKey,
const bool bAwaitingInput
)
{
BoundKey = InKey;
if (KeyLabel != nullptr)
{
KeyLabel->SetText(FText::FromString(
bAwaitingInput ? TEXT("Press a key...") : BoundKey.GetDisplayName().ToString()));
}
}
TSharedRef<SWidget> UHyperTwistKeyBindingRowWidget::RebuildWidget()
{
Initialize();
EnsureWidgetTreeBuilt();
return Super::RebuildWidget();
}
void UHyperTwistKeyBindingRowWidget::EnsureWidgetTreeBuilt()
{
if (WidgetTree == nullptr || WidgetTree->RootWidget != nullptr)
{
return;
}
UHorizontalBox* Row = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("KeyBindingRow"));
WidgetTree->RootWidget = Row;
USizeBox* LabelSize = WidgetTree->ConstructWidget<USizeBox>(
USizeBox::StaticClass(),
TEXT("KeyBindingLabelSize"));
LabelSize->SetWidthOverride(360.0f);
BindingLabel = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass(),
TEXT("KeyBindingLabel"));
BindingLabel->SetText(FText::FromString(DisplayLabel));
BindingLabel->SetColorAndOpacity(FSlateColor(FLinearColor(0.88f, 0.94f, 0.98f, 1.0f)));
FSlateFontInfo BindingFont = BindingLabel->GetFont();
BindingFont.Size = 14;
BindingLabel->SetFont(BindingFont);
LabelSize->AddChild(BindingLabel);
if (UHorizontalBoxSlot* LabelSlot = Row->AddChildToHorizontalBox(LabelSize))
{
LabelSlot->SetPadding(FMargin(0.0f, 6.0f, 12.0f, 6.0f));
LabelSlot->SetVerticalAlignment(VAlign_Center);
}
UButton* RebindButton = WidgetTree->ConstructWidget<UButton>(
UButton::StaticClass(),
TEXT("KeyBindingButton"));
RebindButton->SetBackgroundColor(FLinearColor(0.07f, 0.15f, 0.20f, 1.0f));
KeyLabel = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass(),
TEXT("KeyBindingValue"));
KeyLabel->SetText(FText::FromString(BoundKey.GetDisplayName().ToString()));
KeyLabel->SetJustification(ETextJustify::Center);
KeyLabel->SetColorAndOpacity(FSlateColor(FLinearColor(0.12f, 0.95f, 0.82f, 1.0f)));
FSlateFontInfo KeyFont = KeyLabel->GetFont();
KeyFont.Size = 14;
KeyLabel->SetFont(KeyFont);
RebindButton->AddChild(KeyLabel);
if (UButtonSlot* KeyButtonSlot = Cast<UButtonSlot>(KeyLabel->Slot))
{
KeyButtonSlot->SetPadding(FMargin(18.0f, 7.0f));
KeyButtonSlot->SetHorizontalAlignment(HAlign_Center);
}
RebindButton->OnClicked.AddDynamic(
this,
&UHyperTwistKeyBindingRowWidget::HandleRebindClicked);
if (UHorizontalBoxSlot* ButtonSlot = Row->AddChildToHorizontalBox(RebindButton))
{
ButtonSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
ButtonSlot->SetPadding(FMargin(0.0f, 4.0f));
ButtonSlot->SetVerticalAlignment(VAlign_Center);
}
}
void UHyperTwistKeyBindingRowWidget::HandleRebindClicked()
{
UpdateBoundKey(BoundKey, true);
OnRebindRequested.Broadcast(ActionId);
}
UHyperTwistSettingsPanelWidget::UHyperTwistSettingsPanelWidget(
const FObjectInitializer& ObjectInitializer
)
: Super(ObjectInitializer)
{
SetIsFocusable(true);
}
void UHyperTwistSettingsPanelWidget::NativeConstruct()
{
Super::NativeConstruct();
EnsureWidgetTreeBuilt();
ReloadPreferences();
}
TSharedRef<SWidget> UHyperTwistSettingsPanelWidget::RebuildWidget()
{
Initialize();
EnsureWidgetTreeBuilt();
return Super::RebuildWidget();
}
bool UHyperTwistSettingsPanelWidget::PrepareSettingsSurface()
{
EnsureWidgetTreeBuilt();
ReloadPreferences();
return IsSettingsSurfaceReady();
}
bool UHyperTwistSettingsPanelWidget::IsSettingsSurfaceReady() const
{
return WidgetTree != nullptr
&& WidgetTree->RootWidget != nullptr
&& SettingsPageSwitcher != nullptr
&& SettingsPageSwitcher->GetNumWidgets() == 5
&& KeyboardProfileCombo != nullptr
&& MasterVolumeSlider != nullptr;
}
void UHyperTwistSettingsPanelWidget::ReloadPreferences()
{
Preferences = UHyperTwistPlayerSettingsLibrary::LoadPreferences();
RefreshWidgetsFromPreferences();
SetStatus(TEXT("Settings are saved automatically on this device."));
}
FReply UHyperTwistSettingsPanelWidget::NativeOnKeyDown(
const FGeometry& InGeometry,
const FKeyEvent& InKeyEvent
)
{
static_cast<void>(InGeometry);
const FKey PressedKey = InKeyEvent.GetKey();
if (TryCompletePendingRebind(PressedKey))
{
return FReply::Handled();
}
if (PressedKey == EKeys::Escape && bShowCloseButton)
{
OnCloseRequested.Broadcast();
return FReply::Handled();
}
return Super::NativeOnKeyDown(InGeometry, InKeyEvent);
}
FReply UHyperTwistSettingsPanelWidget::NativeOnPreviewMouseButtonDown(
const FGeometry& InGeometry,
const FPointerEvent& InMouseEvent
)
{
if (TryCompletePendingRebind(InMouseEvent.GetEffectingButton()))
{
return FReply::Handled();
}
return Super::NativeOnPreviewMouseButtonDown(InGeometry, InMouseEvent);
}
bool UHyperTwistSettingsPanelWidget::TryCompletePendingRebind(
const FKey PressedKey
)
{
if (PendingRebindActionId.IsNone())
{
return false;
}
if (PressedKey == EKeys::Escape)
{
PendingRebindActionId = NAME_None;
RefreshBindingRows();
SetStatus(TEXT("Key change cancelled."));
return true;
}
FString FailureReason;
if (!UHyperTwistPlayerSettingsLibrary::TrySetKeyBinding(
Preferences,
PendingRebindActionId,
PressedKey,
FailureReason))
{
SetStatus(FailureReason, true);
return true;
}
PendingRebindActionId = NAME_None;
RefreshBindingRows();
SavePreferencesAndApply(TEXT("Control binding updated."));
if (KeyboardProfileCombo != nullptr)
{
KeyboardProfileCombo->SetSelectedOption(TEXT("Custom"));
}
return true;
}
void UHyperTwistSettingsPanelWidget::EnsureWidgetTreeBuilt()
{
if (WidgetTree == nullptr || WidgetTree->RootWidget != nullptr)
{
return;
}
Preferences = UHyperTwistPlayerSettingsLibrary::LoadPreferences();
UBorder* RootBorder = WidgetTree->ConstructWidget<UBorder>(
UBorder::StaticClass(),
TEXT("HyperTwistSettingsRoot"));
RootBorder->SetPadding(FMargin(28.0f));
RootBorder->SetBrushColor(PanelColor);
WidgetTree->RootWidget = RootBorder;
UVerticalBox* Root = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass(),
TEXT("HyperTwistSettingsLayout"));
RootBorder->AddChild(Root);
UHorizontalBox* Header = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("HyperTwistSettingsHeader"));
if (UVerticalBoxSlot* HeaderSlot = Root->AddChildToVerticalBox(Header))
{
HeaderSlot->SetPadding(FMargin(0.0f, 0.0f, 0.0f, 18.0f));
}
UVerticalBox* HeaderCopy = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass(),
TEXT("HyperTwistSettingsHeaderCopy"));
if (UHorizontalBoxSlot* CopySlot = Header->AddChildToHorizontalBox(HeaderCopy))
{
CopySlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
}
AddText(HeaderCopy, TEXT("SETTINGS"), 13, AccentColor);
AddText(HeaderCopy, TEXT("Shape HyperTwist around you"), 29, PrimaryTextColor);
AddText(
HeaderCopy,
TEXT("Every desktop preference is local-first, reversible, and available without a headset."),
14,
MutedTextColor);
if (bShowCloseButton)
{
UButton* CloseButton = AddActionButton(
Header,
TEXT("Close"),
TEXT("HyperTwistSettingsCloseButton"));
CloseButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCloseClicked);
}
UUniformGridPanel* Tabs = WidgetTree->ConstructWidget<UUniformGridPanel>(
UUniformGridPanel::StaticClass(),
TEXT("HyperTwistSettingsTabs"));
Tabs->SetMinDesiredSlotWidth(150.0f);
Tabs->SetSlotPadding(FMargin(3.0f));
if (UVerticalBoxSlot* TabSlot = Root->AddChildToVerticalBox(Tabs))
{
TabSlot->SetPadding(FMargin(0.0f, 0.0f, 0.0f, 16.0f));
}
UButton* ControlsTab = AddTabButton(Tabs, TEXT("Controls"), 0, TEXT("SettingsControlsTab"));
UButton* AudioTab = AddTabButton(Tabs, TEXT("Audio"), 1, TEXT("SettingsAudioTab"));
UButton* GraphicsTab = AddTabButton(Tabs, TEXT("Graphics"), 2, TEXT("SettingsGraphicsTab"));
UButton* AccessibilityTab = AddTabButton(
Tabs,
TEXT("Accessibility"),
3,
TEXT("SettingsAccessibilityTab"));
UButton* SpeechTab = AddTabButton(
Tabs,
TEXT("Speech & Coach"),
4,
TEXT("SettingsSpeechTab"));
ControlsTab->OnClicked.AddDynamic(this, &UHyperTwistSettingsPanelWidget::ShowControlsPage);
AudioTab->OnClicked.AddDynamic(this, &UHyperTwistSettingsPanelWidget::ShowAudioPage);
GraphicsTab->OnClicked.AddDynamic(this, &UHyperTwistSettingsPanelWidget::ShowGraphicsPage);
AccessibilityTab->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::ShowAccessibilityPage);
SpeechTab->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::ShowSpeechAndCoachPage);
SettingsPageSwitcher = WidgetTree->ConstructWidget<UWidgetSwitcher>(
UWidgetSwitcher::StaticClass(),
TEXT("HyperTwistSettingsPageSwitcher"));
if (UVerticalBoxSlot* SwitcherSlot = Root->AddChildToVerticalBox(SettingsPageSwitcher))
{
SwitcherSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
}
BuildControlsPage(AddScrollablePage(TEXT("SettingsControlsPage")));
BuildAudioPage(AddScrollablePage(TEXT("SettingsAudioPage")));
BuildGraphicsPage(AddScrollablePage(TEXT("SettingsGraphicsPage")));
BuildAccessibilityPage(AddScrollablePage(TEXT("SettingsAccessibilityPage")));
BuildSpeechAndCoachPage(AddScrollablePage(TEXT("SettingsSpeechPage")));
SettingsPageSwitcher->SetActiveWidgetIndex(
HyperTwistSettingsPanelWidgetInternal::ControlsPageIndex);
StatusText = AddText(
Root,
TEXT("Settings are saved automatically on this device."),
13,
MutedTextColor,
TEXT("HyperTwistSettingsStatus"));
if (UVerticalBoxSlot* StatusSlot = Cast<UVerticalBoxSlot>(
StatusText != nullptr ? StatusText->Slot : nullptr))
{
StatusSlot->SetPadding(FMargin(0.0f, 14.0f, 0.0f, 0.0f));
}
RefreshWidgetsFromPreferences();
}
void UHyperTwistSettingsPanelWidget::BuildControlsPage(UVerticalBox* Parent)
{
AddSectionIntro(
Parent,
TEXT("INPUT"),
TEXT("Fast when you want it, forgiving when you need it"),
TEXT("Choose the established WCA speedcubing layout, a mnemonic pair layout, or bind every action yourself. Clicking a key starts capture; Escape cancels."));
UHorizontalBox* ProfileRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("KeyboardProfileRow"));
Parent->AddChildToVerticalBox(ProfileRow);
UTextBlock* ProfileLabel = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass(),
TEXT("KeyboardProfileLabel"));
ProfileLabel->SetText(FText::FromString(TEXT("Keyboard layout")));
ProfileLabel->SetColorAndOpacity(FSlateColor(PrimaryTextColor));
FSlateFontInfo LabelFont = ProfileLabel->GetFont();
LabelFont.Size = 15;
ProfileLabel->SetFont(LabelFont);
if (UHorizontalBoxSlot* LabelSlot = ProfileRow->AddChildToHorizontalBox(ProfileLabel))
{
LabelSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
LabelSlot->SetVerticalAlignment(VAlign_Center);
}
KeyboardProfileCombo = WidgetTree->ConstructWidget<UComboBoxString>(
UComboBoxString::StaticClass(),
TEXT("KeyboardProfileCombo"));
KeyboardProfileCombo->AddOption(TEXT("WCA Pro"));
KeyboardProfileCombo->AddOption(TEXT("Mnemonic Pairs"));
KeyboardProfileCombo->AddOption(TEXT("Custom"));
KeyboardProfileCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleKeyboardProfileChanged);
if (UHorizontalBoxSlot* ComboSlot = ProfileRow->AddChildToHorizontalBox(KeyboardProfileCombo))
{
ComboSlot->SetPadding(FMargin(18.0f, 6.0f));
ComboSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
}
PointerSensitivitySlider = AddSliderRow(
Parent,
TEXT("Pointer sensitivity"),
TEXT("Controls picking and direct manipulation."),
TEXT("PointerSensitivitySlider"),
PointerSensitivityValue);
OrbitSensitivitySlider = AddSliderRow(
Parent,
TEXT("Orbit sensitivity"),
TEXT("Controls middle-mouse and Shift + right-mouse camera movement."),
TEXT("OrbitSensitivitySlider"),
OrbitSensitivityValue);
ZoomSensitivitySlider = AddSliderRow(
Parent,
TEXT("Zoom sensitivity"),
TEXT("Controls wheel and trackpad zoom speed."),
TEXT("ZoomSensitivitySlider"),
ZoomSensitivityValue);
PointerSensitivitySlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandlePointerSensitivityChanged);
OrbitSensitivitySlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleOrbitSensitivityChanged);
ZoomSensitivitySlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleZoomSensitivityChanged);
InvertOrbitCheck = AddCheckRow(
Parent,
TEXT("Invert vertical orbit"),
TEXT("Reverses vertical camera drag without changing puzzle turns."),
TEXT("InvertOrbitCheck"));
TouchInputCheck = AddCheckRow(
Parent,
TEXT("Enable touch input"),
TEXT("Keeps direct touch turns available on compatible displays."),
TEXT("TouchInputCheck"));
InvertOrbitCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleInvertOrbitChanged);
TouchInputCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleTouchInputChanged);
AddText(Parent, TEXT("KEY BINDINGS"), 13, WarmAccentColor);
AddText(
Parent,
TEXT("Conflicts are resolved by swapping the two keys, so no action silently disappears."),
13,
MutedTextColor);
BindingRows.Reset();
for (const FHyperTwistKeyBindingDescriptor& Descriptor :
UHyperTwistPlayerSettingsLibrary::BuildKeyBindingDescriptors(Preferences))
{
UHyperTwistKeyBindingRowWidget* Row =
WidgetTree->ConstructWidget<UHyperTwistKeyBindingRowWidget>(
UHyperTwistKeyBindingRowWidget::StaticClass());
Row->ConfigureBinding(
Descriptor.ActionId,
Descriptor.Label,
Descriptor.Category,
Descriptor.Key);
Row->OnRebindRequested.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleBindingRequested);
if (UVerticalBoxSlot* RowSlot = Parent->AddChildToVerticalBox(Row))
{
RowSlot->SetPadding(FMargin(0.0f, 2.0f));
}
BindingRows.Add(Row);
}
UHorizontalBox* ResetRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("SettingsResetRow"));
if (UVerticalBoxSlot* ResetRowSlot = Parent->AddChildToVerticalBox(ResetRow))
{
ResetRowSlot->SetPadding(FMargin(0.0f, 16.0f, 0.0f, 8.0f));
}
UButton* ResetButton = AddActionButton(
ResetRow,
TEXT("Restore all defaults"),
TEXT("SettingsResetDefaultsButton"));
ResetButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleResetDefaultsClicked);
}
void UHyperTwistSettingsPanelWidget::BuildAudioPage(UVerticalBox* Parent)
{
AddSectionIntro(
Parent,
TEXT("SOUND"),
TEXT("Hear the turn, keep the focus"),
TEXT("Balance puzzle feedback, music, spoken teaching, and overall output. Muting never changes saved channel levels."));
MasterVolumeSlider = AddSliderRow(
Parent,
TEXT("Master volume"),
TEXT("Overall HyperTwist output."),
TEXT("MasterVolumeSlider"),
MasterVolumeValue);
MusicVolumeSlider = AddSliderRow(
Parent,
TEXT("Music"),
TEXT("Ambient and menu music."),
TEXT("MusicVolumeSlider"),
MusicVolumeValue);
EffectsVolumeSlider = AddSliderRow(
Parent,
TEXT("Puzzle effects"),
TEXT("Turns, confirmations, timers, and interface feedback."),
TEXT("EffectsVolumeSlider"),
EffectsVolumeValue);
VoiceVolumeSlider = AddSliderRow(
Parent,
TEXT("Coach voice"),
TEXT("Narration and spoken guidance."),
TEXT("VoiceVolumeSlider"),
VoiceVolumeValue);
MasterVolumeSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleMasterVolumeChanged);
MusicVolumeSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleMusicVolumeChanged);
EffectsVolumeSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleEffectsVolumeChanged);
VoiceVolumeSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVoiceVolumeChanged);
MutedCheck = AddCheckRow(
Parent,
TEXT("Mute all sound"),
TEXT("Temporarily silences HyperTwist while preserving the mix above."),
TEXT("MutedCheck"));
MutedCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleMutedChanged);
}
void UHyperTwistSettingsPanelWidget::BuildGraphicsPage(UVerticalBox* Parent)
{
AddSectionIntro(
Parent,
TEXT("DISPLAY"),
TEXT("Clarity before spectacle"),
TEXT("Tune visual quality for the puzzle order and hardware you are using. High is the balanced default; larger 4D slices may benefit from Medium."));
GraphicsQualityCombo = WidgetTree->ConstructWidget<UComboBoxString>(
UComboBoxString::StaticClass(),
TEXT("GraphicsQualityCombo"));
const TArray<FString> QualityOptions = {
TEXT("Low"),
TEXT("Medium"),
TEXT("High"),
TEXT("Epic")
};
for (const FString& Option : QualityOptions)
{
GraphicsQualityCombo->AddOption(Option);
}
GraphicsQualityCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleGraphicsQualityChanged);
AddText(Parent, TEXT("Overall quality"), 15, PrimaryTextColor);
Parent->AddChildToVerticalBox(GraphicsQualityCombo);
WindowModeCombo = WidgetTree->ConstructWidget<UComboBoxString>(
UComboBoxString::StaticClass(),
TEXT("WindowModeCombo"));
const TArray<FString> WindowModeOptions = {
TEXT("Fullscreen"),
TEXT("Borderless"),
TEXT("Windowed")
};
for (const FString& Option : WindowModeOptions)
{
WindowModeCombo->AddOption(Option);
}
WindowModeCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleWindowModeChanged);
AddText(Parent, TEXT("Display mode"), 15, PrimaryTextColor);
Parent->AddChildToVerticalBox(WindowModeCombo);
ResolutionScaleSlider = AddSliderRow(
Parent,
TEXT("Render scale"),
TEXT("Lower this to regain performance without shrinking the interface."),
TEXT("ResolutionScaleSlider"),
ResolutionScaleValue);
ResolutionScaleSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleResolutionScaleChanged);
VSyncCheck = AddCheckRow(
Parent,
TEXT("Vertical sync"),
TEXT("Reduces tearing by synchronizing frames with the display."),
TEXT("VSyncCheck"));
VSyncCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVSyncChanged);
}
void UHyperTwistSettingsPanelWidget::BuildAccessibilityPage(UVerticalBox* Parent)
{
AddSectionIntro(
Parent,
TEXT("ACCESS"),
TEXT("A readable path through every dimension"),
TEXT("Scale interface text and reduce non-essential motion without changing puzzle state or timing."));
UiScaleSlider = AddSliderRow(
Parent,
TEXT("Interface scale"),
TEXT("Scales menus, labels, and teaching surfaces from 80% to 140%."),
TEXT("UiScaleSlider"),
UiScaleValue);
UiScaleSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleUiScaleChanged);
ReducedMotionCheck = AddCheckRow(
Parent,
TEXT("Reduce motion"),
TEXT("Shortens decorative transitions while preserving puzzle animation cues."),
TEXT("ReducedMotionCheck"));
HighContrastCheck = AddCheckRow(
Parent,
TEXT("High contrast"),
TEXT("Strengthens separation between panels, labels, and state colors."),
TEXT("HighContrastCheck"));
SubtitlesCheck = AddCheckRow(
Parent,
TEXT("Subtitles"),
TEXT("Shows text alongside spoken coaching and voice feedback."),
TEXT("SubtitlesCheck"));
ReducedMotionCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleReducedMotionChanged);
HighContrastCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleHighContrastChanged);
SubtitlesCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSubtitlesChanged);
}
void UHyperTwistSettingsPanelWidget::BuildSpeechAndCoachPage(UVerticalBox* Parent)
{
AddSectionIntro(
Parent,
TEXT("VOICE & AI"),
TEXT("Optional intelligence, always under your control"),
TEXT("HyperTwist works without speech or cloud services. Local processing stays the default; remote providers require explicit consent, and secrets are protected for the current Windows account."));
SpeechInputCheck = AddCheckRow(
Parent,
TEXT("Enable speech-to-text"),
TEXT("Registers the global dictation shortcut. Microphone capture occurs only while explicitly active."),
TEXT("SpeechInputCheck"));
CoachNarrationCheck = AddCheckRow(
Parent,
TEXT("Coach narration"),
TEXT("Lets teaching surfaces speak when a configured voice provider is available."),
TEXT("CoachNarrationCheck"));
AssistantPanelEnabledCheck = AddCheckRow(
Parent,
TEXT("Fixed in-game coach"),
TEXT("Makes the native assistant panel available in every puzzle. Use its configured shortcut to open or close it."),
TEXT("AssistantPanelEnabledCheck"));
AssistantPanelOpenByDefaultCheck = AddCheckRow(
Parent,
TEXT("Open coach with each puzzle"),
TEXT("Shows the fixed assistant panel when a playable runtime begins."),
TEXT("AssistantPanelOpenByDefaultCheck"));
CoachAiEnabledCheck = AddCheckRow(
Parent,
TEXT("Enable AI responses"),
TEXT("When off, the panel remains useful as a private built-in controls and puzzle guide."),
TEXT("CoachAiEnabledCheck"));
CloudProvidersCheck = AddCheckRow(
Parent,
TEXT("Allow cloud providers"),
TEXT("Required before HyperTwist sends text, speech, or puzzle context to any non-loopback endpoint."),
TEXT("CloudProvidersCheck"));
SpeechInputCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechInputChanged);
CoachNarrationCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachNarrationChanged);
CloudProvidersCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCloudProvidersChanged);
AssistantPanelEnabledCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleAssistantPanelEnabledChanged);
AssistantPanelOpenByDefaultCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleAssistantPanelOpenByDefaultChanged);
CoachAiEnabledCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachAiEnabledChanged);
AddText(Parent, TEXT("SPEECH TO TEXT"), 13, WarmAccentColor);
SpeechReadinessText = AddText(
Parent,
TEXT("STATUS | Checking local speech setup..."),
11,
MutedTextColor,
TEXT("SpeechReadinessText"));
SpeechProviderCombo = AddComboSetting(
Parent,
TEXT("Transcription provider"),
TEXT("Choose an on-device engine, local bridge, or explicit cloud service."),
TEXT("SpeechProviderCombo"),
HyperTwistSettingsPanelWidgetInternal::BuildProviderLabels(
HyperTwistSettingsPanelWidgetInternal::GetSpeechProviderOptions()));
SpeechActivationModeCombo = AddComboSetting(
Parent,
TEXT("Shortcut behavior"),
TEXT("Hold records while pressed; Toggle starts and stops on separate presses."),
TEXT("SpeechActivationModeCombo"),
{TEXT("Hold to talk"), TEXT("Toggle recording")});
SpeechDictationDestinationCombo = AddComboSetting(
Parent,
TEXT("Dictation destination"),
TEXT("Draft lets you review the transcript; immediate send asks the fixed coach as soon as transcription completes."),
TEXT("SpeechDictationDestinationCombo"),
{TEXT("Coach draft"), TEXT("Ask coach immediately")});
SpeechLanguageCombo = AddComboSetting(
Parent,
TEXT("Language"),
TEXT("Auto-detect, or bias recognition toward a selected language."),
TEXT("SpeechLanguageCombo"),
{
TEXT("Auto-detect"),
TEXT("English"),
TEXT("German"),
TEXT("French"),
TEXT("Spanish"),
TEXT("Italian"),
TEXT("Portuguese"),
TEXT("Japanese"),
TEXT("Korean"),
TEXT("Chinese")
});
SpeechModelInput = AddTextSetting(
Parent,
TEXT("Transcription model"),
TEXT("Provider-qualified model identifier or local model profile."),
TEXT("SpeechModelInput"));
AvailableMicrophones =
UHyperTwistPlayerSettingsLibrary::GetAvailableMicrophones();
TArray<FString> MicrophoneLabels;
for (const FHyperTwistMicrophoneDescriptor& Microphone : AvailableMicrophones)
{
MicrophoneLabels.AddUnique(Microphone.DisplayName);
}
SpeechMicrophoneCombo = AddComboSetting(
Parent,
TEXT("Microphone"),
TEXT("Choose a detected Windows input device. System default follows the current operating-system selection."),
TEXT("SpeechMicrophoneCombo"),
MicrophoneLabels);
SpeechContextInput = AddTextSetting(
Parent,
TEXT("Recognition context"),
TEXT("Puzzle names and specialist terms that should bias transcription accuracy."),
TEXT("SpeechContextInput"));
SpeechEndpointInput = AddTextSetting(
Parent,
TEXT("Endpoint"),
TEXT("Loopback HTTP is allowed. Remote services must use HTTPS and cloud consent."),
TEXT("SpeechEndpointInput"));
SpeechCleanupCheck = AddCheckRow(
Parent,
TEXT("Clean up dictated text"),
TEXT("Applies punctuation and configured vocabulary normalization."),
TEXT("SpeechCleanupCheck"));
SpeechSoundFeedbackCheck = AddCheckRow(
Parent,
TEXT("Recording sound feedback"),
TEXT("Plays a short cue when dictation starts and stops."),
TEXT("SpeechSoundFeedbackCheck"));
SpeechCredentialInput = AddTextSetting(
Parent,
TEXT("API key"),
TEXT("Leave blank to keep the currently protected key."),
TEXT("SpeechCredentialInput"),
true);
UHorizontalBox* SpeechCredentialRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("SpeechCredentialButtonRow"));
Parent->AddChildToVerticalBox(SpeechCredentialRow);
UButton* SpeechSaveButton = AddActionButton(
SpeechCredentialRow,
TEXT("Save protected key"),
TEXT("SpeechCredentialSaveButton"));
UButton* SpeechRemoveButton = AddActionButton(
SpeechCredentialRow,
TEXT("Remove key"),
TEXT("SpeechCredentialRemoveButton"));
UButton* SpeechValidateButton = AddActionButton(
SpeechCredentialRow,
TEXT("Check configuration"),
TEXT("SpeechConfigurationValidateButton"));
UButton* SpeechRefreshMicrophonesButton = AddActionButton(
SpeechCredentialRow,
TEXT("Refresh microphones"),
TEXT("SpeechRefreshMicrophonesButton"));
UButton* SpeechTestMicrophoneButton = AddActionButton(
SpeechCredentialRow,
TEXT("Test microphone"),
TEXT("SpeechTestMicrophoneButton"));
SpeechSaveButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSaveSpeechCredential);
SpeechRemoveButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleRemoveSpeechCredential);
SpeechValidateButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleValidateSpeechConfiguration);
SpeechRefreshMicrophonesButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleRefreshMicrophones);
SpeechTestMicrophoneButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleTestMicrophone);
AddText(Parent, TEXT("TEXT TO SPEECH"), 13, WarmAccentColor);
VoiceReadinessText = AddText(
Parent,
TEXT("STATUS | Checking voice setup..."),
11,
MutedTextColor,
TEXT("VoiceReadinessText"));
VoiceProviderCombo = AddComboSetting(
Parent,
TEXT("Voice provider"),
TEXT("On-device Piper is the private default; cloud voices remain optional."),
TEXT("VoiceProviderCombo"),
HyperTwistSettingsPanelWidgetInternal::BuildProviderLabels(
HyperTwistSettingsPanelWidgetInternal::GetVoiceProviderOptions()));
VoiceModelInput = AddTextSetting(
Parent,
TEXT("Voice model"),
TEXT("Model identifier used by the selected voice service."),
TEXT("VoiceModelInput"));
VoiceIdInput = AddTextSetting(
Parent,
TEXT("Voice"),
TEXT("Provider voice identifier, for example en_US-lessac-medium or alloy."),
TEXT("VoiceIdInput"));
VoiceSpeedSlider = AddSliderRow(
Parent,
TEXT("Speaking speed"),
TEXT("Adjust narration from 0.5x to 2.0x without changing subtitles."),
TEXT("VoiceSpeedSlider"),
VoiceSpeedValue);
VoiceEndpointInput = AddTextSetting(
Parent,
TEXT("Endpoint"),
TEXT("Loopback HTTP is allowed. Remote services must use HTTPS and cloud consent."),
TEXT("VoiceEndpointInput"));
VoiceCredentialInput = AddTextSetting(
Parent,
TEXT("API key"),
TEXT("Leave blank to keep the currently protected key."),
TEXT("VoiceCredentialInput"),
true);
UHorizontalBox* VoiceCredentialRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("VoiceCredentialButtonRow"));
Parent->AddChildToVerticalBox(VoiceCredentialRow);
UButton* VoiceSaveButton = AddActionButton(
VoiceCredentialRow,
TEXT("Save protected key"),
TEXT("VoiceCredentialSaveButton"));
UButton* VoiceRemoveButton = AddActionButton(
VoiceCredentialRow,
TEXT("Remove key"),
TEXT("VoiceCredentialRemoveButton"));
UButton* VoiceValidateButton = AddActionButton(
VoiceCredentialRow,
TEXT("Check configuration"),
TEXT("VoiceConfigurationValidateButton"));
VoiceSaveButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSaveVoiceCredential);
VoiceRemoveButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleRemoveVoiceCredential);
VoiceValidateButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleValidateVoiceConfiguration);
AddText(Parent, TEXT("AI COACH"), 13, WarmAccentColor);
CoachReadinessText = AddText(
Parent,
TEXT("STATUS | Private built-in guide ready."),
11,
MutedTextColor,
TEXT("CoachReadinessText"));
CoachProviderCombo = AddComboSetting(
Parent,
TEXT("Provider"),
TEXT("The built-in guide works without AI. Select a model provider only for generated responses."),
TEXT("CoachProviderCombo"),
HyperTwistSettingsPanelWidgetInternal::BuildProviderLabels(
HyperTwistSettingsPanelWidgetInternal::GetCoachProviderOptions()));
CoachEndpointInput = AddTextSetting(
Parent,
TEXT("Endpoint"),
TEXT("OpenAI Responses or OpenAI-compatible chat-completions endpoint."),
TEXT("CoachEndpointInput"));
CoachModelInput = AddTextSetting(
Parent,
TEXT("Model"),
TEXT("Optional provider model identifier."),
TEXT("CoachModelInput"));
CoachInstructionsInput = AddTextSetting(
Parent,
TEXT("Coach instructions"),
TEXT("Bounded system guidance sent with AI requests. Live puzzle state is never invented."),
TEXT("CoachInstructionsInput"));
CoachTemperatureSlider = AddSliderRow(
Parent,
TEXT("Response creativity"),
TEXT("Lower values favor repeatable technical guidance."),
TEXT("CoachTemperatureSlider"),
CoachTemperatureValue);
CoachMaxResponseTokensSlider = AddSliderRow(
Parent,
TEXT("Response length"),
TEXT("Bounds generated coach answers between 128 and 2,048 tokens."),
TEXT("CoachMaxResponseTokensSlider"),
CoachMaxResponseTokensValue);
CoachRequestTimeoutSlider = AddSliderRow(
Parent,
TEXT("Request timeout"),
TEXT("Stops an unavailable coach request after 5 to 60 seconds."),
TEXT("CoachRequestTimeoutSlider"),
CoachRequestTimeoutValue);
CoachCredentialInput = AddTextSetting(
Parent,
TEXT("API key"),
TEXT("Leave blank to keep the currently protected key."),
TEXT("CoachCredentialInput"),
true);
UHorizontalBox* CoachCredentialRow = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass(),
TEXT("CoachCredentialButtonRow"));
Parent->AddChildToVerticalBox(CoachCredentialRow);
UButton* CoachSaveButton = AddActionButton(
CoachCredentialRow,
TEXT("Save protected key"),
TEXT("CoachCredentialSaveButton"),
true);
UButton* CoachRemoveButton = AddActionButton(
CoachCredentialRow,
TEXT("Remove key"),
TEXT("CoachCredentialRemoveButton"));
UButton* CoachValidateButton = AddActionButton(
CoachCredentialRow,
TEXT("Check configuration"),
TEXT("CoachConfigurationValidateButton"));
CoachSaveButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSaveCoachCredential);
CoachRemoveButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleRemoveCoachCredential);
CoachValidateButton->OnClicked.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleValidateCoachConfiguration);
SpeechProviderCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechProviderChanged);
SpeechActivationModeCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechActivationModeChanged);
SpeechDictationDestinationCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechDictationDestinationChanged);
SpeechLanguageCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechLanguageChanged);
SpeechEndpointInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechEndpointCommitted);
SpeechModelInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechModelCommitted);
SpeechMicrophoneCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechMicrophoneChanged);
SpeechContextInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechContextCommitted);
SpeechCleanupCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechCleanupChanged);
SpeechSoundFeedbackCheck->OnCheckStateChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleSpeechSoundFeedbackChanged);
VoiceProviderCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVoiceProviderChanged);
VoiceEndpointInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVoiceEndpointCommitted);
VoiceModelInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVoiceModelCommitted);
VoiceIdInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVoiceIdCommitted);
VoiceSpeedSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleVoiceSpeedChanged);
CoachProviderCombo->OnSelectionChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachProviderChanged);
CoachEndpointInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachEndpointCommitted);
CoachModelInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachModelCommitted);
CoachInstructionsInput->OnTextCommitted.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachInstructionsCommitted);
CoachTemperatureSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachTemperatureChanged);
CoachMaxResponseTokensSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachMaxResponseTokensChanged);
CoachRequestTimeoutSlider->OnValueChanged.AddDynamic(
this,
&UHyperTwistSettingsPanelWidget::HandleCoachRequestTimeoutChanged);
}
UTextBlock* UHyperTwistSettingsPanelWidget::AddText(
UVerticalBox* Parent,
const FString& Text,
const int32 FontSize,
const FLinearColor& Color,
const FName& WidgetName
)
{
if (Parent == nullptr || WidgetTree == nullptr)
{
return nullptr;
}
UTextBlock* TextBlock = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass(),
WidgetName);
TextBlock->SetText(FText::FromString(Text));
TextBlock->SetAutoWrapText(true);
TextBlock->SetColorAndOpacity(FSlateColor(Color));
FSlateFontInfo Font = TextBlock->GetFont();
Font.Size = FontSize;
TextBlock->SetFont(Font);
if (UVerticalBoxSlot* TextSlot = Parent->AddChildToVerticalBox(TextBlock))
{
TextSlot->SetPadding(FMargin(0.0f, 2.0f));
}
return TextBlock;
}
void UHyperTwistSettingsPanelWidget::AddSectionIntro(
UVerticalBox* Parent,
const FString& Eyebrow,
const FString& Title,
const FString& Description
)
{
AddText(Parent, Eyebrow, 12, AccentColor);
AddText(Parent, Title, 24, PrimaryTextColor);
UTextBlock* DescriptionText = AddText(Parent, Description, 14, MutedTextColor);
if (UVerticalBoxSlot* DescriptionSlot = Cast<UVerticalBoxSlot>(
DescriptionText != nullptr ? DescriptionText->Slot : nullptr))
{
DescriptionSlot->SetPadding(FMargin(0.0f, 3.0f, 0.0f, 18.0f));
}
}
UButton* UHyperTwistSettingsPanelWidget::AddTabButton(
UUniformGridPanel* Parent,
const FString& Label,
const int32 Column,
const FName& WidgetName
)
{
UButton* Button = WidgetTree->ConstructWidget<UButton>(
UButton::StaticClass(),
WidgetName);
Button->SetBackgroundColor(CardColor);
UTextBlock* LabelText = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass());
LabelText->SetText(FText::FromString(Label));
LabelText->SetJustification(ETextJustify::Center);
LabelText->SetColorAndOpacity(FSlateColor(PrimaryTextColor));
FSlateFontInfo Font = LabelText->GetFont();
Font.Size = 13;
LabelText->SetFont(Font);
Button->AddChild(LabelText);
if (UButtonSlot* ButtonSlot = Cast<UButtonSlot>(LabelText->Slot))
{
ButtonSlot->SetPadding(FMargin(10.0f, 8.0f));
ButtonSlot->SetHorizontalAlignment(HAlign_Center);
}
if (UUniformGridSlot* GridSlot = Parent->AddChildToUniformGrid(Button, 0, Column))
{
GridSlot->SetHorizontalAlignment(HAlign_Fill);
}
return Button;
}
UButton* UHyperTwistSettingsPanelWidget::AddActionButton(
UHorizontalBox* Parent,
const FString& Label,
const FName& WidgetName,
const bool bPrimary
)
{
UButton* Button = WidgetTree->ConstructWidget<UButton>(
UButton::StaticClass(),
WidgetName);
Button->SetBackgroundColor(bPrimary ? AccentColor : CardColor);
UTextBlock* LabelText = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass());
LabelText->SetText(FText::FromString(Label));
LabelText->SetColorAndOpacity(FSlateColor(
bPrimary ? FLinearColor(0.01f, 0.04f, 0.05f, 1.0f) : PrimaryTextColor));
FSlateFontInfo Font = LabelText->GetFont();
Font.Size = 14;
LabelText->SetFont(Font);
Button->AddChild(LabelText);
if (UButtonSlot* ButtonSlot = Cast<UButtonSlot>(LabelText->Slot))
{
ButtonSlot->SetPadding(FMargin(18.0f, 9.0f));
ButtonSlot->SetHorizontalAlignment(HAlign_Center);
}
if (UHorizontalBoxSlot* ActionSlot = Parent->AddChildToHorizontalBox(Button))
{
ActionSlot->SetPadding(FMargin(4.0f));
ActionSlot->SetVerticalAlignment(VAlign_Center);
}
return Button;
}
USlider* UHyperTwistSettingsPanelWidget::AddSliderRow(
UVerticalBox* Parent,
const FString& Label,
const FString& SupportingText,
const FName& WidgetName,
TObjectPtr<UTextBlock>& OutValueLabel
)
{
UVerticalBox* Row = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass());
if (UVerticalBoxSlot* RowSlot = Parent->AddChildToVerticalBox(Row))
{
RowSlot->SetPadding(FMargin(0.0f, 8.0f));
}
UHorizontalBox* Header = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass());
Row->AddChildToVerticalBox(Header);
UTextBlock* LabelText = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass());
LabelText->SetText(FText::FromString(Label));
LabelText->SetColorAndOpacity(FSlateColor(PrimaryTextColor));
FSlateFontInfo Font = LabelText->GetFont();
Font.Size = 15;
LabelText->SetFont(Font);
if (UHorizontalBoxSlot* LabelSlot = Header->AddChildToHorizontalBox(LabelText))
{
LabelSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
}
OutValueLabel = WidgetTree->ConstructWidget<UTextBlock>(
UTextBlock::StaticClass());
OutValueLabel->SetColorAndOpacity(FSlateColor(AccentColor));
if (UHorizontalBoxSlot* ValueSlot = Header->AddChildToHorizontalBox(OutValueLabel))
{
ValueSlot->SetHorizontalAlignment(HAlign_Right);
}
AddText(Row, SupportingText, 12, MutedTextColor);
USlider* Slider = WidgetTree->ConstructWidget<USlider>(
USlider::StaticClass(),
WidgetName);
Slider->SetMinValue(0.0f);
Slider->SetMaxValue(1.0f);
Slider->SetStepSize(0.01f);
if (UVerticalBoxSlot* SliderSlot = Row->AddChildToVerticalBox(Slider))
{
SliderSlot->SetPadding(FMargin(0.0f, 6.0f, 0.0f, 0.0f));
}
return Slider;
}
UCheckBox* UHyperTwistSettingsPanelWidget::AddCheckRow(
UVerticalBox* Parent,
const FString& Label,
const FString& SupportingText,
const FName& WidgetName
)
{
UHorizontalBox* Row = WidgetTree->ConstructWidget<UHorizontalBox>(
UHorizontalBox::StaticClass());
if (UVerticalBoxSlot* RowSlot = Parent->AddChildToVerticalBox(Row))
{
RowSlot->SetPadding(FMargin(0.0f, 8.0f));
}
UVerticalBox* Copy = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass());
if (UHorizontalBoxSlot* CopySlot = Row->AddChildToHorizontalBox(Copy))
{
CopySlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
}
AddText(Copy, Label, 15, PrimaryTextColor);
AddText(Copy, SupportingText, 12, MutedTextColor);
UCheckBox* CheckBox = WidgetTree->ConstructWidget<UCheckBox>(
UCheckBox::StaticClass(),
WidgetName);
if (UHorizontalBoxSlot* CheckSlot = Row->AddChildToHorizontalBox(CheckBox))
{
CheckSlot->SetPadding(FMargin(18.0f, 0.0f));
CheckSlot->SetVerticalAlignment(VAlign_Center);
}
return CheckBox;
}
UEditableTextBox* UHyperTwistSettingsPanelWidget::AddTextSetting(
UVerticalBox* Parent,
const FString& Label,
const FString& SupportingText,
const FName& WidgetName,
const bool bPassword
)
{
UVerticalBox* Row = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass());
if (UVerticalBoxSlot* RowSlot = Parent->AddChildToVerticalBox(Row))
{
RowSlot->SetPadding(FMargin(0.0f, 8.0f));
}
AddText(Row, Label, 15, PrimaryTextColor);
AddText(Row, SupportingText, 12, MutedTextColor);
UEditableTextBox* Input = WidgetTree->ConstructWidget<UEditableTextBox>(
UEditableTextBox::StaticClass(),
WidgetName);
Input->SetIsPassword(bPassword);
Input->SetForegroundColor(PrimaryTextColor);
if (UVerticalBoxSlot* InputSlot = Row->AddChildToVerticalBox(Input))
{
InputSlot->SetPadding(FMargin(0.0f, 6.0f, 0.0f, 0.0f));
}
return Input;
}
UComboBoxString* UHyperTwistSettingsPanelWidget::AddComboSetting(
UVerticalBox* Parent,
const FString& Label,
const FString& SupportingText,
const FName& WidgetName,
const TArray<FString>& Options
)
{
UVerticalBox* Row = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass());
if (UVerticalBoxSlot* RowSlot = Parent->AddChildToVerticalBox(Row))
{
RowSlot->SetPadding(FMargin(0.0f, 8.0f));
}
AddText(Row, Label, 15, PrimaryTextColor);
AddText(Row, SupportingText, 12, MutedTextColor);
UComboBoxString* Combo = WidgetTree->ConstructWidget<UComboBoxString>(
UComboBoxString::StaticClass(),
WidgetName);
for (const FString& Option : Options)
{
Combo->AddOption(Option);
}
if (UVerticalBoxSlot* ComboSlot = Row->AddChildToVerticalBox(Combo))
{
ComboSlot->SetPadding(FMargin(0.0f, 6.0f, 0.0f, 0.0f));
}
return Combo;
}
UVerticalBox* UHyperTwistSettingsPanelWidget::AddScrollablePage(
const FName& WidgetName
)
{
UScrollBox* Scroll = WidgetTree->ConstructWidget<UScrollBox>(
UScrollBox::StaticClass(),
WidgetName);
SettingsPageSwitcher->AddChild(Scroll);
UVerticalBox* Page = WidgetTree->ConstructWidget<UVerticalBox>(
UVerticalBox::StaticClass());
Scroll->AddChild(Page);
return Page;
}
void UHyperTwistSettingsPanelWidget::RefreshWidgetsFromPreferences()
{
if (WidgetTree == nullptr || WidgetTree->RootWidget == nullptr)
{
return;
}
bRefreshingWidgets = true;
if (KeyboardProfileCombo != nullptr)
{
KeyboardProfileCombo->SetSelectedOption(
UHyperTwistPlayerSettingsLibrary::GetKeyboardProfileDisplayName(
Preferences.KeyboardProfileId));
}
if (PointerSensitivitySlider != nullptr)
{
PointerSensitivitySlider->SetValue((Preferences.PointerSensitivity - 0.2f) / 2.8f);
}
if (OrbitSensitivitySlider != nullptr)
{
OrbitSensitivitySlider->SetValue((Preferences.OrbitSensitivity - 0.2f) / 2.8f);
}
if (ZoomSensitivitySlider != nullptr)
{
ZoomSensitivitySlider->SetValue((Preferences.ZoomSensitivity - 0.2f) / 2.8f);
}
HyperTwistSettingsPanelWidgetInternal::SetText(
PointerSensitivityValue,
HyperTwistSettingsPanelWidgetInternal::DecimalLabel(Preferences.PointerSensitivity));
HyperTwistSettingsPanelWidgetInternal::SetText(
OrbitSensitivityValue,
HyperTwistSettingsPanelWidgetInternal::DecimalLabel(Preferences.OrbitSensitivity));
HyperTwistSettingsPanelWidgetInternal::SetText(
ZoomSensitivityValue,
HyperTwistSettingsPanelWidgetInternal::DecimalLabel(Preferences.ZoomSensitivity));
if (InvertOrbitCheck != nullptr)
{
InvertOrbitCheck->SetIsChecked(Preferences.bInvertOrbitY);
}
if (TouchInputCheck != nullptr)
{
TouchInputCheck->SetIsChecked(Preferences.bTouchInputEnabled);
}
const TArray<TPair<USlider*, float>> AudioSliders = {
{MasterVolumeSlider, Preferences.MasterVolume},
{MusicVolumeSlider, Preferences.MusicVolume},
{EffectsVolumeSlider, Preferences.EffectsVolume},
{VoiceVolumeSlider, Preferences.VoiceVolume}
};
for (const TPair<USlider*, float>& Entry : AudioSliders)
{
if (Entry.Key != nullptr)
{
Entry.Key->SetValue(Entry.Value);
}
}
HyperTwistSettingsPanelWidgetInternal::SetText(
MasterVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Preferences.MasterVolume));
HyperTwistSettingsPanelWidgetInternal::SetText(
MusicVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Preferences.MusicVolume));
HyperTwistSettingsPanelWidgetInternal::SetText(
EffectsVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Preferences.EffectsVolume));
HyperTwistSettingsPanelWidgetInternal::SetText(
VoiceVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Preferences.VoiceVolume));
if (MutedCheck != nullptr)
{
MutedCheck->SetIsChecked(Preferences.bMuted);
}
if (GraphicsQualityCombo != nullptr)
{
GraphicsQualityCombo->SetSelectedOption(
HyperTwistSettingsPanelWidgetInternal::QualityLabel(Preferences.GraphicsQuality));
}
if (WindowModeCombo != nullptr)
{
WindowModeCombo->SetSelectedOption(
HyperTwistSettingsPanelWidgetInternal::WindowModeLabel(Preferences.WindowMode));
}
if (ResolutionScaleSlider != nullptr)
{
ResolutionScaleSlider->SetValue((Preferences.ResolutionScalePercent - 50.0f) / 50.0f);
}
HyperTwistSettingsPanelWidgetInternal::SetText(
ResolutionScaleValue,
FString::Printf(TEXT("%d%%"), FMath::RoundToInt(Preferences.ResolutionScalePercent)));
if (VSyncCheck != nullptr)
{
VSyncCheck->SetIsChecked(Preferences.bVSyncEnabled);
}
if (UiScaleSlider != nullptr)
{
UiScaleSlider->SetValue((Preferences.UiScale - 0.8f) / 0.6f);
}
HyperTwistSettingsPanelWidgetInternal::SetText(
UiScaleValue,
FString::Printf(TEXT("%d%%"), FMath::RoundToInt(Preferences.UiScale * 100.0f)));
if (ReducedMotionCheck != nullptr)
{
ReducedMotionCheck->SetIsChecked(Preferences.bReducedMotion);
}
if (HighContrastCheck != nullptr)
{
HighContrastCheck->SetIsChecked(Preferences.bHighContrast);
}
if (SubtitlesCheck != nullptr)
{
SubtitlesCheck->SetIsChecked(Preferences.bSubtitlesEnabled);
}
if (SpeechInputCheck != nullptr)
{
SpeechInputCheck->SetIsChecked(Preferences.bSpeechInputEnabled);
}
if (CoachNarrationCheck != nullptr)
{
CoachNarrationCheck->SetIsChecked(Preferences.bCoachNarrationEnabled);
}
if (CloudProvidersCheck != nullptr)
{
CloudProvidersCheck->SetIsChecked(Preferences.bAllowCloudProviders);
}
if (AssistantPanelEnabledCheck != nullptr)
{
AssistantPanelEnabledCheck->SetIsChecked(Preferences.bAssistantPanelEnabled);
}
if (AssistantPanelOpenByDefaultCheck != nullptr)
{
AssistantPanelOpenByDefaultCheck->SetIsChecked(
Preferences.bAssistantPanelOpenByDefault);
}
if (CoachAiEnabledCheck != nullptr)
{
CoachAiEnabledCheck->SetIsChecked(Preferences.bCoachAiEnabled);
}
if (SpeechProviderCombo != nullptr)
{
SpeechProviderCombo->SetSelectedOption(
HyperTwistSettingsPanelWidgetInternal::ProviderLabel(
HyperTwistSettingsPanelWidgetInternal::GetSpeechProviderOptions(),
Preferences.SpeechProviderId));
}
if (SpeechActivationModeCombo != nullptr)
{
SpeechActivationModeCombo->SetSelectedOption(
Preferences.SpeechActivationMode == TEXT("toggle")
? TEXT("Toggle recording")
: TEXT("Hold to talk"));
}
if (SpeechDictationDestinationCombo != nullptr)
{
SpeechDictationDestinationCombo->SetSelectedOption(
Preferences.SpeechDictationDestination == TEXT("coach-send")
? TEXT("Ask coach immediately")
: TEXT("Coach draft"));
}
if (SpeechLanguageCombo != nullptr)
{
SpeechLanguageCombo->SetSelectedOption(
HyperTwistSettingsPanelWidgetInternal::SpeechLanguageLabel(
Preferences.SpeechLanguage));
}
if (SpeechEndpointInput != nullptr)
{
SpeechEndpointInput->SetText(FText::FromString(Preferences.SpeechEndpoint));
}
if (SpeechModelInput != nullptr)
{
SpeechModelInput->SetText(FText::FromString(Preferences.SpeechModel));
}
if (SpeechMicrophoneCombo != nullptr)
{
const FHyperTwistMicrophoneDescriptor* SelectedMicrophone =
AvailableMicrophones.FindByPredicate(
[this](const FHyperTwistMicrophoneDescriptor& Microphone)
{
return Microphone.DeviceId.Equals(
Preferences.SpeechMicrophoneId,
ESearchCase::IgnoreCase);
});
const FString SelectedLabel = SelectedMicrophone != nullptr
? SelectedMicrophone->DisplayName
: Preferences.SpeechMicrophoneId;
if (SpeechMicrophoneCombo->FindOptionIndex(SelectedLabel) == INDEX_NONE)
{
SpeechMicrophoneCombo->AddOption(SelectedLabel);
}
SpeechMicrophoneCombo->SetSelectedOption(SelectedLabel);
}
if (SpeechContextInput != nullptr)
{
SpeechContextInput->SetText(
FText::FromString(Preferences.SpeechRecognitionContext));
}
if (SpeechCleanupCheck != nullptr)
{
SpeechCleanupCheck->SetIsChecked(Preferences.bSpeechCleanupEnabled);
}
if (SpeechSoundFeedbackCheck != nullptr)
{
SpeechSoundFeedbackCheck->SetIsChecked(
Preferences.bSpeechSoundFeedbackEnabled);
}
if (VoiceProviderCombo != nullptr)
{
VoiceProviderCombo->SetSelectedOption(
HyperTwistSettingsPanelWidgetInternal::ProviderLabel(
HyperTwistSettingsPanelWidgetInternal::GetVoiceProviderOptions(),
Preferences.VoiceProviderId));
}
if (VoiceEndpointInput != nullptr)
{
VoiceEndpointInput->SetText(FText::FromString(Preferences.VoiceEndpoint));
}
if (VoiceModelInput != nullptr)
{
VoiceModelInput->SetText(FText::FromString(Preferences.VoiceModel));
}
if (VoiceIdInput != nullptr)
{
VoiceIdInput->SetText(FText::FromString(Preferences.VoiceId));
}
if (VoiceSpeedSlider != nullptr)
{
VoiceSpeedSlider->SetValue((Preferences.VoiceSpeed - 0.5f) / 1.5f);
}
HyperTwistSettingsPanelWidgetInternal::SetText(
VoiceSpeedValue,
FString::Printf(TEXT("%.2fx"), Preferences.VoiceSpeed));
if (CoachProviderCombo != nullptr)
{
CoachProviderCombo->SetSelectedOption(
HyperTwistSettingsPanelWidgetInternal::ProviderLabel(
HyperTwistSettingsPanelWidgetInternal::GetCoachProviderOptions(),
Preferences.CoachProviderId));
}
if (CoachEndpointInput != nullptr)
{
CoachEndpointInput->SetText(FText::FromString(Preferences.CoachEndpoint));
}
if (CoachModelInput != nullptr)
{
CoachModelInput->SetText(FText::FromString(Preferences.CoachModel));
}
if (CoachInstructionsInput != nullptr)
{
CoachInstructionsInput->SetText(
FText::FromString(Preferences.CoachSystemInstructions));
}
if (CoachTemperatureSlider != nullptr)
{
CoachTemperatureSlider->SetValue(Preferences.CoachTemperature);
}
HyperTwistSettingsPanelWidgetInternal::SetText(
CoachTemperatureValue,
FString::Printf(TEXT("%.2f"), Preferences.CoachTemperature));
if (CoachMaxResponseTokensSlider != nullptr)
{
CoachMaxResponseTokensSlider->SetValue(
static_cast<float>(Preferences.CoachMaxResponseTokens - 128)
/ static_cast<float>(2048 - 128));
}
HyperTwistSettingsPanelWidgetInternal::SetText(
CoachMaxResponseTokensValue,
FString::Printf(TEXT("%d tokens"), Preferences.CoachMaxResponseTokens));
if (CoachRequestTimeoutSlider != nullptr)
{
CoachRequestTimeoutSlider->SetValue(
(Preferences.CoachRequestTimeoutSeconds - 5.0f) / 55.0f);
}
HyperTwistSettingsPanelWidgetInternal::SetText(
CoachRequestTimeoutValue,
FString::Printf(
TEXT("%.0f seconds"),
Preferences.CoachRequestTimeoutSeconds));
RefreshBindingRows();
bRefreshingWidgets = false;
RefreshProviderReadiness();
}
void UHyperTwistSettingsPanelWidget::RefreshBindingRows()
{
const TArray<FHyperTwistKeyBindingDescriptor> Descriptors =
UHyperTwistPlayerSettingsLibrary::BuildKeyBindingDescriptors(Preferences);
for (UHyperTwistKeyBindingRowWidget* Row : BindingRows)
{
if (Row == nullptr)
{
continue;
}
const int32 RowIndex = BindingRows.IndexOfByKey(Row);
if (Descriptors.IsValidIndex(RowIndex))
{
Row->ConfigureBinding(
Descriptors[RowIndex].ActionId,
Descriptors[RowIndex].Label,
Descriptors[RowIndex].Category,
Descriptors[RowIndex].Key);
}
}
}
void UHyperTwistSettingsPanelWidget::RefreshMicrophoneOptions()
{
if (SpeechMicrophoneCombo == nullptr)
{
return;
}
const bool bWasRefreshing = bRefreshingWidgets;
bRefreshingWidgets = true;
AvailableMicrophones =
UHyperTwistPlayerSettingsLibrary::GetAvailableMicrophones();
if (!AvailableMicrophones.ContainsByPredicate(
[this](const FHyperTwistMicrophoneDescriptor& Microphone)
{
return Microphone.DeviceId.Equals(
Preferences.SpeechMicrophoneId,
ESearchCase::IgnoreCase);
}))
{
FHyperTwistMicrophoneDescriptor ExistingSelection;
ExistingSelection.DeviceId = Preferences.SpeechMicrophoneId;
ExistingSelection.DisplayName = Preferences.SpeechMicrophoneId;
AvailableMicrophones.Add(MoveTemp(ExistingSelection));
}
SpeechMicrophoneCombo->ClearOptions();
FString SelectedLabel;
for (const FHyperTwistMicrophoneDescriptor& Microphone : AvailableMicrophones)
{
SpeechMicrophoneCombo->AddOption(Microphone.DisplayName);
if (Microphone.DeviceId.Equals(
Preferences.SpeechMicrophoneId,
ESearchCase::IgnoreCase))
{
SelectedLabel = Microphone.DisplayName;
}
}
SpeechMicrophoneCombo->SetSelectedOption(
SelectedLabel.IsEmpty()
? TEXT("System default microphone")
: SelectedLabel);
bRefreshingWidgets = bWasRefreshing;
}
void UHyperTwistSettingsPanelWidget::RefreshProviderReadiness()
{
auto SetReadiness = [this](
UTextBlock* Target,
const FString& Message,
const bool bReady)
{
HyperTwistSettingsPanelWidgetInternal::SetText(Target, Message);
if (Target != nullptr)
{
Target->SetColorAndOpacity(FSlateColor(
bReady ? AccentColor : WarmAccentColor));
}
};
FString FailureReason;
const bool bSpeechEndpointAllowed =
UHyperTwistPlayerSettingsLibrary::IsProviderEndpointAllowed(
Preferences.SpeechEndpoint,
Preferences.bAllowCloudProviders,
FailureReason);
const bool bSpeechCloud =
Preferences.SpeechProviderId == TEXT("openai")
|| Preferences.SpeechProviderId == TEXT("groq")
|| Preferences.SpeechProviderId == TEXT("deepgram")
|| Preferences.SpeechProviderId == TEXT("elevenlabs");
const bool bSpeechReady =
Preferences.bSpeechInputEnabled
&& bSpeechEndpointAllowed
&& !Preferences.SpeechModel.IsEmpty()
&& (!bSpeechCloud
|| (Preferences.bAllowCloudProviders
&& UHyperTwistPlayerSettingsLibrary::HasProviderCredential(
TEXT("speech-api-key"))));
SetReadiness(
SpeechReadinessText,
bSpeechReady
? TEXT("READY TO TRY | Shortcut, microphone, model, and privacy route configured.")
: Preferences.bSpeechInputEnabled
? TEXT("NEEDS SETUP | Complete the endpoint, model, consent, or protected key.")
: TEXT("OFF | Enable speech-to-text to register the global shortcut."),
bSpeechReady);
FailureReason.Reset();
const bool bVoiceEndpointAllowed =
UHyperTwistPlayerSettingsLibrary::IsProviderEndpointAllowed(
Preferences.VoiceEndpoint,
Preferences.bAllowCloudProviders,
FailureReason);
const bool bVoiceCloud =
Preferences.VoiceProviderId == TEXT("openai")
|| Preferences.VoiceProviderId == TEXT("elevenlabs");
const bool bVoiceReady =
Preferences.bCoachNarrationEnabled
&& bVoiceEndpointAllowed
&& !Preferences.VoiceModel.IsEmpty()
&& !Preferences.VoiceId.IsEmpty()
&& (!bVoiceCloud
|| (Preferences.bAllowCloudProviders
&& UHyperTwistPlayerSettingsLibrary::HasProviderCredential(
TEXT("voice-api-key"))));
SetReadiness(
VoiceReadinessText,
bVoiceReady
? TEXT("READY TO TRY | Voice, model, endpoint, and privacy route configured.")
: Preferences.bCoachNarrationEnabled
? TEXT("NEEDS SETUP | Complete the voice, model, endpoint, consent, or protected key.")
: TEXT("OFF | Enable coach narration when spoken guidance is wanted."),
bVoiceReady);
const bool bBuiltInCoach =
!Preferences.bCoachAiEnabled
|| Preferences.CoachProviderId == TEXT("local-disabled");
FailureReason.Reset();
const bool bCoachEndpointAllowed = bBuiltInCoach
|| UHyperTwistPlayerSettingsLibrary::IsProviderEndpointAllowed(
Preferences.CoachEndpoint,
Preferences.bAllowCloudProviders,
FailureReason);
const bool bCoachCloud =
Preferences.CoachProviderId == TEXT("openai")
|| Preferences.CoachProviderId == TEXT("groq");
const bool bCoachReady = bBuiltInCoach
|| (bCoachEndpointAllowed
&& !Preferences.CoachModel.IsEmpty()
&& (!bCoachCloud
|| (Preferences.bAllowCloudProviders
&& UHyperTwistPlayerSettingsLibrary::HasProviderCredential(
TEXT("coach-api-key")))));
SetReadiness(
CoachReadinessText,
bBuiltInCoach
? TEXT("READY | Private built-in guide; no provider or account required.")
: bCoachReady
? TEXT("READY TO TRY | AI model, endpoint, consent, and protected key configured.")
: TEXT("NEEDS SETUP | Complete the AI model, endpoint, consent, or protected key."),
bCoachReady);
}
void UHyperTwistSettingsPanelWidget::SavePreferencesAndApply(
const FString& StatusMessage,
const bool bApplyGraphics
)
{
if (!UHyperTwistPlayerSettingsLibrary::SavePreferences(Preferences))
{
SetStatus(TEXT("HyperTwist could not save settings on this device."), true);
return;
}
UHyperTwistPlayerSettingsLibrary::ApplyRuntimePreferences(
Preferences,
bApplyGraphics);
RefreshProviderReadiness();
SetStatus(StatusMessage);
}
void UHyperTwistSettingsPanelWidget::SetStatus(
const FString& Message,
const bool bError
)
{
if (StatusText != nullptr)
{
StatusText->SetText(FText::FromString(Message));
StatusText->SetColorAndOpacity(FSlateColor(
bError ? FLinearColor(1.0f, 0.38f, 0.30f, 1.0f) : MutedTextColor));
}
}
void UHyperTwistSettingsPanelWidget::ShowControlsPage()
{
SettingsPageSwitcher->SetActiveWidgetIndex(
HyperTwistSettingsPanelWidgetInternal::ControlsPageIndex);
}
void UHyperTwistSettingsPanelWidget::ShowAudioPage()
{
SettingsPageSwitcher->SetActiveWidgetIndex(
HyperTwistSettingsPanelWidgetInternal::AudioPageIndex);
}
void UHyperTwistSettingsPanelWidget::ShowGraphicsPage()
{
SettingsPageSwitcher->SetActiveWidgetIndex(
HyperTwistSettingsPanelWidgetInternal::GraphicsPageIndex);
}
void UHyperTwistSettingsPanelWidget::ShowAccessibilityPage()
{
SettingsPageSwitcher->SetActiveWidgetIndex(
HyperTwistSettingsPanelWidgetInternal::AccessibilityPageIndex);
}
void UHyperTwistSettingsPanelWidget::ShowSpeechAndCoachPage()
{
SettingsPageSwitcher->SetActiveWidgetIndex(
HyperTwistSettingsPanelWidgetInternal::SpeechPageIndex);
}
void UHyperTwistSettingsPanelWidget::HandleCloseClicked()
{
OnCloseRequested.Broadcast();
}
void UHyperTwistSettingsPanelWidget::HandleResetDefaultsClicked()
{
Preferences = UHyperTwistPlayerSettingsLibrary::GetDefaultPreferences();
RefreshWidgetsFromPreferences();
SavePreferencesAndApply(TEXT("All settings restored to defaults."), true);
}
void UHyperTwistSettingsPanelWidget::HandleBindingRequested(const FName ActionId)
{
PendingRebindActionId = ActionId;
SetStatus(TEXT("Press a keyboard or mouse key. Escape cancels."));
SetKeyboardFocus();
}
void UHyperTwistSettingsPanelWidget::HandleKeyboardProfileChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
FString ProfileId;
if (SelectedItem == TEXT("WCA Pro"))
{
ProfileId = TEXT("classic-wca-keyboard/v1");
}
else if (SelectedItem == TEXT("Mnemonic Pairs"))
{
ProfileId = TEXT("classic-mnemonic-pairs/v1");
}
else
{
return;
}
if (UHyperTwistPlayerSettingsLibrary::ApplyKeyboardProfile(Preferences, ProfileId))
{
RefreshBindingRows();
SavePreferencesAndApply(FString::Printf(TEXT("%s layout applied."), *SelectedItem));
}
}
void UHyperTwistSettingsPanelWidget::HandlePointerSensitivityChanged(const float Value)
{
if (bRefreshingWidgets)
{
return;
}
Preferences.PointerSensitivity = 0.2f + Value * 2.8f;
HyperTwistSettingsPanelWidgetInternal::SetText(
PointerSensitivityValue,
HyperTwistSettingsPanelWidgetInternal::DecimalLabel(Preferences.PointerSensitivity));
SavePreferencesAndApply(TEXT("Pointer sensitivity updated."));
}
void UHyperTwistSettingsPanelWidget::HandleOrbitSensitivityChanged(const float Value)
{
if (bRefreshingWidgets)
{
return;
}
Preferences.OrbitSensitivity = 0.2f + Value * 2.8f;
HyperTwistSettingsPanelWidgetInternal::SetText(
OrbitSensitivityValue,
HyperTwistSettingsPanelWidgetInternal::DecimalLabel(Preferences.OrbitSensitivity));
SavePreferencesAndApply(TEXT("Orbit sensitivity updated."));
}
void UHyperTwistSettingsPanelWidget::HandleZoomSensitivityChanged(const float Value)
{
if (bRefreshingWidgets)
{
return;
}
Preferences.ZoomSensitivity = 0.2f + Value * 2.8f;
HyperTwistSettingsPanelWidgetInternal::SetText(
ZoomSensitivityValue,
HyperTwistSettingsPanelWidgetInternal::DecimalLabel(Preferences.ZoomSensitivity));
SavePreferencesAndApply(TEXT("Zoom sensitivity updated."));
}
void UHyperTwistSettingsPanelWidget::HandleInvertOrbitChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bInvertOrbitY = bChecked;
SavePreferencesAndApply(TEXT("Orbit direction updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleTouchInputChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bTouchInputEnabled = bChecked;
SavePreferencesAndApply(TEXT("Touch input preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleMasterVolumeChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.MasterVolume = Value;
HyperTwistSettingsPanelWidgetInternal::SetText(
MasterVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Value));
SavePreferencesAndApply(TEXT("Master volume updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleMusicVolumeChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.MusicVolume = Value;
HyperTwistSettingsPanelWidgetInternal::SetText(
MusicVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Value));
SavePreferencesAndApply(TEXT("Music volume updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleEffectsVolumeChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.EffectsVolume = Value;
HyperTwistSettingsPanelWidgetInternal::SetText(
EffectsVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Value));
SavePreferencesAndApply(TEXT("Puzzle effects volume updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleVoiceVolumeChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.VoiceVolume = Value;
HyperTwistSettingsPanelWidgetInternal::SetText(
VoiceVolumeValue,
HyperTwistSettingsPanelWidgetInternal::PercentLabel(Value));
SavePreferencesAndApply(TEXT("Coach voice volume updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleMutedChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bMuted = bChecked;
SavePreferencesAndApply(bChecked ? TEXT("Sound muted.") : TEXT("Sound restored."));
}
}
void UHyperTwistSettingsPanelWidget::HandleGraphicsQualityChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
Preferences.GraphicsQuality = SelectedItem == TEXT("Low")
? EHyperTwistGraphicsQuality::Low
: SelectedItem == TEXT("Medium")
? EHyperTwistGraphicsQuality::Medium
: SelectedItem == TEXT("Epic")
? EHyperTwistGraphicsQuality::Epic
: EHyperTwistGraphicsQuality::High;
SavePreferencesAndApply(TEXT("Graphics quality applied."), true);
}
void UHyperTwistSettingsPanelWidget::HandleWindowModeChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
Preferences.WindowMode = SelectedItem == TEXT("Fullscreen")
? EHyperTwistWindowMode::Fullscreen
: SelectedItem == TEXT("Windowed")
? EHyperTwistWindowMode::Windowed
: EHyperTwistWindowMode::Borderless;
SavePreferencesAndApply(TEXT("Display mode applied."), true);
}
void UHyperTwistSettingsPanelWidget::HandleResolutionScaleChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.ResolutionScalePercent = 50.0f + Value * 50.0f;
HyperTwistSettingsPanelWidgetInternal::SetText(
ResolutionScaleValue,
FString::Printf(
TEXT("%d%%"),
FMath::RoundToInt(Preferences.ResolutionScalePercent)));
SavePreferencesAndApply(TEXT("Render scale applied."), true);
}
}
void UHyperTwistSettingsPanelWidget::HandleVSyncChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bVSyncEnabled = bChecked;
SavePreferencesAndApply(TEXT("Vertical sync preference applied."), true);
}
}
void UHyperTwistSettingsPanelWidget::HandleUiScaleChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.UiScale = 0.8f + Value * 0.6f;
HyperTwistSettingsPanelWidgetInternal::SetText(
UiScaleValue,
FString::Printf(TEXT("%d%%"), FMath::RoundToInt(Preferences.UiScale * 100.0f)));
SavePreferencesAndApply(TEXT("Interface scale applied."));
}
}
void UHyperTwistSettingsPanelWidget::HandleReducedMotionChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bReducedMotion = bChecked;
SavePreferencesAndApply(TEXT("Motion preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleHighContrastChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bHighContrast = bChecked;
SavePreferencesAndApply(TEXT("Contrast preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSubtitlesChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bSubtitlesEnabled = bChecked;
SavePreferencesAndApply(TEXT("Subtitle preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechInputChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bSpeechInputEnabled = bChecked;
SavePreferencesAndApply(TEXT("Speech input preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachNarrationChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bCoachNarrationEnabled = bChecked;
SavePreferencesAndApply(TEXT("Coach narration preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCloudProvidersChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bAllowCloudProviders = bChecked;
SavePreferencesAndApply(TEXT("Cloud provider preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleAssistantPanelEnabledChanged(
const bool bChecked
)
{
if (!bRefreshingWidgets)
{
Preferences.bAssistantPanelEnabled = bChecked;
if (!bChecked)
{
Preferences.bAssistantPanelOpenByDefault = false;
}
SavePreferencesAndApply(TEXT("In-game coach availability updated."));
RefreshWidgetsFromPreferences();
}
}
void UHyperTwistSettingsPanelWidget::HandleAssistantPanelOpenByDefaultChanged(
const bool bChecked
)
{
if (!bRefreshingWidgets)
{
Preferences.bAssistantPanelOpenByDefault = bChecked;
if (bChecked)
{
Preferences.bAssistantPanelEnabled = true;
}
SavePreferencesAndApply(TEXT("Coach startup visibility updated."));
RefreshWidgetsFromPreferences();
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachAiEnabledChanged(const bool bChecked)
{
if (bRefreshingWidgets)
{
return;
}
if (bChecked
&& Preferences.CoachProviderId.Equals(
TEXT("local-disabled"),
ESearchCase::IgnoreCase))
{
Preferences.bCoachAiEnabled = false;
SetStatus(TEXT("Choose an AI provider before enabling generated responses."), true);
RefreshWidgetsFromPreferences();
return;
}
Preferences.bCoachAiEnabled = bChecked;
SavePreferencesAndApply(
bChecked
? TEXT("AI coach responses enabled.")
: TEXT("AI responses disabled; the built-in guide remains available."));
}
void UHyperTwistSettingsPanelWidget::HandleSpeechProviderChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
const HyperTwistSettingsPanelWidgetInternal::FProviderOption* Option =
HyperTwistSettingsPanelWidgetInternal::FindProviderByLabel(
HyperTwistSettingsPanelWidgetInternal::GetSpeechProviderOptions(),
SelectedItem);
if (Option == nullptr)
{
return;
}
Preferences.SpeechProviderId = Option->Id;
Preferences.SpeechEndpoint = Option->DefaultEndpoint;
Preferences.SpeechModel = Option->DefaultModel;
SavePreferencesAndApply(TEXT("Transcription provider and safe defaults updated."));
RefreshWidgetsFromPreferences();
}
void UHyperTwistSettingsPanelWidget::HandleSpeechActivationModeChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (!bRefreshingWidgets)
{
Preferences.SpeechActivationMode =
SelectedItem == TEXT("Toggle recording") ? TEXT("toggle") : TEXT("hold");
SavePreferencesAndApply(TEXT("Dictation shortcut behavior updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechDictationDestinationChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (!bRefreshingWidgets)
{
Preferences.SpeechDictationDestination =
SelectedItem == TEXT("Ask coach immediately")
? TEXT("coach-send")
: TEXT("coach-draft");
SavePreferencesAndApply(TEXT("Dictation destination updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechLanguageChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (!bRefreshingWidgets)
{
Preferences.SpeechLanguage =
HyperTwistSettingsPanelWidgetInternal::SpeechLanguageCode(SelectedItem);
SavePreferencesAndApply(TEXT("Transcription language updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechEndpointCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.SpeechEndpoint = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Speech endpoint saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechModelCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.SpeechModel = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Transcription model saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechMicrophoneChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
const FHyperTwistMicrophoneDescriptor* Microphone =
AvailableMicrophones.FindByPredicate(
[&SelectedItem](const FHyperTwistMicrophoneDescriptor& Candidate)
{
return Candidate.DisplayName.Equals(
SelectedItem,
ESearchCase::IgnoreCase);
});
if (Microphone == nullptr)
{
return;
}
Preferences.SpeechMicrophoneId = Microphone->DeviceId;
SavePreferencesAndApply(TEXT("Microphone preference saved."));
}
void UHyperTwistSettingsPanelWidget::HandleSpeechContextCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.SpeechRecognitionContext = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Recognition context saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechCleanupChanged(const bool bChecked)
{
if (!bRefreshingWidgets)
{
Preferences.bSpeechCleanupEnabled = bChecked;
SavePreferencesAndApply(TEXT("Dictation cleanup preference updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSpeechSoundFeedbackChanged(
const bool bChecked
)
{
if (!bRefreshingWidgets)
{
Preferences.bSpeechSoundFeedbackEnabled = bChecked;
SavePreferencesAndApply(TEXT("Dictation sound feedback updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleVoiceProviderChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
const HyperTwistSettingsPanelWidgetInternal::FProviderOption* Option =
HyperTwistSettingsPanelWidgetInternal::FindProviderByLabel(
HyperTwistSettingsPanelWidgetInternal::GetVoiceProviderOptions(),
SelectedItem);
if (Option == nullptr)
{
return;
}
Preferences.VoiceProviderId = Option->Id;
Preferences.VoiceEndpoint = Option->DefaultEndpoint;
Preferences.VoiceModel = Option->DefaultModel;
if (Preferences.VoiceProviderId == TEXT("openai"))
{
Preferences.VoiceId = TEXT("alloy");
}
else if (Preferences.VoiceProviderId == TEXT("local-piper"))
{
Preferences.VoiceId = TEXT("en_US-lessac-medium");
}
SavePreferencesAndApply(TEXT("Voice provider and safe defaults updated."));
RefreshWidgetsFromPreferences();
}
void UHyperTwistSettingsPanelWidget::HandleVoiceEndpointCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.VoiceEndpoint = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Voice endpoint saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleVoiceModelCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.VoiceModel = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Voice model saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleVoiceIdCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.VoiceId = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Voice selection saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleVoiceSpeedChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.VoiceSpeed = 0.5f + Value * 1.5f;
HyperTwistSettingsPanelWidgetInternal::SetText(
VoiceSpeedValue,
FString::Printf(TEXT("%.2fx"), Preferences.VoiceSpeed));
SavePreferencesAndApply(TEXT("Narration speed updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachProviderChanged(
FString SelectedItem,
ESelectInfo::Type SelectionType
)
{
static_cast<void>(SelectionType);
if (bRefreshingWidgets)
{
return;
}
const HyperTwistSettingsPanelWidgetInternal::FProviderOption* Option =
HyperTwistSettingsPanelWidgetInternal::FindProviderByLabel(
HyperTwistSettingsPanelWidgetInternal::GetCoachProviderOptions(),
SelectedItem);
if (Option == nullptr)
{
return;
}
Preferences.CoachProviderId = Option->Id;
Preferences.CoachEndpoint = Option->DefaultEndpoint;
Preferences.CoachModel = Option->DefaultModel;
if (Preferences.CoachProviderId == TEXT("local-disabled"))
{
Preferences.bCoachAiEnabled = false;
}
SavePreferencesAndApply(TEXT("Coach provider and safe defaults updated."));
RefreshWidgetsFromPreferences();
}
void UHyperTwistSettingsPanelWidget::HandleCoachEndpointCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.CoachEndpoint = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Coach endpoint saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachModelCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.CoachModel = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Coach model saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachInstructionsCommitted(
const FText& Text,
ETextCommit::Type CommitMethod
)
{
static_cast<void>(CommitMethod);
if (!bRefreshingWidgets)
{
Preferences.CoachSystemInstructions = Text.ToString().TrimStartAndEnd();
SavePreferencesAndApply(TEXT("Coach instructions saved."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachTemperatureChanged(const float Value)
{
if (!bRefreshingWidgets)
{
Preferences.CoachTemperature = Value;
HyperTwistSettingsPanelWidgetInternal::SetText(
CoachTemperatureValue,
FString::Printf(TEXT("%.2f"), Value));
SavePreferencesAndApply(TEXT("Coach response style updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachMaxResponseTokensChanged(
const float Value
)
{
if (!bRefreshingWidgets)
{
Preferences.CoachMaxResponseTokens = FMath::RoundToInt(
128.0f + FMath::Clamp(Value, 0.0f, 1.0f) * (2048.0f - 128.0f));
HyperTwistSettingsPanelWidgetInternal::SetText(
CoachMaxResponseTokensValue,
FString::Printf(
TEXT("%d tokens"),
Preferences.CoachMaxResponseTokens));
SavePreferencesAndApply(TEXT("Coach response length updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleCoachRequestTimeoutChanged(
const float Value
)
{
if (!bRefreshingWidgets)
{
Preferences.CoachRequestTimeoutSeconds =
5.0f + FMath::Clamp(Value, 0.0f, 1.0f) * 55.0f;
HyperTwistSettingsPanelWidgetInternal::SetText(
CoachRequestTimeoutValue,
FString::Printf(
TEXT("%.0f seconds"),
Preferences.CoachRequestTimeoutSeconds));
SavePreferencesAndApply(TEXT("Coach request timeout updated."));
}
}
void UHyperTwistSettingsPanelWidget::HandleSaveSpeechCredential()
{
FString FailureReason;
const FString Secret = SpeechCredentialInput != nullptr
? SpeechCredentialInput->GetText().ToString()
: FString();
if (Secret.IsEmpty())
{
SetStatus(TEXT("Enter a speech API key, or leave the protected key unchanged."));
return;
}
if (UHyperTwistPlayerSettingsLibrary::StoreProviderCredential(
TEXT("speech-api-key"),
Secret,
FailureReason))
{
SpeechCredentialInput->SetText(FText::GetEmpty());
RefreshProviderReadiness();
SetStatus(TEXT("Speech API key protected for this Windows account."));
}
else
{
SetStatus(FailureReason, true);
}
}
void UHyperTwistSettingsPanelWidget::HandleRemoveSpeechCredential()
{
UHyperTwistPlayerSettingsLibrary::DeleteProviderCredential(TEXT("speech-api-key"));
if (SpeechCredentialInput != nullptr)
{
SpeechCredentialInput->SetText(FText::GetEmpty());
}
RefreshProviderReadiness();
SetStatus(TEXT("Protected speech API key removed."));
}
void UHyperTwistSettingsPanelWidget::HandleSaveVoiceCredential()
{
FString FailureReason;
const FString Secret = VoiceCredentialInput != nullptr
? VoiceCredentialInput->GetText().ToString()
: FString();
if (Secret.IsEmpty())
{
SetStatus(TEXT("Enter a voice API key, or leave the protected key unchanged."));
return;
}
if (UHyperTwistPlayerSettingsLibrary::StoreProviderCredential(
TEXT("voice-api-key"),
Secret,
FailureReason))
{
VoiceCredentialInput->SetText(FText::GetEmpty());
RefreshProviderReadiness();
SetStatus(TEXT("Voice API key protected for this Windows account."));
}
else
{
SetStatus(FailureReason, true);
}
}
void UHyperTwistSettingsPanelWidget::HandleRemoveVoiceCredential()
{
UHyperTwistPlayerSettingsLibrary::DeleteProviderCredential(TEXT("voice-api-key"));
if (VoiceCredentialInput != nullptr)
{
VoiceCredentialInput->SetText(FText::GetEmpty());
}
RefreshProviderReadiness();
SetStatus(TEXT("Protected voice API key removed."));
}
void UHyperTwistSettingsPanelWidget::HandleSaveCoachCredential()
{
FString FailureReason;
const FString Secret = CoachCredentialInput != nullptr
? CoachCredentialInput->GetText().ToString()
: FString();
if (Secret.IsEmpty())
{
SetStatus(TEXT("Enter a coach API key, or leave the protected key unchanged."));
return;
}
if (UHyperTwistPlayerSettingsLibrary::StoreProviderCredential(
TEXT("coach-api-key"),
Secret,
FailureReason))
{
CoachCredentialInput->SetText(FText::GetEmpty());
RefreshProviderReadiness();
SetStatus(TEXT("Coach API key protected for this Windows account."));
}
else
{
SetStatus(FailureReason, true);
}
}
void UHyperTwistSettingsPanelWidget::HandleRemoveCoachCredential()
{
UHyperTwistPlayerSettingsLibrary::DeleteProviderCredential(TEXT("coach-api-key"));
if (CoachCredentialInput != nullptr)
{
CoachCredentialInput->SetText(FText::GetEmpty());
}
RefreshProviderReadiness();
SetStatus(TEXT("Protected coach API key removed."));
}
void UHyperTwistSettingsPanelWidget::HandleRefreshMicrophones()
{
RefreshMicrophoneOptions();
SetStatus(FString::Printf(
TEXT("Detected %d microphone route%s."),
AvailableMicrophones.Num(),
AvailableMicrophones.Num() == 1 ? TEXT("") : TEXT("s")));
}
void UHyperTwistSettingsPanelWidget::HandleTestMicrophone()
{
FString FailureReason;
if (UHyperTwistPlayerSettingsLibrary::TestMicrophoneDevice(
Preferences.SpeechMicrophoneId,
FailureReason))
{
SetStatus(
TEXT("Microphone opened successfully. No test audio was retained."));
return;
}
SetStatus(FailureReason, true);
}
void UHyperTwistSettingsPanelWidget::HandleValidateSpeechConfiguration()
{
FString FailureReason;
const bool bCloudProviderSelected =
Preferences.SpeechProviderId == TEXT("openai")
|| Preferences.SpeechProviderId == TEXT("groq")
|| Preferences.SpeechProviderId == TEXT("deepgram")
|| Preferences.SpeechProviderId == TEXT("elevenlabs");
if (bCloudProviderSelected && !Preferences.bAllowCloudProviders)
{
SetStatus(
TEXT("Enable cloud providers before using the selected transcription route."),
true);
return;
}
if (!UHyperTwistPlayerSettingsLibrary::IsProviderEndpointAllowed(
Preferences.SpeechEndpoint,
Preferences.bAllowCloudProviders,
FailureReason))
{
SetStatus(FailureReason, true);
return;
}
if (Preferences.SpeechModel.IsEmpty())
{
SetStatus(TEXT("Choose a transcription model."), true);
return;
}
const bool bCloudCredentialRequired =
Preferences.SpeechProviderId == TEXT("openai")
|| Preferences.SpeechProviderId == TEXT("groq")
|| Preferences.SpeechProviderId == TEXT("deepgram")
|| Preferences.SpeechProviderId == TEXT("elevenlabs");
if (bCloudCredentialRequired
&& !UHyperTwistPlayerSettingsLibrary::HasProviderCredential(
TEXT("speech-api-key")))
{
SetStatus(TEXT("Save a protected key for the selected transcription provider."), true);
return;
}
SetStatus(
TEXT("Speech configuration is complete. Service reachability is checked when dictation starts."));
}
void UHyperTwistSettingsPanelWidget::HandleValidateVoiceConfiguration()
{
FString FailureReason;
const bool bCloudProviderSelected =
Preferences.VoiceProviderId == TEXT("openai")
|| Preferences.VoiceProviderId == TEXT("elevenlabs");
if (bCloudProviderSelected && !Preferences.bAllowCloudProviders)
{
SetStatus(
TEXT("Enable cloud providers before using the selected voice route."),
true);
return;
}
if (!UHyperTwistPlayerSettingsLibrary::IsProviderEndpointAllowed(
Preferences.VoiceEndpoint,
Preferences.bAllowCloudProviders,
FailureReason))
{
SetStatus(FailureReason, true);
return;
}
if (Preferences.VoiceModel.IsEmpty() || Preferences.VoiceId.IsEmpty())
{
SetStatus(TEXT("Choose both a voice model and voice identifier."), true);
return;
}
const bool bCloudCredentialRequired =
Preferences.VoiceProviderId == TEXT("openai")
|| Preferences.VoiceProviderId == TEXT("elevenlabs");
if (bCloudCredentialRequired
&& !UHyperTwistPlayerSettingsLibrary::HasProviderCredential(
TEXT("voice-api-key")))
{
SetStatus(TEXT("Save a protected key for the selected voice provider."), true);
return;
}
SetStatus(
TEXT("Voice configuration is complete. Service reachability is checked before narration."));
}
void UHyperTwistSettingsPanelWidget::HandleValidateCoachConfiguration()
{
if (Preferences.CoachProviderId == TEXT("local-disabled"))
{
SetStatus(TEXT("The private built-in guide is ready; no AI service is configured."));
return;
}
FString FailureReason;
if (!UHyperTwistPlayerSettingsLibrary::IsProviderEndpointAllowed(
Preferences.CoachEndpoint,
Preferences.bAllowCloudProviders,
FailureReason))
{
SetStatus(FailureReason, true);
return;
}
if (Preferences.CoachModel.IsEmpty())
{
SetStatus(TEXT("Choose a coach model before enabling AI responses."), true);
return;
}
const bool bCloudCredentialRequired =
Preferences.CoachProviderId == TEXT("openai")
|| Preferences.CoachProviderId == TEXT("groq");
if (bCloudCredentialRequired
&& !UHyperTwistPlayerSettingsLibrary::HasProviderCredential(
TEXT("coach-api-key")))
{
SetStatus(TEXT("Save a protected key for the selected coach provider."), true);
return;
}
SetStatus(
TEXT("Coach configuration is complete. The fixed assistant will verify the service on its first request."));
}