diff --git a/.vscode/settings.json b/.vscode/settings.json index 224589c..e3192ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,5 +29,13 @@ "**/__pycache__": true, "*.egg-info": true, ".pytest_cache": true + }, + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bd3c68f..a3983bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,30 @@ # Changelog ## [UNRELEASED] -- Updated construct-typing dependency to v0.8.1+ and updated the DataClass definitions to reflect the breaking changes. -- Bumped minimum required Python version to 3.10 (previously: 3.8 which has reached end-of-life). -- Updated `typing_extensions` dependency to >=4.12.0 for Python 3.13 compatibility. -- Removed `version.py`, use `importlib.metadata` instead to get the version number. -- Fixed a bug where multiple instances of `WxConstructHexEditor` might share the same default dict, leading to potentially unexpected behavior. +**Breaking changes:** +- Updated construct-typing dependency to v0.8.1+ and updated the DataClass definitions to reflect the breaking changes. ([#43](https://github.com/timrid/construct-editor/pull/43), [#42](https://github.com/timrid/construct-editor/pull/42)) +- Bumped minimum required Python version to 3.10 (previously: 3.8 which has reached end-of-life). ([#39](https://github.com/timrid/construct-editor/pull/39)) +- Removed `version.py`, use `importlib.metadata` instead to get the version number. ([#39](https://github.com/timrid/construct-editor/pull/39)) + +**New features:** +- Optimized Tooltip handling when hovering over a construct in the ConstructEditor. The text is now selectable in the hover tooltip and the tooltip does not automatically disappear after 5s on Windows. ([#46](https://github.com/timrid/construct-editor/pull/46)) + +**Changes:** +- Updated `wxPython` dependency to >=4.2.2. ([#39](https://github.com/timrid/construct-editor/pull/39)) +- Updated `typing_extensions` dependency to >=4.12.0 for Python 3.13 compatibility. ([#39](https://github.com/timrid/construct-editor/pull/39)) +- Updated `wrapt` dependency to >=2.2.2 for better typing support. ([#40](https://github.com/timrid/construct-editor/pull/40)) +- Fix many typing related issues. ([#42](https://github.com/timrid/construct-editor/pull/42), [#44](https://github.com/timrid/construct-editor/pull/44)) + +**Bugfixes:** +- Fixed a bug where multiple instances of `WxConstructHexEditor` might share the same default dict, leading to potentially unexpected behavior. ([#42](https://github.com/timrid/construct-editor/pull/42)) + +**Organizational changes:** +- Switch from `setup.py` to `pyproject.toml`. ([#39](https://github.com/timrid/construct-editor/pull/39)) +- Use `uv` as a project management tool and `poe` as a task runner. ([#39](https://github.com/timrid/construct-editor/pull/39)) +- Add `pyright`, `ty` and `mypy` for static type checking. ([#39](https://github.com/timrid/construct-editor/pull/39), [#41](https://github.com/timrid/construct-editor/pull/41)) +- Add `ruff` for linting. ([#39](https://github.com/timrid/construct-editor/pull/39)) +- Add basic unit and integration tests with `pytest`. ([#40](https://github.com/timrid/construct-editor/pull/40)) +- Add a CI Pipeline to perform linting, static type checking and unit/integration testing. ([#40](https://github.com/timrid/construct-editor/pull/40)) ------------------------------------------------------------------------------- diff --git a/construct_editor/wx_widgets/wx_construct_editor.py b/construct_editor/wx_widgets/wx_construct_editor.py index a11e9df..9a4a002 100644 --- a/construct_editor/wx_widgets/wx_construct_editor.py +++ b/construct_editor/wx_widgets/wx_construct_editor.py @@ -14,6 +14,7 @@ from construct_editor.core.model import ConstructEditorColumn, ConstructEditorModel from construct_editor.wx_widgets.wx_context_menu import WxContextMenu from construct_editor.wx_widgets.wx_exception_dialog import WxExceptionDialog +from construct_editor.wx_widgets.wx_hover_tooltip import WxHoverToolTip from construct_editor.wx_widgets.wx_obj_view import ( WxObjEditor, WxObjRendererHelper, @@ -42,9 +43,7 @@ def __init__(self): def SetValue(self, value: EntryConstruct): self.entry = value - self.entry_renderer_helper = create_obj_renderer_helper( - self.entry.obj_view_settings - ) + self.entry_renderer_helper = create_obj_renderer_helper(self.entry.obj_view_settings) return True def GetValue(self): @@ -111,9 +110,7 @@ def ActivateCell( ) -> bool: if self.entry_renderer_helper is None: raise ValueError("`entry_renderer_helper` not set") - return self.entry_renderer_helper.activate_cell( - self, cell, model, item, col, mouseEvent - ) + return self.entry_renderer_helper.activate_cell(self, cell, model, item, col, mouseEvent) # The HasEditorCtrl, CreateEditorCtrl and GetValueFromEditorCtrl # methods need to be implemented if this renderer is going to @@ -123,9 +120,7 @@ def ActivateCell( def HasEditorCtrl(self): return True - def CreateEditorCtrl( - self, parent, labelRect: wx.Rect, value: EntryConstruct - ) -> WxObjEditor: + def CreateEditorCtrl(self, parent, labelRect: wx.Rect, value: EntryConstruct) -> WxObjEditor: view_settings = value.obj_view_settings editor: WxObjEditor = create_obj_editor(parent, view_settings) editor.SetPosition(labelRect.GetPosition()) @@ -308,18 +303,14 @@ def _init_gui(self): self._parse_error_info_bar = wx.InfoBar(self) btn_id = wx.NewIdRef() self._parse_error_info_bar.AddButton(btn_id, "Exception Infos") - self._parse_error_info_bar.Bind( - wx.EVT_BUTTON, self._parse_error_info_bar_btn_clicked, id=btn_id - ) + self._parse_error_info_bar.Bind(wx.EVT_BUTTON, self._parse_error_info_bar_btn_clicked, id=btn_id) self._parse_error_ex: Exception | None = None vsizer.Add(self._parse_error_info_bar, 0, wx.EXPAND) self._build_error_info_bar = wx.InfoBar(self) btn_id = wx.NewIdRef() self._build_error_info_bar.AddButton(btn_id, "Exception Infos") - self._build_error_info_bar.Bind( - wx.EVT_BUTTON, self._build_error_info_bar_btn_clicked, id=btn_id - ) + self._build_error_info_bar.Bind(wx.EVT_BUTTON, self._build_error_info_bar_btn_clicked, id=btn_id) self._build_error_ex: Exception | None = None vsizer.Add(self._build_error_info_bar, 0, wx.EXPAND) @@ -329,9 +320,7 @@ def _init_gui(self): style=wx.STB_SHOW_TIPS | wx.STB_ELLIPSIZE_END | wx.FULL_REPAINT_ON_RESIZE, ) self._status_bar.SetFieldsCount(2) - self._status_bar.SetStatusStyles( - [wx.SB_NORMAL, wx.SB_FLAT] - ) # remove vertical line after the last field + self._status_bar.SetStatusStyles([wx.SB_NORMAL, wx.SB_FLAT]) # remove vertical line after the last field self._status_bar.SetStatusWidths([-2, -1]) vsizer.Add(self._status_bar, 0, wx.ALL | wx.EXPAND, 0) @@ -353,7 +342,9 @@ def _init_gui(self): self._dvc_main_window.Bind(wx.EVT_MOTION, self._on_dvc_motion) self._dvc_main_window.Bind(wx.EVT_KEY_DOWN, self._on_dvc_key_down) self._dvc_main_window.Bind(wx.EVT_CHAR, self._on_dvc_char) - self._last_tooltip: t.Tuple[EntryConstruct, ConstructEditorColumn] | None = None + self._dvc_main_window.Bind(wx.EVT_SCROLLWIN, self._on_dvc_scroll) + self._dvc_main_window.Bind(wx.EVT_MOUSEWHEEL, self._on_dvc_scroll) + self._hover_tooltip = WxHoverToolTip(self._dvc_main_window) def reload(self): """ @@ -362,6 +353,8 @@ def reload(self): try: self.Freeze() + self._hover_tooltip.hide() + # reload dvc columns self._reload_dvc_columns() @@ -522,6 +515,8 @@ def _on_dvc_selection_changed(self, event): Then the infos of the new selected entry is shown. """ + self._hover_tooltip.hide() + item = self._dvc.GetSelection() if item.ID is not None: entry = self._model.dvc_item_to_entry(item) @@ -551,25 +546,34 @@ def _on_dvc_motion(self, event: wx.MouseEvent): pos += self._dvc_main_window.GetPosition() # correct the dvc header item, col = self._dvc.HitTest(pos) if item.GetID() is None: - self._dvc_main_window.SetToolTip("") return entry = self._model.dvc_item_to_entry(item) if col.ModelColumn == ConstructEditorColumn.Name: - # only set tooltip if the obj changed. this prevents flickering - if self._last_tooltip != (entry, ConstructEditorColumn.Name): - self._dvc_main_window.SetToolTip( - textwrap.dedent(entry.docs or entry.name).strip() - ) - self._last_tooltip = (entry, ConstructEditorColumn.Name) + text = textwrap.dedent(entry.docs or entry.name).strip() elif col.ModelColumn == ConstructEditorColumn.Type: - # only set tooltip if the obj changed. this prevents flickering - if self._last_tooltip != (entry, ConstructEditorColumn.Type): - self._dvc_main_window.SetToolTip(str(entry.construct)) - self._last_tooltip = (entry, ConstructEditorColumn.Type) + text = str(entry.construct) else: - self._dvc_main_window.SetToolTip("") - self._last_tooltip = None + return + + if not text: + return + + cell_rect: wx.Rect = self._dvc.GetItemRect(item, col) + # `GetItemRect` returns a rect in the same coordinate space as + # `HitTest` above (relative to the whole dvc control, including the + # header) - so convert via `self._dvc`, not `self._dvc_main_window` + # (which would double-count the header offset and shift the + # tooltip down by roughly one row). + anchor_screen_rect = wx.Rect( + self._dvc.ClientToScreen(cell_rect.GetPosition()), + cell_rect.GetSize(), + ) + self._hover_tooltip.notify_hover(text, anchor_screen_rect) + + def _on_dvc_scroll(self, event): + self._hover_tooltip.hide() + event.Skip() def _on_dvc_right_clicked(self, event: dv.DataViewEvent): """ diff --git a/construct_editor/wx_widgets/wx_hover_tooltip.py b/construct_editor/wx_widgets/wx_hover_tooltip.py new file mode 100644 index 0000000..8d10db7 --- /dev/null +++ b/construct_editor/wx_widgets/wx_hover_tooltip.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import dataclasses + +import wx +import wx.lib.wordwrap + +# how long the mouse has to hover a cell before the tooltip appears +_SHOW_DELAY_MS = 500 + +# how often we poll the mouse position to decide whether the tooltip +# (still pending, or already shown) is still relevant +_POLL_INTERVAL_MS = 100 + +_MAX_TEXT_WIDTH_DEFAULT = 450 +# clamps how tall the popup (and its wx.TextCtrl) can grow - text that +# doesn't fit is reachable via the TextCtrl's own native scrollbar instead +_MAX_TEXT_HEIGHT_DEFAULT = 200 +_PADDING = 4 + + +def _get_text_ctrl_size( + dc: wx.DC, + text: str, + max_text_width: int, + max_text_height: int, +) -> tuple[int, int, bool]: + """ + Determines the size needed for a `wx.TextCtrl` showing `text`, clamped + to `max_text_width`/`max_text_height`. The actual line-wrapping (at + `max_text_width`) is delegated to `wx.lib.wordwrap.wordwrap()`, which + measures directly via `dc` (no throwaway widget needed) and returns the + text with hard line breaks inserted at the wrap points. `dc` is then + used to measure the already-wrapped text in a single call. + + The wrapped text returned by `wordwrap()` is used ONLY for this + measurement - it is deliberately NOT what gets shown in the + `wx.TextCtrl` (see `WxHoverToolTipPopup.__init__`), since its hard + line breaks would otherwise end up in copy-pasted text. The + `wx.TextCtrl` instead displays the original `text` and relies on its + own native soft word-wrap (no `wx.TE_DONTWRAP`/`wx.TE_NO_VSCROLL`-only + style) to wrap it visually at render time, without altering `GetValue()`. + """ + wrapped_text = wx.lib.wordwrap.wordwrap(text, max_text_width, dc) + + content_width, full_content_height = dc.GetMultiLineTextExtent(wrapped_text) + # a single word that is on its own wider than `max_text_width` is + # broken at a character boundary by `wordwrap()`, but the resulting + # width still needs to be clamped here just in case + content_width = min(content_width, max_text_width) + content_height = min(full_content_height, max_text_height) + + needs_vscroll = full_content_height > max_text_height + + return content_width, content_height, needs_vscroll + + +class WxHoverToolTipPopup(wx.PopupWindow): + """ + A tooltip-like popup window that hosts a read-only, multiline + `wx.TextCtrl`, so its text can be selected and copied by the user - + unlike a native tooltip. + + Like `wx.lib.agw.supertooltip.ToolTipWindow`, this is a throwaway + window: a fresh instance is created (and fully sized/positioned/shown) + for every tooltip, and it is `Destroy()`-ed once it should disappear, + instead of being hidden and reused for the next tooltip. + """ + + def __init__( + self, + parent: wx.Window, + text: str, + anchor_screen_rect: wx.Rect, + max_text_width: int, + max_text_height: int, + bg_colour: wx.Colour | None = None, + fg_colour: wx.Colour | None = None, + ): + # `wx.PU_CONTAINS_CONTROLS` is required (MSW-only) for a child + # control such as our `wx.TextCtrl` to be able to take focus at + # all - by default a `wx.PopupWindow` never lets its children take + # focus from the parent window, which silently breaks native + # mouse-drag text selection (it relies on the control actually + # being focused), even though `SetFocus()` calls on it don't raise + # any error. + # `wx.SIMPLE_BORDER` gives the popup a thin native border - a + # manually-painted border was tried first but never actually + # rendered, so this relies on the native border instead. + wx.PopupWindow.__init__(self, parent, flags=wx.SIMPLE_BORDER | wx.PU_CONTAINS_CONTROLS) + + # A light gray background (`#F9F9F9`, matching the standard Windows + # tooltip colour - no `wx.SYS_COLOUR_*` constant matches it exactly), + # instead of the yellowish `wx.SYS_COLOUR_INFOBK`. + if bg_colour is None: + bg_colour = wx.Colour(0xF9, 0xF9, 0xF9) + if fg_colour is None: + fg_colour = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT) + self.SetBackgroundColour(bg_colour) + + dc = wx.ClientDC(self) + dc.SetFont(self.GetFont()) + content_width, content_height, needs_vscroll = _get_text_ctrl_size(dc, text, max_text_width, max_text_height) + + # `wx.TE_NO_VSCROLL` must be applied at construction time (it has + # no effect if toggled afterwards), and a multiline `wx.TextCtrl` + # *without* it always reserves space for - and shows - a native + # vertical scrollbar on MSW, even when the text fully fits without + # one. So it's only omitted when the text actually needs a + # scrollbar (i.e. was clipped to `_MAX_TEXT_HEIGHT`). + style = wx.TE_MULTILINE | wx.TE_READONLY | wx.BORDER_NONE + if not needs_vscroll: + style |= wx.TE_NO_VSCROLL + + self._text_ctrl = wx.TextCtrl(self, value=text, style=style) + self._text_ctrl.SetBackgroundColour(bg_colour) + self._text_ctrl.SetForegroundColour(fg_colour) + + sizer = wx.BoxSizer(wx.VERTICAL) + sizer.Add(self._text_ctrl, 1, wx.EXPAND | wx.ALL, _PADDING) + self.SetSizer(sizer) + + # `SetClientSize()` (rather than `SetSize()`) accounts for the + # popup's native `wx.SIMPLE_BORDER`, so the text area itself ends up + # exactly this size - otherwise the border would eat into it, + # clipping the last column/row of text. + self.SetClientSize(wx.Size(content_width + 2 * _PADDING, content_height + 2 * _PADDING)) + self.Layout() + self._position_at(anchor_screen_rect) + + self.Show() + self._text_ctrl.HideNativeCaret() + + def _position_at(self, anchor_screen_rect: wx.Rect): + """ + Positions the popup below the given anchor rect (which must be in + screen coordinates), clamped to the display the anchor is on. + """ + # The popup's actual outer size (including the border) is what + # matters for screen-edge clamping below. + popup_size = self.GetSize() + + popup_pos = wx.Point(anchor_screen_rect.x, anchor_screen_rect.GetBottom()) + + # Clamp to the display the anchor is actually on (not necessarily + # the primary display), so multi-monitor setups are positioned + # correctly instead of being clamped back onto display 0. + display_index = wx.Display.GetFromPoint(anchor_screen_rect.GetPosition()) + if display_index == wx.NOT_FOUND: + display_index = 0 + display_rect = wx.Display(display_index).GetGeometry() + + if popup_pos.x + popup_size.x > display_rect.GetRight(): + popup_pos.x = display_rect.GetRight() - popup_size.x + if popup_pos.y + popup_size.y > display_rect.GetBottom(): + popup_pos.y = anchor_screen_rect.y - popup_size.y + popup_pos.x = max(popup_pos.x, display_rect.x) + popup_pos.y = max(popup_pos.y, display_rect.y) + self.SetPosition(popup_pos) + + +@dataclasses.dataclass(frozen=True) +class _HoverState: + text: str + anchor_screen_rect: wx.Rect + + +class WxHoverToolTip: + """ + Shows a persistent, text-selectable tooltip anchored to a target window. + + Unlike a native tooltip, this tooltip: + - does not disappear automatically after a few seconds + - can be entered with the mouse, to select/copy its text + - only closes once the mouse has genuinely left both the triggering + area and the tooltip popup itself - determined geometrically (via + a periodic poll of the actual mouse position), not via fragile + per-window enter/leave events + """ + + def __init__( + self, + target: wx.Window, + show_delay_ms: int = _SHOW_DELAY_MS, + poll_interval_ms: int = _POLL_INTERVAL_MS, + max_text_width: int = _MAX_TEXT_WIDTH_DEFAULT, + max_text_height: int = _MAX_TEXT_HEIGHT_DEFAULT, + bg_colour: wx.Colour | None = None, + fg_colour: wx.Colour | None = None, + ): + self._target = target + self._show_delay_ms = show_delay_ms + self._poll_interval_ms = poll_interval_ms + self._max_text_width = max_text_width + self._max_text_height = max_text_height + self._bg_colour = bg_colour + self._fg_colour = fg_colour + + self._popup: WxHoverToolTipPopup | None = None + + self._current_state: _HoverState | None = None + self._pending_state: _HoverState | None = None + + # the window that held keyboard focus right before the popup first + # stole it (see `_on_show_timer`) during the current hover session - + # `None` whenever no popup has stolen focus yet + self._prior_focus_owner: wx.Window | None = None + + self._show_timer: wx.CallLater[[], None] | None = None + self._poll_timer = wx.Timer() + self._poll_timer.Bind(wx.EVT_TIMER, self._on_poll_timer) + + def notify_hover(self, text: str, anchor_screen_rect: wx.Rect): + """ + Called (eg. from a mouse-motion handler) while the mouse is over a + region that should show `text` in the tooltip. Re-hovering the same + `text` at the same `anchor_screen_rect` (ie. the currently-shown or + still-pending content) is a no-op. + """ + new_state = _HoverState(text, wx.Rect(anchor_screen_rect)) + + # If the new state is identical to either the current or pending state, + # don't reset the show timer (which would restart the delay) or + # change the pending state. + if self._current_state == new_state or self._pending_state == new_state: + return + + # If the new state is different, cancel any pending show and start a new show timer. + self._stop_show_timer() + self._pending_state = new_state + self._show_timer = wx.CallLater(self._show_delay_ms, self._on_show_timer) + self._start_poll_timer() + + def hide(self): + """Hides the tooltip immediately, cancelling any pending timers.""" + self._stop_show_timer() + self._stop_poll_timer() + self._current_state = None + self._pending_state = None + self._restore_prior_focus_owner() + self._destroy_popup() + + def _restore_prior_focus_owner(self): + """ + Restores keyboard focus to the window captured (in `_on_show_timer`) + as having had focus right before the popup stole it - but only if + focus is still on the popup (or its `TextCtrl`) at this point, so a + deliberate focus change elsewhere by the user while the popup + happened to still be open is never overridden. + """ + owner = self._prior_focus_owner + self._prior_focus_owner = None + if owner is None: + return + + popup = self._popup + if popup is None: + return + # `wx.Window.FindFocus()`'s stub claims a non-optional return, but + # it can genuinely return `None` at runtime (nothing focused) - the + # `try` guards against that mismatch as well as a since-destroyed + # `focused` window. + try: + focused = wx.Window.FindFocus() + focus_is_on_popup = focused is popup or focused.GetParent() is popup + except (AttributeError, RuntimeError): + focus_is_on_popup = False + if not focus_is_on_popup: + return + + try: + owner.SetFocus() + except RuntimeError: + # the prior focus owner was destroyed in the meantime + pass + + def _destroy_popup(self): + if self._popup is None: + return + popup = self._popup + self._popup = None + try: + popup.Destroy() + except RuntimeError: + # already destroyed (e.g. its parent window went away) + pass + + def _check_still_relevant(self): + """ + Re-checks the current mouse position against the pending/current + hover target (and, if shown, the popup itself), hiding or + cancelling the pending show if the mouse has genuinely left. Called + periodically by the poll timer. + """ + mouse_pos = wx.GetMousePosition() + + # If the mouse has moved outside the pending hover target, cancel the pending show (if any) + if self._pending_state is not None and not self._pending_state.anchor_screen_rect.Contains(mouse_pos): + self._stop_show_timer() + self._pending_state = None + + # Don't hide the currently-shown popup while a *different* hover + # target is already pending - this lets the mouse move directly + # from one hoverable cell to another without a hide-then-show + # flicker; the popup is simply repositioned/updated in place once + # the new pending target's show delay elapses. + if self._pending_state is None and self._current_state is not None: + # Create a new `wx.Rect`, because `wx.Rect.Union()` will mutate the rect + relevant_rect = wx.Rect(self._current_state.anchor_screen_rect) + try: + if self._popup is not None and self._popup.IsShown(): + relevant_rect = relevant_rect.Union(self._popup.GetScreenRect()) + except RuntimeError: + # The popup's underlying C++ object has already been destroyed (e.g. its parent window went away). + # Clear state immediately so we don't keep polling forever with stale current data. + self.hide() + return + + if not relevant_rect.Contains(mouse_pos): + self.hide() + + if self._pending_state is None and self._current_state is None: + self._stop_poll_timer() + + def _start_poll_timer(self): + if not self._poll_timer.IsRunning(): + self._poll_timer.Start(self._poll_interval_ms) + + def _stop_poll_timer(self): + if self._poll_timer.IsRunning(): + self._poll_timer.Stop() + + def _on_poll_timer(self, event: wx.TimerEvent): + self._check_still_relevant() + + def _stop_show_timer(self): + if self._show_timer is not None: + self._show_timer.Stop() + self._show_timer = None + + def _on_show_timer(self): + self._show_timer = None + if self._pending_state is None: + return + text = self._pending_state.text + rect = self._pending_state.anchor_screen_rect + self._current_state = self._pending_state + self._pending_state = None + # Any previous popup is fully destroyed before creating the new + # one - a fresh popup is always created from scratch for each + # shown tooltip rather than being reused. + self._destroy_popup() + # Only capture once per hover session (ie. only while no prior + # focus owner is already tracked) - switching directly between + # tooltip-triggering cells destroys+recreates the popup without a + # visible hide gap, and re-capturing on every such switch would + # just capture the popup's own (already-stolen) focus instead of + # the window that had focus before the FIRST popup in this chain. + if self._prior_focus_owner is None: + self._prior_focus_owner = wx.Window.FindFocus() + try: + self._popup = WxHoverToolTipPopup( + self._target, + text, + rect, + max_text_width=self._max_text_width, + max_text_height=self._max_text_height, + bg_colour=self._bg_colour, + fg_colour=self._fg_colour, + ) + except RuntimeError: + # the target was destroyed in the meantime + self._popup = None diff --git a/tests/wx_integration/test_wx_construct_editor_tooltip.py b/tests/wx_integration/test_wx_construct_editor_tooltip.py new file mode 100644 index 0000000..8bb20f1 --- /dev/null +++ b/tests/wx_integration/test_wx_construct_editor_tooltip.py @@ -0,0 +1,162 @@ +# pyright: reportPrivateUsage=false +from __future__ import annotations + +import construct as cs +import wx + +from construct_editor.core.model import ConstructEditorColumn +from construct_editor.wx_widgets.wx_construct_editor import WxConstructEditor +from tests.wx_integration.wx_test_helpers import ( + WxTestHarness, + construct_editor_cell_screen_rect, +) + +_SHOW_DELAY_MS = 30 +_MARGIN_MS = 150 +# comfortably larger than WxHoverToolTip's production poll interval (100ms) +# plus a safety margin, so these tests reliably observe a poll tick. +_POLL_MARGIN_MS = 250 + +# long enough (after wrapping) to exceed wx_hover_tooltip's _MAX_TEXT_HEIGHT +# clamp, so the popup's wx.TextCtrl needs its own native vertical scrollbar +_LONG_ROOT_DOCS = "This is a very long line of documentation text that repeats many times. " * 30 + + +def _make_editor( + harness: WxTestHarness, + root_docs: str = "This is a documentation to the root.", +) -> WxConstructEditor: + """Create a WxConstructEditor with one parsed row (the root struct).""" + editor = WxConstructEditor( + harness.frame, + cs.Renamed( + cs.Struct(cs.Renamed(cs.Int8ub, "value", "This is a documentation to the value.")), + "root", + root_docs, + ), + ) + editor.parse(b"\x01") + + # Speed up the tooltip's show delay for testing, so we don't have to wait + editor._hover_tooltip._show_delay_ms = _SHOW_DELAY_MS + + harness.frame.Layout() + harness.app.Yield() + return editor + + +def _name_cell_screen_rect(editor: WxConstructEditor) -> wx.Rect: + model = editor._model + root_entry = model.root_entry + assert root_entry is not None + return construct_editor_cell_screen_rect(editor, root_entry, ConstructEditorColumn.Name) + + +def _center_of(rect: wx.Rect) -> wx.Point: + return wx.Point(rect.x + rect.width // 2, rect.y + rect.height // 2) + + +def test_hovering_the_name_cell_shows_a_tooltip_positioned_directly_below_it( + wx_harness, +) -> None: + editor = _make_editor(wx_harness) + + # Move the mouse over the name cell to show the tooltip, then move it + cell_rect = _name_cell_screen_rect(editor) + wx_harness.move_mouse_to(_center_of(cell_rect)) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing, and that it is positioned + popup = editor._hover_tooltip._popup + assert popup is not None + assert popup.IsShown() + assert popup._text_ctrl.GetValue() == "This is a documentation to the root." + + # directly below the hovered cell - not offset by an extra row, as + # happened when the dvc header offset was counted twice. + assert popup.GetPosition() == wx.Point(cell_rect.x, cell_rect.GetBottom()) + + +def test_moving_the_mouse_slowly_into_the_popup_to_select_text_does_not_hide_it( + wx_harness, +) -> None: + # Uses long root docs, so the popup's wx.TextCtrl needs its own native + # vertical scrollbar - this also lets this test cover hovering over + # that scrollbar further below. + editor = _make_editor(wx_harness, root_docs=_LONG_ROOT_DOCS) + + # Move the mouse over the name cell to show the tooltip, then move it + cell_rect = _name_cell_screen_rect(editor) + cell_center = _center_of(cell_rect) + wx_harness.move_mouse_to(cell_center) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing before we move the mouse into it + popup = editor._hover_tooltip._popup + assert popup is not None + assert popup.IsShown() + + # Move the mouse "slowly" from the cell down into the popup itself, and + # then further onto its child text_ctrl (as if about to select text), + # taking a few small steps with real delays in between - this is the + # exact scenario that used to make the popup disappear on its own (a + # nested wx.EVT_ENTER_WINDOW/EVT_LEAVE_WINDOW artifact between the + # popup and its child - now replaced by a geometric check of the real + # mouse position against the popup's own screen rect). + text_ctrl = popup._text_ctrl + text_ctrl_center = _center_of(text_ctrl.GetScreenRect()) + wx_harness.move_mouse_linear(text_ctrl_center) + wx_harness.wait_ms(_POLL_MARGIN_MS) + + # Check that the tooltip is still showing after moving the mouse into it + assert popup.IsShown() + + # Regression: moving further onto the text_ctrl's own vertical + # scrollbar (its docs are long enough to need one) used to hide the + # popup immediately - the scrollbar is part of the text_ctrl's native + # window, so entering it used to fire a spurious EVT_LEAVE_WINDOW on + # the text_ctrl without any matching new enter elsewhere. The + # geometric containment check against the popup's own screen rect is + # unaffected by which specific child window the mouse is over. + text_ctrl_rect = text_ctrl.GetScreenRect() + scrollbar_width = wx.SystemSettings.GetMetric(wx.SYS_VSCROLL_X) + scrollbar_point = wx.Point( + text_ctrl_rect.GetRight() - max(scrollbar_width, 1) // 2, + text_ctrl_rect.y + text_ctrl_rect.height // 2, + ) + wx_harness.move_mouse_linear(scrollbar_point) + wx_harness.wait_ms(_POLL_MARGIN_MS) + + # Check that the tooltip is still showing after moving the mouse onto the scrollbar + assert popup.IsShown() + + +def test_moving_the_mouse_out_of_the_dvc_towards_other_gui_elements_hides_the_tooltip( + wx_harness, +) -> None: + editor = _make_editor(wx_harness) + + # Move the mouse over the name cell to show the tooltip, then move it + cell_rect = _name_cell_screen_rect(editor) + wx_harness.move_mouse_to(_center_of(cell_rect)) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing before we move the mouse away from it + popup = editor._hover_tooltip._popup + assert popup is not None + assert popup.IsShown() + + # Move the mouse away from the dvc entirely, towards another real GUI + # element - the editor's own status bar, sitting right below the dvc - + # simulating the mouse leaving for another part of the GUI. This is + # detected by the tooltip's own periodic poll of the real mouse + # position (not a dedicated wx.EVT_LEAVE_WINDOW handler on the dvc): + # wx.EVT_KILL_FOCUS alone never fires for plain mouse movement without + # a click. + status_bar = editor._status_bar + wx_harness.move_mouse_linear(_center_of(status_bar.GetScreenRect())) + wx_harness.wait_ms(_POLL_MARGIN_MS) + + # Check that the tooltip is no longer showing after moving the mouse away + popup = editor._hover_tooltip._popup + assert popup is None diff --git a/tests/wx_integration/test_wx_hover_tooltip.py b/tests/wx_integration/test_wx_hover_tooltip.py new file mode 100644 index 0000000..dab5aa2 --- /dev/null +++ b/tests/wx_integration/test_wx_hover_tooltip.py @@ -0,0 +1,306 @@ +# pyright: reportPrivateUsage=false +from __future__ import annotations + +import wx + +from construct_editor.wx_widgets.wx_hover_tooltip import _POLL_INTERVAL_MS, WxHoverToolTip +from tests.wx_integration.wx_test_helpers import WxTestHarness + +_SHOW_DELAY_MS = 30 +_MARGIN_MS = 120 + + +def _anchor_rect(harness: WxTestHarness, x: int, y: int, width: int, height: int) -> wx.Rect: + """Builds an anchor rect at `(x, y)`/`(width, height)` relative to the + harness frame's client area, converted to screen coordinates. + """ + screen_pos = harness.frame.ClientToScreen(wx.Point(x, y)) + return wx.Rect(screen_pos, wx.Size(width, height)) + + +def test_popup_appears_with_text_after_show_delay(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + + # Position the mouse over a small anchor rect + anchor = _anchor_rect(wx_harness, 10, 10, 50, 20) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + # Show the tooltip + tooltip.notify_hover("hello world", anchor) + + # Check that the tooltip is not showing yet, because the show delay has not elapsed + popup = tooltip._popup + assert popup is None + + # Wait for the show delay to elapse + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + + # Check that the tooltip has the correct text and is read only + assert popup._text_ctrl.GetValue() == "hello world" + assert not popup._text_ctrl.IsEditable() + + # Check that the tooltip is positioned directly below the anchor rect + position = popup.GetPosition() + assert position.x == anchor.x + assert position.y == anchor.GetBottom() + + # Move the mouse into the popup's text control, and check that the tooltip is still showing + text_ctrl = popup._text_ctrl + text_ctrl_center = wx.Point( + text_ctrl.GetScreenRect().x + text_ctrl.GetScreenRect().width // 2, + text_ctrl.GetScreenRect().y + text_ctrl.GetScreenRect().height // 2, + ) + wx_harness.move_mouse_linear(text_ctrl_center) + wx_harness.wait_ms(_POLL_INTERVAL_MS + _MARGIN_MS) + + # Check that the tooltip is still showing after moving the mouse into it + assert popup.IsShown() + + +def test_hovering_the_same_content_again_keeps_the_same_popup_shown(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + anchor = _anchor_rect(wx_harness, 10, 10, 50, 20) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + tooltip.notify_hover("hello", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + popup_before = tooltip._popup + + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + tooltip.notify_hover("hello", anchor) + popup_after = tooltip._popup + + assert popup_before is popup_after + assert popup_after is not None + assert popup_after.IsShown() + + +def test_hovering_different_content_before_show_delay_replaces_pending_content(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + + anchor = _anchor_rect(wx_harness, 10, 10, 50, 20) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + tooltip.notify_hover("content-a", anchor) + tooltip.notify_hover("content-b", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + assert popup._text_ctrl.GetValue() == "content-b" + + +def test_poll_tick_hides_popup_once_mouse_leaves_the_anchor_and_popup(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + anchor = _anchor_rect(wx_harness, 10, 10, 50, 20) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + # Show the tooltip + tooltip.notify_hover("hello", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + + # Move the mouse to a point above the anchor rect, so that it is no longer over + # the anchor or the popup + above_anchor_point = wx.Point(anchor.x, anchor.y - 10) + wx_harness.move_mouse_to(above_anchor_point, delay_ms=0) + wx_harness.wait_ms(_POLL_INTERVAL_MS + _MARGIN_MS) + + # Tooltip should be hidden now + assert tooltip._popup is None + + +def test_poll_tick_cancels_a_pending_show_once_mouse_leaves_before_the_delay_elapses(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + anchor = _anchor_rect(wx_harness, 20, 20, 10, 10) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + # Notify that the mouse is hovering over some content, but do not wait for the show delay to elapse yet + tooltip.notify_hover("hello", anchor) + + # Move the mouse to a point above the anchor rect, so that it is no longer over the anchor + above_anchor_point = wx.Point(anchor.x, anchor.y - 10) + wx_harness.move_mouse_to(above_anchor_point, delay_ms=0) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is not showing, because the mouse left before the show delay elapsed + popup = tooltip._popup + assert popup is None + + # re-hovering the same content afterwards must restart the show timer - + # proving the pending content was actually cleared, not left stale + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + tooltip.notify_hover("hello", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is now showing + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + + +def test_switching_to_different_hoverable_content_does_not_flicker_hide_the_old_popup(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + anchor_a = _anchor_rect(wx_harness, 20, 30, 10, 10) + wx_harness.move_mouse_to(anchor_a.GetPosition(), delay_ms=0) + + # Show the tooltip for the first content + tooltip.notify_hover("content-a", anchor_a) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + + # mouse moved directly to a different, hoverable cell - a new + # hover is pending, so the still-shown old popup must not flicker-hide + # while waiting for the new content's own show delay to elapse. + anchor_b = wx.Rect(anchor_a.x, anchor_a.y - 20, 10, 10) + wx_harness.move_mouse_to(anchor_b.GetPosition(), delay_ms=0) + tooltip.notify_hover("content-b", anchor_b) + + # Check that the tooltip is still showing the old content + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + assert popup._text_ctrl.GetValue() == "content-a" + + # Wait for the new content's show delay to elapse, at which point the old + # popup should be replaced by a new one showing the new content. + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + new_popup = tooltip._popup + assert new_popup is not popup + assert new_popup is not None + assert new_popup.IsShown() + assert new_popup._text_ctrl.GetValue() == "content-b" + + +def test_hide_hides_the_popup_immediately(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + anchor = _anchor_rect(wx_harness, 20, 20, 10, 10) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + # Show the tooltip + tooltip.notify_hover("hello", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + assert tooltip._popup is not None + + # Hide the tooltip + tooltip.hide() + + # Tooltip should be destroyed immediately + assert tooltip._popup is None + + +def test_long_text_is_wrapped_onto_multiple_lines(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + anchor = _anchor_rect(wx_harness, 20, 20, 10, 10) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + long_text = " ".join(["word"] * 200) + tooltip.notify_hover(long_text, anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + popup = tooltip._popup + assert popup is not None + # the wx.TextCtrl must show the ORIGINAL, unwrapped text - no hard + # line breaks - so that copy-pasting it doesn't carry any wrap-point + # newlines along; wrapping onto multiple lines is purely the + # wx.TextCtrl's own visual (soft) word-wrap. + assert popup._text_ctrl.GetValue() == long_text + # proves it actually wrapped onto multiple lines (rather than just + # growing horizontally forever): the popup's clamped client height is + # noticeably taller than a single line of text. + single_line_height = popup._text_ctrl.GetCharHeight() + assert popup.GetClientSize().height > single_line_height * 2 + + +def test_popup_is_clamped_within_its_display(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + + # anchor right at the bottom-right corner of the display, so the popup + # would overflow off-screen unless it is clamped + display_index = wx.Display.GetFromWindow(wx_harness.frame) + if display_index == wx.NOT_FOUND: + raise RuntimeError("Test frame is not on any display, cannot test clamping") + display_rect = wx.Display(display_index).GetGeometry() + anchor = wx.Rect(display_rect.GetRight() - 5, display_rect.GetBottom() - 5, 10, 10) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + # Show the tooltip + tooltip.notify_hover("hello", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing and is clamped within the display + popup = tooltip._popup + assert popup is not None + assert popup.GetRect().GetRight() <= display_rect.GetRight() + assert popup.GetRect().GetBottom() <= display_rect.GetBottom() + + +def test_popup_closes_when_frame_is_closed(wx_harness: WxTestHarness): + tooltip = WxHoverToolTip( + wx_harness.frame, + show_delay_ms=_SHOW_DELAY_MS, + ) + + # Position the mouse over a small anchor rect + anchor = _anchor_rect(wx_harness, 10, 10, 50, 20) + wx_harness.move_mouse_to(anchor.GetPosition(), delay_ms=0) + + # Show the tooltip + tooltip.notify_hover("hello world", anchor) + wx_harness.wait_ms(_SHOW_DELAY_MS + _MARGIN_MS) + + # Check that the tooltip is actually showing + popup = tooltip._popup + assert popup is not None + assert popup.IsShown() + + # Close the frame, which should also close the tooltip popup after the next poll tick + wx_harness.frame.Close() + wx_harness.wait_ms(_POLL_INTERVAL_MS + _MARGIN_MS) + + # Check that the tooltip popup is no longer showing after the frame is closed + popup = tooltip._popup + assert popup is None diff --git a/tests/wx_integration/wx_test_helpers.py b/tests/wx_integration/wx_test_helpers.py index fb49682..5fe4749 100644 --- a/tests/wx_integration/wx_test_helpers.py +++ b/tests/wx_integration/wx_test_helpers.py @@ -13,6 +13,11 @@ import wx import wx.grid as Grid +from construct_editor.core.entries import EntryConstruct +from construct_editor.core.model import ConstructEditorColumn +from construct_editor.wx_widgets.wx_construct_editor import ( + WxConstructEditor, +) from construct_editor.wx_widgets.wx_hex_editor import ( HexEditorBinaryData, HexEditorGrid, @@ -46,9 +51,7 @@ class WxTestHarness: @classmethod @contextlib.contextmanager - def create( - cls, app: wx.App, ui_simulator: wx.UIActionSimulator - ) -> t.Generator[WxTestHarness, None, None]: + def create(cls, app: wx.App, ui_simulator: wx.UIActionSimulator) -> t.Generator[WxTestHarness, None, None]: """Create a fresh top-level frame for a test, wrap it with `app` and `ui_simulator`, and tear it down again once the `with` block exits. @@ -64,14 +67,54 @@ def create( try: yield cls(app=app, frame=frame, ui_simulator=ui_simulator) finally: - frame.Destroy() - app.ProcessPendingEvents() + try: + frame.Destroy() + app.ProcessPendingEvents() + except RuntimeError: + # When the test already destroyed the frame (e.g. via `frame.Close()`), wx will raise + # a RuntimeError when we try to destroy it again. Ignore that, since the frame is already gone. + pass def move_mouse_to(self, point: wx.Point, delay_ms: int = 150) -> None: """Move the simulated mouse cursor to an absolute screen point.""" sim = self.ui_simulator self._run_steps([lambda: sim.MouseMove(point.x, point.y)], delay_ms) + def move_mouse_linear( + self, + point: wx.Point, + duration_ms: int = 150, + step_delay_ms: int = 10, + ) -> None: + """Move the simulated mouse cursor from its current position to + `point` in a straight line over `duration_ms`, in several + intermediate steps, instead of jumping there directly. + + The number of steps is derived from `duration_ms` and + `step_delay_ms` (i.e. how many `step_delay_ms`-sized slices fit into + `duration_ms`), rather than being specified directly. + + This more closely mimics how a real user moves the mouse, which + matters for widgets that react to mouse-move events along the way + (e.g. hover tooltips that should only appear once the cursor has + settled, or drag handling that tracks intermediate positions). + """ + sim = self.ui_simulator + start = wx.GetMousePosition() + + if step_delay_ms <= 0: + raise ValueError("step_delay_ms must be > 0") + steps = max(duration_ms // step_delay_ms, 1) + + def _make_step(step_index: int) -> t.Callable[[], t.Any]: + fraction = step_index / steps + x = round(start.x + (point.x - start.x) * fraction) + y = round(start.y + (point.y - start.y) * fraction) + return lambda: sim.MouseMove(x, y) + + move_steps = [_make_step(step_index) for step_index in range(1, steps + 1)] + self._run_steps(move_steps, step_delay_ms) + def click(self, delay_ms: int = 150) -> None: """Simulate a left mouse click at the current cursor position.""" self._run_steps([self.ui_simulator.MouseClick], delay_ms) @@ -96,12 +139,7 @@ def type_text(self, text: str, delay_ms: int = 150) -> None: """ sim = self.ui_simulator steps: list[t.Callable[[], t.Any]] = [ - ( - lambda ch=ch: sim.Char( - ord(ch.upper()), wx.MOD_SHIFT if ch.isupper() else wx.MOD_NONE - ) - ) - for ch in text + (lambda ch=ch: sim.Char(ord(ch.upper()), wx.MOD_SHIFT if ch.isupper() else wx.MOD_NONE)) for ch in text ] self._run_steps(steps, delay_ms) @@ -139,6 +177,18 @@ def _run_next_step() -> None: wx.CallLater(delay_ms, _run_next_step) app.MainLoop() + def wait_ms(self, ms: int) -> None: + """Run the wx.MainLoop for approximately `ms` milliseconds, then + return control to the test. + + Unlike wx.Yield()/ProcessPendingEvents(), this reliably lets pending + wx.CallLater/wx.Timer callbacks (e.g. the ones driving + WxHoverToolTip's show/hide delays) actually fire, since a real + MainLoop is running while waiting. + """ + wx.CallLater(ms, self.app.ExitMainLoop) + self.app.MainLoop() + def grid_cell_screen_point(grid: Grid.Grid, row: int, col: int) -> wx.Point: """Convert a wx.grid.Grid cell to an absolute screen point. @@ -208,9 +258,7 @@ def insert_byte_at_selection(grid: HexEditorGrid) -> bool: return grid._insert_byte_at_selection() # pyright: ignore[reportPrivateUsage] -def paste_at_selection( - grid: HexEditorGrid, overwrite: bool = False, insert: bool = False -) -> bool: +def paste_at_selection(grid: HexEditorGrid, overwrite: bool = False, insert: bool = False) -> bool: """Call HexEditorGrid's private `_paste(...)`.""" return grid._paste(overwrite=overwrite, insert=insert) # pyright: ignore[reportPrivateUsage] @@ -225,11 +273,25 @@ def trigger_select_cell(grid: HexEditorGrid, event: Grid.GridEvent) -> None: grid._on_select_cell(event) # pyright: ignore[reportPrivateUsage] -def trigger_range_selecting_keyboard( - grid: HexEditorGrid, row_diff: int = 0, col_diff: int = 0 -) -> None: +def trigger_range_selecting_keyboard(grid: HexEditorGrid, row_diff: int = 0, col_diff: int = 0) -> None: """Call HexEditorGrid's private `_on_range_selecting_keyboard(...)`.""" grid._on_range_selecting_keyboard( # pyright: ignore[reportPrivateUsage] row_diff=row_diff, col_diff=col_diff ) + +def construct_editor_cell_screen_rect(editor: WxConstructEditor, entry: EntryConstruct, column: ConstructEditorColumn) -> wx.Rect: + """Compute the on-screen rect of `entry`'s cell in `column`. + + Uses the exact same coordinate-space conversion as + WxConstructEditor._on_dvc_motion (via `self._dvc`, not + `self._dvc_main_window` - a previously fixed bug double-counted the + dvc header offset when the wrong window was used for this conversion, + shifting the tooltip down by roughly one row). + """ + dvc = editor._dvc # pyright: ignore[reportPrivateUsage] + model = editor._model # pyright: ignore[reportPrivateUsage] + item = model.entry_to_dvc_item(entry) + col = dvc.GetColumn(column) + cell_rect: wx.Rect = dvc.GetItemRect(item, col) + return wx.Rect(dvc.ClientToScreen(cell_rect.GetPosition()), cell_rect.GetSize())