Files
Tushar Rohilla e5295f36aa Add Afterglow 1.0.6, a PS5 internet radio and music visualizer
222 stations in 17 genres, 56 visualizer scenes, Ogg and Opus playback, station logos, crossfade, My Music from USB, a command box and live BPM sync.
2026-10-16 00:59:10 -04:00

422 lines
15 KiB
C++

// ps5-homebrew-ui - Tests: Form, Stepper, ChoicePicker and TextField behaviour.
// Copyright (C) 2026 BlackBearReloaded
// SPDX-License-Identifier: GPL-3.0-or-later
#include "component_fixture.hpp"
#include "ui/components/choice.hpp"
#include "ui/components/form.hpp"
#include "ui/components/stepper.hpp"
#include "ui/components/text_field.hpp"
#include <gtest/gtest.h>
namespace
{
using hui::Action;
using hui::Direction;
using hui::audio::Cue;
using hui::ui::ChoicePicker;
using hui::ui::Event;
using hui::ui::Form;
using hui::ui::Stepper;
using hui::ui::TextField;
enum Id
{
kSwitch = 1,
kChoice,
kSlider,
kStepper,
kValue,
kAction,
kLocked,
kFine,
};
constexpr int kValueRow = 6; // the index of the read-only row
class ComponentsForms : public hui::testing::ComponentFixture
{
protected:
ComponentsForms()
{
form_.add_header("Section");
form_.add_toggle(kSwitch, "Switch", false).description = "What the switch does";
form_.add_choice(kChoice, "Choice", {"One", "Two", "Three"}, 0);
form_.add_slider(kSlider, "Slider", 50.0f, 0.0f, 100.0f, 10.0f).unit = " %";
form_.add_stepper(kStepper, "Stepper", 4, 0, 10, 2);
form_.add_header("More");
form_.add_value(kValue, "Value", "Read only");
form_.add_action(kAction, "Action");
form_.add_toggle(kLocked, "Locked", true).disabled = true;
form_.add_slider(kFine, "Fine", 0.0f, 0.0f, 100.0f, 1.0f);
form_.set_bounds({100.0f, 100.0f, 800.0f, 260.0f});
}
// One input to a component, then half a second of animation.
template <typename Component> Event send(Component &component, const hui::InputFrame &input)
{
feedback_.clear();
const Event event = component.handle(input, feedback_);
for (int i = 0; i < 30; ++i)
component.update(kFrame);
return event;
}
Event send(const hui::InputFrame &input)
{
return send(form_, input);
}
static hui::InputFrame repeat(Direction direction)
{
hui::InputFrame input = nav(direction);
input.nav_repeat = true;
return input;
}
// The pitch the last input asked this cue to play at; 0 when it did not.
float pitch(Cue cue) const
{
for (const hui::audio::CueEvent &event : feedback_.cues)
{
if (event.cue == cue)
return event.pitch;
}
return 0.0f;
}
Form form_;
};
TEST_F(ComponentsForms, FocusSkipsHeadersAndReadOnlyRows)
{
EXPECT_EQ(form_.focus_id(), kSwitch);
EXPECT_EQ(send(nav(Direction::up)), Event::refused);
for (int i = 0; i < 3; ++i)
EXPECT_EQ(send(nav(Direction::down)), Event::moved);
EXPECT_TRUE(asked(Cue::focus));
EXPECT_EQ(form_.focus_id(), kStepper);
EXPECT_EQ(send(nav(Direction::down)), Event::moved);
EXPECT_EQ(form_.focus_id(), kAction);
// The knob that lets read-only rows take the focus: they refuse confirm.
form_.style.focus_values = true;
EXPECT_EQ(send(nav(Direction::up)), Event::moved);
EXPECT_EQ(form_.focus(), kValueRow);
EXPECT_EQ(send(press(Action::confirm)), Event::refused);
}
TEST_F(ComponentsForms, ToggleFlipsWithConfirmAndDirections)
{
EXPECT_EQ(send(press(Action::confirm)), Event::changed);
EXPECT_TRUE(form_.toggle_value(kSwitch));
EXPECT_EQ(form_.changed_id(), kSwitch);
EXPECT_GT(pitch(Cue::toggle), 1.0f); // up for on
EXPECT_EQ(send(nav(Direction::right)), Event::refused); // it is on already
EXPECT_EQ(send(nav(Direction::left)), Event::changed);
EXPECT_FALSE(form_.toggle_value(kSwitch));
EXPECT_GT(pitch(Cue::toggle), 0.0f);
EXPECT_LT(pitch(Cue::toggle), 1.0f); // down for off
}
TEST_F(ComponentsForms, ChoiceCyclesAndWrapIsAKnob)
{
form_.focus_row(kChoice);
EXPECT_TRUE(form_.uses_horizontal());
EXPECT_EQ(send(nav(Direction::left)), Event::changed); // wraps by default
EXPECT_EQ(form_.choice_index(kChoice), 2);
EXPECT_EQ(form_.choice_text(kChoice), "Three");
EXPECT_EQ(form_.changed_id(), kChoice);
EXPECT_TRUE(asked(Cue::toggle));
form_.style.wrap_choices = false;
EXPECT_EQ(send(nav(Direction::right)), Event::refused);
EXPECT_TRUE(asked(Cue::error));
EXPECT_EQ(form_.choice_index(kChoice), 2);
// Confirm always finds a next option.
EXPECT_EQ(send(press(Action::confirm)), Event::changed);
EXPECT_EQ(form_.choice_index(kChoice), 0);
}
TEST_F(ComponentsForms, SliderStepsClampsAndStaysQuietOnRepeat)
{
form_.focus_row(kSlider);
EXPECT_EQ(send(nav(Direction::right)), Event::changed);
EXPECT_FLOAT_EQ(form_.slider_value(kSlider), 60.0f);
EXPECT_EQ(form_.changed_id(), kSlider);
const float low = pitch(Cue::slider);
EXPECT_GT(low, 0.0f);
EXPECT_EQ(form_.slider_text(*form_.row(kSlider)), "60 %");
form_.set_slider(kSlider, 90.0f);
EXPECT_EQ(send(nav(Direction::right)), Event::changed);
EXPECT_GT(pitch(Cue::slider), low); // the pitch follows the value
EXPECT_EQ(send(nav(Direction::right)), Event::refused);
EXPECT_TRUE(asked(Cue::error));
EXPECT_EQ(send(repeat(Direction::right)), Event::refused);
EXPECT_TRUE(feedback_.cues.empty());
EXPECT_FLOAT_EQ(form_.slider_value(kSlider), 100.0f);
}
TEST_F(ComponentsForms, HeldSliderAccelerates)
{
form_.focus_row(kFine);
EXPECT_EQ(send(nav(Direction::right)), Event::changed);
for (int i = 0; i < 5; ++i)
send(repeat(Direction::right));
EXPECT_FLOAT_EQ(form_.slider_value(kFine), 6.0f);
// The seventh step of a hold is fast_factor steps long.
send(repeat(Direction::right));
EXPECT_FLOAT_EQ(form_.slider_value(kFine), 10.0f);
// A fresh press is a single step again.
send(nav(Direction::left));
EXPECT_FLOAT_EQ(form_.slider_value(kFine), 9.0f);
}
TEST_F(ComponentsForms, StepperRowHonoursItsRange)
{
form_.focus_row(kStepper);
for (int i = 0; i < 3; ++i)
EXPECT_EQ(send(nav(Direction::right)), Event::changed);
EXPECT_EQ(form_.stepper_value(kStepper), 10);
EXPECT_EQ(form_.changed_id(), kStepper);
EXPECT_TRUE(asked(Cue::slider));
EXPECT_EQ(send(nav(Direction::right)), Event::refused);
EXPECT_EQ(send(nav(Direction::left)), Event::changed);
EXPECT_EQ(form_.stepper_value(kStepper), 8);
EXPECT_EQ(send(press(Action::confirm)), Event::none); // nothing to confirm
}
TEST_F(ComponentsForms, ActionActivatesAndDisabledRowsRefuse)
{
form_.focus_row(kAction);
EXPECT_FALSE(form_.uses_horizontal());
// Left and right are the screen's to use on a row that has no use for them.
EXPECT_EQ(send(nav(Direction::right)), Event::none);
EXPECT_TRUE(feedback_.cues.empty());
EXPECT_EQ(send(press(Action::confirm)), Event::activated);
EXPECT_EQ(form_.changed_id(), kAction);
EXPECT_TRUE(asked(Cue::select));
form_.focus_row(kLocked);
EXPECT_EQ(send(press(Action::confirm)), Event::refused);
EXPECT_EQ(send(nav(Direction::left)), Event::refused);
EXPECT_TRUE(form_.toggle_value(kLocked));
EXPECT_EQ(send(press(Action::back)), Event::cancelled);
EXPECT_TRUE(asked(Cue::back));
}
TEST_F(ComponentsForms, SettersAreSilentAndStateSurvivesRestyling)
{
form_.set_toggle(kSwitch, true);
form_.set_choice(kChoice, 1);
form_.set_slider(kSlider, 250.0f); // clamped
form_.set_stepper(kStepper, 6);
form_.set_value_text(kValue, "Changed");
form_.focus_row(kSlider);
form_.style.theme = hui::ui::themes()[5];
form_.style.compact = true;
form_.style.values_right = false;
form_.style.panel = true;
for (int i = 0; i < 30; ++i)
form_.update(kFrame);
EXPECT_TRUE(feedback_.cues.empty());
EXPECT_TRUE(form_.toggle_value(kSwitch));
EXPECT_EQ(form_.choice_index(kChoice), 1);
EXPECT_FLOAT_EQ(form_.slider_value(kSlider), 100.0f);
EXPECT_EQ(form_.stepper_value(kStepper), 6);
EXPECT_EQ(form_.value_text(kValue), "Changed");
EXPECT_EQ(form_.focus_id(), kSlider);
// An id the form does not have reads as nothing and writes nowhere.
form_.set_toggle(99, true);
EXPECT_FALSE(form_.toggle_value(99));
EXPECT_EQ(form_.row(99), nullptr);
}
TEST_F(ComponentsForms, ScrollKeepsTheFocusedRowInsideAndHelpFollowsIt)
{
EXPECT_EQ(form_.help_text(), "What the switch does");
for (int i = 0; i < 8; ++i)
send(nav(Direction::down));
EXPECT_EQ(form_.focus_id(), kFine);
EXPECT_TRUE(form_.help_text().empty());
EXPECT_EQ(send(nav(Direction::down)), Event::refused);
const hui::gfx::Rect row = form_.row_rect(form_.focus());
EXPECT_GE(row.y, 100.0f - 0.5f);
EXPECT_LE(row.y + row.h, 360.0f + 0.5f);
form_.style.wrap = true;
EXPECT_EQ(send(nav(Direction::down)), Event::moved);
EXPECT_EQ(form_.focus_id(), kSwitch);
// The focused row is taller by its description line.
EXPECT_GT(form_.row_rect(form_.focus()).h, form_.row_rect(2).h);
}
TEST_F(ComponentsForms, StandaloneStepper)
{
Stepper stepper;
stepper.set_range(0, 30, 4);
stepper.set_value(28);
stepper.set_suffix(" px");
EXPECT_EQ(stepper.text(), "28 px");
EXPECT_EQ(send(stepper, nav(Direction::right)), Event::changed);
EXPECT_EQ(stepper.value(), 30); // lands on the limit, off the step grid
EXPECT_TRUE(asked(Cue::slider));
EXPECT_EQ(send(stepper, nav(Direction::right)), Event::refused);
EXPECT_TRUE(asked(Cue::error));
EXPECT_EQ(send(stepper, nav(Direction::left)), Event::changed);
EXPECT_EQ(stepper.value(), 28); // ... and back onto it
stepper.style.wrap = true;
stepper.set_value(30);
EXPECT_EQ(send(stepper, nav(Direction::right)), Event::changed);
EXPECT_EQ(stepper.value(), 0);
EXPECT_FLOAT_EQ(stepper.fraction(), 0.0f);
stepper.format = [](int value) { return value == 0 ? std::string("Off") : std::string("On"); };
EXPECT_EQ(stepper.text(), "Off");
EXPECT_EQ(send(stepper, press(Action::back)), Event::cancelled);
}
TEST_F(ComponentsForms, StandaloneChoicePicker)
{
ChoicePicker picker;
picker.set_options({"Low", "Medium", "High"});
picker.set_index(2);
EXPECT_EQ(picker.value(), "High");
EXPECT_EQ(send(picker, nav(Direction::right)), Event::changed); // wraps
EXPECT_EQ(picker.index(), 0);
EXPECT_GT(pitch(Cue::toggle), 1.0f);
picker.style.wrap = false;
EXPECT_EQ(send(picker, nav(Direction::left)), Event::refused);
EXPECT_EQ(picker.index(), 0);
EXPECT_EQ(send(picker, press(Action::confirm)), Event::changed);
EXPECT_EQ(picker.index(), 1);
picker.style.confirm_cycles = false;
EXPECT_EQ(send(picker, press(Action::confirm)), Event::activated);
EXPECT_TRUE(asked(Cue::select));
EXPECT_EQ(picker.index(), 1);
ChoicePicker empty;
EXPECT_TRUE(empty.value().empty());
EXPECT_EQ(send(empty, nav(Direction::right)), Event::refused);
}
TEST_F(ComponentsForms, TextFieldEditsAndReportsConfirm)
{
TextField field;
field.style.max_length = 5;
EXPECT_TRUE(field.insert("h\xC3\xA9llo world")); // cut to five characters, not bytes
EXPECT_EQ(field.text(), "h\xC3\xA9llo");
EXPECT_EQ(field.length(), 5);
EXPECT_TRUE(field.full());
EXPECT_FALSE(field.insert('x'));
feedback_.clear();
EXPECT_EQ(field.insert('x', feedback_), Event::refused);
EXPECT_TRUE(asked(Cue::error));
feedback_.clear();
EXPECT_EQ(field.backspace(feedback_), Event::changed);
EXPECT_TRUE(asked(Cue::erase));
EXPECT_EQ(field.text(), "h\xC3\xA9ll");
feedback_.clear();
EXPECT_EQ(field.insert('!', feedback_), Event::changed);
EXPECT_TRUE(asked(Cue::type));
EXPECT_FALSE(field.insert('\n')); // one line only
for (int i = 0; i < 4; ++i)
EXPECT_TRUE(field.backspace());
EXPECT_EQ(field.text(), "h");
EXPECT_TRUE(field.backspace());
EXPECT_FALSE(field.backspace());
field.set_text("abc");
field.clear();
EXPECT_TRUE(field.text().empty());
EXPECT_EQ(send(field, press(Action::confirm)), Event::activated);
EXPECT_TRUE(asked(Cue::select));
field.set_disabled(true);
EXPECT_EQ(send(field, press(Action::confirm)), Event::refused);
// The helper line, and the error that replaces it, need room.
const float bare = field.preferred_height();
field.set_error("Too short");
EXPECT_GT(field.preferred_height(), bare);
EXPECT_EQ(field.error(), "Too short");
}
TEST_F(ComponentsForms, DrawsInEveryThemeAndVariant)
{
using hui::ui::HighlightKind;
Stepper stepper;
stepper.set_range(0, 10);
stepper.set_bounds({100.0f, 400.0f, 240.0f, 56.0f});
stepper.set_active(true);
ChoicePicker picker;
picker.set_options({"One", "Two", "Three"});
picker.set_bounds({400.0f, 400.0f, 360.0f, 56.0f});
picker.set_active(true);
TextField field;
field.set_label("Name");
field.set_placeholder("Nobody yet");
field.set_helper("Shown to other players");
field.set_bounds({100.0f, 500.0f, 420.0f, 130.0f});
field.set_active(true);
// Draw them mid-animation: a change, a refusal and a focus fading in.
send(stepper, nav(Direction::right));
send(picker, nav(Direction::right));
stepper.update(kFrame);
picker.update(kFrame);
field.update(kFrame);
form_.focus_row(kAction);
form_.row(kAction)->danger = true;
int variant = 0;
for (const hui::ui::Theme &theme : hui::ui::themes())
{
for (HighlightKind kind :
{HighlightKind::ring, HighlightKind::fill, HighlightKind::tint, HighlightKind::bar,
HighlightKind::underline, HighlightKind::glow, HighlightKind::none})
{
++variant;
form_.style.theme = theme;
form_.style.highlight.kind = kind;
form_.style.compact = variant % 2 == 0;
form_.style.panel = variant % 3 == 0;
form_.style.dividers = variant % 3 == 1;
form_.style.values_right = variant % 4 != 0;
form_.style.description_inline = variant % 5 != 0;
form_.style.stepper_buttons = variant % 2 == 0 ? hui::ui::StepperButtons::plain
: hui::ui::StepperButtons::surface;
form_.update(kFrame);
stepper.style.theme = theme;
stepper.style.buttons = form_.style.stepper_buttons;
stepper.style.boxed = variant % 3 == 0;
picker.style.theme = theme;
picker.style.boxed = variant % 2 == 0;
picker.style.dots = variant % 3 != 0;
field.style.theme = theme;
field.style.password = variant % 2 == 0;
field.style.max_length = variant % 3 == 0 ? 8 : 0;
field.set_text(variant % 4 == 0 ? "" : "A rather long value for a field");
field.set_error(variant % 5 == 0 ? "Not like this" : "");
{
hui::ui::Canvas target = canvas();
form_.draw(target);
expect_drawn(theme.id);
}
hui::ui::Canvas target = canvas();
stepper.draw(target);
picker.draw(target);
field.draw(target);
expect_drawn(theme.id);
}
}
}
} // namespace