diff --git a/docs/development/application/render-thread.md b/docs/development/application/render-thread.md index be6d440f0..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. +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. @@ -68,3 +68,12 @@ 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. + +## Delayed work goes through the queue + +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 314167f89..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 @@ -147,13 +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. +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 Where they are shown is decided by whoever asks for them. Each grid builds its whole diff --git a/src/sampletones_application/coordinators/tabs/sequencer/blocks.py b/src/sampletones_application/coordinators/tabs/sequencer/blocks.py index a7157fc4e..18d3a1bdb 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 ( @@ -20,21 +21,26 @@ 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, 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,37 @@ 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 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. 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)) + + 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. + + 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._text_clipboard.read(partial(self._paste_on_answer, 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 +118,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 +173,16 @@ 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: 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/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 1a3ec7413..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( @@ -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/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/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/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 deleted file mode 100644 index 9b26290ce..000000000 --- a/src/sampletones_application/utils/gui/clipboard.py +++ /dev/null @@ -1,42 +0,0 @@ -import threading -from typing import Protocol, cast - -import dearpygui.dearpygui as dpg - -from sampletones_application.utils.gui.dpg import dpg_configure_item - - -class TextClipboard(Protocol): - """The clipboard the desktop shares between applications, as text going out and coming back.""" - - def read(self) -> str: ... - - 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 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: - 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() 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..d4930c020 --- /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_application.utils.gui.clipboard.protocol import ClipboardTextCallback + + +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: ClipboardTextCallback) -> 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..48e29fe35 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/clipboard.py @@ -0,0 +1,65 @@ +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 + +CLIPBOARD_ANSWER_SECONDS: Final[float] = 1.0 + + +class SelectionReader(Protocol): + """Reads the clipboard's text, waiting ``seconds`` at most for the application holding it. + + 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: + """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 + 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 + 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[ClipboardTextCallback] = [] + + def read(self, on_text: ClipboardTextCallback) -> 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 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: Optional[str] = None + try: + text = self._reader.read_text(CLIPBOARD_ANSWER_SECONDS) + finally: + CallbackQueue.add(self._answer, text) + + 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/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..c48e6ad92 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/library.py @@ -0,0 +1,206 @@ +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): + """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): + _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..8874354c6 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/reader.py @@ -0,0 +1,34 @@ +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 +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) -> Optional[str]: + """The clipboard's text, or ``None`` where the read went unanswered. + + 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, 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 new file mode 100644 index 000000000..f1bb68e79 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/backends/x11/transfer.py @@ -0,0 +1,136 @@ +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 ends it with a :class:`TimeoutError`. + """ + + def __init__(self, connection: SelectionConnection, *, deadline: float) -> None: + self._connection = connection + 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 "" + + 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.""" + 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..dce14a78f --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/copy_button.py @@ -0,0 +1,27 @@ +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.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. + """ + 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 new file mode 100644 index 000000000..d62c5b018 --- /dev/null +++ b/src/sampletones_application/utils/gui/clipboard/protocol.py @@ -0,0 +1,22 @@ +from typing import Callable, Optional, Protocol + +ClipboardTextCallback = Callable[[Optional[str]], None] + + +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: 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/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/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/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..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, @@ -1659,17 +1660,43 @@ 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, or none at all. + """ def __init__(self) -> None: self.text: str = "" + self.unanswered: List[ClipboardTextCallback] = [] + self._answers_held: bool = False - def read(self) -> str: - return self.text + def read(self, on_text: ClipboardTextCallback) -> None: + if self._answers_held: + self.unanswered.append(on_text) + return + + 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.""" + 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(text) + def _text_clipboard(coordinator: SequencerTabCoordinator) -> FakeTextClipboard: """The desktop clipboard this coordinator's blocks were built over.""" @@ -1995,16 +2022,113 @@ 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() + + 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/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( 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/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..9e9548910 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_clipboard.py @@ -0,0 +1,189 @@ +import threading +import time +from typing import Callable, Final, List, Optional, 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) -> 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[Optional[str], threading.Thread]] = [] + + def __call__(self, text: Optional[str]) -> None: + self.received.append((text, threading.current_thread())) + + @property + def texts(self) -> List[Optional[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_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) + 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_nothing_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) == ([None], [None]) + 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..12ab51595 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/backends/x11/test_transfer.py @@ -0,0 +1,207 @@ +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="", + ), + ) + + @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)}, + ), + TestCase( + label="an_owner_falling_silent_between_pieces", + answers={UTF8: Answer(pieces=_split(BLOCK_TEXT.encode(), size=8), incremental=True, silent_after=2)}, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_transfer_ends_the_conversation_over_the_silence(self, test_case: TestCase) -> None: + owner = ScriptedOwner(test_case.answers) + + with pytest.raises(TimeoutError): + SelectionTransfer(owner, deadline=DEADLINE).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)}) + + 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 new file mode 100644 index 000000000..6d6e2d194 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/clipboard/test_copy_button.py @@ -0,0 +1,112 @@ +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.gui.clipboard import copy_button as copy_button_module +from sampletones_application.utils.gui.clipboard.copy_button import ( + COPIED_LABEL_SECONDS, + 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(copy_button_module.dpg, "set_clipboard_text", texts.append) + 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_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() 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) 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"]