From d694c4b9e239ec7d6f4c3e760da74ebd27ae647e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 19 Sep 2026 20:30:26 +0200 Subject: [PATCH 1/4] Fixed: the Copy button's label restored on the render thread --- docs/development/application/render-thread.md | 10 +- .../utils/callbacks/delay.py | 26 ++++ .../utils/gui/clipboard.py | 19 +-- tests/suite/application.py | 49 ++++++- .../utils/callbacks/conftest.py | 3 + .../utils/callbacks/test_delay.py | 105 ++++++++++++++ .../utils/callbacks/test_queue_reliability.py | 19 +-- .../utils/gui/conftest.py | 3 + .../utils/gui/test_clipboard.py | 136 ++++++++++++++++++ 9 files changed, 342 insertions(+), 28 deletions(-) create mode 100644 src/sampletones_application/utils/callbacks/delay.py create mode 100644 tests/unit/sampletones_application/utils/callbacks/conftest.py create mode 100644 tests/unit/sampletones_application/utils/callbacks/test_delay.py create mode 100644 tests/unit/sampletones_application/utils/gui/conftest.py create mode 100644 tests/unit/sampletones_application/utils/gui/test_clipboard.py diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index be6d440f0..feddec0da 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -4,7 +4,7 @@ This document describes how work reaches DearPyGui from somewhere other than the its context, and what each crossing costs. It governs `utils/gui/render_thread.py`, `utils/gui/callbacks.py`, `utils/gui/frame.py`, and the queue in `utils/callbacks/`. Consult it when a worker thread has something to show, when a gesture rebuilds widgets, or when work needs a frame -to have been drawn first. +to have been drawn first or a span of time to have passed. The design truth it realizes is principle 6 of [`architecture.md`](../architecture.md): DearPyGui's context belongs to the render thread. This document holds the mechanism. @@ -68,3 +68,11 @@ The drain is what makes the wait a scheduled one. The render thread inside a dra rather than inside one, which makes the next frame the drain's own to reach, so `dpg.split_frame` there waits for what the wait itself prevents and the application stops for good. Naming a frame count asks for the same thing and lets the loop keep running. + +## Work that waits for time reads the clock at each drain + +The frame rate is the reader's to set, so a wait measured in seconds is read against the clock. +`utils/callbacks/delay.py::call_after` posts a check to the queue each frame and runs the work at the +first drain past its deadline, on the render thread like any other queued result. The wait travels +through the queue, so the shutdown that stops the queue before the context goes drops a wait still +under way. The Copy buttons restore their label this way. diff --git a/src/sampletones_application/utils/callbacks/delay.py b/src/sampletones_application/utils/callbacks/delay.py new file mode 100644 index 000000000..9c099bd56 --- /dev/null +++ b/src/sampletones_application/utils/callbacks/delay.py @@ -0,0 +1,26 @@ +import time +from typing import Final + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import VoidCallback + +NEXT_FRAME: Final[int] = 1 + + +def call_after(seconds: float, callback: VoidCallback) -> None: + """Runs ``callback`` on the render thread once ``seconds`` have passed. + + The wait is read against the clock at each frame's drain, so it lasts the same span at any frame + rate, and the callback runs on the thread that owns the widgets it reaches. The wait travels + through the queue, so the shutdown that stops the queue drops a wait still under way. + """ + deadline = time.monotonic() + seconds + CallbackQueue.add(_call_when_due, deadline, callback, delay=NEXT_FRAME) + + +def _call_when_due(deadline: float, callback: VoidCallback) -> None: + if time.monotonic() < deadline: + CallbackQueue.add(_call_when_due, deadline, callback, delay=NEXT_FRAME) + return + + callback() diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py index 9b26290ce..139cdd567 100644 --- a/src/sampletones_application/utils/gui/clipboard.py +++ b/src/sampletones_application/utils/gui/clipboard.py @@ -1,10 +1,13 @@ -import threading -from typing import Protocol, cast +from functools import partial +from typing import Final, Protocol, cast import dearpygui.dearpygui as dpg +from sampletones_application.utils.callbacks.delay import call_after from sampletones_application.utils.gui.dpg import dpg_configure_item +COPIED_LABEL_SECONDS: Final[float] = 1.0 + class TextClipboard(Protocol): """The clipboard the desktop shares between applications, as text going out and coming back.""" @@ -31,12 +34,12 @@ def copy_to_clipboard( *, copied_label: str, ) -> None: + """Puts ``text`` on the clipboard, with the button reading ``copied_label`` for a moment. + + The button's own label comes back on the render thread once the moment has passed, the thread + the button lives on. + """ SystemTextClipboard().write(text) dpg_configure_item(button_tag, label=copied_label) - - def restore_label() -> None: - dpg_configure_item(button_tag, label=label) - - timer = threading.Timer(1.0, restore_label) - timer.start() + call_after(COPIED_LABEL_SECONDS, partial(dpg_configure_item, button_tag, label=label)) diff --git a/tests/suite/application.py b/tests/suite/application.py index e34cb868b..100432b75 100644 --- a/tests/suite/application.py +++ b/tests/suite/application.py @@ -1,4 +1,4 @@ -from typing import Any, Iterator, List, Tuple +from typing import Any, Final, Iterator, List, Tuple from unittest.mock import patch import pytest @@ -15,6 +15,11 @@ from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_shared.types.callback import Callback, VoidCallback +REAL_QUEUE_ADD: Final = CallbackQueue.add +FRAME_BUDGET_SECONDS: Final[float] = 1.0 +DELAY_CLOCK: Final[str] = "sampletones_application.utils.callbacks.delay.time.monotonic" +CLOCK_START: Final[float] = 100.0 + @pytest.fixture(autouse=True) def synchronous_queue() -> Iterator[None]: @@ -94,3 +99,45 @@ def held_queue(monkeypatch: pytest.MonkeyPatch) -> HeldQueue: queue = HeldQueue() monkeypatch.setattr(CallbackQueue, "add", queue.add) return queue + + +@pytest.fixture +def live_queue() -> Iterator[None]: + """The real ``CallbackQueue``, live and empty for the case and stopped once it ends. + + The queue belongs to the whole process, so a case before this one may leave work pending or the + queue stopped. The real ``add`` is pinned over the call-through ``synchronous_queue`` installs, + so what a case queues waits for a drain the way it does in the render loop. + """ + with patch.object(CallbackQueue, "add", REAL_QUEUE_ADD): + CallbackQueue.stop() + CallbackQueue.start() + yield + CallbackQueue.stop() + + +def draw_frame() -> None: + """One frame of the render loop as the queue meets it: the count moves on, then the drain.""" + CallbackQueue.notify_frame() + CallbackQueue.process(FRAME_BUDGET_SECONDS) + + +class ManualClock: + """The monotonic clock a wait on the render thread reads, moved by the case alone.""" + + def __init__(self, now: float) -> None: + self.now = now + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +@pytest.fixture +def delay_clock(monkeypatch: pytest.MonkeyPatch) -> ManualClock: + """The clock ``call_after`` measures its wait by, standing still until the case moves it.""" + clock = ManualClock(CLOCK_START) + monkeypatch.setattr(DELAY_CLOCK, clock) + return clock diff --git a/tests/unit/sampletones_application/utils/callbacks/conftest.py b/tests/unit/sampletones_application/utils/callbacks/conftest.py new file mode 100644 index 000000000..2fc42345f --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import delay_clock, live_queue + +__all__ = ["delay_clock", "live_queue"] diff --git a/tests/unit/sampletones_application/utils/callbacks/test_delay.py b/tests/unit/sampletones_application/utils/callbacks/test_delay.py new file mode 100644 index 000000000..68b2488ba --- /dev/null +++ b/tests/unit/sampletones_application/utils/callbacks/test_delay.py @@ -0,0 +1,105 @@ +import threading +from dataclasses import dataclass +from typing import Final, List + +import pytest + +from sampletones_application.utils.callbacks.delay import call_after +from sampletones_application.utils.callbacks.queue import CallbackQueue +from tests.suite.application import ManualClock, draw_frame +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +WAIT_SECONDS: Final[float] = 1.0 +SHORT_OF_THE_WAIT: Final[float] = 0.999 +FRAMES_AFTER: Final[int] = 5 + + +class Calls: + """The threads a callback ran on, one entry per run.""" + + def __init__(self) -> None: + self.threads: List[threading.Thread] = [] + + def __call__(self) -> None: + self.threads.append(threading.current_thread()) + + +@pytest.mark.usefixtures("live_queue") +class TestTheWaitRunsOnTheRenderThread(BaseTestSuite): + """A wait handed to ``call_after`` ends in one call, made by the thread draining the queue.""" + + def test_nothing_runs_while_the_wait_stands(self, delay_clock: ManualClock) -> None: + calls = Calls() + + call_after(WAIT_SECONDS, calls) + delay_clock.advance(SHORT_OF_THE_WAIT) + draw_frame() + + assert calls.threads == [] + + def test_the_first_frame_after_the_wait_runs_it_once(self, delay_clock: ManualClock) -> None: + calls = Calls() + + call_after(WAIT_SECONDS, calls) + delay_clock.advance(WAIT_SECONDS) + for _ in range(FRAMES_AFTER): + draw_frame() + + assert calls.threads == [threading.current_thread()] + + def test_the_wait_starts_no_thread(self, delay_clock: ManualClock) -> None: + standing = set(threading.enumerate()) + + call_after(WAIT_SECONDS, Calls()) + + assert set(threading.enumerate()) == standing + + def test_a_stopped_queue_drops_the_wait(self, delay_clock: ManualClock) -> None: + """The shutdown stops the queue before the context goes, so a wait under way never lands.""" + calls = Calls() + + call_after(WAIT_SECONDS, calls) + CallbackQueue.stop() + delay_clock.advance(WAIT_SECONDS) + CallbackQueue.start() + for _ in range(FRAMES_AFTER): + draw_frame() + + assert calls.threads == [] + + +@pytest.mark.usefixtures("live_queue") +class TestTheWaitLastsTheSameSpanAtAnyFrameRate(BaseTestSuite): + """The frame rate is the reader's to set, so the wait is read against the clock.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + frames_during_the_wait: int + + test_cases = ( + TestCase(label="one_frame", frames_during_the_wait=1), + TestCase(label="a_frame_rate_limited_display", frames_during_the_wait=60), + TestCase(label="an_unlimited_frame_rate", frames_during_the_wait=2000), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_runs_once_the_clock_reaches_the_end( + self, + test_case: TestCase, + delay_clock: ManualClock, + ) -> None: + calls = Calls() + step = SHORT_OF_THE_WAIT / test_case.frames_during_the_wait + + call_after(WAIT_SECONDS, calls) + for _ in range(test_case.frames_during_the_wait): + delay_clock.advance(step) + draw_frame() + + assert calls.threads == [] + + delay_clock.advance(WAIT_SECONDS) + draw_frame() + + assert len(calls.threads) == 1 diff --git a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py index 50c4711aa..31bdd1ed1 100644 --- a/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py +++ b/tests/unit/sampletones_application/utils/callbacks/test_queue_reliability.py @@ -1,31 +1,14 @@ from typing import List -from unittest.mock import patch import pytest from sampletones_application.utils.callbacks.priority import CallbackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -_real_queue_add = CallbackQueue.add - GENEROUS_BUDGET = 1.0 DRAIN_ONE_BUDGET = 0.0 - -@pytest.fixture(autouse=True) -def fresh_queue(): - """Give each test a clean, live CallbackQueue. - - CallbackQueue is a process-global singleton, so a prior test may leave tasks - pending or the queue stopped. stop() clears the heap and marks it stopped, - then start() marks it live again; teardown stops it. The add() patch pins the - real heap-based add() over any call-through stub other suites install. - """ - with patch.object(CallbackQueue, "add", _real_queue_add): - CallbackQueue.stop() - CallbackQueue.start() - yield - CallbackQueue.stop() +pytestmark = pytest.mark.usefixtures("live_queue") class TestCallbackQueueDelivery: diff --git a/tests/unit/sampletones_application/utils/gui/conftest.py b/tests/unit/sampletones_application/utils/gui/conftest.py new file mode 100644 index 000000000..2fc42345f --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/conftest.py @@ -0,0 +1,3 @@ +from tests.suite.application import delay_clock, live_queue + +__all__ = ["delay_clock", "live_queue"] diff --git a/tests/unit/sampletones_application/utils/gui/test_clipboard.py b/tests/unit/sampletones_application/utils/gui/test_clipboard.py new file mode 100644 index 000000000..fac29ff83 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_clipboard.py @@ -0,0 +1,136 @@ +import threading +from typing import Final, Iterator, List +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.themes.items import ThemeItems +from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.clipboard import ( + COPIED_LABEL_SECONDS, + SystemTextClipboard, + copy_to_clipboard, +) +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from tests.suite.application import ManualClock, draw_frame +from tests.suite.base import BaseTestSuite + +WINDOW: Final[str] = "probe.window" +BUTTON: Final[str] = "probe.window.button.copy" +THEME: Final[str] = "probe.theme" +LABEL: Final[str] = "Copy" +COPIED: Final[str] = "Copied" +TEXT: Final[str] = "the text a reader copies" +QUEUE_LOGGER: Final[str] = "sampletones_application.utils.callbacks.queue.logger" + + +@pytest.fixture +def written(monkeypatch: pytest.MonkeyPatch) -> List[str]: + """What reaches the desktop's clipboard, held here so a case leaves the reader's own alone.""" + texts: List[str] = [] + monkeypatch.setattr(SystemTextClipboard, "write", lambda _clipboard, text: texts.append(text)) + return texts + + +@pytest.fixture +def copy_button() -> Iterator[GUIButton]: + """A Copy button standing in a window, built the way the traceback and the instruments build theirs.""" + layout_config = load_layout_config( + LAYOUT_DIRECTORY, + BEHAVIOR_DIRECTORY, + PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default), + ) + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + try: + with dpg.window(tag=WINDOW): + button = GUIButton( + tag=BUTTON, + label=LABEL, + parent=WINDOW, + theme=Theme(tag=THEME, items=ThemeItems()), + ) + + yield button + finally: + GUIButton.delete(BUTTON) + dpg.destroy_context() + + +@pytest.mark.usefixtures("live_queue", "written") +class TestCopyingWithTheButton(BaseTestSuite): + """A press puts the text on the clipboard and says so on the button for a moment.""" + + def test_the_text_reaches_the_clipboard(self, copy_button: GUIButton, written: List[str]) -> None: + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + + assert written == [TEXT] + + def test_the_button_says_copied_while_the_moment_stands( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + draw_frame() + + assert copy_button.get_item_label() == COPIED + + def test_the_button_reads_its_own_label_once_the_moment_passes( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + delay_clock.advance(COPIED_LABEL_SECONDS) + draw_frame() + + assert copy_button.get_item_label() == LABEL + + def test_the_copy_starts_no_thread(self, copy_button: GUIButton) -> None: + """A thread of its own would reach DearPyGui away from the render thread, and past its context.""" + standing = set(threading.enumerate()) + + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + + assert set(threading.enumerate()) == standing + + def test_quitting_within_the_moment_drops_the_restore( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + """The shutdown stops the queue before the context goes, so the restore never reaches DearPyGui.""" + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + CallbackQueue.stop() + delay_clock.advance(COPIED_LABEL_SECONDS) + CallbackQueue.start() + draw_frame() + + assert copy_button.get_item_label() == COPIED + + def test_a_button_closed_within_the_moment_is_passed_over( + self, + copy_button: GUIButton, + delay_clock: ManualClock, + ) -> None: + """The error dialog carrying a Copy button can be closed before its label comes back.""" + copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) + dpg.delete_item(BUTTON) + delay_clock.advance(COPIED_LABEL_SECONDS) + with patch(QUEUE_LOGGER) as queue_logger: + draw_frame() + + queue_logger.error_with_traceback.assert_not_called() From 3dfbd9e1d34250280dd91a266957e079da4e8355 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 19 Sep 2026 20:56:19 +0200 Subject: [PATCH 2/4] Changed: pastes to wait for the clipboard's answer --- .../application/sequencer-blocks.md | 13 ++- .../coordinators/tabs/sequencer/blocks.py | 40 +++++++- .../tabs/sequencer/coordinator.py | 27 ++++-- .../ui/panels/sequencer/grid/gestures.py | 8 +- .../sequencer/grid/surface/clipboard.py | 16 +++- .../ui/panels/sequencer/order/callbacks.py | 2 + .../ui/panels/sequencer/order/panel.py | 2 + .../ui/panels/sequencer/tracker/callbacks.py | 1 + .../ui/panels/sequencer/tracker/panel.py | 2 + .../utils/gui/clipboard.py | 14 ++- tests/suite/surface.py | 6 +- .../coordinators/tabs/test_sequencer.py | 92 ++++++++++++++++++- .../sequencer/grid/surface/test_clipboard.py | 25 ++++- .../ui/panels/sequencer/grid/test_gestures.py | 24 +++++ .../ui/panels/sequencer/test_block_menu.py | 2 + 15 files changed, 239 insertions(+), 35 deletions(-) diff --git a/docs/development/application/sequencer-blocks.md b/docs/development/application/sequencer-blocks.md index 314167f89..89f25a1ef 100644 --- a/docs/development/application/sequencer-blocks.md +++ b/docs/development/application/sequencer-blocks.md @@ -147,12 +147,17 @@ row accepts, so text typed by hand lands the values the grid would. ### Which block a paste writes -A copy writes both clipboards, and a paste reads the desktop's text first: it stands while it +A copy writes both clipboards, and a paste asks the desktop for its text first: it stands while it parses as a block for *that* grid, and any other text leaves the grid's own block in hand. So a block copied in a second instance pastes here, and a copy taken in this one survives whatever -else the desktop picks up afterward. `can_paste_block` asks the same question through a -`ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one -string compare. +else the desktop picks up afterward. + +The application holding the desktop's clipboard hands its text over in its own time, so +`TextClipboard.read` names what receives the text, and `SequencerBlocks` keeps the last answer. +A paste asks first and writes once the answer lands, recording its entry then. `can_paste_block` +asks the paste's question of the last answer through a `ParsedBlockCache`, which reparses only +when the text has changed, and a menu offering Paste asks the clipboard again as it opens +(`refresh_paste_block`), so the item follows the fresh answer once it lands. ## A grid declares its actions once diff --git a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py index a7157fc4e..85e062db5 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py @@ -1,4 +1,5 @@ -from typing import Optional +from functools import partial +from typing import Callable, Optional, ParamSpec from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.clipboard import ( @@ -27,14 +28,19 @@ TrackerCell, TrackerRegion, ) +from sampletones_shared.types.callback import VoidCallback + +GestureParams = ParamSpec("GestureParams") class SequencerBlocks: """The blocks both grids copy, cut and paste, and the two clipboards they travel on. A copy lands in this tab's own slot and, as text, on the system clipboard, so the same block - reaches a paste here and a paste in another instance. A paste reads the system clipboard first - while what stands there is a block, which is what lets one instance hand a block to the next. + reaches a paste here and a paste in another instance. A paste asks the system clipboard first + and takes its text while that text is a block, which is what lets one instance hand a block to + the next. The clipboard answers in its own time, so the blocks keep its last answer, and every + question about the block in hand reads that answer. Every gesture here reads and writes the project as it stands; recording what a gesture undoes is the caller's, so the coordinator wraps the writing ones in a history transaction. @@ -50,6 +56,7 @@ def __init__( ) -> None: self._clipboard: SequencerClipboard = SequencerClipboard() self._text_clipboard: TextClipboard = text_clipboard + self._clipboard_text: str = "" self._tracker_text: TrackerBlockText = TrackerBlockText( samples=ProjectSampleDirectory(project_controller), ) @@ -69,13 +76,32 @@ def can_paste_order(self) -> bool: """Whether the order has a block to write, which is what its Paste item is offered on.""" return self.order_in_hand() is not None + def read_clipboard(self, then: VoidCallback) -> None: + """Asks the system clipboard for its text, running ``then`` once the answer has landed. + + The answer is what the pastes and the menus offering them read from then on, so a gesture + that asked first acts on the text standing on the clipboard as it answered. + """ + self._text_clipboard.read(partial(self._take_clipboard_text, then)) + + def after_reading_clipboard( + self, + paste: Callable[GestureParams, None], + ) -> Callable[GestureParams, None]: + """Holds a paste back until the system clipboard has answered, so it writes the block in hand then.""" + + def wrapped(*args: GestureParams.args, **kwargs: GestureParams.kwargs) -> None: + self.read_clipboard(partial(paste, *args, **kwargs)) + + return wrapped + def tracker_in_hand(self) -> Optional[TrackerBlock]: """The block a tracker paste would write: the system clipboard's while its text is one. Text another instance copied reads as a block here, so it stands ahead of the slot the tracker copied into, and text from anywhere else leaves that slot's own block in hand. """ - parsed = self._tracker_cache.block(self._text_clipboard.read()) + parsed = self._tracker_cache.block(self._clipboard_text) if parsed is not None: return parsed @@ -87,7 +113,7 @@ def order_in_hand(self) -> Optional[OrderBlock]: Text another instance copied reads as a block here, so it stands ahead of the slot the order copied into, and text from anywhere else leaves that slot's own block in hand. """ - parsed = self._order_cache.block(self._text_clipboard.read()) + parsed = self._order_cache.block(self._clipboard_text) if parsed is not None: return parsed @@ -142,3 +168,7 @@ def paste_order(self, cell: OrderCell) -> None: block = self.order_in_hand() if block is not None: self._order_writer.write(block, cell) + + def _take_clipboard_text(self, then: VoidCallback, text: str) -> None: + self._clipboard_text = text + then() diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 1a3ec7413..4e2ce8cf7 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -496,11 +496,14 @@ def _wire_block_callbacks(self) -> None: history has nothing to restore for. The three gestures that do write are whole ones, each recording the single entry that takes the grid back to where it stood. - Each grid also asks whether its own slot holds a block, which is what a menu offering - Paste consults before it is opened. + A paste asks the system clipboard first and records its entry once the answer has landed. + Each grid also asks whether a block stands ready to paste, which a menu offering Paste + consults as it opens, and asks the clipboard again so the item follows the fresh answer. """ self._sequencer_tracker_panel.can_paste_block = self._blocks.can_paste_tracker + self._sequencer_tracker_panel.refresh_paste_block = self._blocks.read_clipboard self._sequencer_order_panel.can_paste_block = self._blocks.can_paste_order + self._sequencer_order_panel.refresh_paste_block = self._blocks.read_clipboard self._sequencer_tracker_panel.on_copy_block = self._blocks.copy_tracker self._sequencer_tracker_panel.on_cut_block = self._recorder.undoable( HistoryAction.CUT_BLOCK, @@ -512,10 +515,12 @@ def _wire_block_callbacks(self) -> None: self._blocks.clear_tracker, detail=self._history_detail.tracker_block, ) - self._sequencer_tracker_panel.on_paste_block = self._recorder.undoable( - HistoryAction.PASTE_BLOCK, - self._blocks.paste_tracker, - detail=self._history_detail.tracker_paste, + self._sequencer_tracker_panel.on_paste_block = self._blocks.after_reading_clipboard( + self._recorder.undoable( + HistoryAction.PASTE_BLOCK, + self._blocks.paste_tracker, + detail=self._history_detail.tracker_paste, + ) ) self._sequencer_order_panel.on_copy_block = self._blocks.copy_order self._sequencer_order_panel.on_cut_block = self._recorder.undoable( @@ -528,10 +533,12 @@ def _wire_block_callbacks(self) -> None: self._blocks.clear_order, detail=self._history_detail.order_block, ) - self._sequencer_order_panel.on_paste_block = self._recorder.undoable( - HistoryAction.PASTE_BLOCK, - self._blocks.paste_order, - detail=self._history_detail.order_paste, + self._sequencer_order_panel.on_paste_block = self._blocks.after_reading_clipboard( + self._recorder.undoable( + HistoryAction.PASTE_BLOCK, + self._blocks.paste_order, + detail=self._history_detail.order_paste, + ) ) def _wire_voices_callbacks(self) -> None: diff --git a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py index 19d5c84d4..2853dbb2d 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py +++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py @@ -1,5 +1,6 @@ from typing import Callable, Generic, Optional, Protocol, TypeVar +from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin RegionT = TypeVar("RegionT") @@ -33,6 +34,7 @@ class BlockGrid(Protocol[RegionT, CellT]): on_delete_block: Optional[Callable[[RegionT], None]] on_paste_block: Optional[Callable[[CellT], None]] can_paste_block: Optional[Callable[[], bool]] + refresh_paste_block: Optional[Callable[[VoidCallback], None]] class BlockGestures(CallbackMixin, Generic[RegionT, CellT]): @@ -47,9 +49,13 @@ def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None: self._grid = grid def can_paste(self) -> bool: - """Whether a block stands ready for a paste to write.""" + """Whether a block stands ready for a paste to write, as the clipboard last answered.""" return self.query(self._grid.can_paste_block, default=False) + def refresh_paste(self, then: VoidCallback) -> None: + """Asks the clipboard again, running ``then`` once :meth:`can_paste` reads the fresh answer.""" + self.call(self._grid.refresh_paste_block, then) + def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None: """Takes what a target covers, leaving the grid as it stands.""" self.call(self._grid.on_copy_block, target.region) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py index 56aeb8386..239ee43c4 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from functools import partial from typing import Dict, Final, Generic, Mapping, Tuple, TypeVar import dearpygui.dearpygui as dpg @@ -7,8 +8,10 @@ from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.manager import LanguageManager from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_shared.types.application import Sender RegionT = TypeVar("RegionT") CellT = TypeVar("CellT") @@ -64,8 +67,10 @@ def labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: def add_items(self, target: BlockTarget[RegionT, CellT]) -> None: """Builds the four items, acting on the block the actions were raised on. - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. + Paste is offered on the block the clipboard last answered with, and the opening asks the + clipboard again, so the item follows the fresh answer once it lands. It anchors at the + target's own cell, so the cell menu lands a block where the pointer is while the keys land + it under the cursor. """ dpg.add_menu_item( label=self._labels[ContextElements.COPY], @@ -77,13 +82,18 @@ def add_items(self, target: BlockTarget[RegionT, CellT]) -> None: shortcut=self._shortcuts.display(self._block_shortcuts.cut), callback=lambda: self._blocks.cut_at(target), ) - dpg.add_menu_item( + paste = dpg.add_menu_item( label=self._labels[ContextElements.PASTE], shortcut=self._shortcuts.display(self._block_shortcuts.paste), enabled=self._blocks.can_paste(), callback=lambda: self._blocks.paste_at(target), ) + self._blocks.refresh_paste(partial(self._offer_paste, paste)) dpg.add_menu_item( label=self._labels[ContextElements.DELETE], callback=lambda: self._blocks.delete_at(target), ) + + def _offer_paste(self, item: Sender) -> None: + """Sets the Paste item by the block the clipboard's fresh answer leaves in hand.""" + dpg_configure_item(item, enabled=self._blocks.can_paste()) diff --git a/src/sampletones_application/ui/panels/sequencer/order/callbacks.py b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py index c8f2776d9..f3cd7dc17 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/callbacks.py +++ b/src/sampletones_application/ui/panels/sequencer/order/callbacks.py @@ -5,6 +5,7 @@ from sampletones_application.ui.panels.sequencer.input.target import OrderTarget from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import VoidCallback OrderKey = Tuple[Optional[ChannelName], int] @@ -19,5 +20,6 @@ OnBlockRegionCallback = Callable[[OrderRegion], None] OnPasteBlockCallback = Callable[[OrderCell], None] CanPasteBlockQuery = Callable[[], bool] +RefreshPasteBlockRequest = Callable[[VoidCallback], None] OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget] diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py index c7c0e3757..3c19f608d 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -73,6 +73,7 @@ OnSetOrderEntryCallback, OrderEditSurface, OrderKey, + RefreshPasteBlockRequest, ) from sampletones_application.ui.panels.sequencer.order.menu import OrderMenu from sampletones_application.ui.panels.sequencer.order.moves import MOVE_DIRECTIONS @@ -190,6 +191,7 @@ def __init__( self.on_delete_block: Optional[OnBlockRegionCallback] = None self.on_paste_block: Optional[OnPasteBlockCallback] = None self.can_paste_block: Optional[CanPasteBlockQuery] = None + self.refresh_paste_block: Optional[RefreshPasteBlockRequest] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py index 937d18bf4..6d901aab9 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/callbacks.py @@ -23,6 +23,7 @@ OnBlockRegionCallback = Callable[[TrackerRegion], None] OnPasteBlockCallback = Callable[[TrackerCell], None] CanPasteBlockQuery = Callable[[], bool] +RefreshPasteBlockRequest = Callable[[VoidCallback], None] TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] ThemeKey = Tuple[SubColumn, Optional[VoiceKind]] diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 8b0e62aa6..4f7fea4dd 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -94,6 +94,7 @@ OnPlayFromRowCallback, OnSetNoteOffCallback, OnSetRowCallback, + RefreshPasteBlockRequest, TrackerEditSurface, ) from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu @@ -237,6 +238,7 @@ def __init__( self.on_delete_block: Optional[OnBlockRegionCallback] = None self.on_paste_block: Optional[OnPasteBlockCallback] = None self.can_paste_block: Optional[CanPasteBlockQuery] = None + self.refresh_paste_block: Optional[RefreshPasteBlockRequest] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py index 139cdd567..2b59259b5 100644 --- a/src/sampletones_application/utils/gui/clipboard.py +++ b/src/sampletones_application/utils/gui/clipboard.py @@ -5,14 +5,20 @@ from sampletones_application.utils.callbacks.delay import call_after from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_shared.types.callback import StringCallback COPIED_LABEL_SECONDS: Final[float] = 1.0 class TextClipboard(Protocol): - """The clipboard the desktop shares between applications, as text going out and coming back.""" + """The clipboard the desktop shares between applications, as text going out and coming back. - def read(self) -> str: ... + The application holding the clipboard hands its text over in its own time, so a read names what + receives the text once it has arrived. + """ + + def read(self, on_text: StringCallback) -> None: + """Hands the text standing on the clipboard to ``on_text``, on the render thread.""" def write(self, text: str) -> None: ... @@ -20,8 +26,8 @@ def write(self, text: str) -> None: ... class SystemTextClipboard: """The desktop's clipboard, reached through the one DearPyGui holds for the viewport.""" - def read(self) -> str: - return cast(str, dpg.get_clipboard_text()) + def read(self, on_text: StringCallback) -> None: + on_text(cast(str, dpg.get_clipboard_text())) def write(self, text: str) -> None: dpg.set_clipboard_text(text) diff --git a/tests/suite/surface.py b/tests/suite/surface.py index 052ff2e9f..e54e56205 100644 --- a/tests/suite/surface.py +++ b/tests/suite/surface.py @@ -12,6 +12,7 @@ CursorTargets, ) from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from sampletones_shared.types.callback import VoidCallback from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS from tests.suite.shortcuts import shipped_source @@ -77,7 +78,10 @@ def __init__( self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") - self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + self.can_paste = can_paste + self.can_paste_block: Optional[Callable[[], bool]] = lambda: self.can_paste + self.refreshes: List[VoidCallback] = [] + self.refresh_paste_block: Optional[Callable[[VoidCallback], None]] = self.refreshes.append def owns_keys(self) -> bool: return self._owns diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d58931634..a31ff6a91 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -69,6 +69,7 @@ InvalidReconstructionValuesError, MalformedInstrumentError, ) +from sampletones_shared.types.callback import StringCallback from tests.suite.language import FakeLanguageManager FREQUENCY_MISMATCH_MESSAGE_KEY: Final[str] = "global.dialog.message.frequency_mismatch" @@ -1659,17 +1660,36 @@ def test_player_returns_the_guarded_wrapper( class FakeTextClipboard: - """The desktop's clipboard, held in memory so a test reads what a copy put there.""" + """The desktop's clipboard, held in memory so a test reads what a copy put there. + + A read is answered at once, the way DearPyGui's own clipboard answers, until a case holds the + answers back to stand for an application that hands its text over later. + """ def __init__(self) -> None: self.text: str = "" + self.unanswered: List[StringCallback] = [] + self._answers_held: bool = False + + def read(self, on_text: StringCallback) -> None: + if self._answers_held: + self.unanswered.append(on_text) + return - def read(self) -> str: - return self.text + on_text(self.text) def write(self, text: str) -> None: self.text = text + def hold_answers(self) -> None: + self._answers_held = True + + def answer(self) -> None: + """Hands the text standing now to every read still waiting, in the order they asked.""" + waiting, self.unanswered = self.unanswered, [] + for on_text in waiting: + on_text(self.text) + def _text_clipboard(coordinator: SequencerTabCoordinator) -> FakeTextClipboard: """The desktop clipboard this coordinator's blocks were built over.""" @@ -1995,16 +2015,78 @@ def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 1) == 0 - def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard( + def test_a_paste_offers_itself_on_the_text_the_clipboard_answered_with( self, block_coordinator: SequencerTabCoordinator, ) -> None: """The menu asks the same question the paste does, so it offers what the next press reaches.""" coordinator = block_coordinator + _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") assert not coordinator._blocks.can_paste_tracker() - _text_clipboard(coordinator).write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + coordinator._sequencer_tracker_panel.refresh_paste_block(lambda: None) assert coordinator._blocks.can_paste_tracker() assert not coordinator._blocks.can_paste_order() + + +class TestPasteAwaitsTheClipboard: + """The application holding the clipboard hands its text over in its own time, and a paste waits.""" + + def test_a_paste_writes_once_the_clipboard_answers( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + clipboard.hold_answers() + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose is None + assert len(coordinator._history.entries) == recorded + + clipboard.answer() + + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose == 9 + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + + def test_a_paste_writes_the_text_standing_when_the_clipboard_answers( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """What another application copies while the answer is on its way is what the paste lands.""" + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n03") + clipboard.hold_answers() + + coordinator._sequencer_order_panel.on_paste_block(OrderCell(channel=ChannelName.NOISE, position=0)) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n05") + clipboard.answer() + + assert coordinator._sequencer_order_logic.entry(ChannelName.NOISE, 0) == 5 + + def test_a_menu_s_offer_follows_the_answer_it_asked_for( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n03") + clipboard.hold_answers() + answered: List[bool] = [] + + coordinator._sequencer_order_panel.refresh_paste_block(lambda: answered.append(True)) + + assert not answered + assert not coordinator._blocks.can_paste_order() + + clipboard.answer() + + assert answered == [True] + assert coordinator._blocks.can_paste_order() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py index 5d606de1f..7d61cb546 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Callable, List +from typing import Any, Callable, Dict, List, Tuple import pytest @@ -26,8 +26,11 @@ class RecordedItem: class _MenuRecorder: + """The items a menu registered, each named by its place, and every change made to one since.""" + def __init__(self) -> None: self.items: List[RecordedItem] = [] + self.changes: List[Tuple[int, Dict[str, Any]]] = [] def add_menu_item(self, **kwargs: Any) -> int: self.items.append( @@ -38,13 +41,17 @@ def add_menu_item(self, **kwargs: Any) -> int: callback=kwargs["callback"], ) ) - return 0 + return len(self.items) - 1 + + def configure_item(self, item: int, **kwargs: Any) -> None: + self.changes.append((item, kwargs)) @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: recorded = _MenuRecorder() monkeypatch.setattr(clipboard_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(clipboard_module, "dpg_configure_item", recorded.configure_item) return recorded @@ -96,3 +103,17 @@ def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: assert not recorder.items[PASTE_ITEM].enabled assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) + + def test_paste_follows_the_answer_its_opening_asked_for(self, recorder: _MenuRecorder) -> None: + """The clipboard answers in its own time, so the item stands on the last answer until then.""" + grid = Grid(can_paste=False) + grid.clipboard_items().add_items(CLICKED_TARGET) + + assert not recorder.items[PASTE_ITEM].enabled + assert recorder.changes == [] + + grid.can_paste = True + for answered in grid.refreshes: + answered() + + assert recorder.changes == [(PASTE_ITEM, {"enabled": True})] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py index 348eaeefa..742ad6bbd 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py @@ -4,6 +4,7 @@ import pytest from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_shared.types.callback import VoidCallback Gestures = BlockGestures[str, str] @@ -29,6 +30,11 @@ def __init__(self, *, can_paste: bool = True) -> None: self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + self.refresh_paste_block: Optional[Callable[[VoidCallback], None]] = self._answer_at_once + + def _answer_at_once(self, then: VoidCallback) -> None: + self.events.append("refresh") + then() @dataclass(frozen=True) @@ -90,3 +96,21 @@ def test_a_grid_awaiting_its_wiring_offers_no_paste(self) -> None: grid.can_paste_block = None assert BlockGestures(grid=grid).can_paste() is False + + def test_a_refresh_asks_the_grid_and_follows_its_answer(self) -> None: + grid = _Grid() + answered: List[str] = [] + + BlockGestures(grid=grid).refresh_paste(lambda: answered.append("answered")) + + assert grid.events == ["refresh"] + assert answered == ["answered"] + + def test_a_grid_awaiting_its_wiring_leaves_the_offer_as_it_stands(self) -> None: + grid = _Grid() + grid.refresh_paste_block = None + answered: List[str] = [] + + BlockGestures(grid=grid).refresh_paste(lambda: answered.append("answered")) + + assert answered == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index dfe784bbc..93d6243ac 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -168,6 +168,7 @@ def _tracker_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel.refresh_paste_block = None panel._blocks = BlockGestures(grid=panel) attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget) panel._menu = TrackerMenu( @@ -194,6 +195,7 @@ def _order_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel.refresh_paste_block = None panel._blocks = BlockGestures(grid=panel) attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget) panel._menu = OrderMenu( From 2d2b1d96cc3a6ca5b4ab718ba5469506b683003d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 19 Sep 2026 23:29:20 +0200 Subject: [PATCH 3/4] Refactored: clipboard management --- docs/development/application/render-thread.md | 17 +- .../application/sequencer-blocks.md | 21 +- .../coordinators/tabs/sequencer/blocks.py | 2 +- .../tabs/sequencer/coordinator.py | 4 +- .../ui/elements/trace.py | 2 +- .../reconstruction/instruments/instruments.py | 2 +- .../utils/gui/clipboard.py | 51 ---- .../utils/gui/clipboard/__init__.py | 0 .../utils/gui/clipboard/backends/__init__.py | 0 .../utils/gui/clipboard/backends/dearpygui.py | 18 ++ .../gui/clipboard/backends/x11/__init__.py | 0 .../gui/clipboard/backends/x11/clipboard.py | 61 +++++ .../gui/clipboard/backends/x11/connection.py | 222 ++++++++++++++++++ .../gui/clipboard/backends/x11/library.py | 221 +++++++++++++++++ .../gui/clipboard/backends/x11/reader.py | 31 +++ .../gui/clipboard/backends/x11/transfer.py | 134 +++++++++++ .../utils/gui/clipboard/copy_button.py | 26 ++ .../utils/gui/clipboard/protocol.py | 20 ++ .../utils/gui/clipboard/selection.py | 34 +++ .../utils/gui/clipboard/__init__.py | 0 .../utils/gui/clipboard/backends/__init__.py | 0 .../gui/clipboard/backends/x11/__init__.py | 0 .../clipboard/backends/x11/test_clipboard.py | 173 ++++++++++++++ .../clipboard/backends/x11/test_transfer.py | 190 +++++++++++++++ .../test_copy_button.py} | 24 +- .../utils/gui/clipboard/test_selection.py | 83 +++++++ 26 files changed, 1256 insertions(+), 80 deletions(-) delete mode 100644 src/sampletones_application/utils/gui/clipboard.py create mode 100644 src/sampletones_application/utils/gui/clipboard/__init__.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/__init__.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/x11/library.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py create mode 100644 src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py create mode 100644 src/sampletones_application/utils/gui/clipboard/copy_button.py create mode 100644 src/sampletones_application/utils/gui/clipboard/protocol.py create mode 100644 src/sampletones_application/utils/gui/clipboard/selection.py create mode 100644 tests/unit/sampletones_application/utils/gui/clipboard/__init__.py create mode 100644 tests/unit/sampletones_application/utils/gui/clipboard/backends/__init__.py create mode 100644 tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py create mode 100644 tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py create mode 100644 tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py rename tests/unit/sampletones_application/utils/gui/{test_clipboard.py => clipboard/test_copy_button.py} (86%) create mode 100644 tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index feddec0da..4cff27662 100644 --- a/docs/development/application/render-thread.md +++ b/docs/development/application/render-thread.md @@ -3,8 +3,8 @@ This document describes how work reaches DearPyGui from somewhere other than the thread that owns its context, and what each crossing costs. It governs `utils/gui/render_thread.py`, `utils/gui/callbacks.py`, `utils/gui/frame.py`, and the queue in `utils/callbacks/`. Consult it when -a worker thread has something to show, when a gesture rebuilds widgets, or when work needs a frame -to have been drawn first or a span of time to have passed. +a worker thread has something to show, when a gesture rebuilds widgets, when work needs a frame to +have been drawn first, or when work should run after a delay. The design truth it realizes is principle 6 of [`architecture.md`](../architecture.md): DearPyGui's context belongs to the render thread. This document holds the mechanism. @@ -69,10 +69,11 @@ rather than inside one, which makes the next frame the drain's own to reach, so there waits for what the wait itself prevents and the application stops for good. Naming a frame count asks for the same thing and lets the loop keep running. -## Work that waits for time reads the clock at each drain +## Delayed work goes through the queue -The frame rate is the reader's to set, so a wait measured in seconds is read against the clock. -`utils/callbacks/delay.py::call_after` posts a check to the queue each frame and runs the work at the -first drain past its deadline, on the render thread like any other queued result. The wait travels -through the queue, so the shutdown that stops the queue before the context goes drops a wait still -under way. The Copy buttons restore their label this way. +To change the interface after a delay, post the change with `utils/callbacks/delay.py::call_after`. +The change waits in the queue until the delay has passed, and then runs on the render thread. + +Work still waiting in the queue at shutdown is discarded. This prevents delayed work from accessing +the interface after the context has been closed. A separate timer thread could otherwise make such an +access and cause a crash. diff --git a/docs/development/application/sequencer-blocks.md b/docs/development/application/sequencer-blocks.md index 89f25a1ef..4784c7819 100644 --- a/docs/development/application/sequencer-blocks.md +++ b/docs/development/application/sequencer-blocks.md @@ -122,9 +122,9 @@ SampleToNES/1 order rows=1 positions=0..1 The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and strings alone; the desktop's clipboard is reached through -`utils/gui/clipboard.py::TextClipboard`, one more piece of external behavior standing behind -a protocol ([Architecture](../architecture.md), principle 11). The sequencer coordinator wires -the two. +`utils/gui/clipboard/protocol.py::TextClipboard`, one more piece of external behavior standing +behind a protocol ([Architecture](../architecture.md), principle 11). The sequencer coordinator +wires the two. **A field prints what the grid prints in its cell**, which is what carries the three states across: a value reads as its value, an empty cell as the dots beneath it, and a mixed one as @@ -150,14 +150,13 @@ row accepts, so text typed by hand lands the values the grid would. A copy writes both clipboards, and a paste asks the desktop for its text first: it stands while it parses as a block for *that* grid, and any other text leaves the grid's own block in hand. So a block copied in a second instance pastes here, and a copy taken in this one survives whatever -else the desktop picks up afterward. - -The application holding the desktop's clipboard hands its text over in its own time, so -`TextClipboard.read` names what receives the text, and `SequencerBlocks` keeps the last answer. -A paste asks first and writes once the answer lands, recording its entry then. `can_paste_block` -asks the paste's question of the last answer through a `ParsedBlockCache`, which reparses only -when the text has changed, and a menu offering Paste asks the clipboard again as it opens -(`refresh_paste_block`), so the item follows the fresh answer once it lands. +else the desktop picks up afterward. `can_paste_block` asks the same question through a +`ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one +string compare. + +The program that owns the desktop's clipboard answers a read in its own time. A paste therefore +writes its block only after the answer arrives. A menu shows Paste based on the last answer it +received, and updates the item when the new answer arrives. ## A grid declares its actions once diff --git a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py index 85e062db5..0545a1ce6 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py @@ -21,7 +21,7 @@ TrackerBlockReader, TrackerBlockWriter, ) -from sampletones_application.utils.gui.clipboard import TextClipboard +from sampletones_application.utils.gui.clipboard.protocol import TextClipboard from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 4e2ce8cf7..4b831e9bf 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -61,7 +61,7 @@ from sampletones_application.ui.panels.sequencer.voices.panel import ( GUISequencerVoicesPanel, ) -from sampletones_application.utils.gui.clipboard import SystemTextClipboard +from sampletones_application.utils.gui.clipboard.selection import select_text_clipboard from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter @@ -157,7 +157,7 @@ def __init__( self._sequencer_tracker_logic, self._sequencer_order_logic, project_controller, - text_clipboard=SystemTextClipboard(), + text_clipboard=select_text_clipboard(), ) self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) self._sequencer_voices_logic: SequencerVoicesLogic = SequencerVoicesLogic( diff --git a/src/sampletones_application/ui/elements/trace.py b/src/sampletones_application/ui/elements/trace.py index 40d08639a..b3d17a130 100644 --- a/src/sampletones_application/ui/elements/trace.py +++ b/src/sampletones_application/ui/elements/trace.py @@ -19,7 +19,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.gui.clipboard import copy_to_clipboard +from sampletones_application.utils.gui.clipboard.copy_button import copy_to_clipboard class GUITraceback: diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 22c9e664d..ebeeb7f7c 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -67,7 +67,7 @@ make_feature_plot_configs, ) from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.clipboard import copy_to_clipboard +from sampletones_application.utils.gui.clipboard.copy_button import copy_to_clipboard from sampletones_application.utils.gui.dpg import ( dpg_configure_item, dpg_set_value, diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py deleted file mode 100644 index 2b59259b5..000000000 --- a/src/sampletones_application/utils/gui/clipboard.py +++ /dev/null @@ -1,51 +0,0 @@ -from functools import partial -from typing import Final, Protocol, cast - -import dearpygui.dearpygui as dpg - -from sampletones_application.utils.callbacks.delay import call_after -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_shared.types.callback import StringCallback - -COPIED_LABEL_SECONDS: Final[float] = 1.0 - - -class TextClipboard(Protocol): - """The clipboard the desktop shares between applications, as text going out and coming back. - - The application holding the clipboard hands its text over in its own time, so a read names what - receives the text once it has arrived. - """ - - def read(self, on_text: StringCallback) -> None: - """Hands the text standing on the clipboard to ``on_text``, on the render thread.""" - - def write(self, text: str) -> None: ... - - -class SystemTextClipboard: - """The desktop's clipboard, reached through the one DearPyGui holds for the viewport.""" - - def read(self, on_text: StringCallback) -> None: - on_text(cast(str, dpg.get_clipboard_text())) - - def write(self, text: str) -> None: - dpg.set_clipboard_text(text) - - -def copy_to_clipboard( - text: str, - label: str, - button_tag: str, - *, - copied_label: str, -) -> None: - """Puts ``text`` on the clipboard, with the button reading ``copied_label`` for a moment. - - The button's own label comes back on the render thread once the moment has passed, the thread - the button lives on. - """ - SystemTextClipboard().write(text) - - dpg_configure_item(button_tag, label=copied_label) - call_after(COPIED_LABEL_SECONDS, partial(dpg_configure_item, button_tag, label=label)) diff --git a/src/sampletones_application/utils/gui/clipboard/__init__.py b/src/sampletones_application/utils/gui/clipboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/clipboard/backends/__init__.py b/src/sampletones_application/utils/gui/clipboard/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py b/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py new file mode 100644 index 000000000..cc129ca2f --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py @@ -0,0 +1,18 @@ +from typing import cast + +import dearpygui.dearpygui as dpg + +from sampletones_shared.types.callback import StringCallback + + +class DearPyGuiTextClipboard: + """The desktop's clipboard, reached through the one DearPyGui holds for the viewport. + + The platform's clipboard hands its text over within the call, so a read answers at once. + """ + + def read(self, on_text: StringCallback) -> None: + on_text(cast(str, dpg.get_clipboard_text())) + + def write(self, text: str) -> None: + dpg.set_clipboard_text(text) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py new file mode 100644 index 000000000..38b86031e --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py @@ -0,0 +1,61 @@ +from typing import Final, List, Protocol + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_shared.types.callback import StringCallback + +CLIPBOARD_ANSWER_SECONDS: Final[float] = 1.0 + + +class SelectionReader(Protocol): + """Reads the clipboard's text, waiting ``seconds`` at most for the application holding it.""" + + def read_text(self, seconds: float) -> str: ... + + +class X11TextClipboard: + """The desktop's clipboard on X11: written through DearPyGui, read over a connection of its own. + + DearPyGui reads the clipboard through GLFW, which waits for the application holding it for as + long as that application takes, holding DearPyGui's lock and the interpreter meanwhile; an owner + that stays silent stops every frame for good. This clipboard reads on a worker instead, giving + the owner ``CLIPBOARD_ANSWER_SECONDS`` to hand its text over, and an owner silent past that + reads as holding no text. The frames keep being drawn meanwhile, which is also what lets this + application's own window answer: GLFW hands over the text it holds as a frame polls its events. + + The answer reaches the render thread through ``CallbackQueue``, so the shutdown that stops the + queue drops an answer still on its way, and the worker joins at teardown like any other. Reads + asked while one is out wait for its answer, so a burst of them shares one transfer. + """ + + def __init__(self, reader: SelectionReader) -> None: + self._reader = reader + self._executor = SingleThreadExecutor() + self._waiting: List[StringCallback] = [] + + def read(self, on_text: StringCallback) -> None: + self._waiting.append(on_text) + if len(self._waiting) == 1: + self._executor.execute(self._transfer, wait=True) + + def write(self, text: str) -> None: + dpg.set_clipboard_text(text) + + def _transfer(self) -> None: + """Reads on the worker and hands the text to the render thread, an empty one if the read fails. + + The reads waiting on this transfer hear from it whichever way it ends, so the next read + starts a transfer of its own, and a failure still reaches the thread's own report. + """ + text = "" + try: + text = self._reader.read_text(CLIPBOARD_ANSWER_SECONDS) + finally: + CallbackQueue.add(self._answer, text) + + def _answer(self, text: str) -> None: + waiting, self._waiting = self._waiting, [] + for on_text in waiting: + on_text(text) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py new file mode 100644 index 000000000..9206b4dfa --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/connection.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import ctypes +import select +import time +from dataclasses import dataclass +from enum import IntEnum +from types import TracebackType +from typing import Any, Final, Optional, Type, Union + +from sampletones_application.utils.gui.clipboard.backends.x11.library import ( + Cookie, + GenericEvent, + PropertyNotifyEvent, + Screen, + SelectionNotifyEvent, + XcbLibrary, +) + +SENT_EVENT_BIT: Final[int] = 0x80 +PROPERTY_NEW_VALUE: Final[int] = 0 +COPY_FROM_PARENT: Final[int] = 0 +INPUT_ONLY_WINDOW: Final[int] = 2 +EVENT_MASK_VALUE: Final[int] = 1 << 11 +PROPERTY_CHANGE_EVENTS: Final[int] = 1 << 22 +CURRENT_TIME: Final[int] = 0 +ANY_PROPERTY_TYPE: Final[int] = 0 +DELETE_AFTER_READING: Final[int] = 1 +WHOLE_PROPERTY_WORDS: Final[int] = 0x3FFFFFFF + + +class EventCode(IntEnum): + PROPERTY_NOTIFY = 28 + SELECTION_NOTIFY = 31 + + +@dataclass(frozen=True) +class SelectionNotify: + """The owner's answer to a conversion: the property it wrote the text to, or none at all.""" + + requestor: int + landing: int + + +@dataclass(frozen=True) +class PropertyNotify: + """A property on a window changed, which is how a text sent in pieces announces each piece.""" + + window: int + atom: int + new_value: bool + + +SelectionEvent = Union[SelectionNotify, PropertyNotify] + + +@dataclass(frozen=True) +class PropertyValue: + """What a window property held when it was read: its type and its bytes.""" + + value_type: int + data: bytes + + +class XcbConnection: + """A connection of our own to the X server, speaking the calls a selection transfer makes. + + A connection carries its own requests and events, so a transfer run on it stays apart from the + one DearPyGui draws through. Every call that waits on the server asks the X server alone, which + answers right away; the owner of the clipboard is heard from through events, which + :meth:`next_event` waits for until a deadline. + + Raises: + ConnectionError: If the display refuses the connection or the connection breaks. + """ + + def __init__(self, library: XcbLibrary, display: str) -> None: + self._library = library + self._handle: int = library.connect(display.encode(), None) + if library.connection_has_error(self._handle): + library.disconnect(self._handle) + raise ConnectionError(f"The X display {display} refused a connection") + + def __enter__(self) -> XcbConnection: + return self + + def __exit__( + self, + _exception_type: Optional[Type[BaseException]], + _exception: Optional[BaseException], + _traceback: Optional[TracebackType], + ) -> None: + self._library.disconnect(self._handle) + + def atom(self, name: str) -> int: + encoded = name.encode() + cookie = self._library.intern_atom(self._handle, 0, len(encoded), encoded) + reply = self._library.intern_atom_reply(self._handle, cookie, None) + self._require(reply) + atom = int(reply.contents.atom) + self._library.free(reply) + return atom + + def selection_owner(self, selection: int) -> int: + cookie = self._library.get_selection_owner(self._handle, selection) + reply = self._library.get_selection_owner_reply(self._handle, cookie, None) + self._require(reply) + owner = int(reply.contents.owner) + self._library.free(reply) + return owner + + def create_requestor(self) -> int: + """Creates the window an owner writes the text to, reporting each change to its properties.""" + roots = self._library.setup_roots_iterator(self._library.get_setup(self._handle)) + screen = ctypes.cast(roots.data, ctypes.POINTER(Screen)).contents + window = int(self._library.generate_id(self._handle)) + events = ctypes.c_uint32(PROPERTY_CHANGE_EVENTS) + self._library.create_window( + self._handle, + COPY_FROM_PARENT, + window, + screen.root, + 0, + 0, + 1, + 1, + 0, + INPUT_ONLY_WINDOW, + COPY_FROM_PARENT, + EVENT_MASK_VALUE, + ctypes.byref(events), + ) + return window + + def convert_selection( + self, + *, + requestor: int, + selection: int, + target: int, + landing: int, + ) -> None: + """Asks the owner of ``selection`` to write it as ``target`` to ``landing`` on ``requestor``.""" + self._library.convert_selection(self._handle, requestor, selection, target, landing, CURRENT_TIME) + self._library.flush(self._handle) + + def take_property(self, window: int, atom: int) -> PropertyValue: + """Reads a property whole and deletes it, which an owner sending in pieces waits for.""" + cookie: Cookie = self._library.get_property( + self._handle, + DELETE_AFTER_READING, + window, + atom, + ANY_PROPERTY_TYPE, + 0, + WHOLE_PROPERTY_WORDS, + ) + reply = self._library.get_property_reply(self._handle, cookie, None) + self._require(reply) + value_type = int(reply.contents.type) + data = ctypes.string_at( + self._library.get_property_value(reply), + self._library.get_property_value_length(reply), + ) + self._library.free(reply) + self._library.flush(self._handle) + return PropertyValue(value_type=value_type, data=data) + + def next_event(self, deadline: float) -> Optional[SelectionEvent]: + """The next selection or property event, or ``None`` once ``deadline`` passes first. + + ``deadline`` is read against ``time.monotonic``. Events of any other kind are passed over. + """ + while True: + raw = self._poll_event() + if raw is not None: + event = self._selection_event(raw) + if event is not None: + return event + + continue + + remaining = deadline - time.monotonic() + if remaining <= 0.0: + return None + + select.select([self._library.get_file_descriptor(self._handle)], [], [], remaining) + + def _poll_event(self) -> Optional[bytes]: + """The next event libxcb has read, as its raw bytes, while one is waiting.""" + event = self._library.poll_for_event(self._handle) + if not event: + if self._library.connection_has_error(self._handle): + raise ConnectionError("The connection to the X display broke") + + return None + + raw = ctypes.string_at(event, ctypes.sizeof(GenericEvent)) + self._library.free(event) + return raw + + @staticmethod + def _selection_event(raw: bytes) -> Optional[SelectionEvent]: + match raw[0] & ~SENT_EVENT_BIT: + case EventCode.SELECTION_NOTIFY: + notify = SelectionNotifyEvent.from_buffer_copy(raw) + return SelectionNotify(requestor=notify.requestor, landing=notify.property) + case EventCode.PROPERTY_NOTIFY: + changed = PropertyNotifyEvent.from_buffer_copy(raw) + return PropertyNotify( + window=changed.window, + atom=changed.atom, + new_value=changed.state == PROPERTY_NEW_VALUE, + ) + + return None + + @staticmethod + def _require(reply: Any) -> None: + """Stops the transfer once the server's reply is missing, which a broken connection leaves.""" + if not reply: + raise ConnectionError("The X display answered no reply") diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py new file mode 100644 index 000000000..883bf0561 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py @@ -0,0 +1,221 @@ +import ctypes +from functools import cache +from typing import Any, Final, Optional + +XCB_LIBRARY: Final[str] = "libxcb.so.1" + + +class Cookie(ctypes.Structure): + _fields_ = [("sequence", ctypes.c_uint)] + + +class ScreenIterator(ctypes.Structure): + _fields_ = [ + ("data", ctypes.c_void_p), + ("rem", ctypes.c_int), + ("index", ctypes.c_int), + ] + + +class Screen(ctypes.Structure): + _fields_ = [ + ("root", ctypes.c_uint32), + ("default_colormap", ctypes.c_uint32), + ("white_pixel", ctypes.c_uint32), + ("black_pixel", ctypes.c_uint32), + ("current_input_masks", ctypes.c_uint32), + ("width_in_pixels", ctypes.c_uint16), + ("height_in_pixels", ctypes.c_uint16), + ("width_in_millimeters", ctypes.c_uint16), + ("height_in_millimeters", ctypes.c_uint16), + ("min_installed_maps", ctypes.c_uint16), + ("max_installed_maps", ctypes.c_uint16), + ("root_visual", ctypes.c_uint32), + ("backing_stores", ctypes.c_uint8), + ("save_unders", ctypes.c_uint8), + ("root_depth", ctypes.c_uint8), + ("allowed_depths_len", ctypes.c_uint8), + ] + + +class InternAtomReply(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("length", ctypes.c_uint32), + ("atom", ctypes.c_uint32), + ] + + +class SelectionOwnerReply(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("length", ctypes.c_uint32), + ("owner", ctypes.c_uint32), + ] + + +class PropertyReply(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("format", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("length", ctypes.c_uint32), + ("type", ctypes.c_uint32), + ("bytes_after", ctypes.c_uint32), + ("value_len", ctypes.c_uint32), + ("pad0", ctypes.c_uint8 * 12), + ] + + +class GenericEvent(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("body", ctypes.c_uint8 * 31), + ] + + +class SelectionNotifyEvent(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("time", ctypes.c_uint32), + ("requestor", ctypes.c_uint32), + ("selection", ctypes.c_uint32), + ("target", ctypes.c_uint32), + ("property", ctypes.c_uint32), + ] + + +class PropertyNotifyEvent(ctypes.Structure): + _fields_ = [ + ("response_type", ctypes.c_uint8), + ("pad0", ctypes.c_uint8), + ("sequence", ctypes.c_uint16), + ("window", ctypes.c_uint32), + ("atom", ctypes.c_uint32), + ("time", ctypes.c_uint32), + ("state", ctypes.c_uint8), + ] + + +class XcbLibrary: + """libxcb as ctypes reaches it, each call declared with the C types it takes and returns. + + Replies and events arrive in memory libxcb allocated, and the C library's ``free`` releases + them, so it is declared here beside the calls that hand them out. + """ + + def __init__(self, library: ctypes.CDLL) -> None: + connection = ctypes.c_void_p + window = ctypes.c_uint32 + atom = ctypes.c_uint32 + self.free = self._declare(ctypes.CDLL(None).free, None, ctypes.c_void_p) + self.connect = self._declare(library.xcb_connect, connection, ctypes.c_char_p, ctypes.c_void_p) + self.connection_has_error = self._declare(library.xcb_connection_has_error, ctypes.c_int, connection) + self.disconnect = self._declare(library.xcb_disconnect, None, connection) + self.flush = self._declare(library.xcb_flush, ctypes.c_int, connection) + self.get_file_descriptor = self._declare(library.xcb_get_file_descriptor, ctypes.c_int, connection) + self.get_setup = self._declare(library.xcb_get_setup, ctypes.c_void_p, connection) + self.setup_roots_iterator = self._declare(library.xcb_setup_roots_iterator, ScreenIterator, ctypes.c_void_p) + self.generate_id = self._declare(library.xcb_generate_id, ctypes.c_uint32, connection) + self.create_window = self._declare( + library.xcb_create_window, + Cookie, + connection, + ctypes.c_uint8, + window, + window, + ctypes.c_int16, + ctypes.c_int16, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint16, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ) + self.intern_atom = self._declare( + library.xcb_intern_atom, + Cookie, + connection, + ctypes.c_uint8, + ctypes.c_uint16, + ctypes.c_char_p, + ) + self.intern_atom_reply = self._declare( + library.xcb_intern_atom_reply, + ctypes.POINTER(InternAtomReply), + connection, + Cookie, + ctypes.c_void_p, + ) + self.get_selection_owner = self._declare(library.xcb_get_selection_owner, Cookie, connection, atom) + self.get_selection_owner_reply = self._declare( + library.xcb_get_selection_owner_reply, + ctypes.POINTER(SelectionOwnerReply), + connection, + Cookie, + ctypes.c_void_p, + ) + self.convert_selection = self._declare( + library.xcb_convert_selection, + Cookie, + connection, + window, + atom, + atom, + atom, + ctypes.c_uint32, + ) + self.get_property = self._declare( + library.xcb_get_property, + Cookie, + connection, + ctypes.c_uint8, + window, + atom, + atom, + ctypes.c_uint32, + ctypes.c_uint32, + ) + self.get_property_reply = self._declare( + library.xcb_get_property_reply, + ctypes.POINTER(PropertyReply), + connection, + Cookie, + ctypes.c_void_p, + ) + self.get_property_value = self._declare( + library.xcb_get_property_value, + ctypes.c_void_p, + ctypes.POINTER(PropertyReply), + ) + self.get_property_value_length = self._declare( + library.xcb_get_property_value_length, + ctypes.c_int, + ctypes.POINTER(PropertyReply), + ) + self.poll_for_event = self._declare(library.xcb_poll_for_event, ctypes.POINTER(GenericEvent), connection) + + @staticmethod + def _declare(function: Any, returns: Any, *takes: Any) -> Any: + function.restype = returns + function.argtypes = list(takes) + return function + + +@cache +def load_xcb() -> Optional[XcbLibrary]: + """libxcb, loaded once, while the system provides it.""" + try: + library = ctypes.CDLL(XCB_LIBRARY) + except OSError: + return None + + return XcbLibrary(library) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py new file mode 100644 index 000000000..fd4221742 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py @@ -0,0 +1,31 @@ +import time + +from sampletones_application.utils.gui.clipboard.backends.x11.connection import XcbConnection +from sampletones_application.utils.gui.clipboard.backends.x11.library import XcbLibrary +from sampletones_application.utils.gui.clipboard.backends.x11.transfer import SelectionTransfer +from sampletones_shared.logger import logger + + +class XcbSelectionReader: + """Reads the clipboard's text over a connection of its own to ``display``, one per read. + + A fresh connection leaves nothing behind from a read before it, so an answer arriving after + its deadline lands on a connection already closed. + """ + + def __init__(self, library: XcbLibrary, display: str) -> None: + self._library = library + self._display = display + + def read_text(self, seconds: float) -> str: + """The clipboard's text, or an empty text once ``seconds`` pass before the owner hands it over. + + A display that refuses the connection reads as an empty clipboard too, and is logged. + """ + deadline = time.monotonic() + seconds + try: + with XcbConnection(self._library, self._display) as connection: + return SelectionTransfer(connection, deadline=deadline).text() + except ConnectionError as error: + logger.warning(f"The clipboard was read as empty: {error}") + return "" diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py new file mode 100644 index 000000000..feea15158 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py @@ -0,0 +1,134 @@ +from typing import Final, List, Optional, Protocol, Tuple + +from sampletones_application.utils.gui.clipboard.backends.x11.connection import ( + PropertyNotify, + PropertyValue, + SelectionEvent, + SelectionNotify, +) + +CLIPBOARD: Final[str] = "CLIPBOARD" +INCREMENTAL: Final[str] = "INCR" +LANDING_PROPERTY: Final[str] = "SAMPLETONES_CLIPBOARD" +NO_WINDOW: Final[int] = 0 +NO_PROPERTY: Final[int] = 0 +TEXT_TARGETS: Final[Tuple[Tuple[str, str], ...]] = ( + ("UTF8_STRING", "utf-8"), + ("STRING", "latin-1"), +) +UNREADABLE_BYTES: Final[str] = "replace" + + +class SelectionConnection(Protocol): + """The calls a selection transfer makes on its connection to the X server.""" + + def atom(self, name: str) -> int: ... + + def selection_owner(self, selection: int) -> int: ... + + def create_requestor(self) -> int: ... + + def convert_selection( + self, + *, + requestor: int, + selection: int, + target: int, + landing: int, + ) -> None: ... + + def take_property(self, window: int, atom: int) -> PropertyValue: ... + + def next_event(self, deadline: float) -> Optional[SelectionEvent]: ... + + +class SelectionTransfer: + """One conversation with the application holding the clipboard, carrying its text across. + + The conversation is the one the ICCCM lays out: the requestor asks the owner to convert the + clipboard to a text type, the owner writes the text to a property of the requestor's window + and says so, and a long text comes in pieces, each written once the requestor has taken the + one before. UTF-8 is asked for first and Latin-1 after it, the order GLFW asks in. + + The whole conversation runs until ``deadline``, read against ``time.monotonic``, and an owner + still silent then reads as holding no text, as does a clipboard with no owner. + """ + + def __init__(self, connection: SelectionConnection, *, deadline: float) -> None: + self._connection = connection + self._deadline = deadline + + def text(self) -> str: + clipboard = self._connection.atom(CLIPBOARD) + if self._connection.selection_owner(clipboard) == NO_WINDOW: + return "" + + try: + return self._first_text(clipboard) + except TimeoutError: + return "" + + def _first_text(self, clipboard: int) -> str: + """The clipboard's text in the first type the owner writes it as, or an empty text.""" + requestor = self._connection.create_requestor() + landing = self._connection.atom(LANDING_PROPERTY) + for target, encoding in TEXT_TARGETS: + data = self._convert(clipboard, self._connection.atom(target), requestor, landing) + if data is not None: + return data.decode(encoding, errors=UNREADABLE_BYTES) + + return "" + + def _convert( + self, + clipboard: int, + target: int, + requestor: int, + landing: int, + ) -> Optional[bytes]: + """The clipboard's bytes as ``target``, or ``None`` once the owner declines that type.""" + self._connection.convert_selection( + requestor=requestor, + selection=clipboard, + target=target, + landing=landing, + ) + if self._await_notify(requestor) == NO_PROPERTY: + return None + + value = self._connection.take_property(requestor, landing) + if value.value_type != self._connection.atom(INCREMENTAL): + return value.data + + return self._receive_pieces(requestor, landing) + + def _await_notify(self, requestor: int) -> int: + """The property the owner names in its answer, the changes announced ahead of it passed over.""" + while True: + match self._next_event(): + case SelectionNotify(requestor=answered, landing=landing) if answered == requestor: + return landing + + def _receive_pieces(self, requestor: int, landing: int) -> bytes: + """Takes each piece as the owner writes it, until the empty piece that closes the text.""" + pieces: List[bytes] = [] + while True: + match self._next_event(): + case PropertyNotify(window=window, atom=atom, new_value=True) if (window, atom) == (requestor, landing): + piece = self._connection.take_property(requestor, landing).data + if not piece: + return b"".join(pieces) + + pieces.append(piece) + + def _next_event(self) -> SelectionEvent: + """The next event the owner's side of the conversation brings. + + Raises: + TimeoutError: If the deadline passes before the next event. + """ + event = self._connection.next_event(self._deadline) + if event is None: + raise TimeoutError("The application holding the clipboard answered nothing in time") + + return event diff --git a/src/sampletones_application/utils/gui/clipboard/copy_button.py b/src/sampletones_application/utils/gui/clipboard/copy_button.py new file mode 100644 index 000000000..71bfa6bc2 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/copy_button.py @@ -0,0 +1,26 @@ +from functools import partial +from typing import Final + +from sampletones_application.utils.callbacks.delay import call_after +from sampletones_application.utils.gui.clipboard.selection import select_text_clipboard +from sampletones_application.utils.gui.dpg import dpg_configure_item + +COPIED_LABEL_SECONDS: Final[float] = 1.0 + + +def copy_to_clipboard( + text: str, + label: str, + button_tag: str, + *, + copied_label: str, +) -> None: + """Puts ``text`` on the clipboard, with the button reading ``copied_label`` for a moment. + + The button's own label comes back on the render thread once the moment has passed, the thread + the button lives on. + """ + select_text_clipboard().write(text) + + dpg_configure_item(button_tag, label=copied_label) + call_after(COPIED_LABEL_SECONDS, partial(dpg_configure_item, button_tag, label=label)) diff --git a/src/sampletones_application/utils/gui/clipboard/protocol.py b/src/sampletones_application/utils/gui/clipboard/protocol.py new file mode 100644 index 000000000..8c8ded8bc --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/protocol.py @@ -0,0 +1,20 @@ +from typing import Protocol + +from sampletones_shared.types.callback import StringCallback + + +class TextClipboard(Protocol): + """The clipboard the desktop shares between applications, as text going out and coming back. + + The application holding the clipboard hands its text over in its own time, so a read names what + receives the text once it has arrived. The selector in ``selection`` picks the implementation + that fits the running environment. + """ + + def read(self, on_text: StringCallback) -> None: + """Hands the text standing on the clipboard to ``on_text``, on the render thread. + + A read is asked for on the render thread, where every gesture reaching the clipboard runs. + """ + + def write(self, text: str) -> None: ... diff --git a/src/sampletones_application/utils/gui/clipboard/selection.py b/src/sampletones_application/utils/gui/clipboard/selection.py new file mode 100644 index 000000000..a0cbc556c --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/selection.py @@ -0,0 +1,34 @@ +import os +from typing import Final, Optional + +from sampletones_application.utils.gui.clipboard.backends.dearpygui import DearPyGuiTextClipboard +from sampletones_application.utils.gui.clipboard.backends.x11.clipboard import X11TextClipboard +from sampletones_application.utils.gui.clipboard.backends.x11.library import load_xcb +from sampletones_application.utils.gui.clipboard.backends.x11.reader import XcbSelectionReader +from sampletones_application.utils.gui.clipboard.protocol import TextClipboard +from sampletones_shared.utils.system.system import System + +DISPLAY_VARIABLE: Final[str] = "DISPLAY" + + +def select_text_clipboard() -> TextClipboard: + """Returns the clipboard implementation that fits the running environment. + + On Linux, DearPyGui draws on an X display, and the X11 clipboard reads over a connection of its + own to that same display while libxcb, which the X client library itself stands on, loads. On + other platforms DearPyGui's own clipboard serves reads and writes alike. + """ + match System.current(): + case System.LINUX: + return _x11_clipboard() or DearPyGuiTextClipboard() + case _: + return DearPyGuiTextClipboard() + + +def _x11_clipboard() -> Optional[TextClipboard]: + display = os.environ.get(DISPLAY_VARIABLE) + library = load_xcb() + if not display or library is None: + return None + + return X11TextClipboard(XcbSelectionReader(library, display)) diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/__init__.py b/tests/unit/sampletones_application/utils/gui/clipboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/__init__.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py new file mode 100644 index 000000000..d445c2679 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py @@ -0,0 +1,173 @@ +import threading +import time +from typing import Callable, Final, List, Tuple + +import pytest + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.clipboard.backends.x11.clipboard import ( + CLIPBOARD_ANSWER_SECONDS, + X11TextClipboard, +) +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from tests.suite.application import draw_frame +from tests.suite.base import BaseTestSuite + +TEXT: Final[str] = "SampleToNES/1 order rows=1 positions=0..0\n03" +WAIT_LIMIT_SECONDS: Final[float] = 5.0 +FRAME_PAUSE_SECONDS: Final[float] = 0.001 + + +class HeldReader: + """An owner that hands its text over once a case lets it, counting the transfers asked of it.""" + + def __init__(self, text: str) -> None: + self.text = text + self.release = threading.Event() + self.threads: List[threading.Thread] = [] + self.deadlines: List[float] = [] + + def read_text(self, seconds: float) -> str: + self.threads.append(threading.current_thread()) + self.deadlines.append(seconds) + self.release.wait(WAIT_LIMIT_SECONDS) + return self.text + + +class FailingReader: + """A reader whose transfer breaks, the way an unforeseen failure on the worker would.""" + + def __init__(self) -> None: + self.transfers: int = 0 + + def read_text(self, seconds: float) -> str: + self.transfers += 1 + raise RuntimeError("the transfer broke") + + +class Answers: + """The texts a read was answered with, each beside the thread that answered it.""" + + def __init__(self) -> None: + self.received: List[Tuple[str, threading.Thread]] = [] + + def __call__(self, text: str) -> None: + self.received.append((text, threading.current_thread())) + + @property + def texts(self) -> List[str]: + return [text for text, _ in self.received] + + +def _draw_until(answered: Callable[[], bool]) -> None: + """Draws frames until ``answered`` holds, the way the render loop drains between frames.""" + deadline = time.monotonic() + WAIT_LIMIT_SECONDS + while not answered() and time.monotonic() < deadline: + draw_frame() + time.sleep(FRAME_PAUSE_SECONDS) + + +@pytest.mark.usefixtures("live_queue") +class TestAReadAnswersOnTheRenderThread(BaseTestSuite): + """The owner is read on a worker, and its text reaches the thread that drains the queue.""" + + def test_the_text_reaches_the_thread_draining_the_queue(self) -> None: + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + reader.release.set() + _draw_until(lambda: bool(answers.received)) + + assert answers.received == [(TEXT, threading.current_thread())] + assert reader.threads[0] is not threading.current_thread() + + def test_the_owner_has_the_answer_span_to_hand_its_text_over(self) -> None: + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + reader.release.set() + _draw_until(lambda: bool(answers.received)) + + assert reader.deadlines == [CLIPBOARD_ANSWER_SECONDS] + + def test_the_frames_go_on_while_the_owner_is_silent(self) -> None: + """This application's own window hands its text over as a frame polls its events.""" + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + for _ in range(3): + draw_frame() + + assert answers.received == [] + + reader.release.set() + _draw_until(lambda: bool(answers.received)) + + assert answers.texts == [TEXT] + + +@pytest.mark.usefixtures("live_queue") +class TestReadsShareATransfer(BaseTestSuite): + """A burst of reads — a menu opening and a paste after it — asks the owner once.""" + + def test_reads_asked_while_one_is_out_share_its_answer(self) -> None: + reader = HeldReader(TEXT) + clipboard = X11TextClipboard(reader) + first, second = Answers(), Answers() + + clipboard.read(first) + clipboard.read(second) + reader.release.set() + _draw_until(lambda: bool(first.received and second.received)) + + assert (first.texts, second.texts) == ([TEXT], [TEXT]) + assert len(reader.threads) == 1 + + def test_a_read_after_an_answer_asks_the_owner_again(self) -> None: + """The clipboard changes between gestures, so each answered read leaves the next to ask.""" + reader = HeldReader(TEXT) + reader.release.set() + clipboard = X11TextClipboard(reader) + first, second = Answers(), Answers() + + clipboard.read(first) + _draw_until(lambda: bool(first.received)) + reader.text = "the text copied since" + clipboard.read(second) + _draw_until(lambda: bool(second.received)) + + assert second.texts == ["the text copied since"] + assert len(reader.threads) == 2 + + @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") + def test_a_broken_transfer_answers_empty_and_the_next_read_asks_again(self) -> None: + reader = FailingReader() + clipboard = X11TextClipboard(reader) + first, second = Answers(), Answers() + + clipboard.read(first) + _draw_until(lambda: bool(first.received)) + clipboard.read(second) + _draw_until(lambda: bool(second.received)) + + assert (first.texts, second.texts) == ([""], [""]) + assert reader.transfers == 2 + + +@pytest.mark.usefixtures("live_queue") +class TestShutdown(BaseTestSuite): + def test_an_answer_on_its_way_at_shutdown_is_dropped(self) -> None: + """The shutdown stops the queue before the context goes, so the answer reaches no widget.""" + reader = HeldReader(TEXT) + answers = Answers() + + X11TextClipboard(reader).read(answers) + CallbackQueue.stop() + reader.release.set() + SingleThreadExecutor.join_all(timeout=WAIT_LIMIT_SECONDS) + draw_frame() + + assert answers.received == [] diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py new file mode 100644 index 000000000..398349943 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py @@ -0,0 +1,190 @@ +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, Dict, Final, List, Mapping, Optional, Tuple + +import pytest + +from sampletones_application.utils.gui.clipboard.backends.x11.connection import ( + PropertyNotify, + PropertyValue, + SelectionEvent, + SelectionNotify, +) +from sampletones_application.utils.gui.clipboard.backends.x11.transfer import ( + INCREMENTAL, + NO_PROPERTY, + NO_WINDOW, + SelectionTransfer, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +UTF8: Final[str] = "UTF8_STRING" +LATIN1: Final[str] = "STRING" +OWNER_WINDOW: Final[int] = 0x400001 +REQUESTOR_WINDOW: Final[int] = 0x600001 +DEADLINE: Final[float] = 0.0 +BLOCK_TEXT: Final[str] = "SampleToNES/1 order rows=1 positions=0..1\n00 03" +WIDE_TEXT: Final[str] = "Zażółć gęślą jaźń" + + +@dataclass(frozen=True) +class Answer: + """How the owner answers a request for one text type: in one write, in pieces, or with silence. + + No pieces at all is a type the owner declines. + """ + + pieces: Tuple[bytes, ...] = () + incremental: bool = False + silent_after: Optional[int] = None + + +class ScriptedOwner: + """The X server and the application holding the clipboard, answering from a script. + + It answers the way an ICCCM owner does: a property written and announced before the + notification naming it, and a text sent in pieces written a piece at a time, each once the + requestor has taken the one before. Events it has no more of mean the deadline passed. + """ + + def __init__(self, answers: Mapping[str, Answer], *, owner: int = OWNER_WINDOW) -> None: + self.answers = answers + self.owner = owner + self.asked: List[str] = [] + self._atoms: Dict[str, int] = {} + self._events: Deque[SelectionEvent] = deque() + self._properties: Dict[int, PropertyValue] = {} + self._pieces: Deque[bytes] = deque() + self._pieces_sent: int = 0 + self._answer: Answer = Answer() + + def atom(self, name: str) -> int: + return self._atoms.setdefault(name, len(self._atoms) + 1) + + def selection_owner(self, selection: int) -> int: + return self.owner + + def create_requestor(self) -> int: + return REQUESTOR_WINDOW + + def convert_selection( + self, + *, + requestor: int, + selection: int, + target: int, + landing: int, + ) -> None: + name = self._name(target) + self.asked.append(name) + self._answer = self.answers.get(name, Answer()) + if self._answer.silent_after == 0: + return + + if not self._answer.pieces: + self._events.append(SelectionNotify(requestor=requestor, landing=NO_PROPERTY)) + return + + if self._answer.incremental: + self._pieces = deque(self._answer.pieces + (b"",)) + self._write(landing, PropertyValue(value_type=self.atom(INCREMENTAL), data=b"\x00\x00\x10\x00")) + else: + self._write(landing, PropertyValue(value_type=target, data=self._answer.pieces[0])) + + self._events.append(SelectionNotify(requestor=requestor, landing=landing)) + + def take_property(self, window: int, atom: int) -> PropertyValue: + value = self._properties.pop(atom, PropertyValue(value_type=0, data=b"")) + self._events.append(PropertyNotify(window=window, atom=atom, new_value=False)) + if self._pieces and self._answer.silent_after != self._pieces_sent: + self._pieces_sent += 1 + self._write(atom, PropertyValue(value_type=self.atom(UTF8), data=self._pieces.popleft())) + + return value + + def next_event(self, deadline: float) -> Optional[SelectionEvent]: + return self._events.popleft() if self._events else None + + def _write(self, atom: int, value: PropertyValue) -> None: + self._properties[atom] = value + self._events.append(PropertyNotify(window=REQUESTOR_WINDOW, atom=atom, new_value=True)) + + def _name(self, atom: int) -> str: + return next(name for name, known in self._atoms.items() if known == atom) + + +def _split(data: bytes, *, size: int) -> Tuple[bytes, ...]: + return tuple(data[start : start + size] for start in range(0, len(data), size)) + + +class TestTheTextATransferReads(BaseTestSuite): + """The owner answers in whichever way it answers, and the transfer reads the text it carried.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + answers: Mapping[str, Answer] = field(default_factory=dict) + text: str + + test_cases = ( + TestCase( + label="text_written_as_utf8", + answers={UTF8: Answer(pieces=(WIDE_TEXT.encode("utf-8"),))}, + text=WIDE_TEXT, + ), + TestCase( + label="latin1_from_an_owner_declining_utf8", + answers={LATIN1: Answer(pieces=("café".encode("latin-1"),))}, + text="café", + ), + TestCase( + label="a_long_text_in_pieces_split_inside_a_character", + answers={UTF8: Answer(pieces=_split(WIDE_TEXT.encode("utf-8"), size=3), incremental=True)}, + text=WIDE_TEXT, + ), + TestCase( + label="an_owner_declining_every_text_type", + answers={}, + text="", + ), + TestCase( + label="an_owner_staying_silent", + answers={UTF8: Answer(pieces=(BLOCK_TEXT.encode(),), silent_after=0)}, + text="", + ), + TestCase( + label="an_owner_falling_silent_between_pieces", + answers={UTF8: Answer(pieces=_split(BLOCK_TEXT.encode(), size=8), incremental=True, silent_after=2)}, + text="", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_transfer_reads_the_text_the_owner_carried(self, test_case: TestCase) -> None: + owner = ScriptedOwner(test_case.answers) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == test_case.text + + +class TestWhatATransferAsksFor(BaseTestSuite): + """A transfer asks for what it needs and no more, so a quiet clipboard costs nothing.""" + + def test_a_clipboard_with_no_owner_reads_as_empty_unasked(self) -> None: + owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),))}, owner=NO_WINDOW) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == "" + assert owner.asked == [] + + def test_utf8_is_asked_for_first(self) -> None: + owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),)), LATIN1: Answer(pieces=(b"other",))}) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == BLOCK_TEXT + assert owner.asked == [UTF8] + + def test_a_silent_owner_is_asked_once(self) -> None: + """The deadline covers the whole conversation, so silence ends it.""" + owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),), silent_after=0)}) + + SelectionTransfer(owner, deadline=DEADLINE).text() + + assert owner.asked == [UTF8] diff --git a/tests/unit/sampletones_application/utils/gui/test_clipboard.py b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py similarity index 86% rename from tests/unit/sampletones_application/utils/gui/test_clipboard.py rename to tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py index fac29ff83..94bc27912 100644 --- a/tests/unit/sampletones_application/utils/gui/test_clipboard.py +++ b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py @@ -16,13 +16,14 @@ from sampletones_application.ui.themes.items import ThemeItems from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_application.utils.gui.clipboard import ( +from sampletones_application.utils.gui.clipboard import copy_button as copy_button_module +from sampletones_application.utils.gui.clipboard.copy_button import ( COPIED_LABEL_SECONDS, - SystemTextClipboard, copy_to_clipboard, ) from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource +from sampletones_shared.types.callback import StringCallback from tests.suite.application import ManualClock, draw_frame from tests.suite.base import BaseTestSuite @@ -35,12 +36,25 @@ QUEUE_LOGGER: Final[str] = "sampletones_application.utils.callbacks.queue.logger" +class WrittenClipboard: + """The desktop's clipboard as a copy reaches it, keeping what was written for a case to read.""" + + def __init__(self) -> None: + self.texts: List[str] = [] + + def read(self, on_text: StringCallback) -> None: + on_text(self.texts[-1] if self.texts else "") + + def write(self, text: str) -> None: + self.texts.append(text) + + @pytest.fixture def written(monkeypatch: pytest.MonkeyPatch) -> List[str]: """What reaches the desktop's clipboard, held here so a case leaves the reader's own alone.""" - texts: List[str] = [] - monkeypatch.setattr(SystemTextClipboard, "write", lambda _clipboard, text: texts.append(text)) - return texts + clipboard = WrittenClipboard() + monkeypatch.setattr(copy_button_module, "select_text_clipboard", lambda: clipboard) + return clipboard.texts @pytest.fixture diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py b/tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py new file mode 100644 index 000000000..3be504920 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/test_selection.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from typing import Final, Optional, Type +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.utils.gui.clipboard.backends.dearpygui import DearPyGuiTextClipboard +from sampletones_application.utils.gui.clipboard.backends.x11.clipboard import X11TextClipboard +from sampletones_application.utils.gui.clipboard.protocol import TextClipboard +from sampletones_application.utils.gui.clipboard.selection import ( + DISPLAY_VARIABLE, + select_text_clipboard, +) +from sampletones_shared.utils.system.system import System +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +MODULE: Final[str] = "sampletones_application.utils.gui.clipboard.selection" +DISPLAY: Final[str] = ":7" + + +class TestTheClipboardFitsTheEnvironment(BaseTestSuite): + """Linux reads over a connection of its own while it can reach the display; elsewhere DearPyGui serves.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + system: System + display: Optional[str] + xcb_loads: bool + expected: Type[TextClipboard] + + test_cases = ( + TestCase( + label="linux_on_a_display", + system=System.LINUX, + display=DISPLAY, + xcb_loads=True, + expected=X11TextClipboard, + ), + TestCase( + label="linux_naming_no_display", + system=System.LINUX, + display=None, + xcb_loads=True, + expected=DearPyGuiTextClipboard, + ), + TestCase( + label="linux_lacking_libxcb", + system=System.LINUX, + display=DISPLAY, + xcb_loads=False, + expected=DearPyGuiTextClipboard, + ), + TestCase( + label="windows", + system=System.WINDOWS, + display=None, + xcb_loads=False, + expected=DearPyGuiTextClipboard, + ), + TestCase( + label="macos", + system=System.MACOS, + display=DISPLAY, + xcb_loads=False, + expected=DearPyGuiTextClipboard, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_selected_clipboard_fits(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None: + if test_case.display is None: + monkeypatch.delenv(DISPLAY_VARIABLE, raising=False) + else: + monkeypatch.setenv(DISPLAY_VARIABLE, test_case.display) + + with ( + patch(f"{MODULE}.System.current", return_value=test_case.system), + patch(f"{MODULE}.load_xcb", return_value=MagicMock() if test_case.xcb_loads else None), + ): + clipboard = select_text_clipboard() + + assert isinstance(clipboard, test_case.expected) From 1adbd28be5697320cc1d2789883e99197df0e8a1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 20 Sep 2026 00:19:32 +0200 Subject: [PATCH 4/4] Minor improvements --- .../coordinators/tabs/sequencer/blocks.py | 26 ++++++--- .../utils/gui/clipboard/backends/dearpygui.py | 4 +- .../gui/clipboard/backends/x11/clipboard.py | 24 +++++---- .../gui/clipboard/backends/x11/library.py | 21 ++------ .../gui/clipboard/backends/x11/reader.py | 15 +++--- .../gui/clipboard/backends/x11/transfer.py | 12 +++-- .../utils/gui/clipboard/copy_button.py | 5 +- .../utils/gui/clipboard/protocol.py | 8 +-- .../coordinators/tabs/test_sequencer.py | 54 ++++++++++++++++--- .../clipboard/backends/x11/test_clipboard.py | 30 ++++++++--- .../clipboard/backends/x11/test_transfer.py | 27 ++++++++-- .../utils/gui/clipboard/test_copy_button.py | 44 ++------------- 12 files changed, 159 insertions(+), 111 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py index 0545a1ce6..18d3a1bdb 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py @@ -77,10 +77,11 @@ def can_paste_order(self) -> bool: return self.order_in_hand() is not None def read_clipboard(self, then: VoidCallback) -> None: - """Asks the system clipboard for its text, running ``then`` once the answer has landed. + """Asks the system clipboard for its text, running ``then`` once the read is over. The answer is what the pastes and the menus offering them read from then on, so a gesture - that asked first acts on the text standing on the clipboard as it answered. + that asked first acts on the text standing on the clipboard as it answered. A read the + clipboard leaves unanswered keeps the answer before it, and ``then`` runs on that one. """ self._text_clipboard.read(partial(self._take_clipboard_text, then)) @@ -88,10 +89,14 @@ def after_reading_clipboard( self, paste: Callable[GestureParams, None], ) -> Callable[GestureParams, None]: - """Holds a paste back until the system clipboard has answered, so it writes the block in hand then.""" + """Holds a paste back until the system clipboard has answered, so it writes the block in hand then. + + A clipboard that answers nothing leaves the text it holds unknown, and the paste it was + asked for is let go of rather than written from a block the answer would have stood ahead of. + """ def wrapped(*args: GestureParams.args, **kwargs: GestureParams.kwargs) -> None: - self.read_clipboard(partial(paste, *args, **kwargs)) + self._text_clipboard.read(partial(self._paste_on_answer, partial(paste, *args, **kwargs))) return wrapped @@ -169,6 +174,15 @@ def paste_order(self, cell: OrderCell) -> None: if block is not None: self._order_writer.write(block, cell) - def _take_clipboard_text(self, then: VoidCallback, text: str) -> None: - self._clipboard_text = text + def _take_clipboard_text(self, then: VoidCallback, text: Optional[str]) -> None: + if text is not None: + self._clipboard_text = text + then() + + def _paste_on_answer(self, paste: VoidCallback, text: Optional[str]) -> None: + if text is None: + return + + self._clipboard_text = text + paste() diff --git a/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py b/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py index cc129ca2f..d4930c020 100644 --- a/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py +++ b/src/sampletones_application/utils/gui/clipboard/backends/dearpygui.py @@ -2,7 +2,7 @@ import dearpygui.dearpygui as dpg -from sampletones_shared.types.callback import StringCallback +from sampletones_application.utils.gui.clipboard.protocol import ClipboardTextCallback class DearPyGuiTextClipboard: @@ -11,7 +11,7 @@ class DearPyGuiTextClipboard: The platform's clipboard hands its text over within the call, so a read answers at once. """ - def read(self, on_text: StringCallback) -> None: + def read(self, on_text: ClipboardTextCallback) -> None: on_text(cast(str, dpg.get_clipboard_text())) def write(self, text: str) -> None: diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py index 38b86031e..48e29fe35 100644 --- a/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py @@ -1,18 +1,22 @@ -from typing import Final, List, Protocol +from typing import Final, List, Optional, Protocol import dearpygui.dearpygui as dpg from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.clipboard.protocol import ClipboardTextCallback from sampletones_application.utils.parallelization.thread import SingleThreadExecutor -from sampletones_shared.types.callback import StringCallback CLIPBOARD_ANSWER_SECONDS: Final[float] = 1.0 class SelectionReader(Protocol): - """Reads the clipboard's text, waiting ``seconds`` at most for the application holding it.""" + """Reads the clipboard's text, waiting ``seconds`` at most for the application holding it. - def read_text(self, seconds: float) -> str: ... + An application answering nothing in that span leaves the text unknown, which reads as + ``None``. + """ + + def read_text(self, seconds: float) -> Optional[str]: ... class X11TextClipboard: @@ -22,7 +26,7 @@ class X11TextClipboard: long as that application takes, holding DearPyGui's lock and the interpreter meanwhile; an owner that stays silent stops every frame for good. This clipboard reads on a worker instead, giving the owner ``CLIPBOARD_ANSWER_SECONDS`` to hand its text over, and an owner silent past that - reads as holding no text. The frames keep being drawn meanwhile, which is also what lets this + answers nothing at all. The frames keep being drawn meanwhile, which is also what lets this application's own window answer: GLFW hands over the text it holds as a frame polls its events. The answer reaches the render thread through ``CallbackQueue``, so the shutdown that stops the @@ -33,9 +37,9 @@ class X11TextClipboard: def __init__(self, reader: SelectionReader) -> None: self._reader = reader self._executor = SingleThreadExecutor() - self._waiting: List[StringCallback] = [] + self._waiting: List[ClipboardTextCallback] = [] - def read(self, on_text: StringCallback) -> None: + def read(self, on_text: ClipboardTextCallback) -> None: self._waiting.append(on_text) if len(self._waiting) == 1: self._executor.execute(self._transfer, wait=True) @@ -44,18 +48,18 @@ def write(self, text: str) -> None: dpg.set_clipboard_text(text) def _transfer(self) -> None: - """Reads on the worker and hands the text to the render thread, an empty one if the read fails. + """Reads on the worker and hands the answer to the render thread, none where the read fails. The reads waiting on this transfer hear from it whichever way it ends, so the next read starts a transfer of its own, and a failure still reaches the thread's own report. """ - text = "" + text: Optional[str] = None try: text = self._reader.read_text(CLIPBOARD_ANSWER_SECONDS) finally: CallbackQueue.add(self._answer, text) - def _answer(self, text: str) -> None: + def _answer(self, text: Optional[str]) -> None: waiting, self._waiting = self._waiting, [] for on_text in waiting: on_text(text) diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py index 883bf0561..c48e6ad92 100644 --- a/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py @@ -18,24 +18,9 @@ class ScreenIterator(ctypes.Structure): class Screen(ctypes.Structure): - _fields_ = [ - ("root", ctypes.c_uint32), - ("default_colormap", ctypes.c_uint32), - ("white_pixel", ctypes.c_uint32), - ("black_pixel", ctypes.c_uint32), - ("current_input_masks", ctypes.c_uint32), - ("width_in_pixels", ctypes.c_uint16), - ("height_in_pixels", ctypes.c_uint16), - ("width_in_millimeters", ctypes.c_uint16), - ("height_in_millimeters", ctypes.c_uint16), - ("min_installed_maps", ctypes.c_uint16), - ("max_installed_maps", ctypes.c_uint16), - ("root_visual", ctypes.c_uint32), - ("backing_stores", ctypes.c_uint8), - ("save_unders", ctypes.c_uint8), - ("root_depth", ctypes.c_uint8), - ("allowed_depths_len", ctypes.c_uint8), - ] + """A screen as libxcb lays it out, read as far as the root window it opens with.""" + + _fields_ = [("root", ctypes.c_uint32)] class InternAtomReply(ctypes.Structure): diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py index fd4221742..8874354c6 100644 --- a/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py @@ -1,4 +1,5 @@ import time +from typing import Optional from sampletones_application.utils.gui.clipboard.backends.x11.connection import XcbConnection from sampletones_application.utils.gui.clipboard.backends.x11.library import XcbLibrary @@ -17,15 +18,17 @@ def __init__(self, library: XcbLibrary, display: str) -> None: self._library = library self._display = display - def read_text(self, seconds: float) -> str: - """The clipboard's text, or an empty text once ``seconds`` pass before the owner hands it over. + def read_text(self, seconds: float) -> Optional[str]: + """The clipboard's text, or ``None`` where the read went unanswered. - A display that refuses the connection reads as an empty clipboard too, and is logged. + An owner staying silent past ``seconds`` and a display refusing the connection both leave + the clipboard's text unknown, which reads as no answer and is logged. A clipboard holding + nothing answers with an empty text. """ deadline = time.monotonic() + seconds try: with XcbConnection(self._library, self._display) as connection: return SelectionTransfer(connection, deadline=deadline).text() - except ConnectionError as error: - logger.warning(f"The clipboard was read as empty: {error}") - return "" + except (ConnectionError, TimeoutError) as error: + logger.warning(f"The clipboard answered nothing: {error}") + return None diff --git a/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py index feea15158..f1bb68e79 100644 --- a/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py @@ -51,7 +51,7 @@ class SelectionTransfer: one before. UTF-8 is asked for first and Latin-1 after it, the order GLFW asks in. The whole conversation runs until ``deadline``, read against ``time.monotonic``, and an owner - still silent then reads as holding no text, as does a clipboard with no owner. + still silent then ends it with a :class:`TimeoutError`. """ def __init__(self, connection: SelectionConnection, *, deadline: float) -> None: @@ -59,14 +59,16 @@ def __init__(self, connection: SelectionConnection, *, deadline: float) -> None: self._deadline = deadline def text(self) -> str: + """The clipboard's text, an empty one where it holds nothing a reader can take. + + Raises: + TimeoutError: If the application holding the clipboard answers nothing in time. + """ clipboard = self._connection.atom(CLIPBOARD) if self._connection.selection_owner(clipboard) == NO_WINDOW: return "" - try: - return self._first_text(clipboard) - except TimeoutError: - return "" + return self._first_text(clipboard) def _first_text(self, clipboard: int) -> str: """The clipboard's text in the first type the owner writes it as, or an empty text.""" diff --git a/src/sampletones_application/utils/gui/clipboard/copy_button.py b/src/sampletones_application/utils/gui/clipboard/copy_button.py index 71bfa6bc2..dce14a78f 100644 --- a/src/sampletones_application/utils/gui/clipboard/copy_button.py +++ b/src/sampletones_application/utils/gui/clipboard/copy_button.py @@ -1,8 +1,9 @@ from functools import partial from typing import Final +import dearpygui.dearpygui as dpg + from sampletones_application.utils.callbacks.delay import call_after -from sampletones_application.utils.gui.clipboard.selection import select_text_clipboard from sampletones_application.utils.gui.dpg import dpg_configure_item COPIED_LABEL_SECONDS: Final[float] = 1.0 @@ -20,7 +21,7 @@ def copy_to_clipboard( The button's own label comes back on the render thread once the moment has passed, the thread the button lives on. """ - select_text_clipboard().write(text) + dpg.set_clipboard_text(text) dpg_configure_item(button_tag, label=copied_label) call_after(COPIED_LABEL_SECONDS, partial(dpg_configure_item, button_tag, label=label)) diff --git a/src/sampletones_application/utils/gui/clipboard/protocol.py b/src/sampletones_application/utils/gui/clipboard/protocol.py index 8c8ded8bc..d62c5b018 100644 --- a/src/sampletones_application/utils/gui/clipboard/protocol.py +++ b/src/sampletones_application/utils/gui/clipboard/protocol.py @@ -1,6 +1,6 @@ -from typing import Protocol +from typing import Callable, Optional, Protocol -from sampletones_shared.types.callback import StringCallback +ClipboardTextCallback = Callable[[Optional[str]], None] class TextClipboard(Protocol): @@ -11,10 +11,12 @@ class TextClipboard(Protocol): that fits the running environment. """ - def read(self, on_text: StringCallback) -> None: + def read(self, on_text: ClipboardTextCallback) -> None: """Hands the text standing on the clipboard to ``on_text``, on the render thread. A read is asked for on the render thread, where every gesture reaching the clipboard runs. + An application that gives no answer leaves ``on_text`` with ``None``, which stands apart + from the empty text a clipboard holding nothing reads as. """ def write(self, text: str) -> None: ... diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index a31ff6a91..f430990c4 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime from pathlib import Path -from typing import Dict, Final, List +from typing import Dict, Final, List, Optional from unittest.mock import MagicMock import pytest @@ -41,6 +41,7 @@ from sampletones_application.ui.panels.sequencer.order.panel import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import panel as tracker_module from sampletones_application.ui.panels.sequencer.tracker.panel import GUISequencerTrackerPanel +from sampletones_application.utils.gui.clipboard.protocol import ClipboardTextCallback from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS from sampletones_application.view_model.sequencer.region import ( OrderCell, @@ -69,7 +70,6 @@ InvalidReconstructionValuesError, MalformedInstrumentError, ) -from sampletones_shared.types.callback import StringCallback from tests.suite.language import FakeLanguageManager FREQUENCY_MISMATCH_MESSAGE_KEY: Final[str] = "global.dialog.message.frequency_mismatch" @@ -1663,15 +1663,15 @@ class FakeTextClipboard: """The desktop's clipboard, held in memory so a test reads what a copy put there. A read is answered at once, the way DearPyGui's own clipboard answers, until a case holds the - answers back to stand for an application that hands its text over later. + answers back to stand for an application that hands its text over later, or none at all. """ def __init__(self) -> None: self.text: str = "" - self.unanswered: List[StringCallback] = [] + self.unanswered: List[ClipboardTextCallback] = [] self._answers_held: bool = False - def read(self, on_text: StringCallback) -> None: + def read(self, on_text: ClipboardTextCallback) -> None: if self._answers_held: self.unanswered.append(on_text) return @@ -1686,9 +1686,16 @@ def hold_answers(self) -> None: def answer(self) -> None: """Hands the text standing now to every read still waiting, in the order they asked.""" + self._hand_over(self.text) + + def silence(self) -> None: + """Leaves every read still waiting with no answer, the way an owner that never replies does.""" + self._hand_over(None) + + def _hand_over(self, text: Optional[str]) -> None: waiting, self.unanswered = self.unanswered, [] for on_text in waiting: - on_text(self.text) + on_text(text) def _text_clipboard(coordinator: SequencerTabCoordinator) -> FakeTextClipboard: @@ -2090,3 +2097,38 @@ def test_a_menu_s_offer_follows_the_answer_it_asked_for( assert answered == [True] assert coordinator._blocks.can_paste_order() + + def test_a_paste_the_clipboard_answers_nothing_to_writes_nothing( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """What the clipboard holds stays unknown, so the block copied here is not written in its place.""" + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + _place_transpose(coordinator, 5) + coordinator._blocks.copy_tracker(PULSE1_CELL) + clipboard.hold_answers() + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, channel=ChannelName.PULSE1)) + clipboard.silence() + + assert coordinator._sequencer_tracker_logic.row(ChannelName.PULSE1, 1).transpose is None + assert len(coordinator._history.entries) == recorded + + def test_a_menu_the_clipboard_answers_nothing_to_keeps_its_last_answer( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + clipboard = _text_clipboard(coordinator) + clipboard.write("SampleToNES/1 order rows=1 positions=0..0\n03") + coordinator._sequencer_order_panel.refresh_paste_block(lambda: None) + clipboard.hold_answers() + answered: List[bool] = [] + + coordinator._sequencer_order_panel.refresh_paste_block(lambda: answered.append(True)) + clipboard.silence() + + assert answered == [True] + assert coordinator._blocks.can_paste_order() diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py index d445c2679..9e9548910 100644 --- a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py @@ -1,6 +1,6 @@ import threading import time -from typing import Callable, Final, List, Tuple +from typing import Callable, Final, List, Optional, Tuple import pytest @@ -40,22 +40,29 @@ class FailingReader: def __init__(self) -> None: self.transfers: int = 0 - def read_text(self, seconds: float) -> str: + def read_text(self, seconds: float) -> Optional[str]: self.transfers += 1 raise RuntimeError("the transfer broke") +class SilentReader: + """An owner that answers nothing in the span it is given, the way a stopped program does.""" + + def read_text(self, seconds: float) -> Optional[str]: + return None + + class Answers: """The texts a read was answered with, each beside the thread that answered it.""" def __init__(self) -> None: - self.received: List[Tuple[str, threading.Thread]] = [] + self.received: List[Tuple[Optional[str], threading.Thread]] = [] - def __call__(self, text: str) -> None: + def __call__(self, text: Optional[str]) -> None: self.received.append((text, threading.current_thread())) @property - def texts(self) -> List[str]: + def texts(self) -> List[Optional[str]]: return [text for text, _ in self.received] @@ -92,6 +99,15 @@ def test_the_owner_has_the_answer_span_to_hand_its_text_over(self) -> None: assert reader.deadlines == [CLIPBOARD_ANSWER_SECONDS] + def test_an_owner_answering_nothing_reaches_the_reads_as_no_answer(self) -> None: + """The text the clipboard holds stays unknown, which stands apart from an empty clipboard.""" + answers = Answers() + + X11TextClipboard(SilentReader()).read(answers) + _draw_until(lambda: bool(answers.received)) + + assert answers.texts == [None] + def test_the_frames_go_on_while_the_owner_is_silent(self) -> None: """This application's own window hands its text over as a frame polls its events.""" reader = HeldReader(TEXT) @@ -143,7 +159,7 @@ def test_a_read_after_an_answer_asks_the_owner_again(self) -> None: assert len(reader.threads) == 2 @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") - def test_a_broken_transfer_answers_empty_and_the_next_read_asks_again(self) -> None: + def test_a_broken_transfer_answers_nothing_and_the_next_read_asks_again(self) -> None: reader = FailingReader() clipboard = X11TextClipboard(reader) first, second = Answers(), Answers() @@ -153,7 +169,7 @@ def test_a_broken_transfer_answers_empty_and_the_next_read_asks_again(self) -> N clipboard.read(second) _draw_until(lambda: bool(second.received)) - assert (first.texts, second.texts) == ([""], [""]) + assert (first.texts, second.texts) == ([None], [None]) assert reader.transfers == 2 diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py index 398349943..12ab51595 100644 --- a/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py @@ -147,23 +147,39 @@ class TestCase(BaseRegularTestCase): answers={}, text="", ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_transfer_reads_the_text_the_owner_carried(self, test_case: TestCase) -> None: + owner = ScriptedOwner(test_case.answers) + + assert SelectionTransfer(owner, deadline=DEADLINE).text() == test_case.text + + +class TestAnOwnerThatFallsSilent(BaseTestSuite): + """Silence leaves the clipboard's text unknown, which the transfer ends the conversation over.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + answers: Mapping[str, Answer] + + test_cases = ( TestCase( label="an_owner_staying_silent", answers={UTF8: Answer(pieces=(BLOCK_TEXT.encode(),), silent_after=0)}, - text="", ), TestCase( label="an_owner_falling_silent_between_pieces", answers={UTF8: Answer(pieces=_split(BLOCK_TEXT.encode(), size=8), incremental=True, silent_after=2)}, - text="", ), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) - def test_the_transfer_reads_the_text_the_owner_carried(self, test_case: TestCase) -> None: + def test_the_transfer_ends_the_conversation_over_the_silence(self, test_case: TestCase) -> None: owner = ScriptedOwner(test_case.answers) - assert SelectionTransfer(owner, deadline=DEADLINE).text() == test_case.text + with pytest.raises(TimeoutError): + SelectionTransfer(owner, deadline=DEADLINE).text() class TestWhatATransferAsksFor(BaseTestSuite): @@ -185,6 +201,7 @@ def test_a_silent_owner_is_asked_once(self) -> None: """The deadline covers the whole conversation, so silence ends it.""" owner = ScriptedOwner({UTF8: Answer(pieces=(BLOCK_TEXT.encode(),), silent_after=0)}) - SelectionTransfer(owner, deadline=DEADLINE).text() + with pytest.raises(TimeoutError): + SelectionTransfer(owner, deadline=DEADLINE).text() assert owner.asked == [UTF8] diff --git a/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py index 94bc27912..6d6e2d194 100644 --- a/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py +++ b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py @@ -1,4 +1,3 @@ -import threading from typing import Final, Iterator, List from unittest.mock import patch @@ -15,7 +14,6 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.themes.items import ThemeItems from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.gui.clipboard import copy_button as copy_button_module from sampletones_application.utils.gui.clipboard.copy_button import ( COPIED_LABEL_SECONDS, @@ -23,7 +21,6 @@ ) from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource -from sampletones_shared.types.callback import StringCallback from tests.suite.application import ManualClock, draw_frame from tests.suite.base import BaseTestSuite @@ -36,25 +33,12 @@ QUEUE_LOGGER: Final[str] = "sampletones_application.utils.callbacks.queue.logger" -class WrittenClipboard: - """The desktop's clipboard as a copy reaches it, keeping what was written for a case to read.""" - - def __init__(self) -> None: - self.texts: List[str] = [] - - def read(self, on_text: StringCallback) -> None: - on_text(self.texts[-1] if self.texts else "") - - def write(self, text: str) -> None: - self.texts.append(text) - - @pytest.fixture def written(monkeypatch: pytest.MonkeyPatch) -> List[str]: """What reaches the desktop's clipboard, held here so a case leaves the reader's own alone.""" - clipboard = WrittenClipboard() - monkeypatch.setattr(copy_button_module, "select_text_clipboard", lambda: clipboard) - return clipboard.texts + texts: List[str] = [] + monkeypatch.setattr(copy_button_module.dpg, "set_clipboard_text", texts.append) + return texts @pytest.fixture @@ -113,28 +97,6 @@ def test_the_button_reads_its_own_label_once_the_moment_passes( assert copy_button.get_item_label() == LABEL - def test_the_copy_starts_no_thread(self, copy_button: GUIButton) -> None: - """A thread of its own would reach DearPyGui away from the render thread, and past its context.""" - standing = set(threading.enumerate()) - - copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) - - assert set(threading.enumerate()) == standing - - def test_quitting_within_the_moment_drops_the_restore( - self, - copy_button: GUIButton, - delay_clock: ManualClock, - ) -> None: - """The shutdown stops the queue before the context goes, so the restore never reaches DearPyGui.""" - copy_to_clipboard(TEXT, LABEL, BUTTON, copied_label=COPIED) - CallbackQueue.stop() - delay_clock.advance(COPIED_LABEL_SECONDS) - CallbackQueue.start() - draw_frame() - - assert copy_button.get_item_label() == COPIED - def test_a_button_closed_within_the_moment_is_passed_over( self, copy_button: GUIButton,