// SPDX-FileCopyrightText: 2026 Reion Wong // SPDX-License-Identifier: BSD-3-Clause #include #include #include #include #include #include #include #include #include #include #include #include "base/dbus.h" #include "base/event_loop.h" #include "base/timer.h" #include "cutefish/display/display_types.h" #include "ui/application.h" #include "ui/keys.h" #include "ui/label.h" #include "ui/test_input.h" #include "ui/window.h" #include "widgets/listrow.h" #include "widgets/rowcombobox.h" #include "window/page.h" #include "window/settingswindow.h" using namespace cutefish; using namespace cutefish::base; using namespace cutefish::display; using namespace cutefish::ui; using namespace settings; using namespace std::chrono_literals; namespace { bool waitFor(const std::function& condition, std::chrono::milliseconds timeout = 5s) { EventLoop& loop = Application::instance()->loop(); if (condition()) return true; Timer poll(&loop); poll.onTimeout = [&] { if (condition()) loop.quit(); }; Timer limit(&loop); limit.setSingleShot(true); limit.onTimeout = [&loop] { loop.quit(); }; poll.start(5ms); limit.start(timeout); loop.run(); return condition(); } View* findView(View* view, const std::string& name) { if (!view) return nullptr; if (view->name() == name) return view; for (const auto& child : view->children()) { if (View* found = findView(child.get(), name)) return found; } return nullptr; } bool isShown(View* view) { if (!view) return false; for (; view; view = view->parent()) { if (!view->isVisible()) return false; } return true; } std::string texts(View* view) { std::string result; if (auto* label = dynamic_cast(view); label && isShown(label)) result += label->text() + "|"; for (const auto& child : view->children()) result += texts(child.get()); return result; } // A DRM card node that exists, for the brightness row's connector name. std::string cardDevice() { std::error_code error; for (const auto& entry : std::filesystem::directory_iterator("/dev/dri", error)) { if (entry.path().filename().string().starts_with("card")) return entry.path().string(); } return {}; } using Request = std::tuple; // The compositor's display service, the night light and brightness of // cutefish-services, on a thread of their own: the page reads brightness // with blocking calls. class FakeServices { public: FakeServices() { std::promise ready; m_thread = std::thread([this, &ready] { run(ready); }); ready.get_future().get(); } ~FakeServices() { m_handle.post([this] { m_loop->quit(); }); m_thread.join(); } // False when another connection owns a name: the page would talk to that // service instead. bool owned() const { return m_owned; } std::vector> requests() const { return locked(m_requests); } std::vector previews() const { return locked(m_previews); } std::vector confirmed() const { return locked(m_confirmed); } int32_t schedule() const { return locked(m_schedule); } std::vector brightnessDisplays() const { return locked(m_brightnessDisplays); } private: template T locked(const T& value) const { std::lock_guard lock(m_mutex); return value; } void run(std::promise& ready) { EventLoop loop; m_loop = &loop; m_handle = loop.handle(); std::unique_ptr bus = dbus::Connection::open(dbus::Connection::Bus::Session, &loop); std::unique_ptr services = dbus::Connection::open(dbus::Connection::Bus::Session, &loop); PhysicalDisplay panel; panel.sessionId = "eDP-1"; panel.connectorName = "eDP-1"; panel.devicePath = cardDevice(); panel.connectionType = 1; panel.capabilities = ColorTransformCapability; panel.recommendedScale120 = 180; for (const auto& [width, height] : {std::pair{2160, 1440}, std::pair{1920, 1280}, std::pair{1080, 720}}) { Mode mode; mode.width = width; mode.height = height; mode.refreshMilliHz = 60000; mode.preferred = width == 2160; mode.current = width == 2160; panel.modes.push_back(mode); } std::vector physical = {panel}; LogicalDisplay logical; logical.id = "eDP-1"; logical.destinations = {"eDP-1"}; logical.width = 1440; logical.height = 960; logical.scale120 = 180; logical.primary = true; std::vector logicals = {logical}; uint64_t revision = 1; std::string previewToken; dbus::Object display(bus.get(), "/org/cutefish/DisplayConfig1", "org.cutefish.DisplayConfig1"); display.addMethod("GetState", [&] { return std::make_tuple(std::string("test"), std::string("seat0"), uint64_t(1), revision, physical, logicals, std::vector(), previewToken, uint64_t(previewToken.empty() ? 0 : 1)); }); const auto configure = [&](bool preview) { return [&, preview](const std::string&, uint64_t, uint64_t, const std::vector& displays, const std::string&, const std::vector&, const MirrorGroups&) { std::string token; { std::lock_guard lock(m_mutex); m_requests.push_back(displays); m_previews.push_back(preview); token = "token-" + std::to_string(m_requests.size()); } if (preview) previewToken = token; ++revision; for (const auto& [id, enabled, mode, scale120, rotation] : displays) { for (Mode& candidate : physical.front().modes) candidate.current = candidate.width == mode.width && candidate.height == mode.height; logicals.front().scale120 = scale120; logicals.front().rotation = rotation; } display.emitSignal("OperationFinished", token, true, false, uint32_t(0), std::string(), std::vector(), uint64_t(1), revision); display.emitSignal("StateChanged", uint64_t(1), revision); return token; }; }; display.addMethod("SetConfiguration", configure(false)); display.addMethod("ApplyConfiguration", configure(true)); display.addMethod("ConfirmConfiguration", [&](const std::string& token) { { std::lock_guard lock(m_mutex); m_confirmed.push_back(token); } previewToken.clear(); display.emitSignal("StateChanged", uint64_t(1), revision); }); display.addMethod("RevertConfiguration", [&](const std::string&) { previewToken.clear(); display.emitSignal("StateChanged", uint64_t(1), revision); }); display.addSignal("StateChanged", "tt"); display.addSignal("OperationFinished", "sbbusastt"); display.addSignal("PreviewChanged", "sttt"); display.publish(); m_owned = bus->requestName("org.cutefish.DisplayConfig"); bool enabled = false; dbus::Object nightLight(services.get(), "/org/cutefish/Services/NightLight", "org.cutefish.Services.NightLight"); nightLight.addProperty("enabled", [&] { return enabled; }); nightLight.addProperty("temperature", [] { return 0.5; }); nightLight.addProperty("schedule", [this] { return locked(m_schedule); }); nightLight.addProperty("customStart", [] { return int32_t(18 * 60); }); nightLight.addProperty("customEnd", [] { return int32_t(6 * 60); }); nightLight.addProperty("available", [] { return true; }); nightLight.addProperty("sunset", [] { return int32_t(18 * 60 + 30); }); nightLight.addProperty("sunrise", [] { return int32_t(6 * 60 + 15); }); nightLight.addMethod("setEnabled", [&](bool value) { enabled = value; nightLight.emitSignal("enabledChanged", value); }); nightLight.addMethod("setSchedule", [&](int32_t value) { { std::lock_guard lock(m_mutex); m_schedule = value; } nightLight.emitSignal("scheduleChanged", value); }); for (const char* name : {"enabledChanged", "availableChanged"}) nightLight.addSignal(name, "b"); for (const char* name : {"scheduleChanged", "customStartChanged", "customEndChanged", "sunsetChanged", "sunriseChanged"}) nightLight.addSignal(name, "i"); nightLight.addSignal("temperatureChanged", "d"); nightLight.publish(); if (!panel.devicePath.empty()) m_brightnessDisplays = {std::filesystem::path(panel.devicePath).filename().string() + "-eDP-1"}; dbus::Object brightness(services.get(), "/org/cutefish/Services/Brightness", "org.cutefish.Services.Brightness"); brightness.addMethod("displays", [this] { return locked(m_brightnessDisplays); }); brightness.addMethod("displayBrightness", [](const std::string&) { return int32_t(70); }); brightness.addMethod("setDisplayBrightness", [](const std::string&, int32_t) {}); brightness.addSignal("displaysChanged", ""); brightness.addSignal("displayBrightnessChanged", "si"); brightness.publish(); m_owned = services->requestName("org.cutefish.Services") && m_owned; ready.set_value(); loop.run(); } std::thread m_thread; EventLoop* m_loop = nullptr; LoopHandle m_handle; bool m_owned = false; mutable std::mutex m_mutex; std::vector> m_requests; std::vector m_previews; std::vector m_confirmed; int32_t m_schedule = 0; std::vector m_brightnessDisplays; }; struct DisplayPageTest : testing::Test { void SetUp() override { services = std::make_unique(); ASSERT_TRUE(services->owned()); settingsWindow = std::make_unique(); settingsWindow->window().setScale(1.5); settingsWindow->window().resize({900, 820}); settingsWindow->reveal(); settingsWindow->switchToPage("display"); settingsWindow->window().grab(); ASSERT_TRUE(waitFor([this] { return isShown(view("display:name")); })); } void TearDown() override { settingsWindow.reset(); services.reset(); } View* view(const std::string& name) { return findView(settingsWindow->currentPage(), name); } RowComboBox* combo(const std::string& name) { return dynamic_cast(view(name)); } std::unique_ptr services; std::unique_ptr settingsWindow; }; } // namespace TEST_F(DisplayPageTest, NamesTheOnlyScreen) { EXPECT_EQ(texts(view("display:name")), "Built-in Display|"); EXPECT_FALSE(isShown(view("display:tabs"))); EXPECT_FALSE(isShown(view("display:arrangement"))); EXPECT_FALSE(isShown(view("display:error"))); EXPECT_EQ(combo("display:resolution")->currentText(), "2160 × 1440 (Recommended)"); EXPECT_EQ(combo("display:resolution")->count(), 3); EXPECT_EQ(combo("display:refresh")->currentText(), "60.00 Hz"); EXPECT_EQ(combo("display:scale")->currentText(), "150% (Recommended)"); EXPECT_EQ(combo("display:rotation")->currentText(), "Normal"); EXPECT_EQ(isShown(view("display:brightness")), !services->brightnessDisplays().empty()); } TEST_F(DisplayPageTest, ScaleAppliesAtOnce) { RowComboBox* scale = combo("display:scale"); ASSERT_TRUE(scale && scale->onActivated); // 125%. scale->onActivated(1); ASSERT_TRUE(waitFor([this] { return services->requests().size() == 1; })); EXPECT_FALSE(services->previews().front()); EXPECT_EQ(std::get<3>(services->requests().front().front()), 150u); ASSERT_TRUE(waitFor([this] { return combo("display:scale")->currentText() == "125%"; })); } TEST_F(DisplayPageTest, ResolutionWaitsForConfirmation) { const size_t windows = Application::instance()->windows().size(); RowComboBox* resolution = combo("display:resolution"); ASSERT_TRUE(resolution && resolution->onActivated); resolution->onActivated(1); ASSERT_TRUE(waitFor([this] { return services->requests().size() == 1; })); EXPECT_TRUE(services->previews().front()); EXPECT_EQ(std::get<2>(services->requests().front().front()).width, 1920); // The confirmation opens over the window; Return keeps the mode. ASSERT_TRUE(waitFor([windows] { return Application::instance()->windows().size() == windows + 1; })); Window* dialog = Application::instance()->windows().back(); TestInput::setKeyboardFocus(*dialog, true); TestInput::typeKey(*dialog, key::Return); ASSERT_TRUE(waitFor([this] { return services->confirmed().size() == 1; })); EXPECT_EQ(services->confirmed().front(), "token-1"); ASSERT_TRUE(waitFor([windows] { return Application::instance()->windows().size() == windows; })); EXPECT_EQ(combo("display:resolution")->currentText(), "1920 × 1280"); } TEST_F(DisplayPageTest, NightLightScheduleRows) { ASSERT_TRUE(waitFor([this] { return isShown(view("display:nightlight")); })); EXPECT_FALSE(isShown(view("display:sunset"))); EXPECT_FALSE(isShown(view("display:from"))); RowComboBox* schedule = combo("display:schedule"); ASSERT_TRUE(schedule && schedule->onActivated); schedule->onActivated(1); ASSERT_TRUE(waitFor([this] { return services->schedule() == 1 && isShown(view("display:sunset")); })); // Sunset 18:30 in the session's short time format. EXPECT_NE(texts(view("display:sunset")).find("30"), std::string::npos); schedule->onActivated(2); ASSERT_TRUE(waitFor([this] { return isShown(view("display:from")) && !isShown(view("display:sunset")); })); }