diff --git a/automation/views/sequencer.py b/automation/views/sequencer.py index 8161526e8..5ca7de6d6 100644 --- a/automation/views/sequencer.py +++ b/automation/views/sequencer.py @@ -3,8 +3,9 @@ import dearpygui.dearpygui as dpg from automation.dearpygui.bridge import Bridge -from automation.dearpygui.geometry import Point +from automation.dearpygui.geometry import Point, Rect from automation.dearpygui.hand import Hand +from automation.dearpygui.items.colors import read_theme_text_color from automation.dearpygui.items.reading import read_item from automation.dearpygui.items.regions import read_region_view, read_table from automation.dearpygui.items.texts import read_label @@ -207,6 +208,19 @@ def positions(self) -> int: lambda: len(read_table(TAG_SEQUENCER_ORDER_TABLE)[MASTER_ORDER_ROW]) - ORDER_LABEL_CELLS ) + def cell_box(self, channel: Optional[ChannelName], position: int) -> Optional[Rect]: + """Where the entry of ``channel`` at frame ``position`` stands on the screen.""" + return self._bridge.ask(lambda: read_item(_order_entry(channel, position)).rect) + + def text_color(self, channel: Optional[ChannelName], position: int) -> Optional[Tuple[float, ...]]: + """The text color the theme bound to the entry of ``channel`` at frame ``position`` sets.""" + + def read() -> Optional[Tuple[float, ...]]: + theme = dpg.get_item_info(_order_entry(channel, position))["theme"] + return read_theme_text_color(theme) if theme is not None else None + + return self._bridge.ask(read) + def click(self, channel: Optional[ChannelName], position: int) -> None: """Clicks the entry of ``channel`` at frame ``position``, scrolling it into view first.""" entry = self._bridge.ask(lambda: _order_entry(channel, position)) diff --git a/automation/views/tracker.py b/automation/views/tracker.py index 9c7ca9cc0..e64888d93 100644 --- a/automation/views/tracker.py +++ b/automation/views/tracker.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg from automation.dearpygui.bridge import Bridge -from automation.dearpygui.geometry import Point +from automation.dearpygui.geometry import Point, Rect from automation.dearpygui.hand import Hand from automation.dearpygui.items.colors import read_theme_text_color from automation.dearpygui.items.reading import read_item @@ -61,7 +61,7 @@ def _table_rows() -> Tuple[Item, ...]: return tuple(dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, 1)) -def _frame_rows() -> Dict[int, int]: +def frame_rows() -> Dict[int, int]: """Where each pattern row of the shown frame stands among the table's rows, by its index. Runs on the render thread. """ @@ -181,6 +181,10 @@ def has_caret(self, row: int, channel: Optional[ChannelName]) -> bool: """Whether the caret stands in ``channel``'s cell on ``row``, as the box drawn over the grid shows it.""" return self._bridge.ask(_caret_cell) == (row, channel) + def caret_box(self) -> Optional[Rect]: + """Where the caret's mark stands on the screen, while it shows one.""" + return self._bridge.ask(caret_mark) + def caret_row(self, channel: Optional[ChannelName]) -> Optional[int]: """The pattern row the caret stands on in ``channel``'s column, if it stands in that column.""" cell = self._bridge.ask(_caret_cell) @@ -208,7 +212,7 @@ def read() -> Optional[int]: return None middle = band.y + band.height * HALF - for row, table_index in _frame_rows().items(): + for row, table_index in frame_rows().items(): number = read_item(_number(table_index)) if number.rect is None or not number.visible: continue @@ -237,7 +241,7 @@ def numbers_below(self, count: int) -> Tuple[str, ...]: """What the row numbers of the ``count`` rows nearest below the frame read, top to bottom.""" def read() -> Tuple[str, ...]: - last = max(_frame_rows().values()) + last = max(frame_rows().values()) return tuple(read_label(_number(index)) for index in range(last + 1, last + 1 + count)) return self._bridge.ask(read) @@ -262,7 +266,7 @@ def tinted_rows_in_view(self) -> FrozenSet[int]: def read() -> FrozenSet[int]: return frozenset( row - for row, table_index in _frame_rows().items() + for row, table_index in frame_rows().items() if read_item(_number(table_index)).visible and dpg.is_table_row_highlighted(TAG_SEQUENCER_TRACKER_TABLE, table_index) ) @@ -304,7 +308,7 @@ def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]: return None x, y = middle - for row, table_index in _frame_rows().items(): + for row, table_index in frame_rows().items(): number = read_item(_number(table_index)) if number.rect is None or not number.visible or not number.rect.y <= y < number.rect.y + number.rect.height: continue @@ -318,9 +322,9 @@ def _caret_cell() -> Optional[Tuple[int, Optional[ChannelName]]]: return None -def _caret_middle() -> Optional[Tuple[float, float]]: - """The middle of the box the caret overlay draws, while it shows one. Runs on the render thread.""" - rectangle = CaretOverlay._rectangle # pylint: disable=protected-access +def caret_mark() -> Optional[Rect]: + """The mark the caret overlay draws under the active character, while it shows one. Runs on the render thread.""" + rectangle = CaretOverlay._mark # pylint: disable=protected-access if rectangle is None or not dpg.does_item_exist(rectangle): return None @@ -329,4 +333,13 @@ def _caret_middle() -> Optional[Tuple[float, float]]: return None corner, far = configuration["pmin"], configuration["pmax"] - return (corner[0] + far[0]) * HALF, (corner[1] + far[1]) * HALF + return Rect(x=corner[0], y=corner[1], width=far[0] - corner[0], height=far[1] - corner[1]) + + +def _caret_middle() -> Optional[Tuple[float, float]]: + """The middle of the caret's mark, while it shows one. Runs on the render thread.""" + mark = caret_mark() + if mark is None: + return None + + return mark.x + mark.width * HALF, mark.y + mark.height * HALF diff --git a/docs/development/application/sequencer-blocks.md b/docs/development/application/sequencer-blocks.md index 4530fb5ca..6da7bfdbf 100644 --- a/docs/development/application/sequencer-blocks.md +++ b/docs/development/application/sequencer-blocks.md @@ -132,6 +132,8 @@ Both panels read the cell under a held pointer off their own geometry, because D The tracker's row lookup is arithmetic. It measures from the first row's top edge and divides by the row height, which holds while the rows are evenly pitched. That is what the pattern table's zero vertical cell padding and item spacing are for: a vertical padding there would drift the lookup further down the grid. The order's position lookup is arithmetic in the same way and takes its pitch from the first two columns. Its channel lookup walks the rows, because the master row stands apart from the channels beneath it. +The caret (`ui/elements/table/caret.py`) draws on a front viewport drawlist from the rectangle the active cell reported: a mark under one character, measured off the label's width in the cell's font, and a frame around the cell. Both tables give their selectables zero item spacing, so a label starts at its rectangle's left edge and the character arithmetic holds in each. A table answers a scroll on the frame after the one that draws its highlights, so a move that re-centers the grid holds its painting one frame, the way the playhead does, and hands the caret the distance the pending scroll will carry the row. + ### A drag past the edge carries the view A pointer held past the cells on screen travels the grid under it, so a selection reaches further than the viewport holds. `grid/scroll/` holds the travel. A grid says which axis it scrolls along and where its cells stand along it, and one class reads the two each frame. The tracker travels vertically and the order horizontally, from that same class. diff --git a/docs/images/guide/converting/source-settings.webp b/docs/images/guide/converting/source-settings.webp index c90cf104c..29981d6d4 100644 Binary files a/docs/images/guide/converting/source-settings.webp and b/docs/images/guide/converting/source-settings.webp differ diff --git a/docs/images/guide/interface/tabs.webp b/docs/images/guide/interface/tabs.webp index e46dbef63..c619dd423 100644 Binary files a/docs/images/guide/interface/tabs.webp and b/docs/images/guide/interface/tabs.webp differ diff --git a/docs/images/guide/reconstruction/export-menu.webp b/docs/images/guide/reconstruction/export-menu.webp index 2c93feb52..e0a2ed64e 100644 Binary files a/docs/images/guide/reconstruction/export-menu.webp and b/docs/images/guide/reconstruction/export-menu.webp differ diff --git a/docs/images/guide/sequencer/history.webp b/docs/images/guide/sequencer/history.webp index c80c1ecf2..456ae1ce9 100644 Binary files a/docs/images/guide/sequencer/history.webp and b/docs/images/guide/sequencer/history.webp differ diff --git a/docs/images/guide/sequencer/nsf-window.webp b/docs/images/guide/sequencer/nsf-window.webp index 49b4c95a7..0f56889b3 100644 Binary files a/docs/images/guide/sequencer/nsf-window.webp and b/docs/images/guide/sequencer/nsf-window.webp differ diff --git a/docs/images/guide/sequencer/playback.webp b/docs/images/guide/sequencer/playback.webp index 1d3ac7ccb..55a7980dd 100644 Binary files a/docs/images/guide/sequencer/playback.webp and b/docs/images/guide/sequencer/playback.webp differ diff --git a/docs/images/guide/sequencer/project-properties.webp b/docs/images/guide/sequencer/project-properties.webp index 5f691940f..16bdfe302 100644 Binary files a/docs/images/guide/sequencer/project-properties.webp and b/docs/images/guide/sequencer/project-properties.webp differ diff --git a/docs/images/guide/sequencer/render.webp b/docs/images/guide/sequencer/render.webp index 954b6806c..66069fafa 100644 Binary files a/docs/images/guide/sequencer/render.webp and b/docs/images/guide/sequencer/render.webp differ diff --git a/docs/images/guide/sequencer/tracker.webp b/docs/images/guide/sequencer/tracker.webp index 422de943c..0e21e7576 100644 Binary files a/docs/images/guide/sequencer/tracker.webp and b/docs/images/guide/sequencer/tracker.webp differ diff --git a/docs/images/guide/sequencer/voices.webp b/docs/images/guide/sequencer/voices.webp index 20a113f04..918ac591c 100644 Binary files a/docs/images/guide/sequencer/voices.webp and b/docs/images/guide/sequencer/voices.webp differ diff --git a/docs/images/sampletones.webp b/docs/images/sampletones.webp index 7699554c2..8a09d59b9 100644 Binary files a/docs/images/sampletones.webp and b/docs/images/sampletones.webp differ diff --git a/src/sampletones_application/layout/general/caret.py b/src/sampletones_application/layout/general/caret.py index 4e9bb687f..d02a4a576 100644 --- a/src/sampletones_application/layout/general/caret.py +++ b/src/sampletones_application/layout/general/caret.py @@ -4,7 +4,10 @@ class CaretLayout(BaseModel, extra="forbid", frozen=True): - offset: int - width_padding: int - fill: WrittenColor - border: WrittenColor + """How the tracker's caret is drawn: the underline's height and color, and the color of the frame around the + cell. + """ + + height: int + color: WrittenColor + frame: WrittenColor diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py index c297d3682..ea537a970 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py @@ -11,11 +11,14 @@ class TrackerColors(BaseModel, extra="forbid", frozen=True): is what the slot wears while it names nothing, and ``sample`` and ``instrument`` are the two kinds a named voice can be, so the slot's color reports what it holds. ``transpose`` and ``volume`` carry the other two slots, and the ``frame`` and ``row`` - indices and the ``order`` entries carry the grids around them. Defining them once + indices and the ``order`` entries carry the grids around them. ``pending`` is what a slot + wears while an entry is being typed into it, whatever kind the entry will turn out to be, + so the digits being written stand out from everything around them. Defining them once keeps every panel in step. """ voice: WrittenColor + pending: WrittenColor transpose: WrittenColor volume: WrittenColor sample: WrittenColor diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py index a7a4d1bd7..1afcddf4a 100644 --- a/src/sampletones_application/ui/elements/table/caret.py +++ b/src/sampletones_application/ui/elements/table/caret.py @@ -1,4 +1,5 @@ -from typing import Any, Optional, Tuple +from dataclasses import dataclass +from typing import Any, Final, Optional, Tuple import dearpygui.dearpygui as dpg @@ -7,11 +8,14 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.utils.gui.dpg import dpg_get_item_parent from sampletones_shared.meta import NonInstantiableMeta -from sampletones_shared.types.application import Sender +from sampletones_shared.types.application import ColorRGBA, Sender Box = Tuple[float, float, float, float] - +STILL: Final[float] = 0.0 +UNFILLED: Final[ColorRGBA] = (0, 0, 0, 0) +FRAME_THICKNESS: Final[float] = 1.0 +MARK_CLEARANCE: Final[float] = 1.0 _ANCESTOR_WALK_LIMIT = 32 @@ -27,31 +31,46 @@ def _as_item_id(item: Sender) -> Sender: return item +@dataclass(frozen=True) +class CaretBoxes: + """Where the caret draws this frame: the mark under the active character and the frame around its cell.""" + + mark: Box + frame: Box + + class CaretOverlay(metaclass=NonInstantiableMeta): - """A single-character translucent box marking the tracker edit cursor. - - Tracker cells are atomic ``selectable`` widgets that DearPyGui tints only as a - whole, so a single rectangle is drawn on a front viewport drawlist at the active - character's position and redrawn every frame (from the application loop) so it - follows the table as it scrolls. Because at - most one cell across both tracker tables holds the cursor at a time, one - shared rectangle is enough; the ``owner`` token keeps the order and tracker - panels' arm/clear calls from clobbering each other during focus hand-off. + """A mark under the character the tracker edits and a frame around the cell it stands in. + + Tracker cells are atomic ``selectable`` widgets that DearPyGui tints only as a whole, so the + mark is drawn on a front viewport drawlist at the active character's position and redrawn + every frame (from the application loop) so it follows the table as it scrolls. The mark is an + underline, so the glyph it stands under stays whole; the frame is an outline, so the cell's own + wash can stay faint without the cursor getting lost in a full grid. Because at most one cell + across both tracker tables holds the cursor at a time, one shared pair of rectangles is + enough; the ``owner`` token keeps the order and tracker panels' arm/clear calls from clobbering + each other during focus hand-off. + + A grid that re-centers the row it moved to asks for a scroll the table answers a frame later + than the rectangles it reports, so the panel hands the overlay the distance that scroll will + carry the row (``scroll_shift``), which the next redraw takes off the boxes and then forgets. """ _layout: Optional[CaretLayout] = None - _rectangle: Optional[Sender] = None + _mark: Optional[Sender] = None + _frame: Optional[Sender] = None _owner: Optional[Any] = None _widget: Optional[Sender] = None _caret_index: int = 0 _font: Optional[Font] = None _clip_widget: Optional[Sender] = None + _scroll_shift: float = STILL _root_window: Optional[str] = None @classmethod def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None: - """Creates the (hidden) overlay rectangle. Call once after the viewport exists. + """Creates the (hidden) overlay rectangles. Call once after the viewport exists. ``root_window_tag`` is the primary window that hosts the tracker tables. The caret shows only while the active window sits within that window's tree, so a dialog or @@ -61,12 +80,21 @@ def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None: cls._layout = layout cls._root_window = root_window_tag drawlist = dpg.add_viewport_drawlist(front=True) - cls._rectangle = dpg.draw_rectangle( + cls._frame = dpg.draw_rectangle( (0.0, 0.0), (0.0, 0.0), parent=drawlist, - fill=layout.fill.rgba, - color=layout.border.rgba, + fill=UNFILLED, + color=layout.frame.rgba, + thickness=FRAME_THICKNESS, + show=False, + ) + cls._mark = dpg.draw_rectangle( + (0.0, 0.0), + (0.0, 0.0), + parent=drawlist, + fill=layout.color.rgba, + color=UNFILLED, show=False, ) @@ -79,11 +107,14 @@ def set_target( caret_index: int, font: Font, clip_widget: Sender, + scroll_shift: float, ) -> None: """Arms the caret on ``widget`` at character ``caret_index``. - ``clip_widget`` is the scrolling table the cell lives in; the box is - clamped to its on-screen rectangle so it stays within the table. + ``clip_widget`` is the scrolling table the cell lives in; the boxes are clamped to its + on-screen rectangle so they stay within the table. ``scroll_shift`` is how far a scroll + already asked for will carry the cell before the next frame is drawn, ``STILL`` where + none is pending. """ if widget is None: cls.clear(owner) @@ -94,6 +125,7 @@ def set_target( cls._caret_index = caret_index cls._font = font cls._clip_widget = clip_widget + cls._scroll_shift = scroll_shift @classmethod def clear(cls, owner: Any) -> None: @@ -107,36 +139,49 @@ def clear(cls, owner: Any) -> None: @classmethod def redraw(cls) -> None: - """Repositions the box for the current frame. Cheap no-op when disarmed. + """Repositions the boxes for the current frame. Cheap no-op when disarmed. - Hides the box while the active window sits outside the tracker's window tree (a + Hides the boxes while the active window sits outside the tracker's window tree (a dialog or another window holds focus), keeping the armed state so the caret returns to the same cell once focus comes back. """ - if cls._rectangle is None or cls._layout is None: + if cls._mark is None or cls._frame is None or cls._layout is None: return if not cls._active_within_root(): cls._hide() return - box = cls._compute_box() - if box is None: + boxes = cls._compute_boxes() + cls._scroll_shift = STILL + if boxes is None: cls._hide() return - pmin, pmax = (box[0], box[1]), (box[2], box[3]) dpg.configure_item( - cls._rectangle, - pmin=pmin, - pmax=pmax, - fill=cls._layout.fill.rgba, - color=cls._layout.border.rgba, + cls._frame, + pmin=(boxes.frame[0], boxes.frame[1]), + pmax=(boxes.frame[2], boxes.frame[3]), + color=cls._layout.frame.rgba, + show=True, + ) + dpg.configure_item( + cls._mark, + pmin=(boxes.mark[0], boxes.mark[1]), + pmax=(boxes.mark[2], boxes.mark[3]), + fill=cls._layout.color.rgba, show=True, ) @classmethod - def _compute_box(cls) -> Optional[Box]: + def _compute_boxes(cls) -> Optional[CaretBoxes]: + """The mark and the frame for this frame, or None while the cell is off screen or empty. + + The mark spans one character of the cell's label, measured from the label's own width + in the cell's font, and stands under the text band, which the cell centers vertically. + The frame takes the cell the widget belongs to, the group that holds its slots, and the + widget's own rectangle where the widget stands alone in its cell. + """ widget = cls._widget if widget is None or cls._font is None or cls._layout is None: return None @@ -161,10 +206,36 @@ def _compute_box(cls) -> Optional[Box]: x0, y0, _, y1 = cell char_width = size[0] / len(text) - caret_x0 = x0 + cls._layout.offset + cls._caret_index * char_width - caret_x1 = caret_x0 + char_width + cls._layout.width_padding + mark_x0 = x0 + cls._caret_index * char_width + mark_x1 = mark_x0 + char_width + text_bottom = (y0 + y1 + size[1]) / 2 + mark_y1 = text_bottom + MARK_CLEARANCE + mark_y0 = mark_y1 - cls._layout.height + + mark = cls._clip(cls._shifted((mark_x0, mark_y0, mark_x1, mark_y1))) + frame = cls._clip(cls._shifted(cls._cell_box(widget, cell))) + if mark is None or frame is None: + return None + + return CaretBoxes(mark=mark, frame=frame) - return cls._clip((caret_x0, y0, caret_x1, y1)) + @classmethod + def _cell_box(cls, widget: Sender, own: Box) -> Box: + """The cell the frame outlines: the parent group's rectangle, or the widget's own where the parent reports + none. + """ + parent = dpg_get_item_parent(widget) + if parent is None: + return own + + group = cls._rect(parent) + return group if group is not None else own + + @classmethod + def _shifted(cls, box: Box) -> Box: + """``box`` carried by the scroll the table is about to answer.""" + shift = cls._scroll_shift + return (box[0], box[1] - shift, box[2], box[3] - shift) @classmethod def _clip(cls, box: Box) -> Optional[Box]: @@ -241,5 +312,6 @@ def _active_within_root(cls) -> bool: @classmethod def _hide(cls) -> None: - if cls._rectangle is not None and dpg.does_item_exist(cls._rectangle): - dpg.configure_item(cls._rectangle, show=False) + for rectangle in (cls._mark, cls._frame): + if rectangle is not None and dpg.does_item_exist(rectangle): + dpg.configure_item(rectangle, show=False) diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py index 4996e37ca..356361afc 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -32,7 +32,7 @@ GUIPlusMinusButtons, PlusMinusOrder, ) -from sampletones_application.ui.elements.table.caret import CaretOverlay +from sampletones_application.ui.elements.table.caret import STILL, CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer.channels import ( @@ -860,6 +860,7 @@ def _update_caret(self) -> None: caret_index=len(self._input_state.pending), font=font, clip_widget=TAG_SEQUENCER_ORDER_WINDOW, + scroll_shift=STILL, ) def _on_cell_clicked( diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/move.py b/src/sampletones_application/ui/panels/sequencer/tracker/move.py new file mode 100644 index 000000000..178d9604f --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/tracker/move.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Tuple + +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_core.constants.enums import ChannelName + +CellPlace = Tuple[int, Optional[ChannelName]] + + +def cell_place(cursor: Optional[TrackerCursor]) -> Optional[CellPlace]: + """The cell a cursor stands in, its row and its channel, or None for no cursor.""" + if cursor is None: + return None + + return (cursor.row, cursor.channel) + + +@dataclass(frozen=True) +class CursorMove: + """The painting a change of the cursor owes the grid: the cell it left and the cell it reached. + + Attributes: + left: The cell the cursor stood in before, or None where it stood in none. + reached: The cell the cursor stands in now, or None where it stands in none. + """ + + left: Optional[CellPlace] + reached: Optional[CellPlace] + + def then(self, move: CursorMove) -> CursorMove: + """This move carried on by ``move``: the cell first left and the cell last reached.""" + return CursorMove(left=self.left, reached=move.reached) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 71010bc97..c0aeed0ba 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -1,4 +1,6 @@ -from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple +from contextlib import contextmanager +from functools import partial +from typing import Callable, Dict, Final, FrozenSet, Iterator, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -32,7 +34,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.table.caret import CaretOverlay +from sampletones_application.ui.elements.table.caret import STILL, CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer import display as tracker_display @@ -106,6 +108,7 @@ ) from sampletones_application.ui.panels.sequencer.tracker.context import ContextRows from sampletones_application.ui.panels.sequencer.tracker.menu import TrackerMenu +from sampletones_application.ui.panels.sequencer.tracker.move import CursorMove, cell_place from sampletones_application.ui.panels.sequencer.tracker.row import ( add_empty_cell, add_slot_group, @@ -177,9 +180,14 @@ from sampletones_shared.types.callback import VoidCallback PLAYHEAD_PAINT_FRAMES: Final[int] = 1 +CURSOR_PAINT_FRAMES: Final[int] = 1 class GUISequencerTrackerPanel(GUIPanel): + _input_state: TrackerInputState = TrackerInputState() + _paint_held: bool = False + _held_move: Optional[CursorMove] = None + def __init__( self, initial_settings: SequencerSettingsViewModel, @@ -950,7 +958,7 @@ def _update_cursor(self) -> None: self._input_state = TrackerInputState() self._selection.repaint() - self._update_caret() + self._update_caret(STILL) def deselect_cell(self) -> None: cursor = self._input_state.cursor @@ -959,33 +967,93 @@ def deselect_cell(self) -> None: self._remove_cell_highlight(cursor.row, cursor.channel) self._selection.repaint() - self._update_caret() + self._update_caret(STILL) def _apply_state(self, new_state: TrackerInputState) -> None: - old_cursor = self._input_state.cursor - new_cursor = new_state.cursor - - old_pos = (old_cursor.row, old_cursor.channel) if old_cursor is not None else None - new_pos = (new_cursor.row, new_cursor.channel) if new_cursor is not None else None + """Takes the new input state and paints the cursor's move, at once or when the held painting is released. + The cells the cursor leaves and reaches are reported as soon as the state changes; their + painting waits while a gesture holds it (see :meth:`_holding_paint`), so a move that also + re-centers the grid lands on screen together with the scroll it asked for. + """ + move = CursorMove(left=cell_place(self._input_state.cursor), reached=cell_place(new_state.cursor)) self._input_state = new_state - if old_pos != new_pos and old_cursor is not None: - self._remove_cell_highlight(old_cursor.row, old_cursor.channel) + if move.reached != move.left and move.reached is not None: + self.call(self.on_cell_selected) - if old_cursor is not None: - self._update_cell_display(old_cursor.row, old_cursor.channel) + if self._paint_held: + self._held_move = move if self._held_move is None else self._held_move.then(move) + else: + self._paint_move(move, STILL) - if new_cursor is not None: - if old_pos != new_pos: - self._apply_cell_highlight(new_cursor.row, new_cursor.channel) - self._update_cell_display(new_cursor.row, new_cursor.channel) + @contextmanager + def _holding_paint(self) -> Iterator[None]: + """Holds the painting of every cursor change made inside, for :meth:`_release_paint` to paint as one. - if new_pos != old_pos and new_cursor is not None: - self.call(self.on_cell_selected) + A hold belongs to the gesture that opens it, so between gestures the panel stands at its + class's rest: nothing held and nothing owed. + """ + self._paint_held = True + try: + yield + finally: + self._paint_held = False + + def _release_paint(self, *, recentering: bool) -> None: + """Paints the held cursor move: now, or on the frame the scroll of a re-centering grid lands on. + + A cell's mark is drawn on the very next frame while the grid answers a scroll on the frame + after that, so a move painted as it is made stands a row clear of the band's center for a + frame. The painting of a re-centering move is held until its scroll lands and carries the + distance that scroll moves the row, which the caret takes off the rectangle the cell + reported a frame earlier. + """ + landing = self._center_followed_row() if recentering else None + if landing is None: + self._paint_held_move(STILL) + return + + FrameCallbackManager.set_frame_callback(partial(self._paint_landing_move, landing), CURSOR_PAINT_FRAMES) + + def _paint_landing_move(self, landing: float) -> None: + """Paints the held move for the frame on which the grid's scroll reaches ``landing``.""" + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + return + + self._paint_held_move(landing - dpg.get_y_scroll(TAG_SEQUENCER_TRACKER_TABLE)) + + def _paint_held_move(self, scroll_shift: float) -> None: + move = self._held_move + self._held_move = None + if move is not None: + self._paint_move(move, scroll_shift) + + def _paint_move(self, move: CursorMove, scroll_shift: float) -> None: + """Marks the cell the cursor reached, clears the one it left, and redraws both cells' labels. + + A cell of a row the table no longer holds is left alone: the grid was rebuilt under the + move, and the rebuild painted the cursor where it stands. + """ + left = move.left if move.left is not None and self._holds_row(move.left[0]) else None + reached = move.reached if move.reached is not None and self._holds_row(move.reached[0]) else None + + if left is not None and left != reached: + self._remove_cell_highlight(*left) + + if left is not None: + self._update_cell_display(*left) + + if reached is not None: + if reached != left: + self._apply_cell_highlight(*reached) + self._update_cell_display(*reached) self._selection.repaint() - self._update_caret() + self._update_caret(scroll_shift) + + def _holds_row(self, row_index: int) -> bool: + return row_index < self._current_row_count def update_samples(self, view_model: SequencerVoicesViewModel) -> None: self._current_samples = view_model @@ -1035,8 +1103,12 @@ def _cell_theme(self, key: CellKey) -> int: A voice slot takes the color of the kind of voice standing in it, so a reader tells a sample from an instrument across the whole grid; a slot naming none takes the - neutral shade the other slots' colors are read against. + neutral shade the other slots' colors are read against. The slot an entry is being + typed into wears the pending color instead, so the digits being written read first. """ + if self._is_pending_cell(key): + return self._themes.pending + _, channel, subcolumn = key return self._themes.cell( subcolumn, @@ -1044,6 +1116,15 @@ def _cell_theme(self, key: CellKey) -> int: muted=self._is_muted_cell(channel), ) + def _is_pending_cell(self, key: CellKey) -> bool: + """Whether an entry is being typed into the slot ``key`` names.""" + cursor = self._input_state.cursor + return ( + bool(self._input_state.pending) + and cursor is not None + and (cursor.row, cursor.channel, cursor.subcolumn) == key + ) + def _is_muted_cell(self, channel: Optional[ChannelName]) -> bool: """Whether a cell's channel is silenced, the sample column speaking for every channel.""" return channel is not None and self._is_muted(channel) @@ -1059,14 +1140,22 @@ def _update_cell_display( row: int, channel: Optional[ChannelName], ) -> None: + """Redraws a cell's three slots, label and theme, as the input state now has them. + + The theme comes with the label because a slot changes color as an entry starts being typed + into it and again as the entry commits or is let go. + """ for subcolumn in SubColumn: key = (row, channel, subcolumn) cell_id = self._editable_cells.widget(key) if cell_id is not None: dpg.configure_item(cell_id, label=self._render_cell(key)) + dpg.bind_item_theme(cell_id, self._cell_theme(key)) - def _update_caret(self) -> None: - """Arms (or clears) the shared caret box on the active subcolumn cell.""" + def _update_caret(self, scroll_shift: float) -> None: + """Arms (or clears) the shared caret on the active subcolumn cell, ``scroll_shift`` ahead of a landing + scroll. + """ cursor = self._input_state.cursor if cursor is None: CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE) @@ -1079,6 +1168,7 @@ def _update_caret(self) -> None: caret_index=len(self._input_state.pending), font=slot_font(cursor.channel), clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, + scroll_shift=scroll_shift, ) def _resolve_voice( @@ -1540,11 +1630,11 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if cursor is None: return False - consumed = self._answer_key(event, cursor) - moved = self._input_state.cursor - if consumed and moved is not None and moved.row != cursor.row: - self._center_followed_row() + with self._holding_paint(): + consumed = self._answer_key(event, cursor) + moved = self._input_state.cursor + self._release_paint(recentering=consumed and moved is not None and moved.row != cursor.row) return consumed def _answer_key(self, event: KeyEvent, cursor: TrackerCursor) -> bool: @@ -1663,8 +1753,10 @@ def _select(self, new_state: TrackerInputState) -> None: A shape ends at the frame's last row, so the band carries the end the cursor now holds — the same landing a Shift+End reach makes, whether a key or a menu item selected it. """ - self._apply_state(new_state) - self._center_followed_row() + with self._holding_paint(): + self._apply_state(new_state) + + self._release_paint(recentering=True) def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. @@ -1802,30 +1894,31 @@ def _followed_row(self) -> Optional[int]: cursor = self._input_state.cursor return cursor.row if cursor is not None else None - def _center_followed_row(self) -> None: - """Brings the row the band follows to its center, where it follows one.""" + def _center_followed_row(self) -> Optional[float]: + """Brings the row the band follows to its center, where it follows one, and answers the scroll asked for.""" row_index = self._followed_row - if row_index is not None: - self._center_row(row_index) + if row_index is None: + return None + + return self._center_row(row_index) - def _center_row(self, row_index: int) -> None: - """Scrolls the grid so a pattern row stands at the band's center. + def _center_row(self, row_index: int) -> Optional[float]: + """Scrolls the grid so a pattern row stands at the band's center, and answers the scroll asked for. The table holds as many rows of the song either side of the frame as stand above the band's center, so every row of the frame has the room to reach it, and the scroll is worked out from the rows' height. A band awaiting its first measurement, or a table holding no - frame, stays as it stands. + frame, stays as it stands, which answers None. """ if self._band.height <= UNMEASURED_BAND or self._current_row_count == 0: - return + return None if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): - return + return None - dpg.set_y_scroll( - TAG_SEQUENCER_TRACKER_TABLE, - self._band.centering(self._rows_layout.body_row(row_index)), - ) + landing = self._band.centering(self._rows_layout.body_row(row_index)) + dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, landing) + return landing def _row_top(self, row_index: int) -> Optional[float]: """Where a pattern row's top edge stands, in the coordinates the viewport is drawn in.""" diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py index eeddb942c..aa59a7670 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/themes.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/themes.py @@ -30,6 +30,7 @@ def __init__(self, layout: SequencerLayout) -> None: self._subcolumn: Dict[ThemeKey, int] = {} self._muted_subcolumn: Dict[ThemeKey, int] = {} self._context_subcolumn: Dict[ThemeKey, int] = {} + self._pending: int = UNBUILT_THEME self._header: int = UNBUILT_THEME self._muted_header: int = UNBUILT_THEME self._column_label: int = UNBUILT_THEME @@ -39,6 +40,7 @@ def __init__(self, layout: SequencerLayout) -> None: def create(self) -> None: """Builds every theme the grid binds, which a DearPyGui context has to stand behind.""" self._create_subcolumn_themes() + self._pending = create_selectable_text_theme(self._layout.colors.text.pending) self._create_header_themes() row = self._layout.colors.text.row self._row_number = create_selectable_text_theme(row) @@ -113,6 +115,11 @@ def cell( themes = self._muted_subcolumn if muted else self._subcolumn return themes[(subcolumn, kind)] + @property + def pending(self) -> int: + """The theme a cell wears while an entry is being typed into it: the plain text color, whatever the channel.""" + return self._pending + def context_cell(self, subcolumn: SubColumn, kind: Optional[VoiceKind]) -> int: """The theme a cell of another frame's row wears: its slot's color, dimmed, as a plain label.""" return self._context_subcolumn[(subcolumn, kind)] diff --git a/src/sampletones_config/layout/general/caret.yaml b/src/sampletones_config/layout/general/caret.yaml index 76ba18227..ca942d320 100644 --- a/src/sampletones_config/layout/general/caret.yaml +++ b/src/sampletones_config/layout/general/caret.yaml @@ -1,4 +1,3 @@ -offset: 3 -width_padding: 2 -fill: .caret_fill -border: .caret_border +height: 2 +color: .caret +frame: .caret_frame diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index c2fd30026..58a6c1516 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -30,6 +30,7 @@ history: separator: .history_separator text: voice: .text_disabled + pending: .text transpose: .tracker_transpose volume: .tracker_volume sample: .voice_sample diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 10a90f729..8a3e4d232 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -4,7 +4,7 @@ row_height: 29 header_height: 30 octave_width: 90 channel_column_tint: 0.09 -muted_text_fraction: 0.45 +muted_text_fraction: 0.7 subcolumn_widths: voice: 26 transpose: 30 diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index fe9493c6a..f543f4c14 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -103,7 +103,7 @@ colors: text: "#e6e6e8" text_muted: "#9a9aa0" - text_disabled: "#75757b" + text_disabled: "#85858c" text_trace: "#c0c0c4" contrast: "#ffffff" @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#a99cff" feature_duty_cycle: "#e6c26a" - caret_fill: "#4fa6ff55" - caret_border: "#7cc4ffff" + caret: "#7cc4ffff" + caret_frame: "#7cc4ffff" graph_bar: "#4fa6ff" waveform_sample: "#4fa6ff" @@ -182,12 +182,12 @@ colors: spectrum_dim: "#2a2a30" tracker_beat_row: "#ffffff14" - tracker_bar_row: "#ffffff28" + tracker_bar_row: "#ffffff1c" - block_selection: "#b98af360" + block_selection: "#b98af330" pattern_highlight: "#ffffff40" - cell_cursor: "#4fa6ffb0" - cursor_row: "#4fa6ff3a" + cell_cursor: "#4fa6ff24" + cursor_row: "#4fa6ff2c" playback_row: "#5ec46e42" order_label: "#2f3a46" @@ -209,8 +209,8 @@ colors: tracker_transpose: "#c0c0c4ff" tracker_volume: "#7ee787ff" tracker_frame: "#4fa6ffff" - tracker_row: "#9a9aa2ff" + tracker_row: "#a6a6aeff" tracker_order: "#c4d0e0ff" voice_sample: "#e0c860ff" - voice_instrument: "#f476d8ff" + voice_instrument: "#ffa3ecff" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 5c0f08cdc..1db7633b8 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -103,7 +103,7 @@ colors: text: "#0a0c10" text_muted: "#414852" - text_disabled: "#767d88" + text_disabled: "#6b7380" text_trace: "#2c323b" contrast: "#000000" @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#4a3fa8" feature_duty_cycle: "#7a5c00" - caret_fill: "#0b4c8c58" - caret_border: "#073a6cff" + caret: "#073a6cff" + caret_frame: "#073a6cff" graph_bar: "#0a5aa8" waveform_sample: "#0a5aa8" @@ -182,12 +182,12 @@ colors: spectrum_dim: "#dfe4ec" tracker_beat_row: "#00000016" - tracker_bar_row: "#0000002c" + tracker_bar_row: "#00000024" - block_selection: "#6b4ea840" + block_selection: "#6b4ea82a" pattern_highlight: "#00000018" - cell_cursor: "#0b4c8c60" - cursor_row: "#0b4c8c24" + cell_cursor: "#0b4c8c1e" + cursor_row: "#0b4c8c1c" playback_row: "#16702e48" order_label: "#c6cbd5" @@ -207,10 +207,10 @@ colors: history_separator: "#8f97a4ff" tracker_transpose: "#3d434dff" - tracker_volume: "#16702eff" + tracker_volume: "#0f5a24ff" tracker_frame: "#0a5aa8ff" - tracker_row: "#5f6773ff" + tracker_row: "#4a515cff" tracker_order: "#28303dff" - voice_sample: "#7a5200ff" + voice_sample: "#704b00ff" voice_instrument: "#9a137cff" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 2223d0cd2..e6cbc49ca 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -103,7 +103,7 @@ colors: text: "#e7e7f0" text_muted: "#9497ab" - text_disabled: "#767a8e" + text_disabled: "#8c90a4" text_trace: "#c0c0c0" contrast: "#ffffff" @@ -172,8 +172,8 @@ colors: feature_hi_pitch: "#9664ff" feature_duty_cycle: "#ffc864" - caret_fill: "#8888ff80" - caret_border: "#88bbffff" + caret: "#a8c8ffff" + caret_frame: "#a8c8ffff" graph_bar: "#64c8ff" waveform_sample: "#64c8ff" @@ -182,12 +182,12 @@ colors: spectrum_dim: "#1c1c1c" tracker_beat_row: "#ffffff14" - tracker_bar_row: "#ffffff26" + tracker_bar_row: "#ffffff1a" - block_selection: "#b98af360" + block_selection: "#b98af32e" pattern_highlight: "#ffffff40" - cell_cursor: "#66bbffa0" - cursor_row: "#ffffff18" + cell_cursor: "#66bbff18" + cursor_row: "#ffffff14" playback_row: "#64dc6440" order_label: "#2d3241" @@ -206,11 +206,11 @@ colors: history_value: "#d0d0d0ff" history_separator: "#707070ff" - tracker_transpose: "#c0c0c0ff" - tracker_volume: "#64dc64ff" + tracker_transpose: "#c8c8ccff" + tracker_volume: "#78e878ff" tracker_frame: "#22ccffff" - tracker_row: "#a0a0a0ff" + tracker_row: "#aaaab0ff" tracker_order: "#c8d0e0ff" voice_sample: "#e0c860ff" - voice_instrument: "#ff70dfff" + voice_instrument: "#ffadeeff" diff --git a/src/sampletones_config/theme/tables/order.yaml b/src/sampletones_config/theme/tables/order.yaml index 71a8af6e8..e7f0ad197 100644 --- a/src/sampletones_config/theme/tables/order.yaml +++ b/src/sampletones_config/theme/tables/order.yaml @@ -12,6 +12,14 @@ components: value: .transparent - item_type: Selectable entries: + - type: style + key: ItemSpacing + x: 0 + y: 0 + - type: style + key: SelectableTextAlign + x: 0 + y: 0.5 - type: color key: Header value: .block_selection diff --git a/src/sampletones_config/theme/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml index 1b0d55127..97e9213f3 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -10,7 +10,7 @@ components: y: 0 - type: color key: HeaderHovered - value: .overlay/0.25 + value: .overlay/0.08 - type: color key: HeaderActive value: .transparent diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index 69e9ba481..b14d866e4 100644 --- a/src/sampletones_shared/utils/color.py +++ b/src/sampletones_shared/utils/color.py @@ -8,6 +8,13 @@ from sampletones_shared.utils.arrays import clamp MAX_CHANNEL_VALUE: Final[int] = 255 +RGB_CHANNELS: Final[int] = 3 +SRGB_LINEAR_LIMIT: Final[float] = 0.03928 +SRGB_LINEAR_SLOPE: Final[float] = 12.92 +SRGB_OFFSET: Final[float] = 0.055 +SRGB_GAMMA: Final[float] = 2.4 +LUMINANCE_WEIGHTS: Final[np.ndarray] = np.array([0.2126, 0.7152, 0.0722]) +CONTRAST_OFFSET: Final[float] = 0.05 def with_alpha_fraction(color: ColorRGBA, fraction: float) -> ColorRGBA: @@ -53,6 +60,35 @@ def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA: return (int(channels[0]), int(channels[1]), int(channels[2]), int(channels[3])) +def relative_luminance(color: ColorRGBA) -> float: + """Return how bright ``color`` reads, from ``0.0`` for black to ``1.0`` for white, as the WCAG defines it. + + Each channel is linearized out of the sRGB curve before the channels are weighted, so two + colors with the same figure read equally bright whatever their hue. The alpha is left out: + the figure describes the color as drawn. + """ + channels = np.array(color[:RGB_CHANNELS], dtype=np.float64) / MAX_CHANNEL_VALUE + linear = np.where( + channels <= SRGB_LINEAR_LIMIT, + channels / SRGB_LINEAR_SLOPE, + ((channels + SRGB_OFFSET) / (1.0 + SRGB_OFFSET)) ** SRGB_GAMMA, + ) + return float(np.dot(LUMINANCE_WEIGHTS, linear)) + + +def contrast_ratio(first: ColorRGBA, second: ColorRGBA) -> float: + """Return the WCAG contrast between two colors as drawn, from ``1.0`` for alike to ``21.0`` for black on white. + + The ratio reads the same whichever color is the text and whichever the background, so a + floor a palette is held to can be stated once for every pairing. + """ + first_luminance = relative_luminance(first) + second_luminance = relative_luminance(second) + lighter = max(first_luminance, second_luminance) + darker = min(first_luminance, second_luminance) + return (lighter + CONTRAST_OFFSET) / (darker + CONTRAST_OFFSET) + + def to_grayscale(color: ColorRGBA) -> ColorRGBA: """Return ``color`` desaturated to its luminance-preserving gray, keeping its alpha. diff --git a/tests/screens/sequencer/colors/test_readability.py b/tests/screens/sequencer/colors/test_readability.py new file mode 100644 index 000000000..46478f4f5 --- /dev/null +++ b/tests/screens/sequencer/colors/test_readability.py @@ -0,0 +1,176 @@ +import operator +from functools import partial +from typing import Final, Optional, Tuple + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.palettes import shipped_palettes +from automation.screen import Screen +from automation.steps.sequencer import leave_letting_the_project_go +from automation.views.tracker import tracker_cell +from sampletones_application.categories.hierarchy import Tab +from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import MAX_CHANNEL_VALUE, composite, contrast_ratio +from tests.suite.screens.worlds.songs import PAD_ROW + +FILLED_FLOOR: Final[float] = 4.5 +STACKED_FLOOR: Final[float] = 3.5 +DIM_FLOOR: Final[float] = 3.0 +BAR_ROW: Final[int] = 0 +PLAIN_ROW: Final[int] = 1 +TYPED_ROW: Final[int] = 2 +HOVERED_ROW: Final[int] = 3 +LINE_NUMBER: Final[str] = "00" +PAD_NUMBER: Final[str] = "02" +TYPED_DIGIT: Final[str] = "0" +TYPING_FRAMES: Final[int] = 10 +SETTLING_FRAMES: Final[int] = 6 +INSET: Final[int] = 2 + +Color = Tuple[float, ...] + + +def in_steps(color: Color) -> ColorRGBA: + """A color DearPyGui holds in fractions, in the whole steps the contrast arithmetic takes.""" + red, green, blue, alpha = (round(part * MAX_CHANNEL_VALUE) for part in color) + return (red, green, blue, alpha) + + +class TestEveryKindReadsOnEveryBackground: + """Every kind of text the tracker draws keeps a readable contrast against every background it can land on, in + every palette: plain, beat and bar rows, the cursor's row and cell, a selected block, a hovered slot, and a + muted channel. + + A filled voice, a transpose, a volume and the digit being typed are held to 4.5:1 on any row and under one + wash on a plain row: the cursor's, a selection's or the pointer's; 3.5:1 where such a wash stacks on a + beat or bar row. A placeholder and a muted channel's value are held to 3:1 on a plain row. The background is read from the + frame's own pixels beside the text, and the text color from the slot's theme, so the figure is the one a + reader sees. The scenario walks the default palette and then every shipped palette through Display + settings. + """ + + def test_contrast_holds_in_every_palette(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + settings = screen.display_settings + catalog = shipped_palettes() + + def slot_box(row: int, channel: ChannelName, slot: SubColumn) -> Rect: + rect: Optional[Rect] = screen.bridge.ask(lambda: read_item(tracker_cell(row, channel, slot)).rect) + assert rect is not None + return rect + + def contrast(row: int, channel: ChannelName, slot: SubColumn) -> float: + """The contrast of a slot's text, as drawn over the background inside the slot's top left corner. + + A muted channel's text is translucent, so the text is laid over the background first and the + color that reaches the screen is what is held against it. + """ + screen.frames(SETTLING_FRAMES) + box = slot_box(row, channel, slot) + pixels = screen.frame_pixels() + background = in_steps(tuple(float(part) for part in pixels[int(box.y) + INSET, int(box.x) + INSET])) + text = tracker.text_color(row, channel, slot) + assert text is not None + return contrast_ratio(composite(background, in_steps(text)), background) + + def expect_at_least(floor: float, row: int, channel: ChannelName, slot: SubColumn) -> None: + ratio = contrast(row, channel, slot) + assert ratio >= floor, f"{slot} at row {row} of {channel} reads at {ratio:.2f}, under {floor}" + + def readable_in(palette: str) -> None: + """Reads every kind on every background in the palette now worn, leaving the grid as it found it.""" + expect_at_least(DIM_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.VOLUME) + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.expect( + lambda: tracker.has_caret(TYPED_ROW, ChannelName.PULSE1), bool, description=f"the caret in {palette}" + ) + tracker.leave() + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + + screen.hand.type_text(TYPED_DIGIT) + screen.frames(TYPING_FRAMES) + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + operator.methodcaller("startswith", TYPED_DIGIT), + description="the typed digit pending", + ) + expect_at_least(FILLED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the entry let go", + ) + + tracker.click(PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + screen.expect( + lambda: tracker.has_caret(PAD_ROW, ChannelName.PULSE2), bool, description="the caret on a beat row" + ) + tracker.leave() + expect_at_least(STACKED_FLOOR, PAD_ROW, ChannelName.PULSE2, SubColumn.VOICE) + + tracker.click(BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + tracker.shift_click(TYPED_ROW, ChannelName.PULSE2, SubColumn.VOLUME) + tracker.leave() + expect_at_least(STACKED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(FILLED_FLOOR, PLAIN_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + expect_at_least(STACKED_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + + tracker.hover(HOVERED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + expect_at_least(FILLED_FLOOR, HOVERED_ROW, ChannelName.PULSE1, SubColumn.TRANSPOSE) + tracker.hover(BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + expect_at_least(STACKED_FLOOR, BAR_ROW, ChannelName.PULSE1, SubColumn.VOICE) + tracker.leave() + + tracker.click_header(ChannelName.PULSE1) + tracker.leave() + expect_at_least(DIM_FLOOR, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + tracker.click_header(ChannelName.PULSE1) + + def a_voice_on_a_plain_row(screen: Screen) -> None: + screen.tabs.bring_to_front(Tab.SEQUENCER) + tracker.click(TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE) + screen.hand.type_text(LINE_NUMBER) + screen.frames(TYPING_FRAMES) + screen.expect( + partial(tracker.label, TYPED_ROW, ChannelName.PULSE1, SubColumn.VOICE), + LINE_NUMBER.__eq__, + description="the voice typed on a plain row", + ) + tracker.leave() + + def the_default_palette_reads(screen: Screen) -> None: + readable_in(DEFAULT_PALETTE_NAME) + + def every_other_palette_reads(screen: Screen) -> None: + for name in catalog.names: + if name == DEFAULT_PALETTE_NAME: + continue + + settings.open() + screen.expect(settings.is_shown, bool, description="Display settings") + settings.choose_palette(name) + screen.frames(SETTLING_FRAMES) + settings.confirm() + screen.expect(settings.is_shown, operator.not_, description="Display settings closed") + + readable_in(name) + + screen.scenario( + a_voice_on_a_plain_row, + the_default_palette_reads, + every_other_palette_reads, + leave_letting_the_project_go, + ).run() diff --git a/tests/screens/sequencer/tracker/caret/__init__.py b/tests/screens/sequencer/tracker/caret/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/screens/sequencer/tracker/caret/constants.py b/tests/screens/sequencer/tracker/caret/constants.py new file mode 100644 index 000000000..bfec1706f --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/constants.py @@ -0,0 +1,18 @@ +from typing import Final + +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName + +CHANNEL: Final[ChannelName] = ChannelName.PULSE2 +SLOT: Final[SubColumn] = SubColumn.VOICE +CLICKED_ROW: Final[int] = 2 +QUIET_FRAMES: Final[int] = 3 +LANDING_FRAMES: Final[int] = 6 +BURST: Final[int] = 3 +FIRST_POSITION: Final[int] = 0 +FIRST_CHARACTER: Final[int] = 0 +SECOND_CHARACTER: Final[int] = 1 +TYPED_DIGIT: Final[str] = "0" +TYPING_FRAMES: Final[int] = 10 +COLOR_TOLERANCE: Final[float] = 0.12 +RGB: Final[int] = 3 diff --git a/tests/screens/sequencer/tracker/caret/steps.py b/tests/screens/sequencer/tracker/caret/steps.py new file mode 100644 index 000000000..9db178a0c --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/steps.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass +from itertools import pairwise +from typing import Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from automation.boundaries.highlights import HighlightKind +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.screen import Screen +from automation.views.tracker import caret_mark, frame_rows, tracker_cell +from sampletones_application.tags.sequencer import TAG_SEQUENCER_TRACKER_TABLE +from sampletones_application.ui.panels.sequencer.columns import tracker_table_column +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.caret.constants import COLOR_TOLERANCE, RGB + + +@dataclass(frozen=True) +class Drawing: + """What one frame left for the next: the row the cursor's cell is laid on and the caret's mark, beside the row + tops the frame itself drew. + + Attributes: + cursor_row: The pattern row whose cell the cursor highlight stands on, or None for none. + mark: The caret's mark as set for the next frame, or None while it is hidden. + row_tops: The top edge of every frame row's slot, as the frame drew them. + """ + + cursor_row: Optional[int] + mark: Optional[Rect] + row_tops: Dict[int, float] + + def pitch(self) -> float: + """How far apart two rows' tops stand.""" + tops = sorted(self.row_tops.values()) + return min(later - earlier for earlier, later in pairwise(tops)) + + +def read_drawing(screen: Screen, channel: ChannelName, slot: SubColumn) -> Drawing: + """Reads the drawing a frame left: the highlighted row and the mark for the next frame, the row tops of this one. + Runs on the render thread. + """ + rows = frame_rows() + column = tracker_table_column(channel) + cursor_row = None + for place in screen.table_highlights(): + if place.table == TAG_SEQUENCER_TRACKER_TABLE and place.kind == HighlightKind.CELL and place.column == column: + cursor_row = next((row for row, index in rows.items() if index == place.row), None) + + tops: Dict[int, float] = {} + for row in rows: + rect = read_item(tracker_cell(row, channel, slot)).rect + if rect is not None: + tops[row] = rect.y + + return Drawing(cursor_row=cursor_row, mark=caret_mark(), row_tops=tops) + + +def frames_apart(drawings: Sequence[Drawing]) -> List[Tuple[int, int, float, float]]: + """Every frame that drew the caret's mark a row clear of the cursor's cell. + + A drawing's mark and cursor row are set for the frame after it, whose row tops the next drawing + reports, so a mark is held against the next drawing's top of the row it was set for. Each entry + names the frame's place in the run, the row, the row's top and the mark's top. + """ + apart = [] + for index, (set_for_next, next_frame) in enumerate(pairwise(drawings)): + if set_for_next.mark is None or set_for_next.cursor_row is None: + continue + + top = next_frame.row_tops[set_for_next.cursor_row] + if not top <= set_for_next.mark.y < top + next_frame.pitch(): + apart.append((index, set_for_next.cursor_row, top, set_for_next.mark.y)) + + return apart + + +def glyph_runs(pixels: np.ndarray, cell: Rect, color: Sequence[float]) -> List[Tuple[int, int]]: + """The stretches of pixel columns inside ``cell`` that carry ``color``, each a glyph, as (first, last) columns.""" + x0, y0 = int(cell.x), int(cell.y) + x1, y1 = int(cell.x + cell.width), int(cell.y + cell.height) + window = pixels[y0:y1, x0:x1, :RGB] + distance = np.abs(window - np.asarray(color[:RGB])).max(axis=2) + columns = np.flatnonzero((distance <= COLOR_TOLERANCE).any(axis=0)) + runs: List[Tuple[int, int]] = [] + for column in columns: + absolute = x0 + int(column) + if runs and absolute == runs[-1][1] + 1: + runs[-1] = (runs[-1][0], absolute) + else: + runs.append((absolute, absolute)) + + return runs + + +def glyph_bottom(pixels: np.ndarray, cell: Rect, color: Sequence[float]) -> int: + """The lowest pixel row inside ``cell`` that carries ``color``.""" + x0, y0 = int(cell.x), int(cell.y) + x1, y1 = int(cell.x + cell.width), int(cell.y + cell.height) + window = pixels[y0:y1, x0:x1, :RGB] + distance = np.abs(window - np.asarray(color[:RGB])).max(axis=2) + rows = np.flatnonzero((distance <= COLOR_TOLERANCE).any(axis=1)) + return y0 + int(rows.max()) diff --git a/tests/screens/sequencer/tracker/caret/test_alignment.py b/tests/screens/sequencer/tracker/caret/test_alignment.py new file mode 100644 index 000000000..afedd6c3b --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/test_alignment.py @@ -0,0 +1,114 @@ +import operator +from functools import partial +from typing import Optional, Sequence, Tuple + +import numpy as np + +from automation.dearpygui.geometry import Rect +from automation.dearpygui.items.reading import read_item +from automation.dearpygui.keys import IMGUI_ESCAPE +from automation.screen import Screen +from automation.steps.sequencer import on_the_sequencer +from automation.views.tracker import tracker_cell +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import ChannelName +from tests.screens.sequencer.tracker.caret.constants import ( + CHANNEL, + FIRST_CHARACTER, + FIRST_POSITION, + SECOND_CHARACTER, + SLOT, + TYPED_DIGIT, + TYPING_FRAMES, +) +from tests.screens.sequencer.tracker.caret.steps import glyph_bottom, glyph_runs +from tests.suite.screens.worlds.songs import PAD_ROW + +Color = Tuple[float, ...] + + +def under_run(mark: Rect, run: Tuple[int, int]) -> bool: + """Whether the glyph drawn over the columns ``run`` lies inside the mark's span.""" + first, last = run + return mark.x <= first and last < mark.x + mark.width + + +class TestTheMarkStandsUnderItsCharacter: + """The caret's mark spans exactly the character it marks, from that character's left edge, under the glyph. + + The frame's pixels say where each glyph of the cell stands. The mark is read on the first + character of a voice, on the second once a digit is typed, and on the order grid's first + position, and a glyph the mark is set beside stays outside it. + """ + + def test_the_mark_spans_the_marked_glyph_alone(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + order = screen.sequencer.order + + def mark_and_glyphs(cell: Rect, color: Optional[Color]) -> Tuple[Rect, Sequence[Tuple[int, int]], int]: + assert color is not None + pixels = screen.frame_pixels() + mark = tracker.caret_box() + assert mark is not None + return mark, glyph_runs(pixels, cell, color), glyph_bottom(pixels, cell, color) + + def tracker_cell_box(row: int, channel: ChannelName, slot: SubColumn) -> Rect: + rect = screen.bridge.ask(lambda: read_item(tracker_cell(row, channel, slot)).rect) + assert rect is not None + return rect + + def the_first_character_of_a_voice(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(PAD_ROW, CHANNEL, SLOT) + screen.expect(lambda: tracker.has_caret(PAD_ROW, CHANNEL), bool, description="the caret clicked in") + + mark, runs, bottom = mark_and_glyphs( + tracker_cell_box(PAD_ROW, CHANNEL, SLOT), + tracker.text_color(PAD_ROW, CHANNEL, SLOT), + ) + + assert len(runs) == 2 + assert under_run(mark, runs[FIRST_CHARACTER]) + assert not under_run(mark, runs[SECOND_CHARACTER]) + assert runs[SECOND_CHARACTER][0] >= mark.x + mark.width + assert mark.y >= bottom + + def the_second_character_once_a_digit_is_typed(screen: Screen) -> None: + screen.hand.type_text(TYPED_DIGIT) + screen.frames(TYPING_FRAMES) + screen.expect( + partial(tracker.label, PAD_ROW, CHANNEL, SLOT), + operator.methodcaller("startswith", TYPED_DIGIT), + description="the typed digit pending", + ) + + mark, runs, _ = mark_and_glyphs( + tracker_cell_box(PAD_ROW, CHANNEL, SLOT), + tracker.text_color(PAD_ROW, CHANNEL, SLOT), + ) + + assert len(runs) == 2 + assert under_run(mark, runs[SECOND_CHARACTER]) + assert not under_run(mark, runs[FIRST_CHARACTER]) + + screen.hand.press_key(IMGUI_ESCAPE, modifiers=[]) + screen.frames(TYPING_FRAMES) + + def the_order_grids_first_position(screen: Screen) -> None: + order.click(CHANNEL, FIRST_POSITION) + screen.expect(lambda: not tracker.has_caret(PAD_ROW, CHANNEL), bool, description="the caret handed over") + + cell = order.cell_box(CHANNEL, FIRST_POSITION) + assert cell is not None + mark, runs, bottom = mark_and_glyphs(cell, order.text_color(CHANNEL, FIRST_POSITION)) + + assert len(runs) == 2 + assert under_run(mark, runs[FIRST_CHARACTER]) + assert not under_run(mark, runs[SECOND_CHARACTER]) + assert mark.y >= bottom + + screen.scenario( + the_first_character_of_a_voice, + the_second_character_once_a_digit_is_typed, + the_order_grids_first_position, + ).run() diff --git a/tests/screens/sequencer/tracker/caret/test_follows_the_row.py b/tests/screens/sequencer/tracker/caret/test_follows_the_row.py new file mode 100644 index 000000000..f3b9799b6 --- /dev/null +++ b/tests/screens/sequencer/tracker/caret/test_follows_the_row.py @@ -0,0 +1,76 @@ +from functools import partial +from typing import List + +from automation.screen import Screen +from automation.steps.sequencer import on_the_sequencer +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.screens.sequencer.tracker.caret.constants import ( + BURST, + CHANNEL, + CLICKED_ROW, + LANDING_FRAMES, + QUIET_FRAMES, + SLOT, +) +from tests.screens.sequencer.tracker.caret.steps import Drawing, frames_apart, read_drawing + + +class TestTheCaretFollowsTheRow: + """A key that carries the caret to another row draws the caret and the cursor's cell on that row from the frame + the grid re-centers on, with no frame drawing the two a row apart. + + Every frame between the press and the landing is read: the caret's mark and the highlighted + row set for the next frame, against the row tops that next frame draws. A step down, a burst + of steps, and a step back up are walked, and a step along the row, which asks for no scroll, + ends the run with the caret where it stood. + """ + + def test_the_mark_stands_on_the_cursor_row_on_every_frame(self, screen: Screen) -> None: + tracker = screen.sequencer.tracker + reading = partial(read_drawing, screen, CHANNEL, SLOT) + + def frames_of(shortcut: ShortcutId, times: int) -> List[Drawing]: + with screen.record(reading) as recording: + screen.frames(QUIET_FRAMES) + for _ in range(times): + screen.press_shortcut(shortcut) + screen.frames(LANDING_FRAMES) + + return recording.values() + + def expect_landed(drawings: List[Drawing], row: int) -> None: + assert drawings[-1].cursor_row == row + assert frames_apart(drawings) == [] + + def a_step_down_lands_as_one(screen: Screen) -> None: + on_the_sequencer(screen) + tracker.click(CLICKED_ROW, CHANNEL, SLOT) + screen.expect(lambda: tracker.has_caret(CLICKED_ROW, CHANNEL), bool, description="the caret clicked in") + + drawings = frames_of(ShortcutId.TRACKER_NEXT_ROW, 1) + + expect_landed(drawings, CLICKED_ROW + 1) + assert any(drawing.cursor_row == CLICKED_ROW for drawing in drawings) + + def a_burst_of_steps_lands_each_as_one(screen: Screen) -> None: + drawings = frames_of(ShortcutId.TRACKER_NEXT_ROW, BURST) + + expect_landed(drawings, CLICKED_ROW + 1 + BURST) + + def a_step_up_lands_as_one(screen: Screen) -> None: + drawings = frames_of(ShortcutId.TRACKER_PREVIOUS_ROW, 1) + + expect_landed(drawings, CLICKED_ROW + BURST) + + def a_step_along_the_row_keeps_the_rows_still(screen: Screen) -> None: + drawings = frames_of(ShortcutId.TRACKER_NEXT_SUBCOLUMN, 1) + + expect_landed(drawings, CLICKED_ROW + BURST) + assert len({tuple(sorted(drawing.row_tops.items())) for drawing in drawings}) == 1 + + screen.scenario( + a_step_down_lands_as_one, + a_burst_of_steps_lands_each_as_one, + a_step_up_lands_as_one, + a_step_along_the_row_keeps_the_rows_still, + ).run() diff --git a/tests/screens/sequencer/tracker/test_notes_typed.py b/tests/screens/sequencer/tracker/test_notes_typed.py index d8e59c06c..f8029cab8 100644 --- a/tests/screens/sequencer/tracker/test_notes_typed.py +++ b/tests/screens/sequencer/tracker/test_notes_typed.py @@ -107,7 +107,8 @@ def a_note_where_the_voice_plays_on_lands(screen: Screen) -> None: class TestAVoiceTyped: """A voice number typed into a slot arrives with its kind's color in the same frame. - An instrument typed into the Sample column keeps the number and the color as they were. + An instrument typed into the Sample column is refused: the digits being typed wear the typing + color while they stand, and the cell ends with the number and the color it had. """ def test_the_color_comes_with_the_number(self, screen: Screen) -> None: @@ -154,8 +155,10 @@ def an_instrument_in_the_sample_column_changes_nothing(screen: Screen) -> None: frames = frames_while_typing(typed_row, SAMPLE_COLUMN, PAD_NUMBER) - assert all(frame[1] == standing[1] for frame in frames) + assert all(theme == standing[1] for label, theme in frames if label == standing[0]) + assert all(theme != standing[1] for label, theme in frames if label != standing[0]) assert all(frame[0] != PAD_NUMBER for frame in frames) + assert frames[-1] == standing assert tracker.label(typed_row, SAMPLE_COLUMN, SubColumn.VOICE) == standing[0] assert history_size(screen) == before diff --git a/tests/unit/sampletones_application/ui/elements/table/test_caret.py b/tests/unit/sampletones_application/ui/elements/table/test_caret.py index 3d493f240..886926c16 100644 --- a/tests/unit/sampletones_application/ui/elements/table/test_caret.py +++ b/tests/unit/sampletones_application/ui/elements/table/test_caret.py @@ -1,5 +1,5 @@ from typing import Dict, Iterator, Optional -from unittest.mock import patch +from unittest.mock import call, patch import pytest @@ -23,11 +23,12 @@ DIALOG_WINDOW: DIALOG_ID, } +MARK = 123 +FRAME = 124 CARET_LAYOUT = CaretLayout( - fill=LiteralColor((102, 187, 255, 64)), - border=LiteralColor((102, 187, 255, 255)), - offset=3, - width_padding=2, + height=2, + color=LiteralColor((102, 187, 255, 255)), + frame=LiteralColor((102, 187, 255, 255)), ) @@ -35,12 +36,14 @@ def caret_state() -> Iterator[None]: CaretOverlay._root_window = ROOT_WINDOW CaretOverlay._layout = CARET_LAYOUT - CaretOverlay._rectangle = 123 + CaretOverlay._mark = MARK + CaretOverlay._frame = FRAME CaretOverlay._widget = None yield CaretOverlay._root_window = None CaretOverlay._layout = None - CaretOverlay._rectangle = None + CaretOverlay._mark = None + CaretOverlay._frame = None CaretOverlay._widget = None @@ -92,4 +95,4 @@ def test_redraw_hides_the_box_when_focus_leaves_the_root_tree(self) -> None: with patch("dearpygui.dearpygui.configure_item") as configure: CaretOverlay.redraw() - configure.assert_called_once_with(123, show=False) + assert configure.call_args_list == [call(MARK, show=False), call(FRAME, show=False)] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py index 5f447bfd9..a0d09402b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py @@ -1,22 +1,30 @@ -from typing import Generator +from typing import Final, Generator import pytest from sampletones_application.ui.panels.sequencer.tracker import panel as tracker +from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback +UNSCROLLED: Final[float] = 0.0 + @pytest.fixture(autouse=True) def immediate_frame_callbacks(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: """Runs a panel's deferred frame work at once, since a suite renders no frames. - The tracker holds the playhead's mark back to the frame its scroll lands on, which a running - application reaches on its next render and a suite never does. Calling the work as it is - handed over keeps what a panel draws observable from the call that asks for it. + The tracker holds the playhead's mark and the cursor's move back to the frame their scroll + lands on, which a running application reaches on its next render and a suite never does. + Calling the work as it is handed over keeps what a panel draws observable from the call that + asks for it, and the table it reads the scroll it was drawn with from reports a grid at rest. """ def run_now(callback: VoidCallback, frame_count: int = 1) -> None: callback() + def at_rest(table: Sender) -> float: + return UNSCROLLED + monkeypatch.setattr(tracker.FrameCallbackManager, "set_frame_callback", run_now) + monkeypatch.setattr(tracker.dpg, "get_y_scroll", at_rest) yield diff --git a/tests/unit/sampletones_shared/utils/test_color.py b/tests/unit/sampletones_shared/utils/test_color.py index 959d1b51d..e760e5812 100644 --- a/tests/unit/sampletones_shared/utils/test_color.py +++ b/tests/unit/sampletones_shared/utils/test_color.py @@ -3,7 +3,14 @@ import pytest -from sampletones_shared.utils.color import blend, composite, parse_hex_color, to_grayscale +from sampletones_shared.utils.color import ( + blend, + composite, + contrast_ratio, + parse_hex_color, + relative_luminance, + to_grayscale, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error @@ -209,3 +216,53 @@ def test_clamps_below_zero(self) -> None: def test_clamps_above_one(self) -> None: assert blend(self.START, self.END, 2.0) == self.END + + +class TestRelativeLuminance(BaseTestSuite): + """Black reads as no light, white as all of it, and a mid gray as the sRGB curve places it, whatever its alpha.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + color: Tuple[int, int, int, int] + expected: float + + test_cases = ( + TestCase(label="black", color=(0, 0, 0, 255), expected=0.0), + TestCase(label="white", color=(255, 255, 255, 255), expected=1.0), + TestCase(label="mid_gray_sits_below_the_middle", color=(128, 128, 128, 255), expected=0.2158), + TestCase(label="alpha_is_left_out", color=(128, 128, 128, 0), expected=0.2158), + TestCase(label="green_weighs_most", color=(0, 255, 0, 255), expected=0.7152), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_luminance(self, test_case: TestCase) -> None: + assert relative_luminance(test_case.color) == pytest.approx(test_case.expected, abs=1e-4) + + +class TestContrastRatio(BaseTestSuite): + """Black on white reaches the full 21:1, alike colors 1:1, and swapping the two colors changes nothing.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + first: Tuple[int, int, int, int] + second: Tuple[int, int, int, int] + expected: float + + test_cases = ( + TestCase(label="black_on_white", first=(0, 0, 0, 255), second=(255, 255, 255, 255), expected=21.0), + TestCase(label="white_on_black", first=(255, 255, 255, 255), second=(0, 0, 0, 255), expected=21.0), + TestCase(label="alike", first=(100, 120, 140, 255), second=(100, 120, 140, 255), expected=1.0), + TestCase(label="gray_on_white", first=(118, 118, 118, 255), second=(255, 255, 255, 255), expected=4.54), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_contrast(self, test_case: TestCase) -> None: + assert contrast_ratio(test_case.first, test_case.second) == pytest.approx(test_case.expected, abs=0.01)