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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/development/application/render-thread.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
12 changes: 8 additions & 4 deletions docs/development/application/sequencer-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
56 changes: 50 additions & 6 deletions src/sampletones_application/coordinators/tabs/sequencer/blocks.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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.
Expand All @@ -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),
)
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/sampletones_application/ui/elements/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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]):
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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],
Expand All @@ -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())
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
OnPlayFromRowCallback,
OnSetNoteOffCallback,
OnSetRowCallback,
RefreshPasteBlockRequest,
TrackerEditSurface,
)
from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading