Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ankihub/gui/overlay_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@


class OverlayTarget:
def __init__(self, parent: QWidget, element: Union[QWidget, QRect]) -> None:
def __init__(self, parent: QWidget, element: Optional[Union[QWidget, QRect]]) -> None:
self.parent = parent
self.element = element

Expand All @@ -35,7 +35,9 @@ def window(self) -> Optional[QWidget]:
return self.element.window()
return None

def rect(self) -> QRect:
def rect(self) -> Optional[QRect]:
if self.element is None:
return None
if isinstance(self.element, QWidget):
geom = self.element.rect()
top_left = self.element.mapToGlobal(geom.topLeft())
Expand Down
30 changes: 24 additions & 6 deletions ankihub/gui/tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ def on_position(self) -> None:
return

rect = self.target.rect()
if rect is None:
return
webview_top_left = self.web.mapFromGlobal(rect.topLeft())
webview_bottom_right = self.web.mapFromGlobal(rect.bottomRight())
top = webview_top_left.y()
Expand Down Expand Up @@ -403,7 +405,7 @@ def __post_init__(self):
@dataclass
class QtTutorialStep(TutorialStep):
parent_widget: Optional[Union[QWidget, Callable[[], QWidget]]] = None
qt_target: Optional[Union[OverlayTarget, Callable[[], OverlayTarget]]] = None
qt_target: Optional[Union[OverlayTarget, Callable[[], Optional[OverlayTarget]]]] = None
target_outline: bool = True
apply_backdrop: bool = False

Expand Down Expand Up @@ -534,7 +536,10 @@ def show_current(self) -> None:
overlay.show()
step.tooltip_context = overlay
step.target_context = overlay
step.target = "#target"
# When the Qt target is unavailable (e.g. transient Browser/widget lifecycle race),
# render this step as tooltip-only instead of anchoring to a missing target.
step.target = "#target" if target is not None else ""
step.click_target = step.click_target if target is not None else ""

def close_overlay() -> None:
overlay.close()
Expand Down Expand Up @@ -1434,6 +1439,21 @@ def _close_browser_and_move_to_next_step(self, on_done: Callable[[], None]) -> N
browser.close()
self.next()

def _get_smart_search_button_target(self) -> Optional[OverlayTarget]:
browser = self._get_live_browser()
if not browser:
return None

smart_search_button = browser.findChild(QToolButton, "AnkiHubSmartSearchButton")
if smart_search_button is None:
LOGGER.debug("Smart Search tutorial target unavailable: button not found")
return None
if sip.isdeleted(smart_search_button):
LOGGER.debug("Smart Search tutorial target unavailable: button deleted")
return None

return OverlayTarget(browser.sidebar, smart_search_button)

def _steps(self) -> list[TutorialStep]:
steps = []

Expand Down Expand Up @@ -1484,10 +1504,8 @@ def _steps(self) -> list[TutorialStep]:
steps.append(
QtTutorialStep(
body=body,
qt_target=lambda: OverlayTarget(
self._browser.sidebar, self._browser.findChild(QToolButton, "AnkiHubSmartSearchButton")
),
parent_widget=lambda: self._browser,
qt_target=self._get_smart_search_button_target,
parent_widget=lambda: self._get_live_browser(),
)
)

Expand Down
42 changes: 41 additions & 1 deletion tests/addon/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
overview_will_render_bottom,
)
from aqt.importing import AnkiPackageImporter
from aqt.qt import QAction, QDialog, QEvent, QLabel, Qt, QUrl, QWebEnginePage, QWidget, sip
from aqt.qt import QAction, QDialog, QEvent, QLabel, Qt, QToolButton, QUrl, QWebEnginePage, QWidget, sip
from aqt.theme import theme_manager
from aqt.webview import AnkiWebView
from pytest import fixture
Expand Down Expand Up @@ -12972,6 +12972,46 @@ def test_init_raises_when_anking_deck_not_installed(
with pytest.raises(AssertionError):
StepDeckTutorial()

def test_get_smart_search_button_target_returns_none_when_button_missing(
self,
anki_session_with_addon_data: AnkiSession,
):
"""Regression: tutorial target helper should handle missing Smart Search button."""
from ankihub.gui.tutorial import StepDeckTutorial

with anki_session_with_addon_data.profile_loaded():
tutorial = StepDeckTutorial.__new__(StepDeckTutorial)
browser = Mock(sidebar=Mock())
browser.findChild.return_value = None
tutorial._browser = browser

assert tutorial._get_smart_search_button_target() is None

def test_get_smart_search_button_target_uses_live_browser(
self,
anki_session_with_addon_data: AnkiSession,
mocker: MockerFixture,
):
"""Regression: target helper should resolve from the live Browser reference."""
from ankihub.gui import tutorial as tutorial_module
from ankihub.gui.overlay_dialog import OverlayTarget
from ankihub.gui.tutorial import StepDeckTutorial

with anki_session_with_addon_data.profile_loaded():
tutorial = StepDeckTutorial.__new__(StepDeckTutorial)
live_browser = Mock(sidebar=Mock())
smart_search_button = Mock(spec=QToolButton)
live_browser.findChild.return_value = smart_search_button
tutorial._browser = None
mocker.patch.object(tutorial, "_get_live_browser", return_value=live_browser)
mocker.patch.object(tutorial_module.sip, "isdeleted", return_value=False)

target = tutorial._get_smart_search_button_target()

assert isinstance(target, OverlayTarget)
assert target.parent is live_browser.sidebar
assert target.element is smart_search_button

def test_hook_browser_startup_does_not_crash_when_browser_missing(
self,
anki_session_with_addon_data: AnkiSession,
Expand Down
Loading