From 68946a7f1db07dbb09d2f5da0619e59f1f74b472 Mon Sep 17 00:00:00 2001 From: Daniel Banariba Date: Thu, 13 Aug 2026 16:48:48 -0600 Subject: [PATCH] ui: show the Bolt passkey as a guided click sequence The passkey page rendered the whole sequence as one run-on sentence ("Press right, right, left, ... and then press left and right buttons simultaneously."), which is hard to follow and impossible to keep a place in. Draw it instead as eleven numbered steps: two rows of five top-down mouse glyphs plus the final both-buttons step, set apart below a separator and drawn larger so it cannot be mistaken for another single click. Every cell always shows which button it needs, so the sequence can be read ahead; the emphasis is a separate channel that only says how far the receiver has got. Keyboards reuse the same strip with digit keys and an enter key as the final step, and keep showing the passcode as text since it is meant to be read. The strip is drawn with cairo in the theme's own colours, so it needs no new assets and stays legible in any theme. A drawing area is invisible to screen readers, so the sequence stays available as the existing sentence through the tooltip and the accessible description. The bit to button mapping is deliberately unchanged. The receiver never reports which button was pressed, so a different polarity cannot be validated from inside Solaar, and the page says as much rather than implying it checked. Without progress notifications the strip degrades to the plain numbered sequence with nothing highlighted, so it never depends on them. Also fixes two defects in the same path: - the passkey page was appended again on every 500 ms check, stacking dozens of duplicate pages during a normal entry and scrolling the pairing instructions out of view. It is now created once and refreshed in place. - pair_device was re-issued every 500 ms for as long as a discovered device was pending. It is now issued once per discovered address. --- lib/solaar/ui/pair_window.py | 359 +++++++++++++++++++++++++++- tests/solaar/ui/test_pair_window.py | 157 ++++++++++++ 2 files changed, 504 insertions(+), 12 deletions(-) diff --git a/lib/solaar/ui/pair_window.py b/lib/solaar/ui/pair_window.py index c89d7470..1b4eeb20 100644 --- a/lib/solaar/ui/pair_window.py +++ b/lib/solaar/ui/pair_window.py @@ -16,7 +16,10 @@ ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import logging +import math +import time +from dataclasses import dataclass from enum import Enum from gi.repository import GLib @@ -32,11 +35,40 @@ logger = logging.getLogger(__name__) _PAIRING_TIMEOUT = 30 # seconds _STATUS_CHECK = 500 # milliseconds +_PROGRESS_GRACE = 3 # seconds to wait for a first progress notification before giving up on them + +# steps the user has to perform to enter a passkey +_STEP_LEFT = "left" +_STEP_RIGHT = "right" +_STEP_BOTH = "both" +_STEP_ENTER = "enter" + +# widget state kept between the periodic checks, stashed on the widgets themselves +_PASSKEY_PAGE = "_solaar_passkey_page" +_PAIRED_ADDRESS = "_solaar_paired_address" +_ON_RETRY = "_solaar_on_retry" +_STEPS = "_solaar_steps" +_PROGRESS = "_solaar_progress" + +# step strip metrics, in pixels +_CELL_WIDTH = 28 +_CELL_HEIGHT = 38 +_CELL_GAP = 8 +_CELL_RADIUS = 9 +_NUMBER_GAP = 6 +_NUMBER_HEIGHT = 12 +_ROW_GAP = 8 +_SEPARATOR_GAP = 10 +_STRIP_MARGIN = 8 +_STEPS_PER_ROW = 5 +_FINAL_STEP_SCALE = 1.25 class GtkSignal(Enum): CANCEL = "cancel" CLOSE = "close" + CLICKED = "clicked" + DRAW = "draw" def create(receiver): @@ -124,7 +156,10 @@ def _check_lock_state(assistant, receiver, count): return True elif receiver.pairing.discovering and receiver.pairing.device_address and receiver.pairing.device_name: add = receiver.pairing.device_address + if getattr(assistant, _PAIRED_ADDRESS, None) == add: + return True # pairing was already requested for this device ent = 20 if receiver.pairing.device_kind == hidpp10_constants.DEVICE_KIND.keyboard else 10 + setattr(assistant, _PAIRED_ADDRESS, add) if receiver.pair_device(address=add, authentication=receiver.pairing.device_authentication, entropy=ent): return True else: @@ -160,22 +195,270 @@ def _finish(assistant, receiver): receiver.pairing.error = None +def _passkey_steps(passkey, authentication): + """Returns the ordered steps needed to enter the passkey on the device. + + Mice and touchpads take the passkey as ten button presses followed by a + simultaneous press of both buttons, keyboards as its digits followed by the + enter key. Returns None when the passkey cannot be interpreted, so that + callers running inside a periodic check never have to handle an exception. + """ + if passkey is None: + return None + if authentication & 0x01: # keyboards spell the passcode out + return [character for character in str(passkey)] + [_STEP_ENTER] + try: + bits = f"{int(passkey):010b}" + except (TypeError, ValueError): + return None + # keep this mapping as it is: the receiver never reports which button was + # pressed, so a changed polarity cannot be validated from inside Solaar + return [_STEP_RIGHT if bit == "1" else _STEP_LEFT for bit in bits] + [_STEP_BOTH] + + +def _passkey_description(steps, passkey, authentication): + """Renders the steps as the sentence used for the tooltip and for screen readers.""" + if authentication & 0x01: + return _("Type %(passcode)s and then press the enter key.") % {"passcode": passkey} + passcode = ", ".join(_("right") if step == _STEP_RIGHT else _("left") for step in steps[:-1]) + return _("Press %(code)s\nand then press left and right buttons simultaneously.") % {"code": passcode} + + +@dataclass +class _StepCell: + """Placement of a single step inside the step strip.""" + + x: float + y: float + width: float + height: float + step: str + index: int + + +def _step_strip_cells(steps): + """Lays the steps out in rows, with the final step alone below a separator.""" + leading = steps[:-1] # the final step gets a band of its own, below the separator + rows = [leading[index : index + _STEPS_PER_ROW] for index in range(0, len(leading), _STEPS_PER_ROW)] + columns = max((len(row) for row in rows), default=1) + width = 2 * _STRIP_MARGIN + columns * _CELL_WIDTH + (columns - 1) * _CELL_GAP + row_height = _CELL_HEIGHT + _NUMBER_GAP + _NUMBER_HEIGHT + cells = [] + index = 0 + y = float(_STRIP_MARGIN) + for row in rows: + row_width = len(row) * _CELL_WIDTH + (len(row) - 1) * _CELL_GAP + x = (width - row_width) / 2 + for step in row: + cells.append(_StepCell(x, y, _CELL_WIDTH, _CELL_HEIGHT, step, index)) + x += _CELL_WIDTH + _CELL_GAP + index += 1 + y += row_height + _ROW_GAP + separator_y = y - _ROW_GAP + _SEPARATOR_GAP + final_width = _CELL_WIDTH * _FINAL_STEP_SCALE + final_height = _CELL_HEIGHT * _FINAL_STEP_SCALE + final_y = separator_y + _SEPARATOR_GAP + cells.append(_StepCell((width - final_width) / 2, final_y, final_width, final_height, steps[-1], index)) + height = final_y + final_height + _NUMBER_GAP + _NUMBER_HEIGHT + _STRIP_MARGIN + return cells, separator_y, width, height + + +def _strip_accent_color(style): + """Takes the highlight colour from the theme, so the strip stays legible everywhere.""" + found, color = style.lookup_color("theme_selected_bg_color") + if found: + return color + color = style.get_color(Gtk.StateFlags.SELECTED) + if color is not None: + return color + return style.get_color(Gtk.StateFlags.NORMAL) + + +def _rounded_rectangle(cr, x, y, width, height, radius): + radius = min(radius, width / 2, height / 2) + cr.new_sub_path() + cr.arc(x + width - radius, y + radius, radius, -0.5 * math.pi, 0.0) + cr.arc(x + width - radius, y + height - radius, radius, 0.0, 0.5 * math.pi) + cr.arc(x + radius, y + height - radius, radius, 0.5 * math.pi, math.pi) + cr.arc(x + radius, y + radius, radius, math.pi, 1.5 * math.pi) + cr.close_path() + + +def _draw_mouse_cell(cr, cell, accent, outline, fill_alpha, outline_alpha): + """Draws a mouse seen from above, with the buttons this step needs filled in.""" + radius = _CELL_RADIUS * cell.width / _CELL_WIDTH + split_y = cell.y + cell.height * 0.42 + split_x = cell.x + cell.width / 2 + cr.save() + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.clip() + cr.set_source_rgba(accent.red, accent.green, accent.blue, fill_alpha) + if cell.step in (_STEP_LEFT, _STEP_BOTH): + cr.rectangle(cell.x, cell.y, cell.width / 2, split_y - cell.y) + cr.fill() + if cell.step in (_STEP_RIGHT, _STEP_BOTH): + cr.rectangle(split_x, cell.y, cell.width / 2, split_y - cell.y) + cr.fill() + cr.restore() + wheel_width = cell.width * 0.20 + wheel_height = cell.height * 0.28 + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.stroke() + cr.move_to(cell.x, split_y) + cr.line_to(cell.x + cell.width, split_y) + cr.stroke() + cr.move_to(split_x, cell.y) + cr.line_to(split_x, split_y - wheel_height / 2) + cr.stroke() + _rounded_rectangle(cr, split_x - wheel_width / 2, split_y - wheel_height / 2, wheel_width, wheel_height, wheel_width / 2) + cr.stroke() + + +def _draw_key_cell(cr, cell, accent, outline, fill_alpha, outline_alpha): + """Draws a key cap carrying either a digit of the passcode or an enter arrow.""" + radius = _CELL_RADIUS * cell.width / _CELL_WIDTH / 2 + cr.set_source_rgba(accent.red, accent.green, accent.blue, fill_alpha) + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.fill() + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + _rounded_rectangle(cr, cell.x, cell.y, cell.width, cell.height, radius) + cr.stroke() + if cell.step == _STEP_ENTER: + # an arrow pointing down and then left, the usual shape of an enter key + top = cell.y + cell.height * 0.32 + bottom = cell.y + cell.height * 0.62 + left = cell.x + cell.width * 0.28 + right = cell.x + cell.width * 0.72 + cr.move_to(right, top) + cr.line_to(right, bottom) + cr.line_to(left, bottom) + cr.stroke() + head = cell.width * 0.14 + cr.move_to(left + head, bottom - head) + cr.line_to(left, bottom) + cr.line_to(left + head, bottom + head) + cr.stroke() + else: + cr.set_font_size(cell.height * 0.46) + extents = cr.text_extents(cell.step) + cr.move_to( + cell.x + (cell.width - extents.width) / 2 - extents.x_bearing, + cell.y + (cell.height - extents.height) / 2 - extents.y_bearing, + ) + cr.show_text(cell.step) + + +def _draw_check_mark(cr, cell, outline, outline_alpha): + """Marks a step the receiver has already accepted.""" + size = cell.width * 0.22 + x = cell.x + cell.width * 0.72 + y = cell.y + cell.height * 0.78 + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + cr.move_to(x - size, y) + cr.line_to(x - size / 3, y + size * 0.7) + cr.line_to(x + size, y - size * 0.8) + cr.stroke() + + +def _draw_step_strip(area, cr): + """Paints which button every step needs, and how far entry has got. + + The two are kept apart on purpose: the filled quadrant always says which + button to press, and only the emphasis says where the user is. + """ + steps = getattr(area, _STEPS, None) + if not steps: + return False + progress = getattr(area, _PROGRESS, None) + # without progress notifications no step is singled out, and the strip is + # simply the whole sequence, numbered and always readable + done = 0 if progress is None else min(progress, len(steps)) + current = None if progress is None else min(progress, len(steps) - 1) + cells, separator_y, width, _height = _step_strip_cells(steps) + style = area.get_style_context() + accent = _strip_accent_color(style) + outline = style.get_color(Gtk.StateFlags.NORMAL) + is_mouse = steps[-1] == _STEP_BOTH + cr.save() + cr.translate(max(0, (area.get_allocated_width() - width) / 2), 0) + cr.set_line_width(1.0) + cr.set_line_join(0) # cairo.LINE_JOIN_MITER, spelled out to avoid importing cairo + cr.select_font_face("sans-serif") + cr.set_source_rgba(outline.red, outline.green, outline.blue, 0.25) + cr.move_to(_STRIP_MARGIN, separator_y) + cr.line_to(width - _STRIP_MARGIN, separator_y) + cr.stroke() + for cell in cells: + if cell.index < done: + fill_alpha, outline_alpha = 0.45, 0.35 + elif cell.index == current: + fill_alpha, outline_alpha = 1.0, 1.0 + else: + fill_alpha, outline_alpha = 0.22, 0.55 + if cell.index == current: + cr.set_source_rgba(accent.red, accent.green, accent.blue, 1.0) + cr.set_line_width(2.0) + _rounded_rectangle(cr, cell.x - 3, cell.y - 3, cell.width + 6, cell.height + 6, _CELL_RADIUS) + cr.stroke() + cr.set_line_width(1.0) + if is_mouse: + _draw_mouse_cell(cr, cell, accent, outline, fill_alpha, outline_alpha) + else: + _draw_key_cell(cr, cell, accent, outline, fill_alpha, outline_alpha) + if cell.index < done: + _draw_check_mark(cr, cell, outline, 0.8) + number = str(cell.index + 1) + cr.set_font_size(_NUMBER_HEIGHT) + cr.set_source_rgba(outline.red, outline.green, outline.blue, outline_alpha) + extents = cr.text_extents(number) + cr.move_to( + cell.x + (cell.width - extents.width) / 2 - extents.x_bearing, + cell.y + cell.height + _NUMBER_GAP - extents.y_bearing, + ) + cr.show_text(number) + cr.restore() + return False + + +def _create_step_strip(steps, description): + area = Gtk.DrawingArea() + setattr(area, _STEPS, steps) + setattr(area, _PROGRESS, 0) + _cells, _separator_y, width, height = _step_strip_cells(steps) + area.set_size_request(width, int(math.ceil(height))) + area.connect(GtkSignal.DRAW.value, _draw_step_strip) + # a drawing area is invisible to screen readers, so the sequence has to + # stay available as text as well + area.set_tooltip_text(description) + area.get_accessible().set_description(description) + return area + + def _show_passcode(assistant, receiver, passkey): + """Shows the passkey page, creating it on the first check and refreshing it afterwards.""" + page = getattr(assistant, _PASSKEY_PAGE, None) + if page is None: + page = _create_passcode_page(assistant, receiver, passkey) + setattr(assistant, _PASSKEY_PAGE, page) + assistant.set_page_complete(page, True) + assistant.next_page() + _update_passcode_page(page, receiver) + + +def _create_passcode_page(assistant, receiver, passkey): logger.debug("%s show passkey: %s", receiver, passkey) name = receiver.pairing.device_name authentication = receiver.pairing.device_authentication intro_text = _("%(receiver_name)s: pair new device") % {"receiver_name": receiver.name} page_text = _("Enter passcode on %(name)s.") % {"name": name} - page_text += "\n" - if authentication & 0x01: - page_text += _("Type %(passcode)s and then press the enter key.") % { - "passcode": receiver.pairing.device_passkey, - } - else: - passcode = ", ".join( - [_("right") if bit == "1" else _("left") for bit in f"{int(receiver.pairing.device_passkey):010b}"] - ) - page_text += _("Press %(code)s\nand then press left and right buttons simultaneously.") % {"code": passcode} + steps = _passkey_steps(passkey, authentication) + if steps is None: # unreadable passkey, so fall back to the plain instructions + page_text += "\n" + page_text += _("Type %(passcode)s and then press the enter key.") % {"passcode": passkey} + elif authentication & 0x01: # for keyboards the passcode is meant to be read + page_text += "\n" + page_text += _passkey_description(steps, passkey, authentication) page = _create_page( assistant, Gtk.AssistantPageType.PROGRESS, @@ -183,8 +466,60 @@ def _show_passcode(assistant, receiver, passkey): "preferences-desktop-peripherals", page_text, ) - assistant.set_page_complete(page, True) - assistant.next_page() + if steps is None: + return page + strip = _create_step_strip(steps, _passkey_description(steps, passkey, authentication)) + page.pack_start(strip, False, False, 0) + status = Gtk.Label(label=_("Waiting for the first click…")) + status.set_line_wrap(True) + page.pack_start(status, False, False, 0) + if steps[-1] == _STEP_BOTH: + # the receiver reports that a button was pressed but never which one, + # so say so rather than let a counted click look like a checked one + honesty = Gtk.Label(label=_("Solaar cannot tell which button was pressed — only that the receiver accepted a click.")) + honesty.set_line_wrap(True) + honesty.get_style_context().add_class("dim-label") + page.pack_start(honesty, False, False, 0) + reminder = Gtk.Label(label=_("Keep the device switched on and within range until pairing finishes.")) + reminder.set_line_wrap(True) + reminder.get_style_context().add_class("dim-label") + page.pack_start(reminder, False, False, 0) + page.show_all() + page.strip = strip + page.status = status + page.started = time.monotonic() + return page + + +def _update_passcode_page(page, receiver): + """Refreshes the page in place, so that the periodic check never appends another one.""" + strip = getattr(page, "strip", None) + if strip is None: + return + steps = getattr(strip, _STEPS) + total = len(steps) - 1 # the receiver counts presses, not the final combined one + entered = receiver.pairing.passkey_entered + if receiver.pairing.passkey_complete: + progress = len(steps) + text = _("The receiver is checking the sequence…") + elif entered > 0: + progress = entered + if entered >= total and steps[-1] == _STEP_BOTH: + text = _("Finally, press the left and right buttons at the same time.") + else: + text = _("Clicks registered by the receiver: %(done)d of %(total)d") % { + "done": min(entered, total), + "total": total, + } + elif time.monotonic() - page.started < _PROGRESS_GRACE: + progress = 0 + text = _("Waiting for the first click…") + else: # this receiver never reports progress, so highlight nothing at all + progress = None + text = _("This receiver does not report entry progress. Follow the numbered steps in order.") + page.status.set_label(text) + setattr(strip, _PROGRESS, progress) + strip.queue_draw() def _create_assistant(receiver, ok, finish, title, text): diff --git a/tests/solaar/ui/test_pair_window.py b/tests/solaar/ui/test_pair_window.py index f31f4801..1e2cefa0 100644 --- a/tests/solaar/ui/test_pair_window.py +++ b/tests/solaar/ui/test_pair_window.py @@ -243,3 +243,160 @@ def test_create_failure_page(error, mocker): pair_window._pairing_failed(Assistant(True), Receiver("nano", "nano"), error) assert spy_create.call_count == 1 + + +@pytest.mark.parametrize( + "passkey, authentication, expected", + [ + ("50", 0x02, ["left"] * 4 + ["right"] * 2 + ["left"] * 2 + ["right"] + ["left"] + ["both"]), + (50, 0x02, ["left"] * 4 + ["right"] * 2 + ["left"] * 2 + ["right"] + ["left"] + ["both"]), + ("0", 0x02, ["left"] * 10 + ["both"]), + ("1023", 0x02, ["right"] * 10 + ["both"]), + ("000918", 0x01, ["0", "0", "0", "9", "1", "8", "enter"]), + ("abcdef", 0x02, None), + ("", 0x02, None), + (None, 0x02, None), + ], +) +def test_passkey_steps(passkey, authentication, expected): + assert pair_window._passkey_steps(passkey, authentication) == expected + + +def test_passkey_steps_bit_polarity(): + """The receiver never reports which button was pressed, so this mapping cannot be + validated from inside Solaar and must not be flipped without hardware evidence.""" + steps = pair_window._passkey_steps(f"{0b1010101010:d}", 0x02) + + assert steps == ["right", "left"] * 5 + ["both"] + + +@pytest.mark.parametrize("steps, expected_cells", [(["left"] * 10 + ["both"], 11), (["1", "2", "enter"], 3)]) +def test_step_strip_cells(steps, expected_cells): + cells, separator_y, width, height = pair_window._step_strip_cells(steps) + + assert [cell.step for cell in cells] == steps + assert [cell.index for cell in cells] == list(range(expected_cells)) + assert cells[-1].width > cells[0].width # the final step is drawn larger + assert cells[-1].y > separator_y # and below the separator + assert width > 0 and height > cells[-1].y + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_appends_a_single_page(): + """The periodic check keeps running while the passkey is shown, so the page has to + be created once and refreshed afterwards instead of being appended again.""" + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02), + ) + assistant = Assistant(True) + + first = pair_window._check_lock_state(assistant, r, 0) + second = pair_window._check_lock_state(assistant, r, 0) + + assert first is True + assert second is True + assert len(assistant.pages) == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_updates_status_in_place(): + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02), + ) + assistant = Assistant(True) + + pair_window._check_lock_state(assistant, r, 0) + page = getattr(assistant, pair_window._PASSKEY_PAGE) + waiting = page.status.get_label() + + r.pairing.passkey_entered = 4 + pair_window._check_lock_state(assistant, r, 0) + counted = page.status.get_label() + + r.pairing.passkey_complete = True + pair_window._check_lock_state(assistant, r, 0) + checking = page.status.get_label() + + assert waiting != counted != checking + assert "4" in counted + assert getattr(page.strip, pair_window._PROGRESS) == len(getattr(page.strip, pair_window._STEPS)) + assert len(assistant.pages) == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_clamps_overshoot(): + """The receiver owns the verdict, so more presses than steps must not raise.""" + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="50", device_authentication=0x02, passkey_entered=99), + ) + assistant = Assistant(True) + + assert pair_window._check_lock_state(assistant, r, 0) is True + + page = getattr(assistant, pair_window._PASSKEY_PAGE) + assert getattr(page.strip, pair_window._PROGRESS) == 99 # clamped when drawn, not when stored + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_show_passcode_survives_unreadable_passkey(): + r = Receiver( + "passcode", + "bolt", + True, + receiver.Pairing(lock_open=True, device_passkey="oops", device_authentication=0x02), + ) + assistant = Assistant(True) + + assert pair_window._check_lock_state(assistant, r, 0) is True + assert pair_window._check_lock_state(assistant, r, 0) is True + assert len(assistant.pages) == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_pair_device_issued_once(mocker): + r = Receiver("discovered", "bolt", True, receiver.Pairing(discovering=True, device_address=2, device_name=5)) + spy_pair_device = mocker.spy(r, "pair_device") + assistant = Assistant(True) + + first = pair_window._check_lock_state(assistant, r, 2) + second = pair_window._check_lock_state(assistant, r, 2) + + assert first is True + assert second is True + assert spy_pair_device.call_count == 1 + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +@pytest.mark.parametrize("progress", [None, 0, 5, 10, 11, 15]) +@pytest.mark.parametrize("passkey, authentication", [("50", 0x02), ("000918", 0x01)]) +def test_draw_step_strip(passkey, authentication, progress): + """Draws against an image surface, so cairo misuse and a bad highlight clamp are + caught without needing a display.""" + import cairo + + steps = pair_window._passkey_steps(passkey, authentication) + strip = pair_window._create_step_strip(steps, "sequence") + _cells, _separator_y, width, height = pair_window._step_strip_cells(steps) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, int(width), int(height)) + setattr(strip, pair_window._PROGRESS, progress) + + assert pair_window._draw_step_strip(strip, cairo.Context(surface)) is False + + +@pytest.mark.skipif(not gtk_init, reason="requires Gtk") +def test_draw_step_strip_without_steps(): + import cairo + + strip = Gtk.DrawingArea() + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) + + assert pair_window._draw_step_strip(strip, cairo.Context(surface)) is False