From 708fe3a29d766479e6e770c46d9ecd16b681e8cd Mon Sep 17 00:00:00 2001 From: ZeroOneZero Date: Fri, 17 Jul 2026 11:41:04 -0500 Subject: [PATCH] Port instance display names + root-color highlighting onto nav-rail UI (#47) #47 (display_name config parsing, root-status color highlighting, and a scroll area for large instance lists) predates #45's rewrite into the views/ package and no longer applies to the old main.py it was written against. This ports the same feature onto views/instances_page.py and views/main_window.py: - constants.py / config_handler.py: unchanged from #47's approach -- DISPLAY_NAME_KEY + display_names dict parsed alongside instance_statuses. Also fixed the isfile()-false early return to include the display_names key (matching the exception-handler fix #47 already had), so both empty paths agree on shape. - views/instances_page.py: display-name column, green highlight on rooted rows, and the QScrollArea from #47 (for Chris's 20+ instance setup) wrapping the grid so a large instance count doesn't force the window taller than the screen. - views/main_window.py: wires display_names through update_instance_data. All credit to Chris Borneman (@Hobbes4Pres) -- the feature, the regex approach, and the scroll-area idea are his from #47. This is that PR ported onto the new file layout plus the test coverage #47 didn't have. Testing - New tests/test_config_handler.py (display_names parsing, both empty- result paths) and 4 new tests in tests/test_instances_page.py (display name rendering + fallback, root-color highlighting, no widget leak on refresh). All 5 confirmed to fail against pre-fix code. - Live-smoke-tested through the real MainWindow with 25 synthetic instances (mixed display names / rooted states): correct row count, correct highlight count, scroll area actually engaged, stable across a simulated refresh. - Full suite: 81 passed. ruff + compileall clean (same gates CI runs). Co-authored-by: Chris Borneman <75288580+Hobbes4Pres@users.noreply.github.com> --- config_handler.py | 29 ++++++++++++++++++--- constants.py | 1 + tests/test_config_handler.py | 37 +++++++++++++++++++++++++++ tests/test_instances_page.py | 49 ++++++++++++++++++++++++++++++++++++ views/instances_page.py | 41 +++++++++++++++++++++++------- views/main_window.py | 2 ++ 6 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 tests/test_config_handler.py diff --git a/config_handler.py b/config_handler.py index a01b03f..a633c8b 100644 --- a/config_handler.py +++ b/config_handler.py @@ -91,16 +91,19 @@ def get_complete_root_statuses(config_path: str) -> dict[str, Any]: config_path: Path to the bluestacks.conf file. Returns: - A dictionary like: {'global_status': bool, 'instance_statuses': {name: bool}} + A dictionary like: + {'global_status': bool, 'instance_statuses': {name: bool}, 'display_names': {name: str}} """ instance_statuses: dict[str, bool] = {} + display_names: dict[str, str] = {} global_status: bool = False + empty_result = {"global_status": False, "instance_statuses": {}, "display_names": {}} if not os.path.isfile(config_path): logger.warning( f"Config file not found for reading root statuses: {config_path}" ) - return {"global_status": False, "instance_statuses": {}} + return empty_result # FIX: Regex for both instance-specific and global root keys instance_pattern = re.compile( @@ -111,6 +114,14 @@ def get_complete_root_statuses(config_path: str) -> dict[str, Any]: + r'\s*=\s*"([^"]*)"', re.IGNORECASE, ) + display_name_pattern = re.compile( + r"^" + + re.escape(constants.INSTANCE_PREFIX) + + r"([^.]+)" + + re.escape(constants.DISPLAY_NAME_KEY) + + r'\s*=\s*"([^"]*)"', + re.IGNORECASE, + ) global_pattern = re.compile( r"^" + re.escape(constants.FEATURE_ROOTING_KEY) + r'\s*=\s*"1"', re.IGNORECASE ) @@ -131,8 +142,18 @@ def get_complete_root_statuses(config_path: str) -> dict[str, Any]: instance_name, value = match.group(1), match.group(2) is_enabled = value == "1" instance_statuses[instance_name] = is_enabled + + # Check for instance display-name key + name_match = display_name_pattern.match(stripped_line) + if name_match: + instance_name, value = name_match.group(1), name_match.group(2) + display_names[instance_name] = value except Exception: logger.exception(f"Error reading config file {config_path} for root statuses.") - return {"global_status": False, "instance_statuses": {}} + return empty_result - return {"global_status": global_status, "instance_statuses": instance_statuses} + return { + "global_status": global_status, + "instance_statuses": instance_statuses, + "display_names": display_names, + } diff --git a/constants.py b/constants.py index d2bf89a..14686db 100644 --- a/constants.py +++ b/constants.py @@ -5,6 +5,7 @@ INSTANCE_PREFIX = "bst.instance." ENABLE_ROOT_KEY = ".enable_root_access" +DISPLAY_NAME_KEY = ".display_name" FEATURE_ROOTING_KEY = "bst.feature.rooting" BLUESTACKS_CONF_FILENAME = "bluestacks.conf" diff --git a/tests/test_config_handler.py b/tests/test_config_handler.py new file mode 100644 index 0000000..da31483 --- /dev/null +++ b/tests/test_config_handler.py @@ -0,0 +1,37 @@ +import config_handler + + +def _write_conf(tmp_path, text): + conf = tmp_path / "bluestacks.conf" + conf.write_text(text, encoding="utf-8") + return str(conf) + + +def test_get_complete_root_statuses_parses_display_names(tmp_path): + conf = _write_conf(tmp_path, '\n'.join([ + 'bst.instance.Nougat64.enable_root_access="1"', + 'bst.instance.Nougat64.display_name="Main Farm Bot"', + 'bst.instance.Pie64.enable_root_access="0"', + 'bst.instance.Pie64.display_name="Alt Account"', + 'bst.instance.NoName.enable_root_access="1"', + 'bst.feature.rooting="1"', + ])) + + result = config_handler.get_complete_root_statuses(conf) + + assert result["global_status"] is True + assert result["instance_statuses"] == {"Nougat64": True, "Pie64": False, "NoName": True} + assert result["display_names"] == {"Nougat64": "Main Farm Bot", "Pie64": "Alt Account"} + + +def test_get_complete_root_statuses_missing_file_includes_display_names_key(tmp_path): + result = config_handler.get_complete_root_statuses(str(tmp_path / "does_not_exist.conf")) + assert result == {"global_status": False, "instance_statuses": {}, "display_names": {}} + + +def test_get_complete_root_statuses_unreadable_file_includes_display_names_key(tmp_path, monkeypatch): + conf = _write_conf(tmp_path, 'bst.instance.Pie64.enable_root_access="1"') + monkeypatch.setattr(config_handler, "open", lambda *a, **k: (_ for _ in ()).throw(OSError("boom")), raising=False) + + result = config_handler.get_complete_root_statuses(conf) + assert result == {"global_status": False, "instance_statuses": {}, "display_names": {}} diff --git a/tests/test_instances_page.py b/tests/test_instances_page.py index 4ec3c6c..878f2b7 100644 --- a/tests/test_instances_page.py +++ b/tests/test_instances_page.py @@ -1,4 +1,5 @@ from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QLabel import constants from views.instances_page import InstancesPage @@ -80,3 +81,51 @@ def test_clicking_fix_it_emits_go_to_dashboard(qtbot): page.show() with qtbot.waitSignal(page.go_to_dashboard_requested, timeout=1000): qtbot.mouseClick(page.banner_fix_button, Qt.LeftButton) + + +def test_set_instances_shows_display_name_and_falls_back_to_unique_id(qtbot): + page = InstancesPage() + qtbot.addWidget(page) + page.show() + data = { + "Nougat64 (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE, + "display_name": "Main Farm Bot"}, + "Pie64 (Normal)": {"root_enabled": False, "rw_mode": constants.MODE_READWRITE}, + } + page.set_instances(data) + + labels = [page.instance_layout.itemAt(i).widget() for i in range(page.instance_layout.count())] + texts = {w.text() for w in labels if isinstance(w, QLabel)} + assert "Main Farm Bot" in texts + assert "Pie64 (Normal)" in texts # no display_name key -> falls back to unique_id + + +def test_set_instances_highlights_rooted_instances_green(qtbot): + page = InstancesPage() + qtbot.addWidget(page) + page.show() + data = { + "Rooted (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE}, + "NotRooted (Normal)": {"root_enabled": False, "rw_mode": constants.MODE_READWRITE}, + } + page.set_instances(data) + + root_labels = { + w.text(): w for i in range(page.instance_layout.count()) + if isinstance((w := page.instance_layout.itemAt(i).widget()), QLabel) + and w.text().startswith("Root:") + } + assert "green" in root_labels["Root: On"].styleSheet() + assert "green" not in root_labels["Root: Off"].styleSheet() + + +def test_set_instances_refresh_does_not_leak_widgets(qtbot): + page = InstancesPage() + qtbot.addWidget(page) + page.show() + data = {"Pie64 (Normal)": {"root_enabled": True, "rw_mode": constants.MODE_READWRITE}} + page.set_instances(data) + count_after_first = page.instance_layout.count() + + page.set_instances(data) + assert page.instance_layout.count() == count_after_first diff --git a/views/instances_page.py b/views/instances_page.py index bd927eb..4b2f300 100644 --- a/views/instances_page.py +++ b/views/instances_page.py @@ -1,10 +1,10 @@ """Instances page: instance grid + Toggle Root/R-W + patch-gating banner.""" from __future__ import annotations -from PyQt5.QtCore import pyqtSignal +from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QGridLayout, QGroupBox, QCheckBox, QLabel, - QPushButton, QHBoxLayout, + QPushButton, QHBoxLayout, QScrollArea, ) import constants @@ -32,13 +32,26 @@ def __init__(self, parent=None): layout.addLayout(banner_row) self.set_engine_locked_banner(False) + # Instances group box: a scroll area wraps the grid so large instance + # counts (20+) don't force the window taller than the screen. self.instance_group = QGroupBox("Instances") - self.instance_layout = QGridLayout() - self.instance_layout.setColumnStretch(0, 4) - self.instance_layout.setColumnStretch(1, 1) + instance_group_layout = QVBoxLayout(self.instance_group) + + self.instance_scroll_area = QScrollArea() + self.instance_scroll_area.setWidgetResizable(True) + self.instance_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.instance_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + self.instance_container = QWidget() + self.instance_layout = QGridLayout(self.instance_container) + self.instance_layout.setColumnStretch(0, 3) + self.instance_layout.setColumnStretch(1, 4) self.instance_layout.setColumnStretch(2, 1) + self.instance_layout.setColumnStretch(3, 1) self.instance_layout.setHorizontalSpacing(15) - self.instance_group.setLayout(self.instance_layout) + + self.instance_scroll_area.setWidget(self.instance_container) + instance_group_layout.addWidget(self.instance_scroll_area) layout.addWidget(self.instance_group) button_row = QHBoxLayout() @@ -73,11 +86,21 @@ def set_instances(self, instance_data: dict, preserve_selection: bool = True) -> data = instance_data[unique_id] checkbox = QCheckBox(unique_id) checkbox.setChecked(unique_id in previous_selection) - root_text = "On" if data.get("root_enabled") else "Off" + display_name = data.get("display_name", unique_id) + root_on = bool(data.get("root_enabled")) + root_text = "On" if root_on else "Off" rw_text = "On" if data.get("rw_mode") == constants.MODE_READWRITE else "Off" + + root_label = QLabel("Root: %s" % root_text) + if root_on: + root_label.setStyleSheet("background-color: green; color: white; padding: 2px;") + else: + root_label.setStyleSheet("padding: 2px;") + self.instance_layout.addWidget(checkbox, row, 0) - self.instance_layout.addWidget(QLabel("Root: %s" % root_text), row, 1) - self.instance_layout.addWidget(QLabel("R/W: %s" % rw_text), row, 2) + self.instance_layout.addWidget(QLabel(display_name), row, 1) + self.instance_layout.addWidget(root_label, row, 2) + self.instance_layout.addWidget(QLabel("R/W: %s" % rw_text), row, 3) self.checkboxes[unique_id] = checkbox def selected_ids(self) -> list[str]: diff --git a/views/main_window.py b/views/main_window.py index 9af8552..ec11d73 100644 --- a/views/main_window.py +++ b/views/main_window.py @@ -293,6 +293,7 @@ def update_instance_data(self) -> None: patch_mode = inst.get("patch_mode", False) root_info = config_handler.get_complete_root_statuses(config_path) instance_root_statuses = root_info['instance_statuses'] + display_names = root_info.get('display_names', {}) disk_instances = set() if os.path.isdir(data_path): @@ -333,6 +334,7 @@ def update_instance_data(self) -> None: "rw_mode": rw_mode, "root_enabled": effective_root_status, "individual_root_status": individual_root_on, + "display_name": display_names.get(name, name), "patch_mode": patch_mode, }