From 512a53bf5026aa3eb080862e0867f03b6dfba3d8 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 5 Sep 2026 16:22:09 +0200 Subject: [PATCH 01/56] refactor: suppress beat measure recomputation through context managers BeatTLComponentManager carries two flags that callers switch off around a bulk operation and back on afterwards. Every one of those windows was a pair of bare attribute assignments that restored a literal True rather than the value found on entry, with no try/finally. That shape is what produced the bug fixed in the previous commit: a misspelled restore (update_is_first_in_measure) silently created a new attribute and left the real flag off for the rest of the timeline's life, and nothing in CI could see it -- the project runs black and ruff with E,W,F,B,I selected, none of which flag an assignment to an attribute that does not exist, and there is no type checker configured. Route all seven windows through suppressing_is_first_in_measure() and the new suppressing_metric_fraction_dict(). Both restore what they found and do so in a finally clause, so a raising body can no longer wedge a flag off. This is behaviour-preserving. Instrumenting every window over a full test run, only delete_components is ever entered with a flag already off (17 of its 32 calls, from restore_state); crop, clear, deserialize_components, restore_state, fill_with_beats and update_is_first_in_measure_of_subsequent_beats find True on entry every single time, so restoring the entry value instead of True cannot change what they do. --- tilia/timelines/beat/timeline.py | 99 ++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 651f77a2a..1eafc54dc 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -50,23 +50,40 @@ def suppressing_is_first_in_measure(self) -> Iterator[bool]: finally: self.compute_is_first_in_measure = was_computing + @contextmanager + def suppressing_metric_fraction_dict(self) -> Iterator[bool]: + """ + Suppress per-beat rebuilds of the metric-fraction dicts for the block. + + Same contract as suppressing_is_first_in_measure: restore what was + found, and restore it even when the body raises. Leaving this flag off + would stop set_component_data from ever rebuilding the dicts again, so + every later get_time_by_measure and by-measure import would silently + read stale time-to-measure data. + """ + was_computing = self.compute_metric_fraction_dict + self.compute_metric_fraction_dict = False + try: + yield was_computing + finally: + self.compute_metric_fraction_dict = was_computing + @property def beat_times(self): return {b.time for b in self._components} def update_is_first_in_measure_of_subsequent_beats(self, start_index): - self.compute_metric_fraction_dict = False - beats_that_start_measure = set(self.timeline.beats_that_start_measures) - for i, beat in enumerate(self.timeline[start_index:]): - is_first_in_measure = start_index + i in beats_that_start_measure - if is_first_in_measure != beat.is_first_in_measure: - self.timeline.set_component_data( - beat.id, - "is_first_in_measure", - is_first_in_measure, - ) + with self.suppressing_metric_fraction_dict(): + beats_that_start_measure = set(self.timeline.beats_that_start_measures) + for i, beat in enumerate(self.timeline[start_index:]): + is_first_in_measure = start_index + i in beats_that_start_measure + if is_first_in_measure != beat.is_first_in_measure: + self.timeline.set_component_data( + beat.id, + "is_first_in_measure", + is_first_in_measure, + ) - self.compute_metric_fraction_dict = True self.timeline.update_metric_fraction_dicts() def create_component( @@ -160,15 +177,13 @@ def scale(self, factor: float) -> None: self.timeline.update_metric_fraction_dicts() def crop(self, length: float) -> None: - self.compute_is_first_in_measure = False - crop_pointlike(self, length) - self.compute_is_first_in_measure = True + with self.suppressing_is_first_in_measure(): + crop_pointlike(self, length) def clear(self): - self.compute_is_first_in_measure = False - for component in self._components.copy(): - self.delete_component(component) - self.compute_is_first_in_measure = True + with self.suppressing_is_first_in_measure(): + for component in self._components.copy(): + self.delete_component(component) def deserialize_components(self, serialized_components: dict[int, dict[str]]): # Storing these attributes so we can restore them below. @@ -176,17 +191,16 @@ def deserialize_components(self, serialized_components: dict[int, dict[str]]): measure_numbers = self.timeline.measure_numbers.copy() measures_to_force_display = self.timeline.measures_to_force_display.copy() - self.compute_is_first_in_measure = False - - # This call will change the attributes above. - super().deserialize_components(serialized_components) - - # But we restore them here. - self.timeline.set_data("measure_numbers", measure_numbers) - self.timeline.set_data("beats_in_measure", beats_in_measure) - self.timeline.set_data("measures_to_force_display", measures_to_force_display) + with self.suppressing_is_first_in_measure(): + # This call will change the attributes above. + super().deserialize_components(serialized_components) - self.compute_is_first_in_measure = True + # But we restore them here. + self.timeline.set_data("measure_numbers", measure_numbers) + self.timeline.set_data("beats_in_measure", beats_in_measure) + self.timeline.set_data( + "measures_to_force_display", measures_to_force_display + ) self.timeline.recalculate_measures() # Files saved by earlier versions can hold duplicate or out-of-range @@ -200,9 +214,8 @@ def deserialize_components(self, serialized_components: dict[int, dict[str]]): def restore_state(self, prev_state: dict): self.timeline.clear_cached_metric_positions() - self.compute_is_first_in_measure = False - super().restore_state(prev_state) - self.compute_is_first_in_measure = True + with self.suppressing_is_first_in_measure(): + super().restore_state(prev_state) self.timeline.recalculate_measures() self.update_is_first_in_measure_of_subsequent_beats(0) post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.timeline.id, 0) @@ -693,21 +706,19 @@ class FillMethod(Enum): @long_operation("Creating beats...") def fill_with_beats(self, method: BeatTimeline.FillMethod, value: int | float): duration = get(Get.MEDIA_DURATION) - self.component_manager.compute_is_first_in_measure = False # only compute at end + with self.component_manager.suppressing_is_first_in_measure(): + if method == BeatTimeline.FillMethod.BY_AMOUNT: + total = int(value) + for i in range(total): + self.create_component(ComponentKind.BEAT, i * duration / value) + post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, total) + elif method == BeatTimeline.FillMethod.BY_INTERVAL: + total = math.floor(duration / value) + for i in range(total): + self.create_component(ComponentKind.BEAT, i * value) + post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, total) - if method == BeatTimeline.FillMethod.BY_AMOUNT: - total = int(value) - for i in range(total): - self.create_component(ComponentKind.BEAT, i * duration / value) - post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, total) - elif method == BeatTimeline.FillMethod.BY_INTERVAL: - total = math.floor(duration / value) - for i in range(total): - self.create_component(ComponentKind.BEAT, i * value) - post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, total) - - self.component_manager.compute_is_first_in_measure = True self.recalculate_measures() self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) From 73094ebb371bb6373da69343fcd862db109b2f1a Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 5 Sep 2026 16:34:14 +0200 Subject: [PATCH 02/56] fix: crop and clear leave a stale beat measure structure behind Both suppress compute_is_first_in_measure around a bulk deletion and then never recompute, so the timeline keeps a beats_in_measure and measure_numbers describing beats that no longer exist. crop() runs whenever the loaded media gets shorter and the user declines scaling. Twelve beats at pattern 4 cropped to 7.5s leaves 8 beats but still reports measure_count 3, beats_that_start_measures [0, 4, 8], and a metric-fraction dict holding the deleted beats. get_time_by_measure(3) then returns a time with no beat, which misplaces every by-measure CSV and MusicXML import, and "Distribute beats" on the last visible measure raises IndexError because the last-measure guard is fooled by the phantom third measure. Saving in that state serializes the phantom too. clear() is reached from CSV/MusicXML import into an existing beat timeline, from the "delete existing beats?" confirmation in Fill with beats, and from Timelines.clear_timelines. After it the timeline has no beats but still reports three measures, so the import or fill that follows adjusts a stale list instead of building from empty. Extract the recalculate/re-derive/notify trio the fixed delete path already performs into BeatTimeline.refresh_measures() and call it from crop, clear, delete_components and restore_state. Those four had four hand-written variants of the same sequence, differing in whether they posted and whether they guarded on emptiness; that divergence is how delete_components came to be missing its recomputation in the first place. fill_with_beats and deserialize_components are left alone: their sequences genuinely differ (no post, and a different post respectively). --- tests/timelines/beat/test_beat_timeline.py | 30 +++++++++++++++++++ tilia/timelines/beat/timeline.py | 35 +++++++++++++++------- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 79087ee15..f790b44e4 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -2,6 +2,7 @@ import pytest +from tests.mock import patch_yes_or_no_dialog from tests.utils import undoable from tilia.timelines.beat.timeline import BeatTimeline from tilia.ui import commands @@ -550,6 +551,35 @@ def test_delete_restores_the_flag_when_a_deletion_raises(self, beat_tlui): assert component_manager.compute_is_first_in_measure is True + def test_shrinking_media_drops_measures_for_cropped_beats( + self, beat_tlui, tilia_state + ): + tilia_state.duration = 100 + self._add_beats(range(12)) + + assert beat_tlui.timeline.beats_in_measure == [4, 4, 4] + + tilia_state.set_duration(7.5, scale_timelines="no") + + # The measure structure has to shrink with the beats. Leaving a third + # measure behind makes measure_count over-report, makes + # get_time_by_measure interpolate against a partition that no longer + # exists, and makes "Distribute beats" on the last measure raise. + assert len(beat_tlui) == 8 + assert beat_tlui.timeline.beats_in_measure == [4, 4] + + def test_clearing_the_timeline_drops_the_measure_structure(self, beat_tlui): + self._add_beats(range(12)) + + assert beat_tlui.timeline.beats_in_measure == [4, 4, 4] + + with patch_yes_or_no_dialog(True): + commands.execute("timeline.clear", beat_tlui) + + assert len(beat_tlui) == 0 + assert beat_tlui.timeline.beats_in_measure == [] + assert beat_tlui.timeline.measure_numbers == [] + class TestGetExtensionFromBeatPattern: def test_starting_measure_emptier_than_beat_pattern(self): diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 1eafc54dc..ce2430999 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -177,14 +177,20 @@ def scale(self, factor: float) -> None: self.timeline.update_metric_fraction_dicts() def crop(self, length: float) -> None: - with self.suppressing_is_first_in_measure(): + with self.suppressing_is_first_in_measure() as was_computing: crop_pointlike(self, length) + if was_computing: + self.timeline.refresh_measures() + def clear(self): - with self.suppressing_is_first_in_measure(): + with self.suppressing_is_first_in_measure() as was_computing: for component in self._components.copy(): self.delete_component(component) + if was_computing: + self.timeline.refresh_measures() + def deserialize_components(self, serialized_components: dict[int, dict[str]]): # Storing these attributes so we can restore them below. beats_in_measure = self.timeline.beats_in_measure.copy() @@ -216,9 +222,7 @@ def restore_state(self, prev_state: dict): self.timeline.clear_cached_metric_positions() with self.suppressing_is_first_in_measure(): super().restore_state(prev_state) - self.timeline.recalculate_measures() - self.update_is_first_in_measure_of_subsequent_beats(0) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.timeline.id, 0) + self.timeline.refresh_measures() class BeatTimeline(Timeline): @@ -675,6 +679,20 @@ def set_beat_amount_in_measure(self, measure_index: int, beat_amount: int) -> No def distribute_beats(self, measure_index: int) -> None: self.component_manager.distribute_beats(measure_index) + def refresh_measures(self) -> None: + """ + Rebuild the measure structure and have the UI relabel every beat. + + Run this after any bulk operation that added or removed beats with + per-beat recomputation suppressed. It is safe on an emptied timeline, + and required there: otherwise beats_in_measure and measure_numbers go + on describing beats that no longer exist, and the labels stay on + screen. + """ + self.recalculate_measures() + self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) + post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + def delete_components(self, components: list[TC]) -> None: self._validate_delete_components(components) @@ -692,12 +710,7 @@ def delete_components(self, components: list[TC]) -> None: # every beat it re-creates afterwards sweep the whole timeline. return - # Unconditional, including when the timeline is now empty: otherwise it - # keeps a beats_in_measure / measure_numbers describing beats that no - # longer exist, and the UI keeps their labels. - self.recalculate_measures() - component_manager.update_is_first_in_measure_of_subsequent_beats(0) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + self.refresh_measures() class FillMethod(Enum): BY_AMOUNT = 0 From f8be552865ea60f9418d425bec4adfba531fc8a3 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 5 Sep 2026 16:35:38 +0200 Subject: [PATCH 03/56] perf: stop recalculating beat measures twice on every deletion BeatTimelineUI.on_delete_component called recalculate_measures() after super().on_delete_component, which routes unconditionally through BeatTimeline.delete_components -- and that now performs the recalculation itself. The override was a pure pass-through plus a duplicate. The duplicate is not cheap. recalculate_measures starts by clearing every cached metric position, so the second pass throws away the caches the first one just built and re-derives all of them; each cold Beat.metric_position resolves itself with components.index(beat), which copies the component list and scans it. That is O(n^2) per pass, paid twice on the most common delete path. Drop the override and inherit the base implementation. --- tilia/ui/timelines/beat/timeline.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 4d5b2a794..e5469c4e1 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -106,12 +106,6 @@ def _get_measure_indices(self, elements: list[BeatUI]): return sorted(list(measure_indices)) - def on_delete_component(self, elements: list[BeatUI] | None = None) -> bool: - success = super().on_delete_component(elements) - if success: - self.timeline.recalculate_measures() - return success - @with_elements def on_set_measure_number(self, elements: list[BeatUI] | None = None): accepted, number = get( From 1a8dfe325510d79e1c3c3ff5269013f15eb7c742 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:58:23 +0200 Subject: [PATCH 04/56] fix: hash each timeline attribute once _get_base_state appended every serializable attribute to the string it hashes twice, through two identical statements. The hash was still deterministic, so nothing misbehaved, but it doubled the work done on every is_file_modified() check and read as though the second line hashed something else. Hashes change, which is harmless: the saved-state baseline is taken again whenever a file is loaded. Part of #582 (item 3). --- tilia/timelines/base/timeline.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index 36c253a12..c77d6a404 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -285,8 +285,6 @@ def _get_base_state(self) -> dict: value = value.copy() state[attr] = value string_to_hash += str(value) + "|" - - string_to_hash += str(value) + "|" state["hash"] = hash_function(f"{state['kind']}|{string_to_hash}") return state From b3daa31495fa405358625dfb918537bfb732224f Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:58:25 +0200 Subject: [PATCH 05/56] docs: correct the console log level in TiliaLogger's docstring The docstring said the console logs from WARNING when dev.log_requests is off; the code uses ERROR. Since the same table maps WARNING to tilia.errors, this made it hard to tell whether a message logged with logger.error or tilia.errors.display would reach the console. It now also says when to use each. Part of #582 (item 4). --- tilia/log.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tilia/log.py b/tilia/log.py index e83e1a9d0..2c0196e4a 100644 --- a/tilia/log.py +++ b/tilia/log.py @@ -17,7 +17,7 @@ class TiliaLogger(logging.Logger): """ Logs to file and console. - settings.dev.log_requests sets console log level to INFO when True, WARNING when False. + settings.dev.log_requests sets console log level to INFO when True, ERROR when False. (Only logs of level or higher will be logged by the handler.) Log level |Message type @@ -25,8 +25,13 @@ class TiliaLogger(logging.Logger): DEBUG |file dumps, autosave file name INFO |tilia.posts WARNING |tilia.errors - ERROR |Qt log messages + ERROR |Qt log messages, internal-invariant violations CRITICAL |Crash messages + + tilia.errors.display is for failures the user must be told about: it + shows a dialog (and logs at WARNING, below the default console level). + logger.error is for internal-invariant violations that need no dialog; + it is the lowest level that reaches the console by default. """ DSN = { From 682e75e38b4a2ca382d5821b89e33d6eca81998d Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:58:26 +0200 Subject: [PATCH 06/56] test: add a patch for integer input dialogs patch_ask_for_int_dialog patches QInputDialog.getInt the way patch_ask_for_string_dialog patches getText, so tests can answer Get.FROM_USER_INT prompts through the dialog instead of serving the request (#605). --- tests/mock.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/mock.py b/tests/mock.py index f24f6f24e..454e1ad2c 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -167,3 +167,24 @@ def patch_ask_for_string_dialog(success: bool | list[bool], string: str | list[s return_values = list(zip(string, success, strict=True)) with patch.object(QInputDialog, "getText", side_effect=return_values): yield + + +@contextmanager +def patch_ask_for_int_dialog(success: bool | list[bool], number: int | list[int]): + """Patches QInputDialog.getInt to return the specified success values and numbers. + + Args: + success: Whether the dialog was successful. If a list, the success value for each iteration. + number: Number input by user. If a list of numbers, the input values for each iteration. + """ + + if not isinstance(success, list): + success = [success] + if not isinstance(number, list): + number = [number] + else: + raise ValueError("number must be an int if success is a bool.") + + return_values = list(zip(number, success, strict=True)) + with patch.object(QInputDialog, "getInt", side_effect=return_values): + yield From daa2ec1d48db6b07cb8fe379e7ce0636030b7d0f Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:38:30 +0200 Subject: [PATCH 07/56] test: cover hand-set measures through crop and refill The crop and clear fixes are tested with plain 4-beat bars. These tests add the state users set by hand, which is what made the stale measures visible: a renumbered measure and a partial last bar after a crop, and a bar set to 3 beats plus a renumbered measure before "Fill with beats" clears and refills the timeline. Both must start over from the beats that remain. Adds patch_fill_beat_timeline_dialog to drive the "Fill with beats" dialog. Part of #582 (items 1 and 2). --- tests/timelines/beat/test_beat_timeline.py | 21 ++++++++++++ tests/ui/timelines/beat/interact.py | 25 +++++++++++++++ .../timelines/beat/test_beat_timeline_ui.py | 32 +++++++++++++++++-- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index f790b44e4..139d61304 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -551,6 +551,27 @@ def test_delete_restores_the_flag_when_a_deletion_raises(self, beat_tlui): assert component_manager.compute_is_first_in_measure is True + def test_crop_recalculates_measures(self, beat_tlui, tilia_state): + beat_tlui.timeline.set_data("beat_pattern", [2, 3]) + self._add_beats(range(10)) + beat_tlui.timeline.set_measure_number(3, 20) + + tilia_state.set_duration(5.5, scale_timelines="no") + + timeline = beat_tlui.timeline + assert len(timeline) == 6 + assert timeline.beats_in_measure == [2, 3, 1] + assert timeline.measure_numbers == [1, 2, 3] + assert timeline.measures_to_force_display == [] + assert [b.is_first_in_measure for b in timeline] == [ + True, + False, + True, + False, + False, + True, + ] + def test_shrinking_media_drops_measures_for_cropped_beats( self, beat_tlui, tilia_state ): diff --git a/tests/ui/timelines/beat/interact.py b/tests/ui/timelines/beat/interact.py index e2838f4bd..0d2d407e7 100644 --- a/tests/ui/timelines/beat/interact.py +++ b/tests/ui/timelines/beat/interact.py @@ -1,5 +1,11 @@ +from unittest.mock import patch + +from PySide6.QtWidgets import QDialog + from tests.ui.timelines.interact import click_timeline_ui_view +from tilia.timelines.beat.timeline import BeatTimeline from tilia.ui.coords import time_x_converter +from tilia.ui.windows.fill_beat_timeline import FillBeatTimeline def click_beat_ui(beat_ui, button="left", modifier=None, double=False): @@ -12,3 +18,22 @@ def click_beat_ui(beat_ui, button="left", modifier=None, double=False): modifier, double, ) + + +def patch_fill_beat_timeline_dialog(timeline, method, value: float): + """ + Makes the "Fill with beats" dialog choose `timeline`, fill `method` and + `value`, then press OK. + """ + + def exec_(dialog): + combobox = dialog._timeline_combobox + combobox.setCurrentIndex(combobox.findData(timeline)) + dialog._options.button(method.value).setChecked(True) + if method == BeatTimeline.FillMethod.BY_AMOUNT: + dialog._by_amount_edit.setValue(int(value)) + else: + dialog._by_interval_edit.setValue(value) + return QDialog.DialogCode.Accepted + + return patch.object(FillBeatTimeline, "exec", new=exec_) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index d156701fe..3bf61efe0 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -2,8 +2,11 @@ import pytest -from tests.mock import Serve, patch_yes_or_no_dialog -from tests.ui.timelines.beat.interact import click_beat_ui +from tests.mock import Serve, patch_ask_for_int_dialog, patch_yes_or_no_dialog +from tests.ui.timelines.beat.interact import ( + click_beat_ui, + patch_fill_beat_timeline_dialog, +) from tests.ui.timelines.interact import drag_mouse_in_timeline_view from tests.utils import ( get_command_action, @@ -691,6 +694,31 @@ def test_reject_delete_existing_beats(self, beat_tlui): assert len(beat_tlui) == 1 + def test_refilling_starts_the_measures_over(self, beat_tlui): + def fill(amount): + with patch_fill_beat_timeline_dialog( + beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, amount + ): + commands.execute("timeline.beat.fill") + + fill(12) + beat_tlui.select_element(beat_tlui[4]) + with patch_ask_for_int_dialog(True, 3): + commands.execute("timeline.beat.set_amount_in_measure") + beat_tlui.deselect_all_elements() + beat_tlui.select_element(beat_tlui[7]) + with patch_ask_for_int_dialog(True, 10): + commands.execute("timeline.beat.set_measure_number") + assert beat_tlui.timeline.measure_numbers == [1, 2, 10, 11] + + with patch_yes_or_no_dialog(True): + with undoable(): + fill(8) + + assert beat_tlui.timeline.beats_in_measure == [4, 4] + assert beat_tlui.timeline.measure_numbers == [1, 2] + assert beat_tlui.timeline.get_time_by_measure(3) == [] + class TestUndoRedo: def test_undo_redo_add_beat(self, beat_tlui, tluis): From 1c1f234029885c1edc13dfeb6d2dd45138437c6d Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:13 +0200 Subject: [PATCH 08/56] refactor: share time signature glyphs between score and beat timelines Moves TimeSignatureBody and its Glyph items out of the score timeline into tilia/ui/timelines/time_signature_glyphs.py so the beat timeline can draw time signatures the same way. The body still takes the pairs a score time signature is written with, composite numerators and the plus between two pairs included. A row may now also hold "/" and spaces, so a numerator can be "4 1/3", and the body can be tinted a colour. Adds a slash glyph in the style of the digit glyphs; the glyphs' icon names, the plus's included, now live with the body. The score timeline draws exactly as before. --- .../timelines/test_time_signature_glyphs.py | 63 +++++++ .../actions/256/time-signature-slash.svg | 1 + .../timelines/score/element/time_signature.py | 95 +--------- tilia/ui/timelines/score/timeline.py | 16 +- tilia/ui/timelines/time_signature_glyphs.py | 175 ++++++++++++++++++ 5 files changed, 243 insertions(+), 107 deletions(-) create mode 100644 tests/ui/timelines/test_time_signature_glyphs.py create mode 100644 tilia/ui/icons/hicolor/actions/256/time-signature-slash.svg create mode 100644 tilia/ui/timelines/time_signature_glyphs.py diff --git a/tests/ui/timelines/test_time_signature_glyphs.py b/tests/ui/timelines/test_time_signature_glyphs.py new file mode 100644 index 000000000..074a72163 --- /dev/null +++ b/tests/ui/timelines/test_time_signature_glyphs.py @@ -0,0 +1,63 @@ +from PySide6.QtGui import QColor + +from tilia.ui.timelines.time_signature_glyphs import ( + GLYPH_ICON_NAMES, + Glyph, + TimeSignatureBody, + load_time_signature_pixmaps, +) + + +def row(body, name): + return [glyph for glyph in body.glyphs if glyph.row == name] + + +def test_every_glyph_has_an_icon(qtui): + pixmaps = load_time_signature_pixmaps() + + assert set(pixmaps) == set(GLYPH_ICON_NAMES) + assert all(not pixmap.isNull() for pixmap in pixmaps.values()) + + +def test_whole_and_fraction_numerator(qtui): + body = TimeSignatureBody(0, 0, [("4 1/3", 4)], 10, load_time_signature_pixmaps()) + + numerator = row(body, Glyph.NUMERATOR) + assert [glyph.character for glyph in numerator] == ["4", "1", "/", "3"] + assert [glyph.character for glyph in row(body, Glyph.DENOMINATOR)] == ["4"] + # The space leaves a gap between the whole number and the fraction. + whole, fraction_start = numerator[0], numerator[1] + assert fraction_start.x() > whole.x() + whole.pixmap().width() + + +def test_narrower_row_is_centered(qtui): + body = TimeSignatureBody(0, 0, [(12, 8)], 10, load_time_signature_pixmaps()) + + (eight,) = row(body, Glyph.DENOMINATOR) + numerator_width = sum(g.pixmap().width() for g in row(body, Glyph.NUMERATOR)) + assert eight.x() == (numerator_width - eight.pixmap().width()) / 2 + assert eight.y() == 10 + + +def test_set_height_keeps_layout(qtui): + body = TimeSignatureBody(0, 0, [(3, 4)], 10, load_time_signature_pixmaps()) + + body.set_height(20) + + assert row(body, Glyph.DENOMINATOR)[0].y() == 20 + assert row(body, Glyph.NUMERATOR)[0].pixmap().height() == 20 + + +def test_color(qtui): + body = TimeSignatureBody(0, 0, [(3, 4)], 10, load_time_signature_pixmaps()) + + body.set_color(QColor("red")) + + image = row(body, Glyph.NUMERATOR)[0].pixmap().toImage() + colors = { + image.pixelColor(x, y).name() + for x in range(image.width()) + for y in range(image.height()) + if image.pixelColor(x, y).alpha() == 255 + } + assert colors == {"#ff0000"} diff --git a/tilia/ui/icons/hicolor/actions/256/time-signature-slash.svg b/tilia/ui/icons/hicolor/actions/256/time-signature-slash.svg new file mode 100644 index 000000000..ca01d111e --- /dev/null +++ b/tilia/ui/icons/hicolor/actions/256/time-signature-slash.svg @@ -0,0 +1 @@ + diff --git a/tilia/ui/timelines/score/element/time_signature.py b/tilia/ui/timelines/score/element/time_signature.py index 214a15656..3a7331577 100644 --- a/tilia/ui/timelines/score/element/time_signature.py +++ b/tilia/ui/timelines/score/element/time_signature.py @@ -1,13 +1,8 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QGraphicsItemGroup, QGraphicsPixmapItem - from tilia.ui.coords import time_x_converter from tilia.ui.timelines.score.element.with_collision import ( TimelineUIElementWithCollision, ) +from tilia.ui.timelines.time_signature_glyphs import TimeSignatureBody class TimeSignatureUI(TimelineUIElementWithCollision): @@ -74,91 +69,3 @@ def on_deselect(self): def on_select(self): return - - -class TimeSignatureBody(QGraphicsItemGroup): - """A time signature's numerators over its denominators, as written, such as - 3+2 over 8. Two pairs (2/4 + 3/8) are drawn side by side, with a plus - halfway down between them.""" - - def __init__( - self, - x: float, - y: float, - pairs: list[tuple[str, int]], - digit_height: int, - pixmaps: dict[str, QPixmap], - ): - super().__init__() - self.pixmaps = pixmaps - # Each pair's numerator and denominator glyphs, and each plus between - # two pairs. - self.columns: list[tuple[list[Glyph], list[Glyph]]] = [] - self.pluses: list[Glyph] = [] - for i, (numerator, denominator) in enumerate(pairs): - if i > 0: - self.pluses.append(Glyph("+", Glyph.BETWEEN, self)) - self.columns.append( - ( - [Glyph(c, Glyph.NUMERATOR, self) for c in numerator], - [Glyph(c, Glyph.DENOMINATOR, self) for c in str(denominator)], - ) - ) - self.glyphs = self.pluses + [ - glyph - for numerator, denominator in self.columns - for glyph in numerator + denominator - ] - self.set_height(digit_height) - self.set_position(x, y) - - def get_scaled_pixmap(self, character: str, height: int): - return self.pixmaps[character].scaledToHeight( - height, mode=Qt.TransformationMode.SmoothTransformation - ) - - def set_height(self, height: int): - for glyph in self.glyphs: - glyph.setPixmap(self.get_scaled_pixmap(glyph.character, height)) - - x = 0 - for i, (numerator, denominator) in enumerate(self.columns): - if i > 0: - plus = self.pluses[i - 1] - gap = height / 4 - plus.setPos(x + gap, height / 2) - x += plus.pixmap().width() + 2 * gap - width = max(_row_width(numerator), _row_width(denominator)) - _place_row(numerator, x + (width - _row_width(numerator)) / 2, 0) - _place_row(denominator, x + (width - _row_width(denominator)) / 2, height) - x += width - - def set_position(self, x: float, y: float): - self.setPos(x, y) - - def canvas_items(self): - return self.glyphs - - -def _row_width(glyphs: list[Glyph]) -> float: - return sum(glyph.pixmap().width() for glyph in glyphs) - - -def _place_row(glyphs: list[Glyph], x: float, y: float): - for glyph in glyphs: - glyph.setPos(x, y) - x += glyph.pixmap().width() - - -class Glyph(QGraphicsPixmapItem): - """A digit or plus of a time signature, in the numerator's row, the - denominator's, or between two pairs.""" - - NUMERATOR = "numerator" - DENOMINATOR = "denominator" - BETWEEN = "between" - - def __init__(self, character: str, row: str, parent: QGraphicsItemGroup): - super().__init__(parent) - self.character = character - self.row = row diff --git a/tilia/ui/timelines/score/timeline.py b/tilia/ui/timelines/score/timeline.py index 852ab69bb..007675975 100644 --- a/tilia/ui/timelines/score/timeline.py +++ b/tilia/ui/timelines/score/timeline.py @@ -4,7 +4,7 @@ from typing import Any, Callable, Iterable from PySide6.QtCore import QPointF, QRectF, Qt -from PySide6.QtGui import QColor, QIcon +from PySide6.QtGui import QColor from PySide6.QtWidgets import QGraphicsRectItem import tilia.errors @@ -33,6 +33,7 @@ TimelineUIElementWithCollision, ) from tilia.ui.timelines.score.toolbar import ScoreTimelineToolbar +from tilia.ui.timelines.time_signature_glyphs import load_time_signature_pixmaps from tilia.ui.windows.svg_viewer import SvgViewer @@ -89,14 +90,7 @@ def __init__(self, *args, **kwargs): ) def _setup_pixmaps(self): - self.pixmaps = { - "time signature": { - character: QIcon.fromTheme( - self.get_time_signature_icon_name(character) - ).pixmap(48, 48) - for character in "0123456789+" - }, - } + self.pixmaps = {"time signature": load_time_signature_pixmaps()} @property def svg_view(self) -> SvgViewer | None: @@ -121,10 +115,6 @@ def get_or_create_svg_view(self) -> SvgViewer: self.measure_tracker.setVisible(not viewer.is_hidden) return viewer - @staticmethod - def get_time_signature_icon_name(character: str) -> str: - return f"time-signature-{'plus' if character == '+' else character}" - def on_settings_updated(self, updated_settings): if "score_timeline" in updated_settings: self.measure_tracker.update_color() diff --git a/tilia/ui/timelines/time_signature_glyphs.py b/tilia/ui/timelines/time_signature_glyphs.py new file mode 100644 index 000000000..99f6602f9 --- /dev/null +++ b/tilia/ui/timelines/time_signature_glyphs.py @@ -0,0 +1,175 @@ +""" +Time signatures drawn as stacked engraved glyphs, shared by the score and +beat timelines. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap +from PySide6.QtWidgets import QGraphicsItemGroup, QGraphicsPixmapItem + +GLYPH_ICON_NAMES = {str(n): f"time-signature-{n}" for n in range(10)} | { + "+": "time-signature-plus", + "/": "time-signature-slash", +} +GLYPH_PIXMAP_SIZE = 48 +# A space between the whole part and the fraction of a numerator such as +# "4 1/3" is this fraction of a glyph's width. +SPACE_WIDTH_RATIO = 0.5 + + +def load_time_signature_pixmaps() -> dict[str, QPixmap]: + return { + char: QIcon.fromTheme(name).pixmap(GLYPH_PIXMAP_SIZE, GLYPH_PIXMAP_SIZE) + for char, name in GLYPH_ICON_NAMES.items() + } + + +def tint_pixmap(pixmap: QPixmap, color: QColor) -> QPixmap: + tinted = QPixmap(pixmap.size()) + tinted.fill(Qt.GlobalColor.transparent) + painter = QPainter(tinted) + painter.drawPixmap(0, 0, pixmap) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) + painter.fillRect(tinted.rect(), color) + painter.end() + return tinted + + +class Glyph(QGraphicsPixmapItem): + """A digit, slash or plus of a time signature, in the numerator's row, the + denominator's, or between two pairs.""" + + NUMERATOR = "numerator" + DENOMINATOR = "denominator" + BETWEEN = "between" + + def __init__(self, character: str, row: str, parent: QGraphicsItemGroup): + super().__init__(parent) + self.character = character + self.row = row + + +class TimeSignatureBody(QGraphicsItemGroup): + """ + A time signature's numerators over its denominators, as written. A + numerator may be composite, such as 3+2, or hold a fraction after a space, + such as "4 1/3". Several pairs (2/4 + 3/8) are drawn side by side, with a + plus halfway down between them. Each numerator is centred over its + denominator. + """ + + def __init__( + self, + x: float, + y: float, + pairs: Sequence[tuple[int | str, int | str]], + digit_height: int, + pixmaps: dict[str, QPixmap], + color: QColor | None = None, + ): + super().__init__() + self.pixmaps = pixmaps + self.color = color + self.digit_height = digit_height + # Each pair's numerator and denominator as text, which keeps its + # spaces, and as glyphs; and each plus between two pairs. + self.texts: list[tuple[str, str]] = [] + self.columns: list[tuple[list[Glyph], list[Glyph]]] = [] + self.pluses: list[Glyph] = [] + for i, (numerator, denominator) in enumerate(pairs): + if i > 0: + self.pluses.append(Glyph("+", Glyph.BETWEEN, self)) + numerator, denominator = str(numerator), str(denominator) + self.texts.append((numerator, denominator)) + self.columns.append( + ( + self._create_glyphs(numerator, Glyph.NUMERATOR), + self._create_glyphs(denominator, Glyph.DENOMINATOR), + ) + ) + self.glyphs = self.pluses + [ + glyph + for numerator, denominator in self.columns + for glyph in numerator + denominator + ] + self.set_height(digit_height) + self.set_position(x, y) + + def _create_glyphs(self, text: str, row: str) -> list[Glyph]: + return [Glyph(character, row, self) for character in text if character != " "] + + def get_scaled_pixmap(self, character: str, height: int) -> QPixmap: + pixmap = self.pixmaps[character].scaledToHeight( + height, mode=Qt.TransformationMode.SmoothTransformation + ) + if self.color is not None: + pixmap = tint_pixmap(pixmap, self.color) + return pixmap + + def set_height(self, height: int) -> None: + self.digit_height = height + for glyph in self.glyphs: + glyph.setPixmap(self.get_scaled_pixmap(glyph.character, height)) + + x = 0.0 + for i, (texts, (numerator, denominator)) in enumerate( + zip(self.texts, self.columns, strict=True) + ): + numerator_text, denominator_text = texts + if i > 0: + plus = self.pluses[i - 1] + gap = height / 4 + plus.setPos(x + gap, height / 2) + x += plus.pixmap().width() + 2 * gap + numerator_width = _row_width(numerator_text, numerator) + denominator_width = _row_width(denominator_text, denominator) + width = max(numerator_width, denominator_width) + _place_row(numerator_text, numerator, x + (width - numerator_width) / 2, 0) + _place_row( + denominator_text, + denominator, + x + (width - denominator_width) / 2, + height, + ) + x += width + + @property + def width(self) -> float: + return self.childrenBoundingRect().width() + + def set_color(self, color: QColor | None) -> None: + self.color = color + self.set_height(self.digit_height) + + def set_position(self, x: float, y: float) -> None: + self.setPos(x, y) + + def canvas_items(self) -> list[Glyph]: + return self.glyphs + + +def _space_width(glyphs: list[Glyph]) -> float: + return glyphs[0].pixmap().width() * SPACE_WIDTH_RATIO if glyphs else 0 + + +def _row_width(text: str, glyphs: list[Glyph]) -> float: + return sum(glyph.pixmap().width() for glyph in glyphs) + text.count( + " " + ) * _space_width(glyphs) + + +def _place_row(text: str, glyphs: list[Glyph], x: float, y: float) -> None: + """Places a row's glyphs from `x`, leaving a gap for each space.""" + space = _space_width(glyphs) + remaining = iter(glyphs) + for character in text: + if character == " ": + x += space + continue + glyph = next(remaining) + glyph.setPos(x, y) + x += glyph.pixmap().width() From 058bd9b8b64f89ee6a749d8e0add335788da9931 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:15 +0200 Subject: [PATCH 09/56] feat: inspector fields that apply when editing finishes Adds SINGLE_LINE_EDIT_ON_FINISH and SPIN_BOX_ON_FINISH row kinds. They post INSPECTOR_FIELD_EDITED on Enter or when focus leaves rather than on every keystroke, for values that aren't valid while half-typed or whose change needs confirming first. A line edit can be given a validator, in which case only acceptable input is ever posted. editingFinished also fires when focus moves to a dialog opened in response to the first emission, so each field reports an edit only once: line edits by their "modified" flag, spin boxes by comparing with the value last shown or reported. --- tilia/ui/windows/inspect.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tilia/ui/windows/inspect.py b/tilia/ui/windows/inspect.py index eb6786015..b0334d44f 100644 --- a/tilia/ui/windows/inspect.py +++ b/tilia/ui/windows/inspect.py @@ -35,6 +35,11 @@ class InspectRowKind(Enum): SPIN_BOX = auto() COMBO_BOX = auto() SINGLE_LINE_EDIT = auto() + # Apply when editing finishes (Enter or focus leaving) rather than on + # every keystroke, for values that aren't valid while half-typed or whose + # change needs confirming. + SINGLE_LINE_EDIT_ON_FINISH = auto() + SPIN_BOX_ON_FINISH = auto() MULTI_LINE_EDIT = auto() LABEL = auto() SEPARATOR = auto() @@ -263,6 +268,7 @@ def set_widget_value(widget, value): widget.setCurrentIndex(widget.findData(value)) elif isinstance(widget, QSpinBox): widget.setValue(value) + widget.last_reported_value = value finally: widget.blockSignals(False) @@ -298,6 +304,22 @@ def delete_all_rows(self): def on_line_edit_changed(self, field_name, value): post(Post.INSPECTOR_FIELD_EDITED, field_name, value, self.element_id, id(self)) + def on_line_edit_finished(self, field_name: str, widget: QLineEdit) -> None: + # editingFinished also fires when focus moves to a dialog opened in + # response to the first emission; only report an edit once. + if not widget.isModified(): + return + widget.setModified(False) + self.on_line_edit_changed(field_name, widget.text()) + + def on_spin_box_finished(self, field_name: str, widget: QSpinBox) -> None: + # As above; spin boxes have no "modified" flag, so compare with the + # value last shown or reported. + if widget.value() == getattr(widget, "last_reported_value", None): + return + widget.last_reported_value = widget.value() + self.on_spin_box_changed(field_name, widget.value()) + def on_text_edit_changed(self, field_name, text_edit): post( Post.INSPECTOR_FIELD_EDITED, @@ -334,6 +356,14 @@ def _get_widget_for_row(self, kind: InspectRowKind, name: str, kwargs): widget.textChanged.connect( functools.partial(self.on_line_edit_changed, name) ) + case InspectRowKind.SINGLE_LINE_EDIT_ON_FINISH: + widget = QLineEdit(self.inspect_widget) + if kwargs and "validator" in kwargs: + # editingFinished only fires for input the validator accepts. + widget.setValidator(kwargs["validator"](widget)) + widget.editingFinished.connect( + functools.partial(self.on_line_edit_finished, name, widget) + ) case InspectRowKind.MULTI_LINE_EDIT: widget = QTextEdit(self.inspect_widget) widget.setAcceptRichText(False) @@ -350,6 +380,13 @@ def _get_widget_for_row(self, kind: InspectRowKind, name: str, kwargs): widget.valueChanged.connect( functools.partial(self.on_spin_box_changed, name) ) + case InspectRowKind.SPIN_BOX_ON_FINISH: + widget = QSpinBox() + widget.setMinimum(kwargs["min"]) + widget.setMaximum(kwargs["max"]) + widget.editingFinished.connect( + functools.partial(self.on_spin_box_finished, name, widget) + ) case InspectRowKind.COMBO_BOX: widget = QComboBox() for value, data in kwargs["items"]: From 7d40b4be9afc8850e0059c74250d3f570182e334 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:17 +0200 Subject: [PATCH 10/56] refactor: route element repositioning through TimelineUI Zoom, resize, height changes and media scaling/cropping reposition a timeline's elements by calling element_manager.update_time_on_elements() directly. They now go through a TimelineUI.update_time_on_elements() method instead, so a timeline UI can redraw items that belong to no element at the same moment. Behaviour is unchanged for every existing timeline. --- tilia/ui/timelines/base/timeline.py | 8 ++++++-- tilia/ui/timelines/collection/collection.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index 3ce1b6b73..9844305f6 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -236,7 +236,7 @@ def update_height(self): self.view.set_height(height) self.collection.update_height() if self.element_manager: - self.element_manager.update_time_on_elements() + self.update_time_on_elements() def update_name(self): self.scene.set_text(self.get_data("name")) @@ -244,7 +244,7 @@ def update_name(self): def set_width(self, width): self.scene.set_width(int(width)) self.view.setFixedWidth(int(width)) - self.element_manager.update_time_on_elements() + self.update_time_on_elements() # Where the lines are now, not the selected time: during a smooth # movement that is already its target. self.scene.set_playback_line_pos( @@ -256,6 +256,10 @@ def set_width(self, width): time_x_converter.get_x_by_time(loop_end), ) + def update_time_on_elements(self) -> None: + """Repositions elements after the time-to-x mapping changed.""" + self.element_manager.update_time_on_elements() + def update_ordinal(self): self.collection.update_timeline_ui_ordinal() diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index e4043dc68..adfbb11f8 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -724,7 +724,7 @@ def update_timeline_times(tlui: TimelineUI): tlui = cast(SliderTimelineUI, tlui) tlui.update_items_position() else: - tlui.element_manager.update_time_on_elements() + tlui.update_time_on_elements() @staticmethod def get_timeline_ui_class(kind: type[Timeline]) -> type[TimelineUI]: From a00f422151a3a4fdc6f755c18c2a0a0f0b682863 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:20 +0200 Subject: [PATCH 11/56] feat: let timelines.add.* take the values they would prompt for timelines.add. accepted only `name`; values like the PDF path were always prompted for. Other keyword arguments are now passed to the UI class's get_additional_args_for_creation, so `commands.execute("timelines.add.pdf", name="", path=...)` skips its prompt, as TESTING.md asks tests to do. Arguments for kinds that take none are logged and ignored. --- .../timelines/test_timelineui_collection.py | 12 ++++++++++++ tilia/ui/timelines/collection/collection.py | 19 +++++++++++++++++-- tilia/ui/timelines/pdf/timeline.py | 8 +++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/ui/timelines/test_timelineui_collection.py b/tests/ui/timelines/test_timelineui_collection.py index 1bbd6efd7..1dc3709fc 100644 --- a/tests/ui/timelines/test_timelineui_collection.py +++ b/tests/ui/timelines/test_timelineui_collection.py @@ -53,6 +53,18 @@ def test_create(self, command, tluis): commands.execute(command) assert len(tluis) == 1 + def test_pdf_path_as_argument(self, tluis, resources): + path = str(resources / "example.pdf") + + commands.execute("timelines.add.pdf", name="", path=path) + + assert tluis[0].timeline.path == path + + def test_arguments_for_kinds_without_any_are_ignored(self, tluis): + commands.execute("timelines.add.marker", name="", beat_pattern="4") + + assert len(tluis) == 1 + def test_beat_creation_uses_pattern_from_user_prompt(self, tluis): # Regression: the kind-refactor passed the backend Timeline class to # on_timeline_add, so `hasattr(ui_cls, "get_additional_args_for_creation")` diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index adfbb11f8..9864f0d17 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -377,7 +377,15 @@ def on_timelines_clear(self): return True return False - def on_timeline_add(self, cls: type[Timeline], name: str | None = None): + def on_timeline_add( + self, cls: type[Timeline], name: str | None = None, **creation_args: Any + ): + """ + `creation_args` are values the timeline kind would otherwise prompt + for (e.g. `beat_pattern`, `path`), passed to its UI class's + get_additional_args_for_creation. + """ + def _get_media_is_loaded(): if get(Get.MEDIA_DURATION) == 0: return False @@ -405,10 +413,17 @@ def _get_timeline_name(): # `cls` here is the backend timeline class — look the UI class up. ui_cls = self.get_timeline_ui_class(cls) if hasattr(ui_cls, "get_additional_args_for_creation"): - success, additional_args = ui_cls.get_additional_args_for_creation() + success, additional_args = ui_cls.get_additional_args_for_creation( + **creation_args + ) if not success: return False kwargs |= additional_args + elif creation_args: + logger.error( + f"{cls.__name__} takes no creation arguments; " + f"ignoring {sorted(creation_args)}." + ) get(Get.TIMELINE_COLLECTION).create_timeline( kind=cls, components=None, name=name, **kwargs diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index df1724bc9..0e9ddcf90 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -55,9 +55,11 @@ def __init__( listen(self, Post.PLAYER_CURRENT_TIME_CHANGED, self.on_media_time_change) @classmethod - def get_additional_args_for_creation(cls): - success, path = get(Get.FROM_USER_PDF_PATH) - return success, {"path": path} + def get_additional_args_for_creation(cls, path: str | None = None): + if path is None: + success, path = get(Get.FROM_USER_PDF_PATH) + return success, {"path": path} + return True, {"path": path} def _handle_invalid_pdf(self): tilia.errors.display(tilia.errors.INVALID_PDF, self.get_data("path")) From 726a6759784295ecf7477b98af460a1c7907ffbd Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:23 +0200 Subject: [PATCH 12/56] refactor: take the saved-state baseline after every file load setup_file only refreshed the "last saved" timelines state when it had to create a slider timeline, so that addition wasn't reported as an unsaved change. Refreshing it after every load generalises this: what loading itself adds to a file is part of the opened file, not an edit. --- tilia/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tilia/app.py b/tilia/app.py index 8c92c3bac..f21465486 100644 --- a/tilia/app.py +++ b/tilia/app.py @@ -612,7 +612,11 @@ def _ensure_slider_timeline(self) -> None: # creates a slider timeline if none was loaded if not get(Get.TIMELINE_COLLECTION).has_timeline_of_type(SliderTimeline): self.timelines.create_timeline(SliderTimeline) - self.file_manager.set_timelines(*self.get_timelines_state()) + + # What loading adds (the slider timeline above, the beat unit every + # beat timeline keeps on its first beat) is part of the opened file, + # not a change to it, so the "last saved" state is taken from here. + self.file_manager.set_timelines(*self.get_timelines_state()) def setup_file(self) -> None: self._ensure_slider_timeline() From 00668900507382e9b2c43aac949cb1700d68c2b1 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:25 +0200 Subject: [PATCH 13/56] feat: add parser for bracketed beat pattern syntax Beat patterns can be written compactly: `10[4] 3 15[4]` is ten bars of 4, one of 3 and fifteen of 4, and groups nest (`2[4 3[3]]`). Plain `4 4 3` keeps its meaning. The parser distinguishes complete, incomplete (a valid prefix such as `10[`) and invalid input, so a line-edit validator can let the user keep typing while still reporting errors. --- tests/timelines/beat/test_pattern.py | 111 +++++++++++++++++ tilia/timelines/beat/pattern.py | 175 +++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/timelines/beat/test_pattern.py create mode 100644 tilia/timelines/beat/pattern.py diff --git a/tests/timelines/beat/test_pattern.py b/tests/timelines/beat/test_pattern.py new file mode 100644 index 000000000..7054c3239 --- /dev/null +++ b/tests/timelines/beat/test_pattern.py @@ -0,0 +1,111 @@ +import pytest + +from tilia.timelines.beat.pattern import ( + MAX_BARS, + BarRun, + ParseStatus, + group_runs, + parse, +) + + +class TestComplete: + @pytest.mark.parametrize( + "text, bars", + [ + ("4", [4]), + ("4 4 3", [4, 4, 3]), + (" 4\t4\n3 ", [4, 4, 3]), + ("10[4] 3 15[4]", [4] * 10 + [3] + [4] * 15), + ("2[4 3[3]]", [4, 3, 3, 3, 4, 3, 3, 3]), + ("2[[4] 3[3]]", [4, 3, 3, 3, 4, 3, 3, 3]), + ("[4 3]", [4, 3]), + ("1[4]", [4]), + ("3 [2]", [2, 2, 2]), + ("2[3]4", [3, 3, 4]), + ("12", [12]), + ("007", [7]), + ], + ) + def test_expands(self, text, bars): + result = parse(text) + assert result.status == ParseStatus.COMPLETE + assert result.is_complete + assert result.bars == bars + assert result.error == "" + + def test_max_bars_is_allowed(self): + assert parse(f"{MAX_BARS}[1]").bars == [1] * MAX_BARS + + +class TestIncomplete: + @pytest.mark.parametrize( + "text, position", + [ + ("", 0), + (" ", 0), + ("10[", 2), + ("2[4 3", 1), + ("2[4 3[3]", 1), + ("2[4 3[3", 5), + ], + ) + def test_incomplete(self, text, position): + result = parse(text) + assert result.status == ParseStatus.INCOMPLETE + assert not result.is_complete + assert result.bars == [] + assert result.error + assert result.position == position + + +class TestInvalid: + @pytest.mark.parametrize( + "text, position, message", + [ + ("4 ]", 2, "Unmatched ']'."), + ("2[4]]", 4, "Unmatched ']'."), + ("4 a", 2, "Unexpected 'a'."), + ("4, 4", 1, "Unexpected ','."), + ("-4", 0, "Unexpected '-'."), + ("0", 0, "Bar lengths must be at least 1."), + ("4 0", 2, "Bar lengths must be at least 1."), + ("0[4]", 0, "Repeat count must be at least 1."), + ("2[]", 1, "Empty group '[]'."), + ("2[ ]", 1, "Empty group '[]'."), + ], + ) + def test_invalid(self, text, position, message): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.bars == [] + assert result.error == message + assert result.position == position + + @pytest.mark.parametrize( + "text", + [f"{MAX_BARS + 1}[1]", "999999999[999999999[4]]", f"{MAX_BARS}[1] 1"], + ) + def test_too_many_bars(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert "more than" in result.error + + +class TestGroupRuns: + def test_empty(self): + assert group_runs([]) == [] + + def test_groups_consecutive_equal_bars(self): + assert group_runs([4] * 10 + [3] + [4] * 15) == [ + BarRun(0, 10, 4), + BarRun(10, 1, 3), + BarRun(11, 15, 4), + ] + + def test_does_not_merge_non_consecutive_bars(self): + assert group_runs([4, 3, 4]) == [ + BarRun(0, 1, 4), + BarRun(1, 1, 3), + BarRun(2, 1, 4), + ] diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py new file mode 100644 index 000000000..a92cf2739 --- /dev/null +++ b/tilia/timelines/beat/pattern.py @@ -0,0 +1,175 @@ +""" +Parsing for the beat pattern syntax. + +A pattern is a whitespace-separated list of items. An item is either a bar +length or a repeated group of items:: + + items := item (whitespace item)* + item := INT | INT? "[" items "]" + +So ``4 4 3`` is three bars, ``10[4] 3 15[4]`` is ten bars of 4, one of 3 and +fifteen of 4, and ``2[4 3[3]]`` expands to ``4 3 3 3 4 3 3 3``. A group with no +count (``[4 3]``) is played once. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum, auto + +# Guards against inputs like "999999[999999[4]]" expanding into a list that +# would exhaust memory. Far above the bar count of any real piece. +MAX_BARS = 100_000 + + +class ParseStatus(Enum): + COMPLETE = auto() + # A valid start that more typing could complete, e.g. "10[" or "". + INCOMPLETE = auto() + INVALID = auto() + + +@dataclass(frozen=True) +class ParseResult: + status: ParseStatus + bars: list[int] = field(default_factory=list) + error: str = "" + position: int | None = None + + @property + def is_complete(self) -> bool: + return self.status == ParseStatus.COMPLETE + + +@dataclass(frozen=True) +class BarRun: + """Consecutive bars with the same length. `start` is a 0-based bar index.""" + + start: int + count: int + beats: int + + +class _Incomplete(Exception): + def __init__(self, message: str, position: int): + super().__init__(message) + self.message = message + self.position = position + + +class _Invalid(Exception): + def __init__(self, message: str, position: int): + super().__init__(message) + self.message = message + self.position = position + + +class _Parser: + def __init__(self, text: str): + self.text = text + self.pos = 0 + + def at_end(self) -> bool: + return self.pos >= len(self.text) + + def peek(self) -> str: + return self.text[self.pos] + + def skip_whitespace(self) -> None: + while not self.at_end() and self.peek().isspace(): + self.pos += 1 + + def parse_int(self) -> int: + start = self.pos + while not self.at_end() and self.peek().isdigit(): + self.pos += 1 + return int(self.text[start : self.pos]) + + def parse_items(self, open_position: int | None = None) -> list[int]: + """ + Parses items up to the end of the text, or up to the "]" closing the + group opened at `open_position`. Leaves that "]" unconsumed. + """ + in_group = open_position is not None + bars: list[int] = [] + while True: + self.skip_whitespace() + if self.at_end(): + if in_group: + raise _Incomplete("Unclosed '['.", open_position) + break + if self.peek() == "]": + if in_group: + break + raise _Invalid("Unmatched ']'.", self.pos) + item_start = self.pos + bars.extend(self.parse_item()) + if len(bars) > MAX_BARS: + raise _Invalid( + f"Pattern expands to more than {MAX_BARS} bars.", item_start + ) + + if not bars: + if in_group: + raise _Invalid("Empty group '[]'.", open_position) + raise _Incomplete("Enter at least one bar length.", 0) + + return bars + + def parse_item(self) -> list[int]: + start = self.pos + count = self.parse_int() if self.peek().isdigit() else None + self.skip_whitespace() + + if not self.at_end() and self.peek() == "[": + open_position = self.pos + self.pos += 1 + group = self.parse_items(open_position) + self.pos += 1 # the closing "]" + repeats = 1 if count is None else count + if repeats < 1: + raise _Invalid("Repeat count must be at least 1.", start) + if len(group) * repeats > MAX_BARS: + raise _Invalid(f"Pattern expands to more than {MAX_BARS} bars.", start) + return group * repeats + + if count is None: + raise _Invalid(f"Unexpected '{self.peek()}'.", self.pos) + if count < 1: + raise _Invalid("Bar lengths must be at least 1.", start) + return [count] + + +def parse(text: str) -> ParseResult: + """Parses a beat pattern into the list of bar lengths it expands to.""" + try: + bars = _Parser(text).parse_items() + except _Incomplete as e: + return ParseResult(ParseStatus.INCOMPLETE, error=e.message, position=e.position) + except _Invalid as e: + return ParseResult(ParseStatus.INVALID, error=e.message, position=e.position) + + return ParseResult(ParseStatus.COMPLETE, bars) + + +def group_runs(bars: list[int]) -> list[BarRun]: + """Groups consecutive bars of equal length, e.g. for previewing a pattern.""" + runs: list[BarRun] = [] + for index, beats in enumerate(bars): + if runs and runs[-1].beats == beats: + last = runs[-1] + runs[-1] = BarRun(last.start, last.count + 1, beats) + else: + runs.append(BarRun(index, 1, beats)) + return runs + + +def format_bars(bars: list[int]) -> str: + """ + Writes bar lengths as a pattern, collapsing runs of equal bars: + ``[4, 4, 4, 3]`` becomes ``3[4] 3``. Nested groups are not recovered. + """ + return " ".join( + f"{run.count}[{run.beats}]" if run.count > 1 else str(run.beats) + for run in group_runs(bars) + ) From 565bef794b53292adc966b941afdd62a4f2296e0 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:43 +0200 Subject: [PATCH 14/56] feat: beat patterns, beat units, time signatures and quarter stamps Beat pattern - The pattern is stored as the text the user typed and accepts the bracketed syntax (`10[4] 3 15[4]`, `2[4 3[3]]`). Lists of bar lengths from older files and callers are still accepted and joined into text. - A single-line dialog validates as you type, pairs brackets, and previews the bars in a scrollable table with a footer on how the pattern repeats. - "Set beat pattern" in the timeline's context menu edits it after creation, recomputing every bar and asking before overwriting bars set by hand. One undo step. - `timelines.add.beat` accepts `beat_pattern=` instead of prompting. Beat units - A BeatUnit component records what one tapped beat is worth: a denominator plus units per tap ("1", "3", "2+3", "1/3"). It is attached to a beat and applies from that beat's measure until the next one. Units are read from the start of the pattern in every measure. - Every timeline with beats keeps one on its first beat: 4 / 1 when the first beat is tapped or a file without one is loaded (not counted as a change). A pickup added in a measure of its own copies the next beat unit's values; deleting the first beat unit resets it to 4 / 1. - A beat unit TiLiA fills in rather than the user is marked assumed, since tapping can't tell 6/8 in two from 2/4: the default 4 / 1, and a measure's old values restored after a one-measure edit. Its labels are drawn faded with a tooltip, and the inspectors and the CLI say "assumed". Setting a beat unit (dialog, inspector, CLI, CSV) clears the flag, and pressing OK in the dialog confirms values left unchanged. - A measure's time signature is derived, never stored: the numerator is the sum of its taps' values. Beat units are kept apart from beats in the component manager, so len(), indexing and iteration still mean beats. Deleting a beat moves its beat unit to another beat of the measure, or to the next measure's first beat if the measure goes. - Time signature labels are drawn with the engraved glyphs wherever the time signature changes or a beat unit starts, centred on the downbeat and kept inside the view's margins: red when a measure holds two beat units, amber when its taps don't fit the units evenly. Clicking a label selects its beat unit and outlines that label, hovering draws a line to its beat, Delete removes it, and a red label offers removing the extra ones. - "Set beat unit" (beat context menu, or double-click a label) and the inspector edit a beat unit from a measure onward or for that measure only. The inspector asks once the mouse is released, so a click that ends the edit completes normally. The beat inspector shows the time signature in effect. - Labels can be shown or hidden per timeline; the timeline grows or shrinks by the label band. New timelines show them; files from before this change load with them hidden. - Labels are redrawn after Fill with beats, crop, scale and zoom. - Copy/paste carries beat units with their beats. They are saved, restored by undo, included in JSON export, imported from optional CSV `denominator`/`units` columns, and managed from the CLI (`timelines beat unit set|remove|list`, `timelines beat time-signatures show|hide`, `timelines beat pattern set`). Quarter stamps - Each beat's position in quarter notes, from the first beat in playback order (as Verovio's and the Measure Map's qstamp), as exact Fractions. BeatTimeline converts time to stamp and back, interpolating between beats and extrapolating past the ends. - The conversion table is cached and rebuilt when a version counter on the beat component manager moves; the counter is bumped on every change to beats or beat units, so no feature has to remember to invalidate it. A test checks the cache against a fresh build after every command that changes beats. Resolves #279. --- tests/parsers/csv/test_beats_from_csv.py | 59 + tests/test_export.py | 25 + tests/timelines/beat/test_beat_units.py | 474 +++++ tests/timelines/beat/test_quarter_stamps.py | 146 ++ .../cli/timelines/test_cli_timelines_add.py | 30 +- .../cli/timelines/test_cli_timelines_beat.py | 77 + .../test_cli_timelines_beat_units.py | 142 ++ tests/ui/timelines/beat/interact.py | 102 +- .../timelines/beat/test_beat_timeline_ui.py | 1555 ++++++++++++++++- .../timelines/test_timelineui_collection.py | 13 +- tests/ui/windows/test_beat_pattern_dialog.py | 215 +++ tests/ui/windows/test_beat_unit_dialog.py | 115 ++ tilia/errors.py | 4 +- tilia/parsers/csv/beat.py | 56 +- tilia/requests/get.py | 2 + tilia/timelines/beat/components.py | 124 +- tilia/timelines/beat/timeline.py | 592 ++++++- tilia/timelines/beat/units.py | 110 ++ tilia/timelines/beat/validators.py | 15 + tilia/timelines/component_kinds.py | 4 +- tilia/ui/cli/timelines/add.py | 33 +- tilia/ui/cli/timelines/beat.py | 318 ++++ tilia/ui/cli/timelines/setup.py | 2 + tilia/ui/consts.py | 7 + tilia/ui/dialog_manager.py | 4 + tilia/ui/format.py | 13 + tilia/ui/strings.py | 37 +- tilia/ui/timelines/beat/beat_unit.py | 304 ++++ tilia/ui/timelines/beat/context_menu.py | 33 +- tilia/ui/timelines/beat/dialogs.py | 33 +- tilia/ui/timelines/beat/element.py | 12 +- tilia/ui/timelines/beat/time_signature.py | 130 ++ tilia/ui/timelines/beat/timeline.py | 401 ++++- tilia/ui/timelines/element_kinds.py | 2 + tilia/ui/windows/beat_pattern.py | 215 ++- tilia/ui/windows/beat_unit.py | 215 +++ tilia/ui/windows/pattern_preview.py | 99 ++ 37 files changed, 5629 insertions(+), 89 deletions(-) create mode 100644 tests/timelines/beat/test_beat_units.py create mode 100644 tests/timelines/beat/test_quarter_stamps.py create mode 100644 tests/ui/cli/timelines/test_cli_timelines_beat.py create mode 100644 tests/ui/cli/timelines/test_cli_timelines_beat_units.py create mode 100644 tests/ui/windows/test_beat_pattern_dialog.py create mode 100644 tests/ui/windows/test_beat_unit_dialog.py create mode 100644 tilia/timelines/beat/units.py create mode 100644 tilia/ui/cli/timelines/beat.py create mode 100644 tilia/ui/timelines/beat/beat_unit.py create mode 100644 tilia/ui/timelines/beat/time_signature.py create mode 100644 tilia/ui/windows/beat_unit.py create mode 100644 tilia/ui/windows/pattern_preview.py diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 7e5f7433d..0fb3aeaea 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -128,3 +128,62 @@ def test_measure_number_on_every_beat_is_forced_once(beat_tlui): # A measure forced once per beat would need a reset per beat. assert not beat_tlui[4].label.isVisible() + + +class TestBeatUnitColumns: + def test_rows_with_values_get_beat_units(self, beat_tl): + beat_tl.beat_pattern = [2] + data = "time,denominator,units\n0,8,3\n1,,\n2,4,2+3\n3,,\n4,,1\n5,," + + success, errors = _import_with_patch(beat_tl, data) + + assert success + assert errors == [] + values = [ + (beat_tl.get_component(u.beat_id).time, u.denominator, u.units) + for u in beat_tl.beat_units + ] + assert values == [(0, 8, "3"), (2, 4, "2+3"), (4, 4, "1")] + + def test_empty_values_carry_over(self, beat_tl): + beat_tl.beat_pattern = [1] + data = "time,denominator,units\n0,8,\n1,,1/2" + + _import_with_patch(beat_tl, data) + + values = [(u.denominator, u.units) for u in beat_tl.beat_units] + assert values == [(8, "1"), (8, "1/2")] + + def test_only_one_of_the_columns(self, beat_tl): + data = "time,units\n0,2+3" + + _import_with_patch(beat_tl, data) + + (beat_unit,) = beat_tl.beat_units + assert (beat_unit.denominator, beat_unit.units) == (4, "2+3") + assert beat_unit.beat_id == beat_tl[0].id + + def test_invalid_values_are_reported(self, beat_tl): + data = "time,denominator,units\n0,0,1\n1,4,2+\n2,8,1" + + success, errors = _import_with_patch(beat_tl, data) + + assert_in_errors("denominator", errors) + assert_in_errors("2+", errors) + # The first beat keeps its default beat unit; only the valid row adds one. + values = [(u.denominator, u.units) for u in beat_tl.beat_units] + assert values == [(4, "1"), (8, "1")] + + def test_without_the_columns(self, beat_tl): + _import_with_patch(beat_tl, "time\n0\n1") + + values = [(u.denominator, u.units) for u in beat_tl.beat_units] + assert values == [(4, "1")] + assert beat_tl.beat_units[0].assumed + + def test_values_from_the_file_are_not_assumed(self, beat_tl): + _import_with_patch(beat_tl, "time,denominator\n0,4\n1,") + + (beat_unit,) = beat_tl.beat_units + assert (beat_unit.denominator, beat_unit.units) == (4, "1") + assert not beat_unit.assumed diff --git a/tests/test_export.py b/tests/test_export.py index d82c1bf5b..6848d6c0b 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -180,3 +180,28 @@ def test_image_export_resize_rejected(self, qtui, monkeypatch, tmp_path): commands.execute("file.export.img", image_path) assert not image_path.exists() + + +class TestExportBeatUnits: + def test_beat_units_are_exported(self, tilia, beat_tlui, tmp_path): + for time in range(4): + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + beat_tlui.select_element(beat_tlui[2]) + commands.execute("timeline.beat.set_beat_unit", denominator=8, units="2+1") + + commands.execute("file.export.json", path=tmp_path / "test.json") + with open(tmp_path / "test.json", encoding="utf-8") as f: + components = json.load(f)["timelines"][0]["components"] + + assert [c["kind"] for c in components] == ["BEAT"] * 4 + ["BEAT_UNIT"] + beat_unit = components[-1] + assert beat_unit == { + "beat_id": beat_tlui.timeline[0].id, + "denominator": 8, + "units": "2+1", + "assumed": False, + "time": 0, + "measure": 1, + "kind": "BEAT_UNIT", + } diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py new file mode 100644 index 000000000..32844f1d7 --- /dev/null +++ b/tests/timelines/beat/test_beat_units.py @@ -0,0 +1,474 @@ +from fractions import Fraction as F + +import pytest + +from tilia.timelines.beat.units import ( + fit_units, + format_units, + is_fit_ambiguous, + parse_units, +) +from tilia.timelines.component_kinds import ComponentKind + + +class TestParseUnits: + @pytest.mark.parametrize( + "text, units", + [ + ("1", [F(1)]), + ("3", [F(3)]), + ("2+3", [F(2), F(3)]), + (" 2 + 2 + 3 ", [F(2), F(2), F(3)]), + ("1/2", [F(1, 2)]), + ("1/3+2/3", [F(1, 3), F(2, 3)]), + ("2/4", [F(1, 2)]), + ], + ) + def test_valid(self, text, units): + result = parse_units(text) + assert result.is_valid + assert result.units == units + + @pytest.mark.parametrize( + "text", ["", " ", "2+", "+2", "2++3", "0", "1/0", "1.5", "-1", "a", "1e2"] + ) + def test_invalid(self, text): + result = parse_units(text) + assert not result.is_valid + assert result.error + assert result.units == [] + + def test_format(self): + assert format_units([F(2), F(3)]) == "2+3" + assert format_units([F(1, 3)]) == "1/3" + assert format_units(parse_units(" 2/4 + 1 ").units) == "1/2+1" + + +class TestFitUnits: + @pytest.mark.parametrize( + "units, beat_count, values", + [ + ([F(1)], 4, [F(1)] * 4), + ([F(2), F(3)], 2, [F(2), F(3)]), + ([F(2), F(3)], 4, [F(2), F(3), F(2), F(3)]), + ([F(2), F(3)], 3, [F(2), F(3), F(2)]), + ([F(3), F(2)], 1, [F(3)]), + ([F(2), F(2), F(3)], 2, [F(2), F(2)]), + ([F(1), F(2), F(3)], 2, [F(1), F(2)]), + ([F(1)], 0, []), + ], + ) + def test_fit(self, units, beat_count, values): + assert fit_units(units, beat_count) == values + + @pytest.mark.parametrize( + "units, beat_count, ambiguous", + [ + ([F(1)], 3, False), + ([F(3)], 1, False), + ([F(1, 2)], 3, False), + ([F(2), F(3)], 2, False), + ([F(2), F(3)], 4, False), + ([F(2), F(3)], 1, True), + ([F(2), F(3)], 3, True), + ([F(2), F(2)], 3, False), + ], + ) + def test_ambiguous(self, units, beat_count, ambiguous): + assert is_fit_ambiguous(units, beat_count) == ambiguous + + +def add_beats(beat_tl, count, start=0): + for i in range(count): + beat_tl.create_beat(start + i) + + +def time_signatures(beat_tl): + return [(m.numerator, m.denominator) for m in beat_tl.measure_meters] + + +def add_beat_unit(beat_tl, beat_index, denominator, units): + # The first beat always has a beat unit, which this edits. + beat_id = beat_tl[beat_index].id + beat_tl.put_beat_unit_on_beat(beat_id, denominator, units) + return beat_tl.get_beat_unit_on_beat(beat_id) + + +def first_beat_unit(beat_tl): + return beat_tl.get_beat_unit_on_beat(beat_tl[0].id) + + +def beat_unit_values(beat_tl): + return [ + ( + beat_tl.get_beat_index(beat_tl.get_component(u.beat_id)), + u.denominator, + u.units, + ) + for u in beat_tl.beat_units + ] + + +class TestMeasureMeters: + def test_first_beat_gets_the_default_beat_unit(self, beat_tl): + add_beats(beat_tl, 7) + + assert time_signatures(beat_tl) == [(4, 4), (3, 4)] + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + first = first_beat_unit(beat_tl) + assert [m.beat_unit_id for m in beat_tl.measure_meters] == [first.id] * 2 + assert [m.starts_here for m in beat_tl.measure_meters] == [True, False] + + def test_empty_timeline(self, beat_tl): + assert beat_tl.measure_meters == [] + + def test_beat_unit_applies_until_next_change(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 8) + beat_unit = add_beat_unit(beat_tl, 2, 8, "3") + + assert time_signatures(beat_tl) == [(2, 4), (6, 8), (6, 8), (6, 8)] + meters = beat_tl.measure_meters + assert [m.starts_here for m in meters] == [True, True, False, False] + first = first_beat_unit(beat_tl) + assert [m.beat_unit_id for m in meters] == [first.id] + [beat_unit.id] * 3 + + def test_beat_unit_on_a_later_beat_applies_from_its_measure(self, beat_tl): + add_beats(beat_tl, 8) + add_beat_unit(beat_tl, 6, 8, "1") + + assert time_signatures(beat_tl) == [(4, 4), (4, 8)] + + def test_numerator_follows_bar_length(self, beat_tl): + beat_tl.set_data("beat_pattern", [5, 7, 3]) + add_beats(beat_tl, 15) + add_beat_unit(beat_tl, 0, 8, "1") + + assert time_signatures(beat_tl) == [(5, 8), (7, 8), (3, 8)] + + def test_fractional_units(self, beat_tl): + beat_tl.set_data("beat_pattern", [4, 3]) + add_beats(beat_tl, 7) + add_beat_unit(beat_tl, 0, 2, "1/2") + + assert time_signatures(beat_tl) == [(2, 2), (F(3, 2), 2)] + + def test_ambiguous_fit(self, beat_tl): + beat_tl.set_data("beat_pattern", [2, 3]) + add_beats(beat_tl, 5) + add_beat_unit(beat_tl, 0, 4, "2+3") + + meters = beat_tl.measure_meters + assert [m.beat_values for m in meters] == [[2, 3], [2, 3, 2]] + assert [m.is_ambiguous for m in meters] == [False, True] + + def test_short_measures_take_the_start_of_the_pattern(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + add_beat_unit(beat_tl, 0, 4, "1+2+3") + + meters = beat_tl.measure_meters + assert [m.beat_values for m in meters] == [[1, 2]] * 3 + assert all(m.is_ambiguous for m in meters) + + def test_pickup_also_takes_the_start_of_the_pattern(self, beat_tl): + beat_tl.set_data("beat_pattern", [1, 2]) + add_beats(beat_tl, 3) + add_beat_unit(beat_tl, 0, 4, "3+2") + + assert beat_tl.get_measure_meter(0).beat_values == [3] + assert beat_tl.get_measure_meter(1).beat_values == [3, 2] + + def test_two_beat_units_in_a_measure_first_wins(self, beat_tl): + add_beats(beat_tl, 8) + first = add_beat_unit(beat_tl, 4, 8, "1") + add_beat_unit(beat_tl, 6, 2, "1") + + meter = beat_tl.get_measure_meter(1) + assert meter.is_conflicting + assert meter.beat_unit_id == first.id + assert meter.denominator == 8 + + def test_beat_unit_follows_its_beat_when_barlines_move(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + add_beat_unit(beat_tl, 4, 8, "3") + assert time_signatures(beat_tl) == [(2, 4), (2, 4), (6, 8)] + + # A forgotten bar's beats, before the beat unit. + beat_tl.create_beat(0.25) + beat_tl.create_beat(0.5) + + assert time_signatures(beat_tl) == [(2, 4), (2, 4), (2, 4), (6, 8)] + + def test_cache_is_refreshed_when_units_change(self, beat_tl): + add_beats(beat_tl, 4) + beat_unit = add_beat_unit(beat_tl, 0, 4, "1") + assert time_signatures(beat_tl) == [(4, 4)] + + beat_tl.set_component_data(beat_unit.id, "units", "1/2") + + assert time_signatures(beat_tl) == [(2, 4)] + + +class TestCreateBeatUnit: + def test_on_missing_beat_fails(self, beat_tl): + add_beats(beat_tl, 1) + beat_unit, error = beat_tl.create_component( + ComponentKind.BEAT_UNIT, beat_id=-1, denominator=4, units="1" + ) + assert beat_unit is None + assert error + + def test_two_on_the_same_beat_fails(self, beat_tl): + add_beats(beat_tl, 1) + # The first beat already has one. + beat_unit, error = beat_tl.create_component( + ComponentKind.BEAT_UNIT, beat_id=beat_tl[0].id, denominator=8, units="1" + ) + assert beat_unit is None + assert error + + @pytest.mark.parametrize("denominator, units", [(0, "1"), (4, "0"), (4, "a")]) + def test_invalid_values_fail(self, beat_tl, denominator, units): + add_beats(beat_tl, 2) + beat_unit, error = beat_tl.create_component( + ComponentKind.BEAT_UNIT, + beat_id=beat_tl[1].id, + denominator=denominator, + units=units, + ) + assert beat_unit is None + assert error + + def test_units_are_stored_canonically(self, beat_tl): + add_beats(beat_tl, 1) + beat_unit = add_beat_unit(beat_tl, 0, 4, " 2/4 + 1 ") + assert beat_unit.units == "1/2+1" + beat_tl.set_component_data(beat_unit.id, "units", "3 + 2") + assert beat_unit.units == "3+2" + + def test_beat_units_do_not_count_as_beats(self, beat_tl): + add_beats(beat_tl, 4) + add_beat_unit(beat_tl, 0, 8, "1") + + assert len(beat_tl) == 4 + assert all(c.KIND == ComponentKind.BEAT for c in beat_tl) + assert beat_tl.beats_in_measure == [4] + + +class TestFirstBeatUnit: + def test_none_without_beats(self, beat_tl): + assert beat_tl.beat_units == [] + + def test_pickup_copies_the_next_beat_unit(self, beat_tl): + beat_tl.set_data("beat_pattern", [1, 2]) + add_beats(beat_tl, 4, start=1) + add_beat_unit(beat_tl, 0, 8, "3") + + # A pickup before the old first beat, in a measure of its own. + beat_tl.create_beat(0) + + assert beat_unit_values(beat_tl) == [(0, 8, "3"), (1, 8, "3")] + assert time_signatures(beat_tl)[0] == (3, 8) + + def test_pickup_in_the_same_measure_needs_no_new_beat_unit(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 4, start=1) + add_beat_unit(beat_tl, 0, 8, "3") + + beat_tl.create_beat(0) + + assert beat_unit_values(beat_tl) == [(1, 8, "3")] + + def test_deleting_the_first_beat_keeps_the_values(self, beat_tl): + beat_tl.set_data("beat_pattern", [1]) + add_beats(beat_tl, 3) + add_beat_unit(beat_tl, 0, 8, "3") + + beat_tl.delete_components([beat_tl[0]]) + + assert beat_unit_values(beat_tl) == [(0, 8, "3")] + + +class TestSetBeatUnit: + def test_onward_creates_on_the_first_beat(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + + assert beat_tl.set_beat_unit(1, 8, "3") + + assert time_signatures(beat_tl) == [(2, 4), (6, 8), (6, 8)] + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (2, 8, "3")] + + def test_onward_edits_in_place(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + beat_tl.set_beat_unit(1, 8, "3") + + beat_tl.set_beat_unit(1, 4, "1/2") + + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (2, 4, "1/2")] + assert time_signatures(beat_tl) == [(2, 4), (1, 4), (1, 4)] + + def test_onward_from_a_later_measure_splits(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 8) + beat_tl.set_beat_unit(1, 8, "3") + + beat_tl.set_beat_unit(2, 4, "1") + + assert time_signatures(beat_tl) == [(2, 4), (6, 8), (2, 4), (2, 4)] + + def test_this_measure_only(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 8) + beat_tl.set_beat_unit(1, 8, "3") + + beat_tl.set_beat_unit(2, 4, "1", only_this_measure=True) + + assert time_signatures(beat_tl) == [(2, 4), (6, 8), (2, 4), (6, 8)] + + def test_this_measure_only_restores_the_default(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + + beat_tl.set_beat_unit(0, 8, "3", only_this_measure=True) + + assert time_signatures(beat_tl) == [(6, 8), (2, 4), (2, 4)] + + def test_this_measure_only_keeps_a_following_change(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + beat_tl.set_beat_unit(1, 2, "1") + + beat_tl.set_beat_unit(0, 8, "3", only_this_measure=True) + + assert time_signatures(beat_tl) == [(6, 8), (2, 2), (2, 2)] + assert len(beat_tl.beat_units) == 2 + + def test_this_measure_only_on_the_last_measure(self, beat_tl): + add_beats(beat_tl, 4) + + beat_tl.set_beat_unit(0, 8, "1", only_this_measure=True) + + assert time_signatures(beat_tl) == [(4, 8)] + assert len(beat_tl.beat_units) == 1 + + @pytest.mark.parametrize( + "measure_index, denominator, units", [(5, 4, "1"), (-1, 4, "1"), (0, 0, "1")] + ) + def test_invalid(self, beat_tl, measure_index, denominator, units): + add_beats(beat_tl, 4) + + assert not beat_tl.set_beat_unit(measure_index, denominator, units) + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + + +class TestDeletingBeats: + def test_beat_unit_moves_to_another_beat_of_its_measure(self, beat_tl): + add_beats(beat_tl, 8) + beat_unit = add_beat_unit(beat_tl, 4, 8, "1") + + beat_tl.delete_components([beat_tl[4]]) + + assert beat_unit.beat_id == beat_tl[4].id + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (4, 8, "1")] + + def test_beat_unit_moves_to_the_next_measure_when_its_measure_goes(self, beat_tl): + beat_tl.set_data("beat_pattern", [1]) + add_beats(beat_tl, 3) + add_beat_unit(beat_tl, 1, 8, "1") + + beat_tl.delete_components([beat_tl[1]]) + + # The measures it governed keep it. + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (1, 8, "1")] + assert time_signatures(beat_tl) == [(1, 4), (1, 8)] + + def test_beat_unit_is_dropped_with_the_last_measure(self, beat_tl): + beat_tl.set_data("beat_pattern", [1]) + add_beats(beat_tl, 3) + add_beat_unit(beat_tl, 2, 8, "1") + + beat_tl.delete_components([beat_tl[2]]) + + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + + def test_beat_unit_is_dropped_if_the_destination_has_one(self, beat_tl): + add_beats(beat_tl, 8) + add_beat_unit(beat_tl, 4, 8, "1") + kept = add_beat_unit(beat_tl, 5, 2, "1") + + beat_tl.delete_components([beat_tl[4]]) + + assert beat_tl.beat_units == [first_beat_unit(beat_tl), kept] + + def test_deleting_a_beat_unit(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 4) + beat_unit = add_beat_unit(beat_tl, 2, 8, "1") + + beat_tl.delete_components([beat_unit]) + + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + assert len(beat_tl) == 4 + assert time_signatures(beat_tl) == [(2, 4), (2, 4)] + + def test_deleting_the_first_beat_unit_resets_it(self, beat_tl): + add_beats(beat_tl, 4) + beat_unit = add_beat_unit(beat_tl, 0, 8, "1") + + beat_tl.delete_components([beat_unit]) + + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + assert time_signatures(beat_tl) == [(4, 4)] + + def test_clear_removes_beat_units(self, beat_tl): + add_beats(beat_tl, 4) + add_beat_unit(beat_tl, 0, 8, "1") + + beat_tl.clear() + + assert beat_tl.beat_units == [] + assert len(beat_tl) == 0 + + +class TestState: + def test_restore_state(self, beat_tl): + add_beats(beat_tl, 8) + add_beat_unit(beat_tl, 4, 8, "3") + state = beat_tl.get_state()["components"] + + beat_tl.delete_components([beat_tl[4], beat_tl[5], beat_tl[6], beat_tl[7]]) + beat_tl.set_beat_unit(0, 2, "1") + assert beat_unit_values(beat_tl) == [(0, 2, "1")] + + beat_tl.component_manager.restore_state(state) + + assert len(beat_tl) == 8 + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (4, 8, "3")] + assert time_signatures(beat_tl) == [(4, 4), (12, 8)] + + def test_serialized_components_include_beat_units(self, beat_tl): + add_beats(beat_tl, 4) + beat_unit = add_beat_unit(beat_tl, 0, 8, "2+2") + + components = beat_tl.get_state()["components"] + + assert components[beat_unit.id] == { + "beat_id": beat_tl[0].id, + "denominator": 8, + "units": "2+2", + "assumed": False, + "kind": "BEAT_UNIT", + "hash": beat_unit.hash, + } + + def test_hash_changes_with_beat_units(self, beat_tl): + add_beats(beat_tl, 4) + before = beat_tl.component_manager.hash_components() + + add_beat_unit(beat_tl, 0, 8, "1") + + assert beat_tl.component_manager.hash_components() != before diff --git a/tests/timelines/beat/test_quarter_stamps.py b/tests/timelines/beat/test_quarter_stamps.py new file mode 100644 index 000000000..21c763f9e --- /dev/null +++ b/tests/timelines/beat/test_quarter_stamps.py @@ -0,0 +1,146 @@ +from fractions import Fraction as F +from unittest.mock import patch + +import pytest + +from tilia.timelines.beat.timeline import BeatTimeline + + +def add_beats(beat_tl, times): + for time in times: + beat_tl.create_beat(time) + + +def add_beat_unit(beat_tl, beat_index, denominator, units): + # The first beat already has a beat unit, which this edits. + beat_tl.put_beat_unit_on_beat(beat_tl[beat_index].id, denominator, units) + + +class TestBeatStamps: + def test_default_is_one_quarter_per_beat(self, beat_tl): + add_beats(beat_tl, range(6)) + + assert beat_tl.quarter_stamps == [0, 1, 2, 3, 4, 5] + + def test_dotted_quarter_beats(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, range(4)) + add_beat_unit(beat_tl, 0, 8, "3") + + assert beat_tl.quarter_stamps == [0, F(3, 2), 3, F(9, 2)] + + def test_changing_beat_unit(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, range(6)) + add_beat_unit(beat_tl, 2, 2, "1") + + assert beat_tl.quarter_stamps == [0, 1, 2, 4, 6, 8] + + def test_uneven_units(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, range(4)) + add_beat_unit(beat_tl, 0, 8, "2+3") + + assert beat_tl.quarter_stamps == [0, 1, F(5, 2), F(7, 2)] + + def test_triplets_are_exact(self, beat_tl): + beat_tl.set_data("beat_pattern", [3]) + add_beats(beat_tl, range(7)) + add_beat_unit(beat_tl, 0, 4, "1/3") + + assert beat_tl.quarter_stamps[6] == 2 + assert beat_tl.quarter_stamps[1] == F(1, 3) + + def test_pickup_starts_at_zero(self, beat_tl): + beat_tl.set_data("beat_pattern", [1, 2]) + add_beats(beat_tl, range(3)) + add_beat_unit(beat_tl, 0, 4, "3+2") + + # One pickup tap, read from the start of 3+2, then a full bar. + assert beat_tl.quarter_stamps == [0, 3, 6] + + def test_stamps_follow_inserted_beats(self, beat_tl): + add_beats(beat_tl, [0, 2, 3]) + assert beat_tl.quarter_stamps == [0, 1, 2] + + beat_tl.create_beat(1) + + assert beat_tl.quarter_stamps == [0, 1, 2, 3] + + def test_of_beat(self, beat_tl): + add_beats(beat_tl, range(3)) + + assert beat_tl.get_quarter_stamp_of_beat(beat_tl[2]) == 2 + + +class TestTimeToStamp: + @pytest.fixture + def tl(self, beat_tl): + # Beats every 2 s, worth a dotted quarter each. + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, [10, 12, 14, 16]) + add_beat_unit(beat_tl, 0, 8, "3") + return beat_tl + + @pytest.mark.parametrize( + "time, stamp", + [(10, 0), (11, 0.75), (12, 1.5), (15, 3.75), (16, 4.5), (18, 6), (8, -1.5)], + ) + def test_time_to_stamp(self, tl, time, stamp): + assert tl.get_quarter_stamp_by_time(time) == pytest.approx(stamp) + + @pytest.mark.parametrize( + "stamp, time", + [(0, 10), (0.75, 11), (1.5, 12), (3.75, 15), (4.5, 16), (6, 18), (-1.5, 8)], + ) + def test_stamp_to_time(self, tl, stamp, time): + assert tl.get_time_by_quarter_stamp(stamp) == pytest.approx(time) + + def test_round_trip(self, tl): + for time in [10.3, 12.7, 13.1, 16.9]: + stamp = tl.get_quarter_stamp_by_time(time) + assert tl.get_time_by_quarter_stamp(stamp) == pytest.approx(time) + + def test_uneven_beat_lengths(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, [0, 1, 2]) + add_beat_unit(beat_tl, 0, 4, "2+3") + + # Beat 0 lasts 2 quarters over 1 s; beat 1 lasts 3 quarters over 1 s. + assert beat_tl.get_quarter_stamp_by_time(0.5) == pytest.approx(1) + assert beat_tl.get_quarter_stamp_by_time(1.5) == pytest.approx(3.5) + + @pytest.mark.parametrize("times", [[], [5]]) + def test_needs_two_beats(self, beat_tl, times): + add_beats(beat_tl, times) + + assert beat_tl.get_quarter_stamp_by_time(5) is None + assert beat_tl.get_time_by_quarter_stamp(0) is None + + def test_follows_dragged_beat(self, tl): + tl.set_component_data(tl[1].id, "time", 13) + + assert tl.get_quarter_stamp_by_time(13) == pytest.approx(1.5) + + +class TestCaching: + def test_conversions_reuse_one_table(self, beat_tl): + add_beats(beat_tl, range(100)) + build = BeatTimeline._build_time_stamp_table + + with patch.object( + BeatTimeline, "_build_time_stamp_table", autospec=True, side_effect=build + ) as spy: + for i in range(500): + stamp = beat_tl.get_quarter_stamp_by_time(i * 0.2) + beat_tl.get_time_by_quarter_stamp(stamp) + + assert spy.call_count == 1 + + def test_table_is_rebuilt_after_a_change(self, beat_tl): + add_beats(beat_tl, range(4)) + assert beat_tl.get_quarter_stamp_by_time(3) == pytest.approx(3) + + beat_tl.set_component_data(beat_tl[3].id, "time", 4) + + assert beat_tl.get_quarter_stamp_by_time(4) == pytest.approx(3) diff --git a/tests/ui/cli/timelines/test_cli_timelines_add.py b/tests/ui/cli/timelines/test_cli_timelines_add.py index c75285cb7..8322a8111 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_add.py +++ b/tests/ui/cli/timelines/test_cli_timelines_add.py @@ -1,7 +1,10 @@ from tests.constants import EXAMPLE_MEDIA_PATH +from tilia.settings import settings from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.hierarchy.timeline import HierarchyTimeline from tilia.timelines.marker.timeline import MarkerTimeline +from tilia.ui.cli.timelines.utils import assert_error +from tilia.ui.consts import BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT class TestTimelineAdd: @@ -59,13 +62,36 @@ def test_add_beat_timeline(self, cli, tls): tl = tls.get_timelines()[0] assert isinstance(tl, BeatTimeline) assert tl.name == "test" - assert tl.beat_pattern == [1, 2, 3] + assert tl.beat_pattern == "1 2 3" + + def test_add_beat_timeline_with_bracketed_pattern(self, cli, tls): + cli.parse_and_run("timeline add beat --beat-pattern 10[4] 3 15[4]") + + tl = tls.get_timelines()[0] + assert tl.beat_pattern == "10[4] 3 15[4]" + assert tl.beat_pattern_bars == [4] * 10 + [3] + [4] * 15 + + def test_new_beat_timeline_shows_time_signatures(self, cli, tls): + cli.parse_and_run("timeline add beat") + + tl = tls.get_timelines()[0] + assert tl.show_time_signatures + assert tl.height == ( + settings.get("beat_timeline", "default_height") + + BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + ) + + def test_add_beat_timeline_with_invalid_pattern(self, cli, tls): + with assert_error(): + cli.parse_and_run("timeline add beat --beat-pattern 2[") + + assert len(tls) == 0 def test_add_beat_timeline_no_beat_pattern_provided(self, cli, tls): cli.parse_and_run("timeline add beat") tl = tls.get_timelines()[0] - assert tl.beat_pattern == [4] + assert tl.beat_pattern == "4" def test_row_height_rejected_for_non_range_kind(self, cli, tls, tilia_errors): cli.parse_and_run("timelines add marker --name M --row-height 50") diff --git a/tests/ui/cli/timelines/test_cli_timelines_beat.py b/tests/ui/cli/timelines/test_cli_timelines_beat.py new file mode 100644 index 000000000..2727a87c7 --- /dev/null +++ b/tests/ui/cli/timelines/test_cli_timelines_beat.py @@ -0,0 +1,77 @@ +from unittest.mock import patch + +import pytest + +from tilia.ui.cli.timelines.utils import assert_error + + +@pytest.fixture +def beat_tl(cli, tls): + cli.parse_and_run("timelines add beat --name B1") + yield tls.get_timelines()[0] + + +def add_beats(cli, times): + for time in times: + cli.parse_and_run(f"components beat -o 1 --time {time}") + + +class TestSetPattern: + def test_bracketed_pattern(self, cli, beat_tl): + add_beats(cli, range(10)) + + cli.parse_and_run("timelines beat pattern set -o 1 2[3] 4") + + assert beat_tl.beat_pattern == "2[3] 4" + assert beat_tl.beats_in_measure == [3, 3, 4] + + def test_by_name(self, cli, beat_tl): + cli.parse_and_run("timelines beat pattern set -n B1 3") + + assert beat_tl.beat_pattern == "3" + + def test_invalid_pattern(self, cli, beat_tl): + with assert_error(): + cli.parse_and_run("timelines beat pattern set -o 1 2[") + + assert beat_tl.beat_pattern == "4" + + def test_non_beat_timeline(self, cli, tls): + cli.parse_and_run("timelines add marker --name M") + + with assert_error(): + cli.parse_and_run("timelines beat pattern set -o 1 3") + + def test_missing_timeline(self, cli, beat_tl): + with assert_error(): + cli.parse_and_run("timelines beat pattern set -o 5 3") + + +class TestSetPatternOverHandSetBars: + @pytest.fixture + def hand_set_tl(self, cli, beat_tl): + add_beats(cli, range(12)) + beat_tl.set_beat_amount_in_measure(1, 3) + assert beat_tl.beats_in_measure == [4, 3, 4, 1] + yield beat_tl + + def test_declining_keeps_bars(self, cli, hand_set_tl): + with patch("tilia.ui.cli.io.ask_yes_or_no", return_value=False) as ask: + cli.parse_and_run("timelines beat pattern set -o 1 4") + + ask.assert_called_once() + assert "measures 2" in ask.call_args.args[0] + assert hand_set_tl.beats_in_measure == [4, 3, 4, 1] + + def test_accepting_overwrites(self, cli, hand_set_tl): + with patch("tilia.ui.cli.io.ask_yes_or_no", return_value=True): + cli.parse_and_run("timelines beat pattern set -o 1 4") + + assert hand_set_tl.beats_in_measure == [4, 4, 4] + + def test_yes_skips_the_question(self, cli, hand_set_tl): + with patch("tilia.ui.cli.io.ask_yes_or_no") as ask: + cli.parse_and_run("timelines beat pattern set -o 1 4 --yes") + + ask.assert_not_called() + assert hand_set_tl.beats_in_measure == [4, 4, 4] diff --git a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py b/tests/ui/cli/timelines/test_cli_timelines_beat_units.py new file mode 100644 index 000000000..87b3d296b --- /dev/null +++ b/tests/ui/cli/timelines/test_cli_timelines_beat_units.py @@ -0,0 +1,142 @@ +from unittest.mock import patch + +import pytest + +from tilia.ui.cli.timelines.utils import assert_error +from tilia.ui.timelines.beat.time_signature import TIME_SIGNATURE_BAND_HEIGHT + + +@pytest.fixture +def beat_tl(cli, tls): + cli.parse_and_run("timelines add beat --name B1 -b 2") + tl = tls.get_timelines()[0] + for time in range(8): + cli.parse_and_run(f"components beat -o 1 --time {time}") + yield tl + + +def assert_only_the_default_beat_unit(tl): + """Every timeline with beats keeps a beat unit on its first beat.""" + assert [(u.beat_id, u.denominator, u.units) for u in tl.beat_units] == [ + (tl[0].id, 4, "1") + ] + + +def time_signatures(tl): + return [(m.numerator, m.denominator) for m in tl.measure_meters] + + +class TestSetUnit: + def test_by_measure_number(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -o 1 --measure 2 -d 8 -u 3") + + assert time_signatures(beat_tl) == [(2, 4), (6, 8), (6, 8), (6, 8)] + + def test_by_measure_index(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -n B1 --measure-index 1 -d 8 -u 3") + + assert time_signatures(beat_tl)[1] == (6, 8) + + def test_this_measure_only(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3 --scope measure") + + assert time_signatures(beat_tl) == [(2, 4), (6, 8), (2, 4), (2, 4)] + + def test_fractional_and_grouped_units(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -o 1 -m 1 -d 4 -u 2+1/2") + + assert time_signatures(beat_tl)[0] == (pytest.approx(2.5), 4) + + @pytest.mark.parametrize( + "args", + [ + "-m 9 -d 4 -u 1", + "--measure-index 9 -d 4 -u 1", + "-m 1 -d 0 -u 1", + "-m 1 -d 4 -u 2+", + "-m 1 -d 4 -u 1.5", + ], + ) + def test_invalid(self, cli, beat_tl, args): + with assert_error(): + cli.parse_and_run(f"timelines beat unit set -o 1 {args}") + + assert_only_the_default_beat_unit(beat_tl) + + def test_repeated_measure_number_is_refused(self, cli, beat_tl): + beat_tl.set_measure_number(2, 1) + + with assert_error(): + cli.parse_and_run("timelines beat unit set -o 1 -m 1 -d 8 -u 1") + + assert_only_the_default_beat_unit(beat_tl) + + +class TestRemoveUnit: + def test_remove(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + + cli.parse_and_run("timelines beat unit remove -o 1 -m 2") + + assert_only_the_default_beat_unit(beat_tl) + + def test_no_unit_at_measure(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + + with assert_error(): + cli.parse_and_run("timelines beat unit remove -o 1 -m 3") + + assert len(beat_tl.beat_units) == 2 + + +class TestListUnits: + def test_list(self, cli, beat_tl): + cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + + with patch("tilia.ui.cli.io.tabulate") as tabulate: + cli.parse_and_run("timelines beat unit list -o 1") + + headers, data = tabulate.call_args.args + assert headers == [ + "measure", + "beat time", + "denominator", + "units", + "time signature", + ] + assert data == [ + ("1", "0.0", "4", "1", "2/4 (assumed)"), + ("2", "2.0", "8", "3", "6/8"), + ] + + def test_empty(self, cli, tls): + # Only a timeline without beats has no beat units. + cli.parse_and_run("timelines add beat --name Empty") + + with patch("tilia.ui.cli.io.output") as output: + cli.parse_and_run("timelines beat unit list -o 1") + + assert "no beat units" in output.call_args.args[0] + + +class TestTimeSignatureVisibility: + def test_show_and_hide(self, cli, beat_tl): + beat_tl.set_data("show_time_signatures", False) + height = beat_tl.height + + cli.parse_and_run("timelines beat time-signatures show -o 1") + assert beat_tl.show_time_signatures + assert beat_tl.height == height + TIME_SIGNATURE_BAND_HEIGHT + + cli.parse_and_run("timelines beat time-signatures hide -o 1") + assert not beat_tl.show_time_signatures + assert beat_tl.height == height + + def test_showing_twice_changes_height_once(self, cli, beat_tl): + beat_tl.set_data("show_time_signatures", False) + height = beat_tl.height + + cli.parse_and_run("timelines beat time-signatures show -o 1") + cli.parse_and_run("timelines beat time-signatures show -o 1") + + assert beat_tl.height == height + TIME_SIGNATURE_BAND_HEIGHT diff --git a/tests/ui/timelines/beat/interact.py b/tests/ui/timelines/beat/interact.py index 0d2d407e7..a60eb95c6 100644 --- a/tests/ui/timelines/beat/interact.py +++ b/tests/ui/timelines/beat/interact.py @@ -1,10 +1,16 @@ +from contextlib import contextmanager from unittest.mock import patch -from PySide6.QtWidgets import QDialog +from PySide6.QtCore import QEvent +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QDialog, QGraphicsSceneHoverEvent, QMessageBox from tests.ui.timelines.interact import click_timeline_ui_view from tilia.timelines.beat.timeline import BeatTimeline +from tilia.ui import strings from tilia.ui.coords import time_x_converter +from tilia.ui.windows.beat_pattern import BeatPatternDialog +from tilia.ui.windows.beat_unit import BeatUnitDialog from tilia.ui.windows.fill_beat_timeline import FillBeatTimeline @@ -20,6 +26,75 @@ def click_beat_ui(beat_ui, button="left", modifier=None, double=False): ) +def patch_beat_pattern_dialog(typed: str | None, on_exec=None): + """ + Makes the beat pattern dialog type `typed` and press OK, or press Cancel + when `typed` is None. `on_exec(dialog)` runs after typing, before OK, e.g. + to check the preview. + """ + + def exec_(dialog): + if typed is None: + return QDialog.DialogCode.Rejected + dialog.line_edit.clear() + QTest.keyClicks(dialog.line_edit, typed) + if on_exec: + on_exec(dialog) + dialog.ok_button.click() + return dialog.result() + + return patch.object(BeatPatternDialog, "exec", new=exec_) + + +def click_time_signature_label( + timeline_ui, label, button="left", modifier=None, double=False +): + """Clicks the middle of a time signature label, hit-testing like the view.""" + center = label.rect().center() + x, y = int(center.x()), int(center.y()) + item = timeline_ui.view.itemAt(x, y) + click_timeline_ui_view(timeline_ui.view, button, x, y, item, modifier, double) + + +def hover_time_signature_label(timeline_ui, label, entered=True): + event_type = ( + QEvent.Type.GraphicsSceneHoverEnter + if entered + else QEvent.Type.GraphicsSceneHoverLeave + ) + timeline_ui.scene.sendEvent(label, QGraphicsSceneHoverEvent(event_type)) + + +def patch_beat_unit_dialog( + denominator: int | None = None, + units: str | None = None, + only_this_measure: bool = False, + cancel: bool = False, + on_exec=None, +): + """ + Makes the beat unit dialog take the given values (keeping any left as + None) and press OK, or press Cancel. `on_exec(dialog)` runs first. + """ + + def exec_(dialog): + if on_exec: + on_exec(dialog) + if cancel: + return QDialog.DialogCode.Rejected + if denominator is not None: + dialog.denominator_edit.setValue(denominator) + if units is not None: + dialog.units_edit.clear() + QTest.keyClicks(dialog.units_edit, units) + if only_this_measure: + dialog.measure_option.click() + dialog.ok_button.click() + return dialog.result() + + return patch.object(BeatUnitDialog, "exec", new=exec_) + + def patch_fill_beat_timeline_dialog(timeline, method, value: float): """ Makes the "Fill with beats" dialog choose `timeline`, fill `method` and @@ -37,3 +112,28 @@ def exec_(dialog): return QDialog.DialogCode.Accepted return patch.object(FillBeatTimeline, "exec", new=exec_) + + +@contextmanager +def patch_beat_unit_scope_prompt(answer: str | None): + """ + Answers the inspector's "Where should this change apply?" prompt by + pressing "onward", "measure" (this measure only) or, for None, Cancel. + Yields a list of the prompts' texts, so tests can count them. + """ + shown = [] + button_texts = { + "onward": strings.BEAT_UNIT_SCOPE_ONWARD, + "measure": strings.BEAT_UNIT_SCOPE_MEASURE, + } + + def exec_(box): + shown.append(box.text()) + if answer is None: + box.button(QMessageBox.StandardButton.Cancel).click() + return + button = next(b for b in box.buttons() if b.text() == button_texts[answer]) + button.click() + + with patch.object(QMessageBox, "exec", new=exec_): + yield shown diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 3bf61efe0..1f6aee6ca 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -1,26 +1,50 @@ -from unittest.mock import MagicMock +import json +from fractions import Fraction as F +from unittest.mock import MagicMock, patch import pytest +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication from tests.mock import Serve, patch_ask_for_int_dialog, patch_yes_or_no_dialog from tests.ui.timelines.beat.interact import ( click_beat_ui, + click_time_signature_label, + hover_time_signature_label, + patch_beat_pattern_dialog, + patch_beat_unit_dialog, + patch_beat_unit_scope_prompt, patch_fill_beat_timeline_dialog, ) -from tests.ui.timelines.interact import drag_mouse_in_timeline_view +from tests.ui.timelines.interact import drag_mouse_in_timeline_view, press_key from tests.utils import ( get_command_action, get_command_names, reloadable, + save_and_reopen, + save_tilia_to_tmp_path, undoable, ) -from tilia.requests import Get, Post, post +from tilia.requests import Get, Post, get, post from tilia.settings import settings from tilia.timelines.beat.timeline import BeatTimeline +from tilia.timelines.component_kinds import ComponentKind from tilia.ui import commands from tilia.ui.commands import get_qaction from tilia.ui.coords import time_x_converter -from tilia.ui.timelines.beat.context_menu import BeatContextMenu +from tilia.ui.strings import BEAT_UNIT_ASSUMED_TOOLTIP +from tilia.ui.timelines.beat.context_menu import ( + BeatContextMenu, + BeatTimelineUIContextMenu, + BeatUnitContextMenu, +) +from tilia.ui.timelines.beat.element import BeatUI +from tilia.ui.timelines.beat.time_signature import ( + ASSUMED_OPACITY, + TIME_SIGNATURE_BAND_HEIGHT, + LabelState, +) from tilia.ui.windows import WindowKind @@ -112,6 +136,210 @@ def test_duplicate_and_out_of_range_forced_measures_are_dropped( assert get_displayed_measure_number(reopened[6]) == "" +def add_beat_timeline_with_pattern(pattern: str) -> None: + commands.execute("timelines.add.beat", name="", beat_pattern=pattern) + + +def add_beats(times) -> None: + for time in times: + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + + +class TestBeatPattern: + def test_bracketed_pattern_sets_bar_lengths(self, tluis): + add_beat_timeline_with_pattern("2[3] 4") + add_beats(range(12)) + + timeline = tluis[0].timeline + assert timeline.beat_pattern == "2[3] 4" + assert timeline.beats_in_measure == [3, 3, 4, 2] + + def test_pattern_text_survives_save_and_reopen(self, tluis, tmp_path): + add_beat_timeline_with_pattern("2[3] 4") + add_beats(range(10)) + + save_and_reopen(tmp_path) + + timeline = tluis[0].timeline + assert timeline.beat_pattern == "2[3] 4" + assert timeline.beats_in_measure == [3, 3, 4] + add_beats(range(10, 13)) + assert timeline.beats_in_measure == [3, 3, 4, 3] + + def test_file_with_list_pattern_loads_as_text(self, tluis, tmp_path): + # Files saved before patterns were text store a list of bar lengths. + # The current version can't write one, so edit a saved file. + add_beat_timeline_with_pattern("4") + path = save_tilia_to_tmp_path(tmp_path) + with open(path, encoding="utf-8") as f: + data = json.load(f) + for timeline_data in data["timelines"].values(): + if "beat_pattern" in timeline_data: + timeline_data["beat_pattern"] = [3, 4] + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + post(Post.APP_CLEAR) + + commands.execute("file.open", path=path) + add_beats(range(7)) + + timeline = tluis[0].timeline + assert timeline.beat_pattern == "3 4" + assert timeline.beats_in_measure == [3, 4] + + def test_set_pattern_recomputes_bars(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(12)) + timeline_ui = tluis[0] + + with undoable(): + commands.execute("timeline.beat.set_pattern", timeline_ui, pattern="3") + + timeline = timeline_ui.timeline + assert timeline.beat_pattern == "3" + assert timeline.beats_in_measure == [3, 3, 3, 3] + assert timeline.measure_numbers == [1, 2, 3, 4] + assert [b.is_first_in_measure for b in timeline].count(True) == 4 + assert timeline[3].is_first_in_measure + + def test_set_pattern_with_fewer_bars(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + timeline_ui = tluis[0] + + commands.execute("timeline.beat.set_pattern", timeline_ui, pattern="4") + + assert timeline_ui.timeline.beats_in_measure == [4, 4] + assert timeline_ui.timeline.measure_numbers == [1, 2] + + def test_set_pattern_on_empty_timeline(self, tluis): + add_beat_timeline_with_pattern("4") + + commands.execute("timeline.beat.set_pattern", tluis[0], pattern="10[4] 3") + + assert tluis[0].timeline.beat_pattern == "10[4] 3" + add_beats(range(8)) + assert tluis[0].timeline.beats_in_measure == [4, 4] + + def test_set_pattern_through_dialog(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(10)) + + with patch_beat_pattern_dialog("2[3] 4"): + commands.execute("timeline.beat.set_pattern", tluis[0]) + + assert tluis[0].timeline.beat_pattern == "2[3] 4" + assert tluis[0].timeline.beats_in_measure == [3, 3, 4] + + def test_dialog_starts_with_current_pattern(self, tluis): + add_beat_timeline_with_pattern("2[3] 4") + add_beats(range(5)) + seen = {} + + def record(dialog): + seen["preview"] = dialog.preview.footer.text() + + with patch_beat_pattern_dialog("4", on_exec=record): + commands.execute("timeline.beat.set_pattern", tluis[0]) + + assert seen["preview"].endswith("5 beats tapped.") + + def test_cancel_dialog_changes_nothing(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(8)) + + with patch_beat_pattern_dialog(None): + commands.execute("timeline.beat.set_pattern", tluis[0]) + + assert tluis[0].timeline.beat_pattern == "4" + assert tluis[0].timeline.beats_in_measure == [4, 4] + + def test_invalid_pattern_shows_error(self, tluis, tilia_errors): + add_beat_timeline_with_pattern("4") + + commands.execute("timeline.beat.set_pattern", tluis[0], pattern="2[") + + tilia_errors.assert_error() + tilia_errors.assert_in_error_message("Unclosed") + assert tluis[0].timeline.beat_pattern == "4" + + @staticmethod + def _set_second_bar_to_three_beats(timeline_ui): + timeline_ui.select_element(timeline_ui[4]) + with patch_ask_for_int_dialog(True, 3): + commands.execute("timeline.beat.set_amount_in_measure") + timeline_ui.deselect_all_elements() + + def test_overwriting_hand_set_bars_asks_first(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(12)) + timeline_ui = tluis[0] + self._set_second_bar_to_three_beats(timeline_ui) + assert timeline_ui.timeline.beats_in_measure == [4, 3, 4, 1] + assert timeline_ui.timeline.get_hand_set_bars() == [1] + + with patch_yes_or_no_dialog(False): + commands.execute("timeline.beat.set_pattern", timeline_ui, pattern="4") + assert timeline_ui.timeline.beats_in_measure == [4, 3, 4, 1] + + with patch_yes_or_no_dialog(True): + with undoable(): + commands.execute("timeline.beat.set_pattern", timeline_ui, pattern="4") + assert timeline_ui.timeline.beats_in_measure == [4, 4, 4] + + def test_hand_set_bar_matching_new_pattern_is_not_asked_about(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(12)) + timeline_ui = tluis[0] + self._set_second_bar_to_three_beats(timeline_ui) + + # Declining would leave the pattern unchanged, so this shows no prompt. + with patch_yes_or_no_dialog(False): + commands.execute("timeline.beat.set_pattern", timeline_ui, pattern="4 3") + + assert timeline_ui.timeline.beat_pattern == "4 3" + assert timeline_ui.timeline.beats_in_measure == [4, 3, 4, 1] + + def test_dialog_preview_lists_bars_to_overwrite(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(12)) + self._set_second_bar_to_three_beats(tluis[0]) + seen = {} + + def record(dialog): + seen["preview"] = dialog.preview.footer.text() + + with patch_beat_pattern_dialog("4", on_exec=record): + with patch_yes_or_no_dialog(False): + commands.execute("timeline.beat.set_pattern", tluis[0]) + + assert seen["preview"].endswith( + "Bar 2 was set by hand and will be overwritten." + ) + + def test_context_menu_has_set_pattern(self, tluis): + add_beat_timeline_with_pattern("4") + menu = BeatTimelineUIContextMenu(tluis[0], 0, 0) + + assert "timeline.beat.set_pattern" in get_command_names(menu) + + def test_set_pattern_from_context_menu(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(6)) + menu = BeatTimelineUIContextMenu(tluis[0], 0, 0) + action = next( + a + for a in menu.actions() + if getattr(a, "command_name", None) == "timeline.beat.set_pattern" + ) + + with patch_beat_pattern_dialog("3"): + action.trigger() + + assert tluis[0].timeline.beats_in_measure == [3, 3] + + class TestCreateDeleteBeat: def test_create_single(self, beat_tlui): beat_tlui.create_beat(0) @@ -283,9 +511,11 @@ def test_get_copy_data_from_beat_uis(self, beat_tlui): beat_tlui.select_element(beat) copy_data = beat_tlui.get_copy_data_from_selected_elements() + # The first beat carries the timeline's first beat unit. beat0_data = { "values": {}, "context": {"time": 0}, + "beat_unit": {"denominator": 4, "units": "1", "assumed": True}, } beat1_data = { "values": {}, @@ -978,3 +1208,1320 @@ def test_drag_beyond_timeline_start_is_clamped(self, beat_tlui, tilia_state): with undoable(): drag_mouse_in_timeline_view(time_x_converter.get_x_by_time(0) - 200, 0) assert beat_tlui[0].get_data("time") == 0 + + +def set_beat_unit(timeline_ui, measure_index, denominator, units, **kwargs): + """Selects the measure's first beat and sets its beat unit, as a user would.""" + timeline = timeline_ui.timeline + first_beat = timeline[timeline.beats_that_start_measures[measure_index]] + timeline_ui.deselect_all_elements() + timeline_ui.select_element(timeline_ui.get_element(first_beat.id)) + commands.execute( + "timeline.beat.set_beat_unit", denominator=denominator, units=units, **kwargs + ) + timeline_ui.deselect_all_elements() + + +def newest_beat_unit_ui(timeline_ui): + """The beat unit on the latest beat, i.e. the one a test set last.""" + timeline = timeline_ui.timeline + return timeline_ui.get_element(timeline.beat_units[-1].id) + + +def assert_only_the_default_beat_unit(timeline): + """Every timeline with beats keeps a beat unit on its first beat.""" + assert [ + (timeline.get_component(u.beat_id), u.denominator, u.units) + for u in timeline.beat_units + ] == [(timeline[0], 4, "1")] + + +def label_texts(labels): + return [(lb.spec.numerator, lb.spec.denominator) for lb in labels] + + +def all_labels(timeline_ui): + labels = timeline_ui.default_time_signature_labels + [ + lb for u in timeline_ui.beat_unit_uis for lb in u.labels + ] + return sorted(labels, key=lambda lb: lb.measure_index) + + +class TestTimeSignatures: + def test_new_timeline_shows_time_signatures(self, tluis): + add_beat_timeline_with_pattern("4") + + timeline_ui = tluis[0] + assert timeline_ui.get_data("show_time_signatures") + assert timeline_ui.get_data("height") == ( + settings.get("beat_timeline", "default_height") + TIME_SIGNATURE_BAND_HEIGHT + ) + + def test_default_label_on_first_measure_only(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(8)) + + labels = all_labels(tluis[0]) + assert label_texts(labels) == [("4", "4")] + assert labels[0].measure_index == 0 + + def test_label_where_bar_length_changes(self, tluis): + add_beat_timeline_with_pattern("4 3") + add_beats(range(11)) + + labels = all_labels(tluis[0]) + assert label_texts(labels) == [("4", "4"), ("3", "4"), ("4", "4")] + + def test_beat_unit_label(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "3") + + first_ui = tluis[0].get_element(tluis[0].timeline.beat_units[0].id) + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + assert label_texts(first_ui.labels) == [("2", "4")] + assert label_texts(beat_unit_ui.labels) == [("6", "8")] + assert tluis[0].default_time_signature_labels == [] + + def test_label_where_beat_unit_starts_without_changing_signature(self, tluis): + add_beat_timeline_with_pattern("4 2") + add_beats(range(6)) + # 4/4 then 4/4 again: tapped in 4, then in 2. + set_beat_unit(tluis[0], 1, 4, "2") + + labels = all_labels(tluis[0]) + assert label_texts(labels) == [("4", "4"), ("4", "4")] + + def test_whole_and_fraction_numerator(self, tluis): + add_beat_timeline_with_pattern("13") + add_beats(range(13)) + set_beat_unit(tluis[0], 0, 4, "1/3") + + assert label_texts(all_labels(tluis[0])) == [("4 1/3", "4")] + + def test_twelve_triplet_taps_show_four_four(self, tluis): + add_beat_timeline_with_pattern("12") + add_beats(range(12)) + set_beat_unit(tluis[0], 0, 4, "1/3") + + assert label_texts(all_labels(tluis[0])) == [("4", "4")] + + def test_label_is_centered_on_the_downbeat(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "1") + + # The first label would reach into the left margin, so it is checked + # separately below. + (label,) = newest_beat_unit_ui(tluis[0]).labels + assert label.rect().center().x() == pytest.approx( + time_x_converter.get_x_by_time(2) + ) + + def test_labels_stay_out_of_the_margins(self, tluis, tilia_state): + add_beat_timeline_with_pattern("1") + add_beats([0, tilia_state.duration]) + set_beat_unit(tluis[0], 1, 8, "1") + + first = all_labels(tluis[0])[0] + (last,) = newest_beat_unit_ui(tluis[0]).labels + assert first.body.x() == get(Get.LEFT_MARGIN_X) + assert last.body.x() + last.body.width == get(Get.RIGHT_MARGIN_X) + + def test_labels_follow_zoom(self, tluis, tilia_state): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + before = beat_unit_ui.labels[0].rect().left() + + commands.execute("view.zoom.in") + + assert beat_unit_ui.labels[0].rect().left() > before + + def test_labels_follow_dragged_downbeat(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "1") + + tluis[0].timeline.set_component_data(tluis[0].timeline[2].id, "time", 2.5) + + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + assert beat_unit_ui.labels[0].rect().center().x() == pytest.approx( + time_x_converter.get_x_by_time(2.5) + ) + + def test_conflicting_beat_units_are_red(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(8)) + timeline = tluis[0].timeline + for index, denominator in [(4, 8), (5, 2)]: + timeline.create_component( + ComponentKind.BEAT_UNIT, + beat_id=timeline[index].id, + denominator=denominator, + units="1", + ) + + label = all_labels(tluis[0])[1] + assert label.spec.state == LabelState.CONFLICTING + assert label.toolTip() + + def test_ambiguous_fit_is_amber(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(5)) + set_beat_unit(tluis[0], 0, 4, "2+3") + + labels = all_labels(tluis[0]) + assert [lb.spec.state for lb in labels] == [ + LabelState.NORMAL, + LabelState.AMBIGUOUS, + ] + assert labels[1].toolTip() == "3 taps under 2+3: treated as 2+3+2." + + def test_clicking_a_label_selects_its_beat_unit(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + click_time_signature_label(tluis[0], beat_unit_ui.labels[0]) + + assert tluis[0].selected_elements == [beat_unit_ui] + assert beat_unit_ui.labels[0].pen().style() != Qt.PenStyle.NoPen + + def test_only_the_clicked_label_is_outlined(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + assert len(beat_unit_ui.labels) > 2 + + click_time_signature_label(tluis[0], beat_unit_ui.get_label(2)) + + outlined = [ + lb.measure_index + for lb in beat_unit_ui.labels + if lb.pen().style() != Qt.PenStyle.NoPen + ] + assert outlined == [2] + + click_time_signature_label(tluis[0], beat_unit_ui.get_label(1)) + outlined = [ + lb.measure_index + for lb in beat_unit_ui.labels + if lb.pen().style() != Qt.PenStyle.NoPen + ] + assert outlined == [1] + + def test_outline_goes_when_deselected(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + click_time_signature_label(tluis[0], beat_unit_ui.labels[0]) + + tluis[0].deselect_all_elements() + + assert all(lb.pen().style() == Qt.PenStyle.NoPen for lb in beat_unit_ui.labels) + + def test_selected_without_a_click_outlines_where_it_starts(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 1, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + tluis[0].select_element(beat_unit_ui) + + outlined = [ + lb.measure_index + for lb in beat_unit_ui.labels + if lb.pen().style() != Qt.PenStyle.NoPen + ] + assert outlined == [1] + + def test_clicking_an_inherited_label_selects_the_governing_beat_unit(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inherited = beat_unit_ui.get_label(1) + assert inherited is not None + + click_time_signature_label(tluis[0], inherited) + + assert tluis[0].selected_elements == [beat_unit_ui] + + def test_first_label_selects_the_first_beats_beat_unit(self, tluis): + # Every timeline with beats has a beat unit on its first beat, so no + # label is left that can't be selected. + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + first_ui = tluis[0].get_element(tluis[0].timeline.beat_units[0].id) + + click_time_signature_label(tluis[0], all_labels(tluis[0])[0]) + + assert tluis[0].selected_elements == [first_ui] + + def test_hover_draws_line_to_the_beat(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + hover_time_signature_label(tluis[0], beat_unit_ui.get_label(1)) + + line = beat_unit_ui.hover_line + assert line is not None + assert line.line().p2().x() == time_x_converter.get_x_by_time(0) + + hover_time_signature_label(tluis[0], beat_unit_ui.get_label(1), False) + assert beat_unit_ui.hover_line is None + + def test_delete_beat_unit_through_its_label(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + click_time_signature_label(tluis[0], beat_unit_ui.labels[0]) + + with undoable(): + commands.execute("timeline.component.delete") + + assert_only_the_default_beat_unit(tluis[0].timeline) + assert label_texts(all_labels(tluis[0])) == [("2", "4")] + + def test_beat_units_do_not_count_as_beats(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "1") + + assert len(tluis[0]) == 4 + assert all(isinstance(e, BeatUI) for e in tluis[0]) + + def test_select_all_and_arrows_skip_beat_units(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "1") + + tluis[0].select_all_elements() + assert all(isinstance(e, BeatUI) for e in tluis[0].selected_elements) + + tluis[0].deselect_all_elements() + tluis[0].select_element(tluis[0][3]) + press_key("Right") + assert tluis[0].selected_elements == [tluis[0][3]] + + def test_toggle_hides_labels_and_shrinks_timeline(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "1") + height = tluis[0].get_data("height") + + with undoable(): + commands.execute("timeline.beat.toggle_time_signatures", tluis[0]) + + assert not tluis[0].get_data("show_time_signatures") + assert tluis[0].get_data("height") == height - TIME_SIGNATURE_BAND_HEIGHT + assert all_labels(tluis[0]) == [] + + commands.execute("timeline.beat.toggle_time_signatures", tluis[0]) + assert tluis[0].get_data("height") == height + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "8")] + + def test_toggle_keeps_a_height_set_by_hand(self, tluis): + add_beat_timeline_with_pattern("4") + commands.execute("timeline.set_height", tluis[0], height=100) + + commands.execute("timeline.beat.toggle_time_signatures", tluis[0]) + + assert tluis[0].get_data("height") == 100 - TIME_SIGNATURE_BAND_HEIGHT + + def test_toggle_in_context_menu(self, tluis): + add_beat_timeline_with_pattern("4") + menu = BeatTimelineUIContextMenu(tluis[0], 0, 0) + + assert "timeline.beat.toggle_time_signatures" in get_command_names(menu) + + def test_toggle_from_context_menu(self, tluis): + add_beat_timeline_with_pattern("4") + menu = BeatTimelineUIContextMenu(tluis[0], 0, 0) + action = next( + a + for a in menu.actions() + if getattr(a, "command_name", None) + == "timeline.beat.toggle_time_signatures" + ) + + action.trigger() + + assert not tluis[0].get_data("show_time_signatures") + + def test_hidden_by_default_for_timelines_without_the_setting(self, beat_tlui): + # e.g. timelines from files saved before time signatures existed. + beat_tlui.create_beat(0) + + assert not beat_tlui.get_data("show_time_signatures") + assert all_labels(beat_tlui) == [] + + def test_beat_units_survive_save_and_reopen(self, tluis, tmp_path): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 1, 8, "2+1") + + save_and_reopen(tmp_path) + + timeline = tluis[0].timeline + beat_unit = timeline.beat_units[-1] + assert (beat_unit.denominator, beat_unit.units) == (8, "2+1") + assert beat_unit.beat_id == timeline[2].id + assert tluis[0].get_data("show_time_signatures") + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("3", "8")] + + +class TestSetBeatUnitCommand: + def test_from_selected_beat_through_dialog(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + timeline_ui = tluis[0] + # A beat in the middle of measure 2 sets that measure's beat unit. + timeline_ui.select_element(timeline_ui[3]) + + with patch_beat_unit_dialog(denominator=8, units="3"): + with undoable(): + commands.execute("timeline.beat.set_beat_unit") + + beat_unit = timeline_ui.timeline.beat_units[-1] + assert beat_unit.beat_id == timeline_ui.timeline[2].id + assert label_texts(all_labels(timeline_ui)) == [("2", "4"), ("6", "8")] + + def test_dialog_starts_with_the_measure_values(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 1, 8, "3") + tluis[0].select_element(tluis[0][4]) + seen = {} + + def record(dialog): + seen["values"] = ( + dialog.denominator_edit.value(), + dialog.units_edit.text(), + ) + seen["preview"] = dialog.preview.footer.text() + + with patch_beat_unit_dialog(cancel=True, on_exec=record): + commands.execute("timeline.beat.set_beat_unit") + + assert seen["values"] == (8, "3") + assert seen["preview"].endswith("becomes 6/8.") + + def test_cancel_changes_nothing(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + tluis[0].select_element(tluis[0][0]) + + with patch_beat_unit_dialog(cancel=True): + commands.execute("timeline.beat.set_beat_unit") + + assert_only_the_default_beat_unit(tluis[0].timeline) + + def test_this_measure_only_through_dialog(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + tluis[0].select_element(tluis[0][2]) + + with patch_beat_unit_dialog(denominator=8, units="3", only_this_measure=True): + with undoable(): + commands.execute("timeline.beat.set_beat_unit") + + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("6", "8"), ("2", "4")] + + @pytest.mark.parametrize( + "beat_index, next_has_own_unit, show_scope", + [(0, False, True), (4, False, False), (0, True, False)], + ) + def test_scope_is_offered_only_when_it_matters( + self, tluis, beat_index, next_has_own_unit, show_scope + ): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + if next_has_own_unit: + set_beat_unit(tluis[0], 1, 4, "1") + tluis[0].select_element(tluis[0][beat_index]) + seen = {} + + def record(dialog): + seen["shown"] = not dialog.measure_option.isHidden() + + with patch_beat_unit_dialog(cancel=True, on_exec=record): + commands.execute("timeline.beat.set_beat_unit") + + assert seen["shown"] == show_scope + + def test_double_clicking_a_label_edits_in_place(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + with patch_beat_unit_dialog(units="1"): + click_time_signature_label(tluis[0], beat_unit_ui.labels[0], double=True) + + assert len(tluis[0].timeline.beat_units) == 2 + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "8")] + + def test_editing_an_inherited_label_starts_a_new_beat_unit(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + with patch_beat_unit_dialog(denominator=4): + click_time_signature_label(tluis[0], beat_unit_ui.get_label(1), double=True) + + assert label_texts(all_labels(tluis[0])) == [ + ("2", "8"), + ("3", "4"), + ("2", "4"), + ("3", "4"), + ] + assert len(tluis[0].timeline.beat_units) == 2 + + def test_beat_context_menu_has_set_beat_unit(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + + menu = BeatContextMenu(tluis[0][0]) + + assert "timeline.beat.set_beat_unit" in get_command_names(menu) + + def test_set_beat_unit_from_beat_context_menu(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + tluis[0].select_element(tluis[0][0]) + action = get_command_action( + BeatContextMenu(tluis[0][0]), "timeline.beat.set_beat_unit" + ) + + with patch_beat_unit_dialog(denominator=8): + action.trigger() + + assert label_texts(all_labels(tluis[0])) == [("4", "8")] + + def test_label_context_menu_items(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + names = get_command_names(BeatUnitContextMenu(beat_unit_ui)) + + assert names == ["timeline.beat.set_beat_unit", "timeline.component.delete"] + + def test_delete_from_label_context_menu(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + click_time_signature_label(tluis[0], beat_unit_ui.labels[0]) + action = get_command_action( + BeatUnitContextMenu(beat_unit_ui), "timeline.component.delete" + ) + + action.trigger() + + assert_only_the_default_beat_unit(tluis[0].timeline) + + def test_edit_from_label_context_menu(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + with patch.object(BeatUnitContextMenu, "exec"): + click_time_signature_label( + tluis[0], beat_unit_ui.get_label(1), button="right" + ) + action = get_command_action( + BeatUnitContextMenu(beat_unit_ui), "timeline.beat.set_beat_unit" + ) + + with patch_beat_unit_dialog(denominator=4, only_this_measure=True): + action.trigger() + + assert label_texts(all_labels(tluis[0])) == [ + ("2", "8"), + ("3", "4"), + ("2", "8"), + ("3", "8"), + ] + + def test_deleting_the_beat_moves_its_beat_unit(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "1") + tluis[0].select_element(tluis[0][4]) + + with undoable(): + commands.execute("timeline.component.delete") + + beat_unit = tluis[0].timeline.beat_units[-1] + assert beat_unit.beat_id == tluis[0].timeline[4].id + + def test_undo_restores_beat_unit_on_its_beat(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_id = tluis[0].timeline.beat_units[-1].beat_id + + commands.execute("edit.undo") + assert_only_the_default_beat_unit(tluis[0].timeline) + + commands.execute("edit.redo") + beat_unit = tluis[0].timeline.beat_units[-1] + assert beat_unit.beat_id == beat_id + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("6", "8")] + + +class TestBeatUnitInspector: + @pytest.fixture(autouse=True) + def close_inspector(self): + yield + post(Post.WINDOW_CLOSE, WindowKind.INSPECT) + + @staticmethod + def open_inspector_for_label(timeline_ui, label, qtui): + click_time_signature_label(timeline_ui, label) + commands.execute("timeline.element.inspect") + return qtui._windows[WindowKind.INSPECT] + + @staticmethod + def type_into(widget, text): + widget.selectAll() + QTest.keyClicks(widget, text) + QTest.keyClick(widget, Qt.Key.Key_Return) + # The edit is applied from the event loop, once the mouse is released. + QApplication.processEvents() + + def test_shows_values(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + widgets = inspector.field_name_to_widgets + assert widgets["Denominator"][1].value() == 8 + assert widgets["Units per tap"][1].text() == "3" + assert widgets["Starts at measure"][1].text() == "2" + + def test_edit_units_until_next_change(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward"): + with undoable(): + self.type_into(inspector.field_name_to_widgets["Units per tap"][1], "1") + + assert len(tluis[0].timeline.beat_units) == 2 + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "8")] + + def test_edit_denominator_this_measure_only(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + with patch_beat_unit_scope_prompt("measure"): + spin_box = inspector.field_name_to_widgets["Denominator"][1] + spin_box.setValue(2) + spin_box.editingFinished.emit() + QApplication.processEvents() + + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "2"), ("2", "8")] + + def test_cancelling_the_scope_question_changes_nothing(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + line_edit = inspector.field_name_to_widgets["Units per tap"][1] + + # Ended by clicking away rather than Enter, which closes the + # inspector, so the field can be seen going back. + with patch_beat_unit_scope_prompt(None): + line_edit.selectAll() + QTest.keyClicks(line_edit, "1") + line_edit.editingFinished.emit() + QApplication.processEvents() + + assert tluis[0].timeline.beat_units[-1].units == "3" + assert line_edit.text() == "3" + + def test_no_question_when_the_beat_unit_covers_one_measure(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward") as prompts: + self.type_into(inspector.field_name_to_widgets["Units per tap"][1], "1") + + assert prompts == [] + assert tluis[0].timeline.beat_units[-1].units == "1" + + def test_edit_from_an_inherited_label_starts_there(self, qtui, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.get_label(2), qtui + ) + + with patch_beat_unit_scope_prompt("onward"): + spin_box = inspector.field_name_to_widgets["Denominator"][1] + spin_box.setValue(4) + spin_box.editingFinished.emit() + QApplication.processEvents() + + assert label_texts(all_labels(tluis[0])) == [ + ("2", "8"), + ("3", "8"), + ("2", "4"), + ("3", "4"), + ] + + def test_half_typed_units_are_not_applied(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + self.type_into(inspector.field_name_to_widgets["Units per tap"][1], "2+") + + assert tluis[0].timeline.beat_units[-1].units == "3" + + def test_edit_reported_twice_asks_once(self, qtui, tluis): + # Opening the question moves focus off the field, which reports the + # same edit again. + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + line_edit = inspector.field_name_to_widgets["Units per tap"][1] + with patch_beat_unit_scope_prompt("onward") as prompts: + line_edit.selectAll() + QTest.keyClicks(line_edit, "1") + line_edit.editingFinished.emit() + line_edit.editingFinished.emit() + QApplication.processEvents() + + assert len(prompts) == 1 + assert tluis[0].timeline.beat_units[-1].units == "1" + + def test_clicking_another_element_keeps_its_inspector(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + line_edit = inspector.field_name_to_widgets["Units per tap"][1] + + with patch_beat_unit_scope_prompt("onward"): + line_edit.selectAll() + QTest.keyClicks(line_edit, "1") + # Clicking a beat ends the edit, then selects the beat. + line_edit.editingFinished.emit() + click_beat_ui(tluis[0][5]) + QApplication.processEvents() + + assert tluis[0].timeline.beat_units[-1].units == "1" + assert inspector.element_id == tluis[0][5].id + + def test_beat_inspector_shows_time_signature(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + tluis[0].select_element(tluis[0][3]) + + commands.execute("timeline.element.inspect") + + inspector = qtui._windows[WindowKind.INSPECT] + assert inspector.field_name_to_widgets["Time signature"][1].text() == "6/8" + + +class TestCopyPasteBeatUnits: + def test_pasted_beats_bring_their_beat_units(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + for beat_ui in tluis[0][2:4]: + tluis[0].select_element(beat_ui) + + commands.execute("timeline.component.copy") + tluis[0].deselect_all_elements() + commands.execute("media.seek", 10) + with undoable(): + commands.execute("timeline.component.paste") + + timeline = tluis[0].timeline + assert len(timeline) == 6 + pasted_beat = timeline.get_component_by_attr("time", 10) + pasted_unit = timeline.get_beat_unit_on_beat(pasted_beat.id) + assert (pasted_unit.denominator, pasted_unit.units) == (8, "3") + assert len(timeline.beat_units) == 3 + + def test_copying_a_label_copies_nothing(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + click_time_signature_label(tluis[0], beat_unit_ui.labels[0]) + before = get(Get.CLIPBOARD_CONTENTS) + + commands.execute("timeline.component.copy") + + assert get(Get.CLIPBOARD_CONTENTS) == before + + +def assumed_flags(timeline): + return [u.assumed for u in timeline.beat_units] + + +def is_drawn_assumed(label): + return ( + label.body.opacity() == ASSUMED_OPACITY + and BEAT_UNIT_ASSUMED_TOOLTIP in label.toolTip() + ) + + +class TestAssumedBeatUnits: + """ + Tapping can't tell 6/8 in two from 2/4, so the beat unit TiLiA gives the + first beat is marked assumed until the user sets one. + """ + + def test_default_beat_unit_is_assumed(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + + assert assumed_flags(tluis[0].timeline) == [True] + (label,) = all_labels(tluis[0]) + assert is_drawn_assumed(label) + + def test_beat_unit_set_by_the_user_is_not_assumed(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + + with undoable(): + set_beat_unit(tluis[0], 1, 8, "3") + + assert assumed_flags(tluis[0].timeline) == [True, False] + first, second = all_labels(tluis[0]) + assert is_drawn_assumed(first) + assert second.body.opacity() == 1 + assert second.toolTip() == "" + + def test_confirming_unchanged_values_in_the_dialog(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + tluis[0].select_element(tluis[0][0]) + + with patch_beat_unit_dialog(): + with undoable(): + commands.execute("timeline.beat.set_beat_unit") + + beat_unit = tluis[0].timeline.beat_units[0] + assert (beat_unit.denominator, beat_unit.units) == (4, "1") + assert not beat_unit.assumed + assert not is_drawn_assumed(all_labels(tluis[0])[0]) + + def test_restored_measure_after_one_measure_edit_stays_assumed(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + + set_beat_unit(tluis[0], 0, 8, "3", only_this_measure=True) + + assert assumed_flags(tluis[0].timeline) == [False, True] + assert [is_drawn_assumed(lb) for lb in all_labels(tluis[0])] == [ + False, + True, + ] + + def test_deleting_the_first_beat_unit_makes_it_assumed_again(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 0, 8, "3") + click_time_signature_label(tluis[0], all_labels(tluis[0])[0]) + + with undoable(): + commands.execute("timeline.component.delete") + + assert assumed_flags(tluis[0].timeline) == [True] + + def test_pickup_copies_the_flag_of_the_next_beat_unit(self, tluis): + add_beat_timeline_with_pattern("1 2") + add_beats(range(1, 5)) + set_beat_unit(tluis[0], 0, 8, "3") + + # A pickup before the old first beat, in a measure of its own. + commands.execute("media.seek", 0) + commands.execute("timeline.beat.add") + + assert assumed_flags(tluis[0].timeline) == [False, False] + + def test_beat_inspector_says_assumed(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + + tluis[0].select_element(tluis[0][0]) + assert tluis[0][0].get_inspector_dict()["Time signature"] == ("2/4 (assumed)") + assert tluis[0][2].get_inspector_dict()["Time signature"] == "6/8" + + def test_beat_unit_inspector_says_assumed(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + click_time_signature_label(tluis[0], all_labels(tluis[0])[0]) + commands.execute("timeline.element.inspect") + inspector = qtui._windows[WindowKind.INSPECT] + + assert inspector.field_name_to_widgets["Assumed"][1].text() == "Yes" + post(Post.WINDOW_CLOSE, WindowKind.INSPECT) + + def test_flag_survives_save_and_reopen(self, tluis, tmp_path): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + + save_and_reopen(tmp_path) + + assert assumed_flags(tluis[0].timeline) == [True, False] + + def test_file_from_before_beat_units_loads_assumed(self, resources, tluis): + commands.execute("file.open", path=resources / "tla" / "many_beats.tla") + + assert assumed_flags(tluis[0].timeline) == [True] + + def test_pasted_beat_unit_keeps_its_flag(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + tluis[0].select_element(tluis[0][2]) + + commands.execute("timeline.component.copy") + tluis[0].deselect_all_elements() + commands.execute("media.seek", 10) + commands.execute("timeline.component.paste") + + timeline = tluis[0].timeline + pasted_beat = timeline.get_component_by_attr("time", 10) + assert not timeline.get_beat_unit_on_beat(pasted_beat.id).assumed + + +class TestConflictingBeatUnits: + @staticmethod + def make_conflict(tluis): + """Two beat units end up in one measure after a beat is deleted.""" + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 0, 8, "1") + set_beat_unit(tluis[0], 1, 2, "1") + tluis[0].select_element(tluis[0][1]) + commands.execute("timeline.component.delete") + tluis[0].deselect_all_elements() + assert tluis[0].timeline.get_measure_meter(0).is_conflicting + + @staticmethod + def right_click_label(timeline_ui, label): + with patch.object(BeatUnitContextMenu, "exec"): + click_time_signature_label(timeline_ui, label, button="right") + + def test_label_is_red(self, tluis): + self.make_conflict(tluis) + + label = all_labels(tluis[0])[0] + assert label.spec.state == LabelState.CONFLICTING + + def test_menu_offers_removing_the_others(self, tluis): + self.make_conflict(tluis) + beat_unit_ui = next(u for u in tluis[0].beat_unit_uis if u.labels) + self.right_click_label(tluis[0], beat_unit_ui.labels[0]) + + names = get_command_names(BeatUnitContextMenu(beat_unit_ui)) + + assert names == [ + "timeline.beat.set_beat_unit", + "timeline.beat.remove_other_beat_units", + "timeline.component.delete", + ] + + def test_menu_does_not_offer_it_without_a_conflict(self, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + set_beat_unit(tluis[0], 0, 8, "1") + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + self.right_click_label(tluis[0], beat_unit_ui.labels[0]) + + names = get_command_names(BeatUnitContextMenu(beat_unit_ui)) + + assert "timeline.beat.remove_other_beat_units" not in names + + def test_removing_the_others_keeps_the_first(self, tluis): + self.make_conflict(tluis) + timeline = tluis[0].timeline + first = timeline.get_beat_units_in_measure(0)[0] + beat_unit_ui = tluis[0].get_element(first.id) + self.right_click_label(tluis[0], beat_unit_ui.labels[0]) + action = get_command_action( + BeatUnitContextMenu(beat_unit_ui), + "timeline.beat.remove_other_beat_units", + ) + + with undoable(): + action.trigger() + + # Undo and redo recreate components, so compare by id. + assert [u.id for u in timeline.beat_units] == [first.id] + assert all_labels(tluis[0])[0].spec.state == LabelState.NORMAL + + +class TestFileFromBeforeBeatUnits: + """tests/resources/tla/many_beats.tla was saved by TiLiA 0.5.15: its beat + pattern is a list, it has no show_time_signatures and no beat units.""" + + @pytest.fixture + def old_timeline_ui(self, resources, tluis): + commands.execute("file.open", path=resources / "tla" / "many_beats.tla") + return tluis[0] + + def test_loads_unchanged(self, old_timeline_ui): + timeline = old_timeline_ui.timeline + assert len(timeline) == 1000 + assert timeline.beat_pattern == "4" + assert timeline.beats_in_measure == [4] * 250 + assert_only_the_default_beat_unit(timeline) + assert not timeline.show_time_signatures + assert timeline.height == 35 + assert all_labels(old_timeline_ui) == [] + + def test_opening_does_not_count_as_a_change(self, old_timeline_ui, tilia): + # Loading adds the first beat's beat unit, which isn't an edit. + assert not tilia.is_file_modified() + + def test_defaults_to_four_four(self, old_timeline_ui): + timeline = old_timeline_ui.timeline + assert {m.time_signature for m in timeline.measure_meters} == {(4, 4)} + assert timeline.quarter_stamps[4] == 4 + + @pytest.mark.timeout(10) + def test_showing_time_signatures(self, old_timeline_ui): + commands.execute("timeline.beat.toggle_time_signatures", old_timeline_ui) + + assert label_texts(all_labels(old_timeline_ui)) == [("4", "4")] + assert old_timeline_ui.get_data("height") == 35 + TIME_SIGNATURE_BAND_HEIGHT + + def test_new_features_work_and_save_in_new_format( + self, old_timeline_ui, tluis, tmp_path + ): + commands.execute("timeline.beat.set_pattern", old_timeline_ui, pattern="3") + set_beat_unit(old_timeline_ui, 1, 8, "3") + + save_and_reopen(tmp_path) + + timeline = tluis[0].timeline + assert timeline.beat_pattern == "3" + assert timeline.measure_count == 334 + beat_unit = timeline.beat_units[-1] + assert (beat_unit.denominator, beat_unit.units) == (8, "3") + assert timeline.get_measure_meter(1).time_signature == (9, 8) + + +class TestTimeSignaturesFollowTimelineChanges: + def test_fill_with_beats_shows_time_signatures(self, tluis): + add_beat_timeline_with_pattern("4") + commands.execute("timeline.beat.set_pattern", tluis[0], pattern="3[2 3]") + + with patch_fill_beat_timeline_dialog( + tluis[0].timeline, BeatTimeline.FillMethod.BY_AMOUNT, 20 + ): + commands.execute("timeline.beat.fill") + + assert label_texts(all_labels(tluis[0])) == [ + ("2", "4"), + ("3", "4"), + ("2", "4"), + ("3", "4"), + ("2", "4"), + ("3", "4"), + ("2", "4"), + ("3", "4"), + ] + + def test_labels_follow_cropped_timeline(self, tluis, tilia_state): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + + tilia_state.set_duration(5.5, scale_timelines="no") + + assert label_texts(all_labels(tluis[0])) == [ + ("2", "4"), + ("3", "4"), + ("1", "4"), + ] + + def test_labels_follow_scaled_timeline(self, tluis, tilia_state): + add_beat_timeline_with_pattern("2 3") + add_beats(range(10)) + + tilia_state.set_duration(200, scale_timelines="yes") + + # Downbeats were at 0, 2, 5 and 7 s; the first label sits against the + # left margin, so only the others are checked. + labels = all_labels(tluis[0])[1:] + for label, time in zip(labels, [4, 10, 14], strict=True): + assert label.rect().center().x() == pytest.approx( + time_x_converter.get_x_by_time(time) + ) + + +def _add_a_beat(tluis, tilia_state): + commands.execute("media.seek", 7.5) + commands.execute("timeline.beat.add") + + +def _delete_a_beat(tluis, tilia_state): + tluis[0].select_element(tluis[0][3]) + commands.execute("timeline.component.delete") + + +def _move_a_beat(tluis, tilia_state): + # What dragging a beat does. + tluis[0].timeline.set_component_data(tluis[0].timeline[3].id, "time", 3.5) + + +def _distribute(tluis, tilia_state): + tluis[0].timeline.set_component_data(tluis[0].timeline[1].id, "time", 0.2) + tluis[0].select_element(tluis[0][0]) + commands.execute("timeline.beat.distribute") + + +def _set_pattern(tluis, tilia_state): + commands.execute("timeline.beat.set_pattern", tluis[0], pattern="3") + + +def _set_amount_in_measure(tluis, tilia_state): + tluis[0].select_element(tluis[0][0]) + with patch_ask_for_int_dialog(True, 3): + commands.execute("timeline.beat.set_amount_in_measure") + + +def _set_another_beat_unit(tluis, tilia_state): + set_beat_unit(tluis[0], 2, 2, "1") + + +def _edit_the_beat_unit(tluis, tilia_state): + set_beat_unit(tluis[0], 1, 8, "1+2") + + +def _delete_the_beat_unit(tluis, tilia_state): + click_time_signature_label(tluis[0], newest_beat_unit_ui(tluis[0]).labels[0]) + commands.execute("timeline.component.delete") + + +def _fill_with_beats(tluis, tilia_state): + with ( + patch_fill_beat_timeline_dialog( + tluis[0].timeline, BeatTimeline.FillMethod.BY_AMOUNT, 30 + ), + patch_yes_or_no_dialog(True), + ): + commands.execute("timeline.beat.fill") + + +def _crop(tluis, tilia_state): + tilia_state.set_duration(5.5, scale_timelines="no") + + +def _scale(tluis, tilia_state): + tilia_state.set_duration(200, scale_timelines="yes") + + +def _undo(tluis, tilia_state): + commands.execute("edit.undo") + + +def _redo(tluis, tilia_state): + commands.execute("edit.undo") + _warm_quarter_stamp_caches(tluis[0].timeline) + commands.execute("edit.redo") + + +def _paste_beats(tluis, tilia_state): + for beat_ui in tluis[0][2:4]: + tluis[0].select_element(beat_ui) + commands.execute("timeline.component.copy") + tluis[0].deselect_all_elements() + commands.execute("media.seek", 20) + commands.execute("timeline.component.paste") + + +def _warm_quarter_stamp_caches(timeline): + timeline.get_quarter_stamp_by_time(1.0) + timeline.get_time_by_quarter_stamp(1.0) + + +class TestQuarterStampCacheStaysFresh: + """ + The time/stamp table is cached and rebuilt when the beats change. After + every kind of change, the cached values must equal a fresh build. + """ + + @pytest.mark.parametrize( + "change", + [ + _add_a_beat, + _delete_a_beat, + _move_a_beat, + _distribute, + _set_pattern, + _set_amount_in_measure, + _set_another_beat_unit, + _edit_the_beat_unit, + _delete_the_beat_unit, + _fill_with_beats, + _crop, + _scale, + _undo, + _redo, + _paste_beats, + ], + ) + def test_after(self, tluis, tilia_state, change): + add_beat_timeline_with_pattern("2") + add_beats(range(8)) + set_beat_unit(tluis[0], 1, 8, "3") + timeline = tluis[0].timeline + _warm_quarter_stamp_caches(timeline) + + change(tluis, tilia_state) + + timeline = tluis[0].timeline + assert timeline.quarter_stamps == timeline._build_quarter_stamps() + assert timeline._get_time_stamp_table() == timeline._build_time_stamp_table() + + +class TestOneMeasureEditRoundTrips: + """ + Pattern "2 3" with 6 taps gives bars of 2, 3 and 1 beats. Changing the + denominator of the middle bar only puts a beat unit on it and one on the + next bar restoring the old values. Everything derived from that must + survive saving, reopening, undo and redo. + """ + + @staticmethod + def setup_timeline(tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(6)) + set_beat_unit(tluis[0], 1, 8, "1", only_this_measure=True) + + @staticmethod + def snapshot(timeline_ui): + timeline = timeline_ui.timeline + return { + "bars": list(timeline.beats_in_measure), + "time_signatures": [m.time_signature for m in timeline.measure_meters], + "beat_units": [ + ( + timeline.get_beat_index(timeline.get_component(u.beat_id)), + u.denominator, + u.units, + ) + for u in timeline.beat_units + ], + "stamps": list(timeline.quarter_stamps), + "stamp_at_4_5s": timeline.get_quarter_stamp_by_time(4.5), + "labels": label_texts(all_labels(timeline_ui)), + } + + EXPECTED = { + "bars": [2, 3, 1], + "time_signatures": [(2, 4), (3, 8), (1, 4)], + "beat_units": [(0, 4, "1"), (2, 8, "1"), (5, 4, "1")], + "stamps": [0, 1, 2, F(5, 2), 3, F(7, 2)], + "stamp_at_4_5s": 3.25, + "labels": [("2", "4"), ("3", "8"), ("1", "4")], + } + + def test_edit(self, tluis): + self.setup_timeline(tluis) + + assert self.snapshot(tluis[0]) == self.EXPECTED + + def test_save_and_reopen(self, tluis, tmp_path): + self.setup_timeline(tluis) + + save_and_reopen(tmp_path) + + assert self.snapshot(tluis[0]) == self.EXPECTED + + def test_reopening_twice_changes_nothing(self, tluis, tmp_path, tilia): + self.setup_timeline(tluis) + save_and_reopen(tmp_path) + components = tluis[0].timeline.get_state()["components"] + + save_and_reopen(tmp_path, "second") + + assert tluis[0].timeline.get_state()["components"] == components + assert not tilia.is_file_modified() + + def test_undo_and_redo(self, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(6)) + tluis[0].select_element(tluis[0][2]) + + with undoable(): + commands.execute( + "timeline.beat.set_beat_unit", + denominator=8, + units="1", + only_this_measure=True, + ) + + assert self.snapshot(tluis[0]) == self.EXPECTED + + def test_tapping_on_after_reopening(self, tluis, tmp_path): + self.setup_timeline(tluis) + save_and_reopen(tmp_path) + + add_beats([6]) + + snapshot = self.snapshot(tluis[0]) + assert snapshot["bars"] == [2, 3, 2] + assert snapshot["time_signatures"] == [(2, 4), (3, 8), (2, 4)] + assert snapshot["stamps"][-1] == F(9, 2) diff --git a/tests/ui/timelines/test_timelineui_collection.py b/tests/ui/timelines/test_timelineui_collection.py index 1dc3709fc..fc59af6c6 100644 --- a/tests/ui/timelines/test_timelineui_collection.py +++ b/tests/ui/timelines/test_timelineui_collection.py @@ -53,6 +53,17 @@ def test_create(self, command, tluis): commands.execute(command) assert len(tluis) == 1 + def test_beat_pattern_as_argument(self, tluis): + commands.execute("timelines.add.beat", name="", beat_pattern="2[3] 4") + + assert tluis[0].timeline.beat_pattern == "2[3] 4" + + def test_invalid_beat_pattern_argument_creates_nothing(self, tluis, tilia_errors): + commands.execute("timelines.add.beat", name="", beat_pattern="2[") + + assert len(tluis) == 0 + tilia_errors.assert_in_error_message("Unclosed") + def test_pdf_path_as_argument(self, tluis, resources): path = str(resources / "example.pdf") @@ -71,7 +82,7 @@ def test_beat_creation_uses_pattern_from_user_prompt(self, tluis): # was being checked on the backend class instead of the UI class and # silently returned False, leaving the beat pattern at its default. # Make sure the prompted value actually reaches the timeline. - prompted_pattern = [3, 2] + prompted_pattern = "3 2" with ( Serve(Get.FROM_USER_BEAT_PATTERN, (True, prompted_pattern)), Serve(Get.FROM_USER_STRING, (True, "")), diff --git a/tests/ui/windows/test_beat_pattern_dialog.py b/tests/ui/windows/test_beat_pattern_dialog.py new file mode 100644 index 000000000..3747c8a8b --- /dev/null +++ b/tests/ui/windows/test_beat_pattern_dialog.py @@ -0,0 +1,215 @@ +import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QValidator +from PySide6.QtTest import QTest + +from tests.ui.timelines.beat.interact import patch_beat_pattern_dialog +from tilia.timelines.beat.pattern import parse +from tilia.ui import commands +from tilia.ui.windows.beat_pattern import ( + BeatPatternDialog, + BeatPatternValidator, + get_pattern_preview, +) +from tilia.ui.windows.pattern_preview import MAX_TABLE_HEIGHT + + +def add_beat_timeline(): + # The pattern dialog is what these tests are about, so only the name is + # passed. + commands.execute("timelines.add.beat", name="") + + +class TestValidator: + @pytest.mark.parametrize( + "text, state", + [ + ("4 4 3", QValidator.State.Acceptable), + ("10[4] 3", QValidator.State.Acceptable), + ("", QValidator.State.Intermediate), + ("10[", QValidator.State.Intermediate), + ("4]", QValidator.State.Intermediate), + ("0", QValidator.State.Intermediate), + ("4a", QValidator.State.Invalid), + ("4,4", QValidator.State.Invalid), + ("-4", QValidator.State.Invalid), + ], + ) + def test_state(self, text, state): + assert BeatPatternValidator().validate(text, len(text))[0] == state + + +class TestPatternPreview: + def test_rows_and_footer(self): + preview = get_pattern_preview(parse("10[4] 3 15[4]")) + + assert preview.headers == ["Bars", "Beats per bar"] + assert preview.rows == [("1\u201310", "4"), ("11", "3"), ("12\u201326", "4")] + assert ( + preview.footer == "26 bars, 103 beats, then the pattern repeats from bar 1." + ) + assert preview.error == "" + + def test_singular(self): + preview = get_pattern_preview(parse("1"), beat_count=1) + + assert preview.footer == ( + "1 bar, 1 beat, then the pattern repeats from bar 1.\n1 beat tapped." + ) + + def test_every_run_is_listed(self): + preview = get_pattern_preview(parse("50[1 2]")) + + assert len(preview.rows) == 100 + + def test_beat_count(self): + footer = get_pattern_preview(parse("4"), beat_count=12).footer + + assert footer.endswith("12 beats tapped.") + + def test_overwritten_bars(self): + assert get_pattern_preview( + parse("4"), overwritten_bars=[10, 13] + ).footer.endswith("Bars 11, 14 were set by hand and will be overwritten.") + assert get_pattern_preview(parse("4"), overwritten_bars=[2]).footer.endswith( + "Bar 3 was set by hand and will be overwritten." + ) + + def test_overwritten_bars_highlight_their_rows(self): + # Bar 22 is the 11th bar of the second pass through the pattern. + preview = get_pattern_preview(parse("10[4] 3"), overwritten_bars=[21]) + + assert preview.highlighted_rows == {1} + + def test_incomplete_shows_error_with_position(self): + preview = get_pattern_preview(parse("10[")) + + assert preview.error == "Unclosed '['. (at character 3)" + assert preview.rows == [] + + def test_empty(self): + assert get_pattern_preview(parse("")).error == "Enter at least one bar length." + + +class TestDialog: + def test_complete_pattern_enables_ok(self, qtui): + dialog = BeatPatternDialog() + QTest.keyClicks(dialog.line_edit, "2[3] 4") + assert dialog.ok_button.isEnabled() + assert dialog.preview.rows() == [("1\u20132", "3"), ("3", "4")] + assert dialog.text == "2[3] 4" + + def test_incomplete_pattern_disables_ok(self, qtui): + dialog = BeatPatternDialog() + # Typed brackets are paired, so set the text to leave one unclosed. + dialog.line_edit.setText("2[3") + assert not dialog.ok_button.isEnabled() + assert dialog.preview.error.text().startswith("Unclosed '['.") + assert dialog.preview.table.isHidden() + assert dialog.preview.footer.isHidden() + + def test_table_comes_back_when_the_pattern_is_complete(self, qtui): + dialog = BeatPatternDialog() + dialog.line_edit.setText("2[3") + dialog.line_edit.setText("2[3]") + + assert not dialog.preview.table.isHidden() + assert dialog.preview.error.isHidden() + + def test_table_scrolls_instead_of_growing(self, qtui): + dialog = BeatPatternDialog("50[1 2]") + + assert dialog.preview.table.rowCount() == 100 + assert dialog.preview.table.maximumHeight() == MAX_TABLE_HEIGHT + + def test_empty_dialog_disables_ok(self, qtui): + dialog = BeatPatternDialog() + assert not dialog.ok_button.isEnabled() + + def test_characters_outside_the_syntax_are_blocked(self, qtui): + dialog = BeatPatternDialog() + QTest.keyClicks(dialog.line_edit, "4a,4") + assert dialog.text == "44" + + def test_initial_text(self, qtui): + dialog = BeatPatternDialog("3 4", beat_count=7) + assert dialog.text == "3 4" + assert dialog.ok_button.isEnabled() + assert dialog.preview.footer.text().endswith("7 beats tapped.") + + def test_overwritten_bars_follow_the_text(self, qtui): + dialog = BeatPatternDialog( + get_overwritten_bars=lambda bars: [0] if bars[0] != 4 else [] + ) + QTest.keyClicks(dialog.line_edit, "3") + assert "Bar 1 was set by hand" in dialog.preview.footer.text() + assert dialog.preview.highlighted_rows() == {0} + dialog.line_edit.setText("4") + assert "set by hand" not in dialog.preview.footer.text() + assert dialog.preview.highlighted_rows() == set() + + +class TestCreateTimeline: + def test_typed_pattern_reaches_the_timeline(self, tluis): + with patch_beat_pattern_dialog("2[3] 4"): + add_beat_timeline() + + assert len(tluis) == 1 + assert tluis[0].timeline.beat_pattern == "2[3] 4" + + def test_cancel_creates_no_timeline(self, tluis): + with patch_beat_pattern_dialog(None): + add_beat_timeline() + + assert len(tluis) == 0 + + +class TestBracketPairing: + def test_opening_bracket_adds_closing_one(self, qtui): + dialog = BeatPatternDialog() + + QTest.keyClicks(dialog.line_edit, "2[") + + assert dialog.line_edit.text() == "2[]" + assert dialog.line_edit.cursorPosition() == 2 + + def test_typing_through_the_closing_bracket(self, qtui): + dialog = BeatPatternDialog() + + QTest.keyClicks(dialog.line_edit, "10[4] 3 2[4 3[3]]") + + assert dialog.text == "10[4] 3 2[4 3[3]]" + assert dialog.ok_button.isEnabled() + + def test_backspace_removes_an_empty_pair(self, qtui): + dialog = BeatPatternDialog() + QTest.keyClicks(dialog.line_edit, "2[") + + QTest.keyClick(dialog.line_edit, Qt.Key.Key_Backspace) + + assert dialog.line_edit.text() == "2" + + def test_backspace_elsewhere_deletes_one_character(self, qtui): + dialog = BeatPatternDialog("2[3]") + dialog.line_edit.setCursorPosition(3) + + QTest.keyClick(dialog.line_edit, Qt.Key.Key_Backspace) + + assert dialog.line_edit.text() == "2[]" + + def test_bracket_wraps_the_selection(self, qtui): + dialog = BeatPatternDialog("4 3") + dialog.line_edit.selectAll() + + QTest.keyClicks(dialog.line_edit, "[") + + assert dialog.line_edit.text() == "[4 3]" + assert dialog.line_edit.cursorPosition() == 5 + + def test_closing_bracket_without_one_after_the_cursor(self, qtui): + dialog = BeatPatternDialog("2[3") + dialog.line_edit.end(False) + + QTest.keyClicks(dialog.line_edit, "]") + + assert dialog.line_edit.text() == "2[3]" diff --git a/tests/ui/windows/test_beat_unit_dialog.py b/tests/ui/windows/test_beat_unit_dialog.py new file mode 100644 index 000000000..b7e09b9e3 --- /dev/null +++ b/tests/ui/windows/test_beat_unit_dialog.py @@ -0,0 +1,115 @@ +import pytest +from PySide6.QtGui import QValidator +from PySide6.QtTest import QTest + +from tilia.ui.windows.beat_unit import ( + BeatUnitDialog, + BeatUnitInput, + UnitsValidator, + get_units_preview, +) + + +class TestValidator: + @pytest.mark.parametrize( + "text, state", + [ + ("1", QValidator.State.Acceptable), + ("2+3", QValidator.State.Acceptable), + ("1/3", QValidator.State.Acceptable), + ("", QValidator.State.Intermediate), + ("2+", QValidator.State.Intermediate), + ("1/", QValidator.State.Intermediate), + ("0", QValidator.State.Intermediate), + ("1.5", QValidator.State.Invalid), + ("-1", QValidator.State.Invalid), + ("a", QValidator.State.Invalid), + ], + ) + def test_state(self, text, state): + assert UnitsValidator().validate(text, len(text))[0] == state + + +class TestUnitsPreview: + def test_one_unit(self): + preview = get_units_preview(8, "3", None) + + assert preview.headers == ["Tap", "Units (1/8)", "Length"] + assert preview.rows == [("1", "3", "1 1/2 quarters")] + assert preview.footer == "Every tap is worth the same." + + def test_several_units_repeat(self): + preview = get_units_preview(4, "2+3", 2) + + assert preview.rows == [("1", "2", "2 quarters"), ("2", "3", "3 quarters")] + assert preview.footer == ( + "Repeats every 2 taps, from tap 1 in each measure.\n" + "This measure (2 taps) becomes 5/4." + ) + + def test_fractional_units(self): + preview = get_units_preview(4, "1/3", None) + + assert preview.rows == [("1", "1/3", "1/3 quarter")] + + def test_whole_and_fraction(self): + assert get_units_preview(4, "1/3", 13).footer.endswith( + "This measure (13 taps) becomes 4 1/3 over 4." + ) + + def test_ambiguous(self): + assert get_units_preview(4, "2+3", 3).footer.endswith( + "3 taps under 2+3: treated as 2+3+2." + ) + + def test_error(self): + preview = get_units_preview(4, "2+", 3) + + assert preview.error == "Missing unit around '+'." + assert preview.rows == [] + + +class TestDialog: + def test_initial_values(self, qtui): + dialog = BeatUnitDialog(8, "3", beat_count=2) + + assert dialog.denominator_edit.value() == 8 + assert dialog.units_edit.text() == "3" + assert dialog.ok_button.isEnabled() + assert dialog.preview.footer.text().endswith("becomes 6/8.") + + def test_preview_follows_input(self, qtui): + dialog = BeatUnitDialog(4, "1", beat_count=2) + + dialog.units_edit.clear() + QTest.keyClicks(dialog.units_edit, "2+3") + + assert dialog.preview.footer.text().endswith("becomes 5/4.") + + def test_incomplete_units_disable_ok(self, qtui): + dialog = BeatUnitDialog(4, "1") + + QTest.keyClicks(dialog.units_edit, "+") + + assert not dialog.ok_button.isEnabled() + + def test_characters_outside_the_syntax_are_blocked(self, qtui): + dialog = BeatUnitDialog(4, "") + + QTest.keyClicks(dialog.units_edit, "1.5a") + + assert dialog.units_edit.text() == "15" + + def test_scope_defaults_to_onward(self, qtui): + dialog = BeatUnitDialog(4, " 2 + 3 ") + + assert dialog.result_input == BeatUnitInput(4, "2+3", False) + + dialog.measure_option.click() + assert dialog.result_input.only_this_measure + + def test_scope_can_be_hidden(self, qtui): + dialog = BeatUnitDialog(4, "1", show_scope=False) + + assert dialog.onward_option.isHidden() + assert dialog.measure_option.isHidden() diff --git a/tilia/errors.py b/tilia/errors.py index 0fceb78e3..ea3232ed6 100644 --- a/tilia/errors.py +++ b/tilia/errors.py @@ -56,9 +56,6 @@ class Error(NamedTuple): BEAT_DISTRIBUTION_ERROR = Error( "Distribute measure", "Cannot distribute beats on last measure." ) -BEAT_PATTERN_ERROR = Error( - "Insert beat pattern", "Beat pattern must be one or more numbers." -) HIERARCHY_CREATE_CHILD_FAILED = Error( "Create child hierarchy", "Create child failed: {}" ) @@ -113,6 +110,7 @@ class Error(NamedTuple): "Invalid return values for command: {}\nValues: {}", ) SCORE_SVG_CREATE_ERROR = Error("Error creating SVG", "{}") +INVALID_BEAT_PATTERN = Error("Set beat pattern", "Invalid beat pattern: {}") INVALID_MEASURE_FRACTION = Error( "Invalid measure fraction", "Measure fraction '{}' is not valid. Using closest valid value.", diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index c722047f1..b8d51dceb 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -6,7 +6,14 @@ get_column_not_found_error_message, get_params_indices, ) +from tilia.timelines.beat.pattern import format_bars from tilia.timelines.beat.timeline import BeatTimeline +from tilia.timelines.beat.units import ( + DEFAULT_DENOMINATOR, + DEFAULT_UNITS, + format_units, + parse_units, +) from tilia.timelines.component_kinds import ComponentKind @@ -21,6 +28,9 @@ def beats_from_csv( Assumes the first row of the file will contain headers. At least 'time' should be present. 'measure' and 'is_first_in_measure' are optional. + 'denominator' and 'units' are optional and only need filling on rows + where the beat unit changes; a filled row puts a beat unit on its beat, + with any value left empty carried over from the previous one. Returns a boolean indicating if the process was successful and an array with descriptions of any errors during the process. @@ -41,10 +51,9 @@ def beats_from_csv( if any(param in params_to_indices for param in optional_params): if "is_first_in_measure" in params_to_indices: is_reading_beats = True - timeline.beat_pattern = [] else: is_reading_beats = False - beats_per_measure = timeline.beat_pattern + beats_per_measure = timeline.beat_pattern_bars current_bpm_index = 0 current_beat = 0 @@ -122,12 +131,13 @@ def check_params() -> bool: beats_in_measure.append(current_beat) measure_numbers.append(current_measure) - timeline.set_data("beat_pattern", beats_in_measure) + timeline.set_data("beat_pattern", format_bars(beats_in_measure)) timeline.set_data("measure_numbers", measure_numbers) timeline.set_data("measures_to_force_display", measures_to_force_display) with TiliaCSVReader(path, file_kwargs, reader_kwargs) as reader: - next(reader) + beat_unit_indices = get_params_indices(["denominator", "units"], next(reader)) + denominator, units = DEFAULT_DENOMINATOR, DEFAULT_UNITS for row in reader: if not row: continue @@ -143,4 +153,42 @@ def check_params() -> bool: errors.append(fail_reason) timeline.recalculate_measures() + + row_values = { + param: row[index].strip() + for param, index in beat_unit_indices.items() + if index < len(row) and row[index].strip() + } + if component and row_values: + beat_unit_values, error = _parse_beat_unit_values( + row_values, denominator, units + ) + if error: + errors.append(f"{row}: {error}") + continue + denominator, units = beat_unit_values + # The first beat already has a beat unit of its own. + timeline.put_beat_unit_on_beat(component.id, denominator, units) return True, errors + + +def _parse_beat_unit_values( + row_values: dict[str, str], denominator: int, units: str +) -> tuple[tuple[int, str], str]: + if "denominator" in row_values: + try: + denominator = int(row_values["denominator"]) + except ValueError: + denominator = 0 + if denominator < 1: + return (denominator, units), ( + f"'{row_values['denominator']}' is not a valid denominator" + ) + if "units" in row_values: + result = parse_units(row_values["units"]) + if not result.is_valid: + return (denominator, units), ( + f"'{row_values['units']}' is not valid units: {result.error}" + ) + units = format_units(result.units) + return (denominator, units), "" diff --git a/tilia/requests/get.py b/tilia/requests/get.py index 561a89122..cf9c35826 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -13,6 +13,8 @@ class Get(Enum): FILE_PATH = auto() FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA = auto() FROM_USER_BEAT_PATTERN = auto() + FROM_USER_BEAT_UNIT = auto() + FROM_USER_BEAT_UNIT_SCOPE = auto() FROM_USER_BEAT_TIMELINE_FILL_METHOD = auto() FROM_USER_COLOR = auto() FROM_USER_EXPORT_PATH = auto() diff --git a/tilia/timelines/beat/components.py b/tilia/timelines/beat/components.py index 794385ba2..6d2fdae7d 100644 --- a/tilia/timelines/beat/components.py +++ b/tilia/timelines/beat/components.py @@ -1,15 +1,31 @@ from __future__ import annotations +import math +from fractions import Fraction from typing import TYPE_CHECKING from tilia.timelines.base.metric_position import MetricPosition -from tilia.timelines.base.validators import validate_bool, validate_time +from tilia.timelines.base.validators import ( + validate_bool, + validate_positive_integer, + validate_read_only, + validate_time, +) +from tilia.timelines.beat.units import ( + DEFAULT_DENOMINATOR, + DEFAULT_UNITS, + format_units, + parse_units, +) from tilia.timelines.component_kinds import ComponentKind if TYPE_CHECKING: from tilia.timelines.beat.timeline import BeatTimeline -from tilia.timelines.base.component import PointLikeTimelineComponent +from tilia.timelines.base.component import ( + PointLikeTimelineComponent, + TimelineComponent, +) class Beat(PointLikeTimelineComponent): @@ -67,3 +83,107 @@ def measure_number(self): @property def beat_number(self): return self.metric_position.beat + + +def validate_units(value: str) -> bool: + return isinstance(value, str) and parse_units(value).is_valid + + +def validate_component_id(value: int | str) -> bool: + # Ids come from Get.ID as strings; tests and old code also use ints. + return isinstance(value, (int, str)) + + +class BeatUnit(TimelineComponent): + """ + What one tapped beat is worth in notation, from the start of the measure + containing the beat it is attached to until the next beat unit. See + `tilia.timelines.beat.units`. + + `assumed` marks values TiLiA filled in rather than the user, such as + the 4 / 1 given to the first beat: tapping can't tell 6/8 in two from + 2/4. Setting the beat unit explicitly clears it. + """ + + SERIALIZABLE = ["beat_id", "denominator", "units", "assumed"] + ORDERING_ATTRS = ("id",) + KIND = ComponentKind.BEAT_UNIT + + validators = { + "timeline": validate_read_only, + "id": validate_read_only, + "beat_id": validate_component_id, + "denominator": validate_positive_integer, + "units": validate_units, + "assumed": validate_bool, + } + + def __init__( + self, + timeline: BeatTimeline, + id: int, + beat_id: int | str, + denominator: int = DEFAULT_DENOMINATOR, + units: str = DEFAULT_UNITS, + assumed: bool = False, + **_, + ): + self.beat_id = beat_id + self.denominator = denominator + self.assumed = assumed + # Stored in canonical text form ("2+3") so it serializes and hashes + # as a string. + self.units = format_units(parse_units(units).units) + + super().__init__(timeline, id) + + def __str__(self): + assumed = ", assumed" if self.assumed else "" + return ( + f"BeatUnit({self.denominator} / {self.units} on beat {self.beat_id}" + f"{assumed})" + ) + + def __repr__(self): + return str(self) + + def set_data(self, attr: str, value): + if attr == "units" and validate_units(value): + value = format_units(parse_units(value).units) + return super().set_data(attr, value) + + @classmethod + def get_export_attributes(cls) -> list[str]: + # Like Staff, a beat unit has no time of its own to export. + return cls.SERIALIZABLE + + @property + def ordinal(self) -> tuple[float, str]: + # Sorts after every beat (whose ordinal is its time), so beat units + # never sit between beats in the timeline UI's element list. + return math.inf, str(self.id) + + @property + def unit_values(self) -> list[Fraction]: + return parse_units(self.units).units + + @classmethod + def validate_creation( + cls, + beat_id: int, + denominator: int = DEFAULT_DENOMINATOR, + units: str = DEFAULT_UNITS, + *, + beat_ids: set[int], + taken_beat_ids: set[int], + **_, + ) -> tuple[bool, str]: + if beat_id not in beat_ids: + return False, f"No beat with id {beat_id}." + if beat_id in taken_beat_ids: + return False, f"Beat {beat_id} already has a beat unit." + if not validate_positive_integer(denominator): + return False, f"Invalid denominator: {denominator}." + if not validate_units(units): + return False, f"Invalid units: {units}." + return True, "" diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index ce2430999..c07814ba0 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -3,15 +3,19 @@ import itertools import math from bisect import bisect -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager +from dataclasses import dataclass from enum import Enum +from fractions import Fraction from math import isclose from typing import Any, cast import tilia.errors +from tilia.log import logger from tilia.requests import Get, LongOperation, Post, get, long_operation, post from tilia.settings import settings +from tilia.timelines import serialize from tilia.timelines.base.component.pointlike import crop_pointlike from tilia.timelines.base.timeline import ( TC, @@ -19,17 +23,60 @@ TimelineComponentManager, TimelineFlag, ) -from tilia.timelines.beat.components import Beat -from tilia.timelines.beat.validators import validate_integer_list +from tilia.timelines.base.validators import validate_bool, validate_positive_integer +from tilia.timelines.beat.components import Beat, BeatUnit +from tilia.timelines.beat.pattern import parse +from tilia.timelines.beat.units import ( + DEFAULT_DENOMINATOR, + DEFAULT_UNITS, + MeasureMeter, + fit_units, + format_units, + is_fit_ambiguous, + parse_units, +) +from tilia.timelines.beat.validators import ( + validate_beat_pattern, + validate_integer_list, +) from tilia.timelines.component_kinds import ComponentKind +from tilia.timelines.hash_timelines import hash_function + +DEFAULT_BEAT_PATTERN = "4" + + +@dataclass(frozen=True) +class TimeStampTable: + """ + Per beat: its time, its quarter stamp and how many seconds a quarter note + lasts from it to the next beat, for converting between the two. + """ + + times: list[float] + stamps: list[float] + seconds_per_quarter: list[float] class BeatTLComponentManager(TimelineComponentManager): def __init__(self, timeline: BeatTimeline): - super().__init__(timeline, [ComponentKind.BEAT]) + super().__init__(timeline, [ComponentKind.BEAT, ComponentKind.BEAT_UNIT]) self.timeline = cast(BeatTimeline, self.timeline) self.compute_is_first_in_measure = True self.compute_metric_fraction_dict = True + # Beat units are kept apart from beats, so everything that treats the + # timeline's components as its beats (len, indexing, iteration) keeps + # doing so. + self._beat_units: list[BeatUnit] = [] + # Set while undo/redo rebuilds components, when beat units are + # restored from the snapshot rather than moved off deleted beats. + self.is_restoring = False + # Bumped on every change to beats or beat units. Caches built from + # them (quarter stamps, the time/stamp table) rebuild when it moves, + # so a change can't leave them stale by missing an invalidation. + self.version = 0 + + def bump_version(self) -> None: + self.version += 1 @contextmanager def suppressing_is_first_in_measure(self) -> Iterator[bool]: @@ -72,6 +119,27 @@ def suppressing_metric_fraction_dict(self) -> Iterator[bool]: def beat_times(self): return {b.time for b in self._components} + @property + def beat_units(self) -> list[BeatUnit]: + return list(self._beat_units) + + def get_beat_unit_on_beat(self, beat_id: int) -> BeatUnit | None: + return next((u for u in self._beat_units if u.beat_id == beat_id), None) + + def _add_to_components(self, component: TC) -> None: + if component.KIND == ComponentKind.BEAT_UNIT: + self._beat_units.append(component) + self.id_to_component[component.id] = component + else: + super()._add_to_components(component) + + def _remove_from_components_set(self, component: TC) -> None: + if component.KIND == ComponentKind.BEAT_UNIT: + self._beat_units.remove(component) + self.id_to_component.pop(component.id) + else: + super()._remove_from_components_set(component) + def update_is_first_in_measure_of_subsequent_beats(self, start_index): with self.suppressing_metric_fraction_dict(): beats_that_start_measure = set(self.timeline.beats_that_start_measures) @@ -92,8 +160,12 @@ def create_component( success, beat, reason = super().create_component( kind, timeline, id, *args, **kwargs ) - if success: + self.bump_version() + + if success and kind == ComponentKind.BEAT_UNIT: + self.timeline.on_beat_units_changed() + elif success: if self.compute_is_first_in_measure: self.timeline.recalculate_measures() beat.is_first_in_measure = self.timeline.is_first_in_measure(beat) @@ -110,22 +182,70 @@ def create_component( def _validate_component_creation( self, - _: ComponentKind, - time: float, + kind: ComponentKind, *args, **kwargs, ): + if kind == ComponentKind.BEAT_UNIT: + return BeatUnit.validate_creation( + *args, + beat_ids={b.id for b in self._components}, + taken_beat_ids={u.beat_id for u in self._beat_units}, + **kwargs, + ) + time = args[0] if args else kwargs["time"] return Beat.validate_creation(time, self.beat_times) + def _get_beat_unit_destination(self, beat: Beat) -> Beat | None: + """ + Where a beat unit goes when the beat it is on is deleted: the first + other beat of the same measure, or, if that was the measure's only + beat, the first beat of the next measure, so the measures it governed + keep it. None if there is no such beat. + """ + beats = self.get_components() + beat_index = beats.index(beat) + measure_index, _ = self.timeline.get_measure_index(beat_index) + measure_start = self.timeline.beats_that_start_measures[measure_index] + measure_end = measure_start + self.timeline.beats_in_measure[measure_index] + for index in range(measure_start, min(measure_end, len(beats))): + if index != beat_index: + return beats[index] + if measure_end < len(beats): + return beats[measure_end] + return None + def delete_component(self, component: TC) -> None: + if component.KIND == ComponentKind.BEAT_UNIT: + super().delete_component(component) + self.bump_version() + self.timeline.on_beat_units_changed() + return + + beat_unit = ( + None if self.is_restoring else self.get_beat_unit_on_beat(component.id) + ) + destination = self._get_beat_unit_destination(component) if beat_unit else None + component_idx = self.get_components().index(component) super().delete_component(component) + self.bump_version() if self.compute_is_first_in_measure: self.update_is_first_in_measure_of_subsequent_beats(component_idx - 1) + if beat_unit: + if destination is None or self.get_beat_unit_on_beat(destination.id): + self.delete_component(beat_unit) + else: + self.set_component_data(beat_unit.id, "beat_id", destination.id) + def set_component_data(self, id: int, attr: str, value: Any): value, success = super().set_component_data(id, attr, value) - if success and self.compute_metric_fraction_dict: + if success: + self.bump_version() + if success and self.get_component(id).KIND == ComponentKind.BEAT_UNIT: + self.timeline.on_beat_units_changed() + elif success and self.compute_metric_fraction_dict: self.timeline.update_metric_fraction_dicts() return value, success @@ -174,6 +294,8 @@ def scale(self, factor: float) -> None: for beat in self._components: beat.time *= factor beat.update_hash() + # Times were written directly, so nothing else bumps the version. + self.bump_version() self.timeline.update_metric_fraction_dicts() def crop(self, length: float) -> None: @@ -185,12 +307,36 @@ def crop(self, length: float) -> None: def clear(self): with self.suppressing_is_first_in_measure() as was_computing: + for beat_unit in self._beat_units.copy(): + self.delete_component(beat_unit) for component in self._components.copy(): self.delete_component(component) if was_computing: self.timeline.refresh_measures() + def hash_components(self): + str_to_hash = "" + for component in self._components + self._beat_units: + str_to_hash += component.hash + "|" + + return hash_function(str_to_hash) + + def serialize_components(self): + return serialize.serialize_components(self._components + self._beat_units) + + @staticmethod + def _split_beat_units( + serialized_components: dict[int | str, dict[str, Any]], + ) -> tuple[dict, dict]: + beats, beat_units = {}, {} + for id, data in serialized_components.items(): + if data["kind"] == ComponentKind.BEAT_UNIT.name: + beat_units[id] = data + else: + beats[id] = data + return beats, beat_units + def deserialize_components(self, serialized_components: dict[int, dict[str]]): # Storing these attributes so we can restore them below. beats_in_measure = self.timeline.beats_in_measure.copy() @@ -198,8 +344,11 @@ def deserialize_components(self, serialized_components: dict[int, dict[str]]): measures_to_force_display = self.timeline.measures_to_force_display.copy() with self.suppressing_is_first_in_measure(): + # Beat units refer to beats, so they are created once all beats are. + beats, beat_units = self._split_beat_units(serialized_components) # This call will change the attributes above. - super().deserialize_components(serialized_components) + super().deserialize_components(beats) + super().deserialize_components(beat_units) # But we restore them here. self.timeline.set_data("measure_numbers", measure_numbers) @@ -220,8 +369,26 @@ def deserialize_components(self, serialized_components: dict[int, dict[str]]): def restore_state(self, prev_state: dict): self.timeline.clear_cached_metric_positions() + beats, beat_units = self._split_beat_units(prev_state) with self.suppressing_is_first_in_measure(): - super().restore_state(prev_state) + # Beat units are few, so they are rebuilt from the snapshot rather + # than diffed. Removing them first also keeps beat deletions below + # from moving them. + self.is_restoring = True + try: + for beat_unit in self._beat_units.copy(): + self.delete_component(beat_unit) + super().restore_state(beats) + for id, data in beat_units.items(): + data = data.copy() + data.pop("kind") + data.pop("hash", None) + self.timeline.create_component( + ComponentKind.BEAT_UNIT, id=id, **data + ) + finally: + self.is_restoring = False + self.bump_version() self.timeline.refresh_measures() @@ -231,6 +398,7 @@ class BeatTimeline(Timeline): "measure_numbers", # order matters, m. ns. need to be restored "beats_in_measure", # before beats in measure "measures_to_force_display", + "show_time_signatures", "height", "is_visible", "name", @@ -242,14 +410,17 @@ class BeatTimeline(Timeline): def __init__( self, - beat_pattern: list[int] = None, + beat_pattern: str | list[int] | None = None, name: str = "", height: int | None = None, beats_in_measure: list[int] | None = None, measure_numbers: list[int] | None = None, measures_to_force_display: list[int] | None = None, + show_time_signatures: bool = False, **kwargs, ): + # Caches keyed by the component manager's version; see _cached. + self._caches: dict[str, tuple[int, Any]] = {} super().__init__( name=name, height=height, @@ -257,16 +428,46 @@ def __init__( ) self.validators = self.validators | { - "beat_pattern": validate_integer_list, + "beat_pattern": validate_beat_pattern, "beats_in_measure": validate_integer_list, "measure_numbers": validate_integer_list, "measures_to_force_display": validate_integer_list, + "show_time_signatures": validate_bool, } - self.beat_pattern = beat_pattern or [4] + self.beat_pattern = beat_pattern or DEFAULT_BEAT_PATTERN self._beats_in_measure = beats_in_measure or [] self.measure_numbers = measure_numbers or [] self.measures_to_force_display = measures_to_force_display or [] + # False for files saved before time signatures existed, so their + # layout doesn't change; new timelines are created with it on. + self.show_time_signatures = show_time_signatures + + @property + def beat_pattern(self) -> str: + """The pattern as the user wrote it, e.g. "10[4] 3".""" + return self._beat_pattern + + @beat_pattern.setter + def beat_pattern(self, value: str | list[int]) -> None: + # Files and callers from before patterns were text pass a list of bar + # lengths, which is always valid pattern text once joined. + text = " ".join(map(str, value)) if isinstance(value, list) else value + result = parse(text) + if not result.is_complete: + logger.error( + f"Invalid beat pattern {text!r} on {self}: {result.error} " + f"Using {DEFAULT_BEAT_PATTERN!r} instead." + ) + text = DEFAULT_BEAT_PATTERN + result = parse(text) + self._beat_pattern = text + self._beat_pattern_bars = result.bars + + @property + def beat_pattern_bars(self) -> list[int]: + """The bar lengths the pattern expands to.""" + return self._beat_pattern_bars @property def default_height(self): @@ -277,7 +478,7 @@ def display_measure_number_period(self): return settings.get("beat_timeline", "display_measure_periodicity") @property - def beats_in_measure(self): + def beats_in_measure(self) -> list[int]: return self._beats_in_measure @beats_in_measure.setter @@ -424,6 +625,240 @@ def is_first_in_measure(self, beat): def clear_cached_metric_positions(self): for beat in self: beat.clear_cached_metric_position() + self._invalidate_meters() + + @property + def beat_units(self) -> list[BeatUnit]: + """Beat units in the order of the beats they are on.""" + beat_index = {beat.id: i for i, beat in enumerate(self)} + return sorted( + self.component_manager.beat_units, + key=lambda u: beat_index.get(u.beat_id, len(beat_index)), + ) + + def get_beat_unit_on_beat(self, beat_id: int) -> BeatUnit | None: + return self.component_manager.get_beat_unit_on_beat(beat_id) + + def _invalidate_meters(self) -> None: + self._measure_meters = None + # Bar lengths or beat units changed: caches built from beats are stale. + self.component_manager.bump_version() + + def on_beat_units_changed(self) -> None: + self._invalidate_meters() + + @property + def measure_meters(self) -> list[MeasureMeter]: + """The meter of every measure, from the beat units in effect.""" + if getattr(self, "_measure_meters", None) is None: + self._measure_meters = self._compute_measure_meters() + return self._measure_meters + + def get_measure_meter(self, measure_index: int) -> MeasureMeter: + return self.measure_meters[measure_index] + + def get_beat_units_in_measure(self, measure_index: int) -> list[BeatUnit]: + """Beat units on the beats of a measure, in beat order.""" + start = self.beats_that_start_measures[measure_index] + beats = self.components[start : start + self.beats_in_measure[measure_index]] + return [ + beat_unit + for beat in beats + if (beat_unit := self.get_beat_unit_on_beat(beat.id)) is not None + ] + + @property + def beat_quarter_lengths(self) -> list[Fraction]: + """How long each beat is, in quarter notes.""" + return [ + meter.quarter_length(value) + for meter in self.measure_meters + for value in meter.beat_values + ][: len(self)] + + def _cached(self, name: str, build: Callable[[], Any]) -> Any: + """ + The value of `build()`, rebuilt only when the component manager's + version has changed since it was last built. + """ + version = self.component_manager.version + cached = self._caches.get(name) + if cached is None or cached[0] != version: + cached = (version, build()) + self._caches[name] = cached + return cached[1] + + @property + def quarter_stamps(self) -> list[Fraction]: + """ + Each beat's position in quarter notes, counted in playback order from + the first beat (pickup included), as Verovio's and the Measure Map's + "qstamp". + """ + return self._cached("quarter_stamps", self._build_quarter_stamps) + + def _build_quarter_stamps(self) -> list[Fraction]: + stamps = [Fraction(0)] + list( + itertools.accumulate(self.beat_quarter_lengths[:-1]) + ) + return stamps[: len(self)] + + def get_quarter_stamp_of_beat(self, beat: Beat) -> Fraction: + return self.quarter_stamps[self.get_beat_index(beat)] + + def _build_time_stamp_table(self) -> TimeStampTable: + times = [beat.time for beat in self.components] + lengths = self.beat_quarter_lengths + seconds_per_quarter = [] + for index in range(len(times)): + # Past the last beat, its length is spread over the last gap. + if index < len(times) - 1: + gap = times[index + 1] - times[index] + else: + gap = times[index] - times[index - 1] + seconds_per_quarter.append(gap / float(lengths[index])) + return TimeStampTable( + times=times, + stamps=[float(stamp) for stamp in self.quarter_stamps], + seconds_per_quarter=seconds_per_quarter, + ) + + def _get_time_stamp_table(self) -> TimeStampTable: + return self._cached("time_stamp_table", self._build_time_stamp_table) + + def get_quarter_stamp_by_time(self, time: float) -> float | None: + """ + The quarter stamp at `time`, interpolated between beats and + extrapolated beyond them. None with fewer than two beats. + """ + if len(self) < 2: + return None + table = self._get_time_stamp_table() + index = max(0, min(bisect(table.times, time) - 1, len(table.times) - 1)) + return ( + table.stamps[index] + + (time - table.times[index]) / table.seconds_per_quarter[index] + ) + + def get_time_by_quarter_stamp(self, stamp: float) -> float | None: + """ + The time of quarter stamp `stamp`, interpolated between beats and + extrapolated beyond them. None with fewer than two beats. + """ + if len(self) < 2: + return None + table = self._get_time_stamp_table() + index = max(0, min(bisect(table.stamps, stamp) - 1, len(table.stamps) - 1)) + return ( + table.times[index] + + (stamp - table.stamps[index]) * table.seconds_per_quarter[index] + ) + + def _compute_measure_meters(self) -> list[MeasureMeter]: + beats = self.components + units_by_beat_id = {u.beat_id: u for u in self.component_manager.beat_units} + starts = getattr(self, "beats_that_start_measures", []) + default_units = parse_units(DEFAULT_UNITS).units + + meters = [] + governing: BeatUnit | None = None + for measure_index, beat_count in enumerate(self.beats_in_measure): + start = starts[measure_index] if measure_index < len(starts) else 0 + in_measure = [ + units_by_beat_id[beat.id] + for beat in beats[start : start + beat_count] + if beat.id in units_by_beat_id + ] + if in_measure: + governing = in_measure[0] + + if governing is None: + denominator, units = DEFAULT_DENOMINATOR, default_units + else: + denominator, units = governing.denominator, governing.unit_values + + meters.append( + MeasureMeter( + denominator=denominator, + units=units, + beat_values=fit_units(units, beat_count), + beat_unit_id=governing.id if governing else None, + starts_here=bool(in_measure), + is_ambiguous=is_fit_ambiguous(units, beat_count), + is_conflicting=len(in_measure) > 1, + is_assumed=governing is None or governing.assumed, + ) + ) + return meters + + def get_export_data(self) -> dict[str, Any]: + # Beat units are kept apart from beats in the component manager, so + # the base export, which iterates it, only sees beats. They have no + # time of their own; their beat's time and measure are added so the + # export reads without looking the beat up. + result = super().get_export_data() + for beat_unit in self.beat_units: + beat = self.get_component(beat_unit.beat_id) + measure_index, _ = self.get_measure_index(self.get_beat_index(beat)) + data = { + attr: getattr(beat_unit, attr) + for attr in beat_unit.get_export_attributes() + } + data["time"] = beat.time + data["measure"] = self.measure_numbers[measure_index] + data["kind"] = beat_unit.KIND.name + result["components"].append(data) + return result + + def set_beat_unit( + self, + measure_index: int, + denominator: int, + units: str, + only_this_measure: bool = False, + ) -> bool: + """ + Sets the beat unit from `measure_index` until the next change, or for + that measure only, restoring the previous values from the next one. + Edits the beat unit starting in that measure if there is one, and + otherwise creates one on the measure's first beat. The values count + as set by the user even when unchanged, which confirms assumed ones. + """ + result = parse_units(units) + if not result.is_valid or not validate_positive_integer(denominator): + return False + if not 0 <= measure_index < self.measure_count: + return False + + previous = self.get_measure_meter(measure_index) + self._put_beat_unit( + measure_index, denominator, format_units(result.units), assumed=False + ) + + next_index = measure_index + 1 + if ( + only_this_measure + and next_index < self.measure_count + and not self.get_measure_meter(next_index).starts_here + ): + self._put_beat_unit( + next_index, + previous.denominator, + format_units(previous.units), + assumed=previous.is_assumed, + ) + + return True + + def _put_beat_unit( + self, measure_index: int, denominator: int, units: str, assumed: bool + ) -> None: + meter = self.get_measure_meter(measure_index) + if meter.starts_here: + beat_id = self.get_component(meter.beat_unit_id).beat_id + else: + beat_id = self[self.beats_that_start_measures[measure_index]].id + self.put_beat_unit_on_beat(beat_id, denominator, units, assumed) def recalculate_measures(self): beat_delta = (len(self)) - sum(self.beats_in_measure) @@ -485,16 +920,19 @@ def get_extension_from_beat_pattern( return extension def _get_beats_in_measure_extension(self, amount: int): - if not self.beat_pattern: + if not self.beat_pattern_bars: raise ValueError("Beat pattern is empty, can't get measure extension.") if self.beats_in_measure: beats_on_starting_measure = self.beats_in_measure[-1] start_index = ( - self.measure_count % len(self.beat_pattern) - 1 + self.measure_count % len(self.beat_pattern_bars) - 1 ) % self.measure_count - if start_index == -1 and beats_on_starting_measure == self.beat_pattern[-1]: + if ( + start_index == -1 + and beats_on_starting_measure == self.beat_pattern_bars[-1] + ): beats_on_starting_measure = 0 start_index = 0 else: @@ -502,7 +940,7 @@ def _get_beats_in_measure_extension(self, amount: int): start_index = 0 return self.get_extension_from_beat_pattern( - self.beat_pattern, + self.beat_pattern_bars, amount, start_index=start_index, beats_on_starting_measure=beats_on_starting_measure, @@ -515,9 +953,9 @@ def extend_beats_in_measure(self, amount: int) -> None: self._beats_in_measure += extension return - bp_index = (self.measure_count % len(self.beat_pattern)) - 1 + bp_index = (self.measure_count % len(self.beat_pattern_bars)) - 1 is_last_measure_complete = ( - self._beats_in_measure[-1] >= self.beat_pattern[bp_index] + self._beats_in_measure[-1] >= self.beat_pattern_bars[bp_index] ) if is_last_measure_complete: @@ -565,7 +1003,69 @@ def update_beats_that_start_measures(self): itertools.accumulate(self.beats_in_measure[:-1]) ) self.beats_that_start_measures_set = set(self.beats_that_start_measures) + self._invalidate_meters() self.update_metric_fraction_dicts() + self._ensure_first_beat_unit() + + def _ensure_first_beat_unit(self, copy_next: bool = True) -> None: + """ + Keeps a beat unit in the first measure, so every measure is governed + by one and every time signature label can be selected and edited. + + When one is missing, e.g. because a pickup was added before the old + first beat, it copies the next beat unit's values (a pickup in a 6/8 + piece starts as 6/8); with no other beat units, or `copy_next` False, + it gets the default. Skipped while beats are being changed in bulk, + which recalculates and calls this once at the end. + """ + manager = self.component_manager + if ( + manager.is_restoring + or not manager.compute_is_first_in_measure + or not len(self) + or not self.beats_in_measure + ): + return + + first_measure = self.components[: self.beats_in_measure[0]] + if any(self.get_beat_unit_on_beat(beat.id) for beat in first_measure): + return + + beat_units = self.beat_units + if copy_next and beat_units: + denominator, units = beat_units[0].denominator, beat_units[0].units + assumed = beat_units[0].assumed + else: + denominator, units, assumed = DEFAULT_DENOMINATOR, DEFAULT_UNITS, True + self.create_component( + ComponentKind.BEAT_UNIT, + beat_id=self[0].id, + denominator=denominator, + units=units, + assumed=assumed, + ) + + def put_beat_unit_on_beat( + self, + beat_id: int | str, + denominator: int, + units: str, + assumed: bool = False, + ) -> None: + """Sets the beat unit on a beat, creating it or editing the one there.""" + beat_unit = self.get_beat_unit_on_beat(beat_id) + if beat_unit is None: + self.create_component( + ComponentKind.BEAT_UNIT, + beat_id=beat_id, + denominator=denominator, + units=units, + assumed=assumed, + ) + else: + self.set_component_data(beat_unit.id, "denominator", denominator) + self.set_component_data(beat_unit.id, "units", units) + self.set_component_data(beat_unit.id, "assumed", assumed) def update_metric_fraction_dicts(self): self.metric_fraction_to_beat_dict = {} @@ -693,6 +1193,52 @@ def refresh_measures(self) -> None: self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + def get_hand_set_bars(self) -> list[int]: + """ + Indices of bars whose length differs from the pattern, i.e. bars set + with "Set amount in measure". The last bar is left out: it may simply + not have all its beats yet. + """ + bars = self.beat_pattern_bars + return [ + i + for i, beats in enumerate(self.beats_in_measure[:-1]) + if beats != bars[i % len(bars)] + ] + + def get_bars_overwritten_by(self, pattern_bars: list[int]) -> list[int]: + """Indices of hand-set bars that applying `pattern_bars` would change.""" + return [ + i + for i in self.get_hand_set_bars() + if self.beats_in_measure[i] != pattern_bars[i % len(pattern_bars)] + ] + + def set_beat_pattern(self, pattern: str) -> bool: + """ + Sets the pattern and recomputes every bar from it, discarding bars set + by hand. Measure numbers are kept by bar position. + """ + _, success = self.set_data("beat_pattern", pattern) + if not success: + return False + + self._beats_in_measure = self.get_extension_from_beat_pattern( + self.beat_pattern_bars, len(self) + ) + # Measure numbers must match the new bar count before measures are + # recalculated, which reads a number for every bar. + if len(self.measure_numbers) < self.measure_count: + self.extend_measure_numbers() + elif len(self.measure_numbers) > self.measure_count: + self.reduce_measure_numbers() + + self.clear_cached_metric_positions() + self.update_beats_that_start_measures() + self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) + post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + return True + def delete_components(self, components: list[TC]) -> None: self._validate_delete_components(components) @@ -710,6 +1256,11 @@ def delete_components(self, components: list[TC]) -> None: # every beat it re-creates afterwards sweep the whole timeline. return + # Deleting the first measure's beat unit resets it to the default, + # rather than copying the next one as a structural change would. + if any(c.KIND == ComponentKind.BEAT_UNIT for c in components): + self._ensure_first_beat_unit(copy_next=False) + self.refresh_measures() class FillMethod(Enum): @@ -734,6 +1285,7 @@ def fill_with_beats(self, method: BeatTimeline.FillMethod, value: int | float): self.recalculate_measures() self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) + post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) def add_measure_zero(self, fraction_of_measure_one: float) -> tuple[bool, str]: if self.measure_count < 2: diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py new file mode 100644 index 000000000..514d69526 --- /dev/null +++ b/tilia/timelines/beat/units.py @@ -0,0 +1,110 @@ +""" +Beat units: what one tapped beat is worth in notation. + +A beat unit is a denominator plus a units pattern, the number of +denominator units each tapped beat in a measure is worth. Units are written +as a "+"-separated list of whole numbers or fractions: "1", "3", "2+3", +"1/2", "1/3". The time signature of a measure is calculated from its beat +unit and number of tapped beats, and is never stored. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction + +DEFAULT_DENOMINATOR = 4 +DEFAULT_UNITS = "1" + +_UNIT_CHARACTERS = set("0123456789/") + + +@dataclass(frozen=True) +class UnitsParseResult: + units: list[Fraction] + error: str = "" + + @property + def is_valid(self) -> bool: + return not self.error + + +def parse_units(text: str) -> UnitsParseResult: + """Parses units text such as "2+3" or "1/3" into fractions.""" + if not text.strip(): + return UnitsParseResult([], "Enter at least one unit.") + + units = [] + for item in text.split("+"): + item = item.strip() + if not item: + return UnitsParseResult([], "Missing unit around '+'.") + not_a_fraction = UnitsParseResult( + [], f"'{item}' is not a whole number or fraction such as 1/2." + ) + # Fraction() also accepts decimals, signs and exponents; units are + # written the way they are in notation. + if not set(item) <= _UNIT_CHARACTERS: + return not_a_fraction + try: + unit = Fraction(item) + except (ValueError, ZeroDivisionError): + return not_a_fraction + if unit <= 0: + return UnitsParseResult([], "Units must be greater than 0.") + units.append(unit) + + return UnitsParseResult(units) + + +def format_units(units: list[Fraction]) -> str: + """Writes units in canonical form, e.g. [2, 3] as "2+3".""" + return "+".join(str(unit) for unit in units) + + +def fit_units(units: list[Fraction], beat_count: int) -> list[Fraction]: + """ + The value of each of `beat_count` tapped beats in a measure. The pattern + is read from its start in every measure, pickups included: a shorter + measure takes its first entries and a longer one repeats it. A pickup + that holds the end of the pattern needs a beat unit of its own. + """ + return [units[i % len(units)] for i in range(beat_count)] + + +def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool: + """ + Whether the measure doesn't hold a whole number of patterns: the pattern + is not uniform and the beat count is not a multiple of its length. One + tap under 2+3 is read as 2, but could be a pickup worth 3. + """ + return len(set(units)) > 1 and beat_count % len(units) != 0 + + +@dataclass(frozen=True) +class MeasureMeter: + """ + The meter a measure ends up with. `beat_unit_id` is the beat unit that + governs the measure, or None where the default applies. `is_assumed` is + True when its values weren't set by the user (see BeatUnit.assumed). + """ + + denominator: int + units: list[Fraction] + beat_values: list[Fraction] + beat_unit_id: int | None + starts_here: bool + is_ambiguous: bool + is_conflicting: bool + is_assumed: bool + + @property + def numerator(self) -> Fraction: + return sum(self.beat_values, Fraction(0)) + + @property + def time_signature(self) -> tuple[Fraction, int]: + return self.numerator, self.denominator + + def quarter_length(self, beat_value: Fraction) -> Fraction: + return beat_value * 4 / self.denominator diff --git a/tilia/timelines/beat/validators.py b/tilia/timelines/beat/validators.py index 5ce3b9623..10a4f3022 100644 --- a/tilia/timelines/beat/validators.py +++ b/tilia/timelines/beat/validators.py @@ -1,2 +1,17 @@ +from tilia.timelines.beat.pattern import parse + + def validate_integer_list(value): return all([isinstance(x, int) for x in value]) + + +def validate_beat_pattern(value: str | list[int]) -> bool: + """ + Accepts pattern text, or a list of bar lengths as stored by files and + callers from before patterns were text. + """ + if isinstance(value, str): + return parse(value).is_complete + if isinstance(value, list): + return bool(value) and all(isinstance(x, int) and x >= 1 for x in value) + return False diff --git a/tilia/timelines/component_kinds.py b/tilia/timelines/component_kinds.py index 1a926719a..2eb4b1a26 100644 --- a/tilia/timelines/component_kinds.py +++ b/tilia/timelines/component_kinds.py @@ -23,11 +23,12 @@ class ComponentKind(Enum): KEY_SIGNATURE = auto() SCORE_ANNOTATION = auto() RANGE = auto() + BEAT_UNIT = auto() def get_component_class_by_kind(kind: ComponentKind) -> type[TimelineComponent]: from tilia.timelines.audiowave.components import AmplitudeBar - from tilia.timelines.beat.components import Beat + from tilia.timelines.beat.components import Beat, BeatUnit from tilia.timelines.harmony.components import Harmony, Mode from tilia.timelines.hierarchy.components import Hierarchy from tilia.timelines.marker.components import Marker @@ -59,6 +60,7 @@ def get_component_class_by_kind(kind: ComponentKind) -> type[TimelineComponent]: ComponentKind.KEY_SIGNATURE: KeySignature, ComponentKind.SCORE_ANNOTATION: ScoreAnnotation, ComponentKind.RANGE: Range, + ComponentKind.BEAT_UNIT: BeatUnit, } return kind_to_class_dict[kind] diff --git a/tilia/ui/cli/timelines/add.py b/tilia/ui/cli/timelines/add.py index a21616b02..3fcb42a07 100644 --- a/tilia/ui/cli/timelines/add.py +++ b/tilia/ui/cli/timelines/add.py @@ -2,13 +2,17 @@ import tilia.errors from tilia.requests import Get, get +from tilia.settings import settings from tilia.timelines.base.timeline import Timeline +from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.hierarchy.timeline import HierarchyTimeline from tilia.timelines.marker.timeline import MarkerTimeline from tilia.timelines.range.timeline import RangeTimeline from tilia.timelines.score.timeline import ScoreTimeline +from tilia.ui.cli import io from tilia.ui.cli.io import output +from tilia.ui.consts import BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT def setup_parser(subparser): @@ -19,6 +23,7 @@ def setup_parser(subparser): epilog=""" Examples: timelines add beat --name "Measures" --beat-pattern 4 + timelines add beat --name "Measures" --beat-pattern 10[4] 3 15[4] timelines add hierarchy --name "Form" timelines add marker --name "Cadences" """, @@ -49,12 +54,13 @@ def setup_parser(subparser): add_subp.add_argument( "--beat-pattern", "-b", - type=int, + type=str, nargs="+", default=None, - help="Pattern as space-separated integers indicating beat count in a measure " - "(beat timelines only). Pattern will be repeated. Pattern '3 4', for " - "instance, will alternate measures of 3 and 4 beats. Defaults to [4].", + help="Beats per measure, separated by spaces (beat timelines only). " + "The pattern repeats after its last measure: '3 4' alternates measures " + "of 3 and 4 beats. Use n[...] to repeat a group: '10[4] 3' is ten " + "measures of 4, then one of 3. Defaults to 4.", ) add_subp.add_argument( "--row-height", @@ -118,8 +124,23 @@ def add(namespace: argparse.Namespace): ) return - output(f"Adding timeline with {kind=}, {name=}") - kwargs = get_kwargs_by_timeline_type(namespace, tl_type) + if kwargs.get("beat_pattern") is not None: + pattern = " ".join(kwargs["beat_pattern"]) + result = parse(pattern) + if not result.is_complete: + io.error(f"Invalid beat pattern '{pattern}': {result.error}") + return + kwargs["beat_pattern"] = pattern + if tl_type is BeatTimeline: + # New beat timelines show time signatures, with room for them. + kwargs["show_time_signatures"] = True + if kwargs.get("height") is None: + kwargs["height"] = ( + settings.get("beat_timeline", "default_height") + + BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + ) + + output(f"Adding timeline with {kind=}, {name=}") get(Get.TIMELINE_COLLECTION).create_timeline(tl_type, **kwargs) diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py new file mode 100644 index 000000000..31761981a --- /dev/null +++ b/tilia/ui/cli/timelines/beat.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import argparse +from collections.abc import Callable +from functools import wraps + +from tilia.requests import Get, get +from tilia.timelines.beat.pattern import parse +from tilia.timelines.beat.timeline import BeatTimeline +from tilia.timelines.beat.units import parse_units +from tilia.ui.cli import io +from tilia.ui.cli.timelines.utils import ( + get_timeline_by_name, + get_timeline_by_ordinal, +) +from tilia.ui.consts import BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT +from tilia.ui.format import format_numerator + + +def setup_parser(subparser): + beat_parser = subparser.add_parser( + "beat", + exit_on_error=False, + help="Beat timeline operations.", + ) + beat_subp = beat_parser.add_subparsers(dest="beat_command", required=True) + + _setup_pattern_parser(beat_subp) + _setup_unit_parser(beat_subp) + _setup_time_signatures_parser(beat_subp) + + +def _setup_pattern_parser(subparser): + pattern_parser = subparser.add_parser( + "pattern", + exit_on_error=False, + help="Beat pattern operations.", + ) + pattern_subp = pattern_parser.add_subparsers(dest="pattern_command", required=True) + + _setup_pattern_set(pattern_subp) + + +def _add_target_args(parser: argparse.ArgumentParser) -> None: + target_group = parser.add_mutually_exclusive_group(required=True) + target_group.add_argument( + "--tl-ordinal", "-o", type=int, help="Target beat timeline ordinal" + ) + target_group.add_argument( + "--tl-name", "-n", type=str, help="Target beat timeline name" + ) + + +def _setup_pattern_set(subparser): + parser = subparser.add_parser( + "set", + exit_on_error=False, + help="Set the beat pattern and recompute every measure from it.", + epilog=""" +Examples: + timelines beat pattern set -o 1 4 + timelines beat pattern set -o 1 10[4] 3 15[4] +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _add_target_args(parser) + parser.add_argument( + "pattern", + type=str, + nargs="+", + help="Beats per measure, separated by spaces. Use n[...] to repeat a group.", + ) + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Overwrite measures set by hand without asking.", + ) + parser.set_defaults(func=set_pattern) + + +def _setup_unit_parser(subparser): + unit_parser = subparser.add_parser( + "unit", + exit_on_error=False, + help="Beat unit operations: what one tapped beat is worth in notation.", + ) + unit_subp = unit_parser.add_subparsers(dest="unit_command", required=True) + + parser = unit_subp.add_parser( + "set", + exit_on_error=False, + help="Set the beat unit of a measure.", + epilog=""" +Examples: + timelines beat unit set -o 1 --measure 12 --denominator 8 --units 3 + timelines beat unit set -o 1 --measure 5 -d 4 -u 2+3 --scope measure +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + _add_target_args(parser) + _add_measure_args(parser) + parser.add_argument( + "--denominator", "-d", type=int, required=True, help="Denominator" + ) + parser.add_argument( + "--units", + "-u", + type=str, + required=True, + help="Units per tap, '+'-separated whole numbers or fractions " + "(e.g. 1, 3, 2+3, 1/2).", + ) + parser.add_argument( + "--scope", + choices=["onward", "measure"], + default="onward", + help="Apply until the next change (default) or to this measure only.", + ) + parser.set_defaults(func=set_unit) + + parser = unit_subp.add_parser( + "remove", + exit_on_error=False, + help="Remove the beat unit that starts at a measure.", + ) + _add_target_args(parser) + _add_measure_args(parser) + parser.set_defaults(func=remove_unit) + + parser = unit_subp.add_parser( + "list", + exit_on_error=False, + help="List the beat units of a beat timeline.", + ) + _add_target_args(parser) + parser.set_defaults(func=list_units) + + +def _setup_time_signatures_parser(subparser): + parser = subparser.add_parser( + "time-signatures", + exit_on_error=False, + help="Show or hide the time signatures of a beat timeline.", + ) + parser.add_argument("action", choices=["show", "hide"]) + _add_target_args(parser) + parser.set_defaults(func=set_time_signatures_visibility) + + +def _add_measure_args(parser: argparse.ArgumentParser) -> None: + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--measure", + "-m", + type=int, + help="Measure number as displayed. Refused if it appears more than once.", + ) + group.add_argument( + "--measure-index", type=int, help="Position of the measure (0-based)." + ) + + +def _resolve_measure( + timeline: BeatTimeline, namespace: argparse.Namespace +) -> int | None: + if namespace.measure_index is not None: + if 0 <= namespace.measure_index < timeline.measure_count: + return namespace.measure_index + io.error( + f"No measure at index {namespace.measure_index} (timeline has " + f"{timeline.measure_count} measure(s))." + ) + return None + + matches = [ + i + for i, number in enumerate(timeline.measure_numbers) + if number == namespace.measure + ] + if not matches: + io.error(f"No measure numbered {namespace.measure}.") + return None + if len(matches) > 1: + io.error( + f"Measure number {namespace.measure} appears more than once " + f"(indices {', '.join(map(str, matches))}). Use --measure-index." + ) + return None + return matches[0] + + +def _resolve_timeline(namespace: argparse.Namespace) -> BeatTimeline | None: + if namespace.tl_ordinal is not None: + success, tl = get_timeline_by_ordinal(namespace.tl_ordinal) + else: + success, tl = get_timeline_by_name(namespace.tl_name) + if not success or tl is None: + return None + if not isinstance(tl, BeatTimeline): + io.error(f"Timeline {tl} is not a beat timeline.") + return None + return tl + + +def with_timeline(func: Callable) -> Callable: + """Resolve --tl-ordinal/--tl-name to a BeatTimeline; abort on failure. + + Wrapped functions take (timeline, namespace) instead of (namespace). + """ + + @wraps(func) + def wrapper(namespace: argparse.Namespace) -> None: + timeline = _resolve_timeline(namespace) + if timeline is None: + return + func(timeline, namespace) + + return wrapper + + +@with_timeline +def set_pattern(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + pattern = " ".join(namespace.pattern) + result = parse(pattern) + if not result.is_complete: + io.error(f"Invalid beat pattern '{pattern}': {result.error}") + return + + overwritten = timeline.get_bars_overwritten_by(result.bars) + if overwritten and not namespace.yes: + measures = ", ".join(str(i + 1) for i in overwritten) + if not io.ask_yes_or_no( + f"This pattern will overwrite measures set by hand (measures " + f"{measures}). Continue?", + default=False, + ): + return + + timeline.set_beat_pattern(pattern) + + +@with_timeline +def set_unit(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + measure_index = _resolve_measure(timeline, namespace) + if measure_index is None: + return + result = parse_units(namespace.units) + if not result.is_valid: + io.error(f"Invalid units '{namespace.units}': {result.error}") + return + if namespace.denominator < 1: + io.error(f"Invalid denominator: {namespace.denominator}.") + return + + timeline.set_beat_unit( + measure_index, + namespace.denominator, + namespace.units, + only_this_measure=namespace.scope == "measure", + ) + + +@with_timeline +def remove_unit(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + measure_index = _resolve_measure(timeline, namespace) + if measure_index is None: + return + meter = timeline.get_measure_meter(measure_index) + if not meter.starts_here: + io.error("No beat unit starts at that measure.") + return + timeline.delete_components([timeline.get_component(meter.beat_unit_id)]) + + +@with_timeline +def list_units(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + beat_units = timeline.beat_units + if not beat_units: + io.output(f"Timeline {timeline.name!r} has no beat units.") + return + + headers = ["measure", "beat time", "denominator", "units", "time signature"] + data = [] + for beat_unit in beat_units: + beat = timeline.get_component(beat_unit.beat_id) + measure_index, _ = timeline.get_measure_index(timeline.get_beat_index(beat)) + numerator, denominator = timeline.get_measure_meter( + measure_index + ).time_signature + time_signature = f"{format_numerator(numerator)}/{denominator}" + if beat_unit.assumed: + time_signature += " (assumed)" + data.append( + ( + str(timeline.measure_numbers[measure_index]), + str(beat.time), + str(beat_unit.denominator), + beat_unit.units, + time_signature, + ) + ) + io.tabulate(headers, data, title=f"Beat units in {timeline.name!r}") + + +@with_timeline +def set_time_signatures_visibility( + timeline: BeatTimeline, namespace: argparse.Namespace +) -> None: + show = namespace.action == "show" + if show == timeline.show_time_signatures: + return + collection = get(Get.TIMELINE_COLLECTION) + height_change = BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + if not show: + height_change = -height_change + collection.set_timeline_data(timeline.id, "show_time_signatures", show) + collection.set_timeline_data(timeline.id, "height", timeline.height + height_change) diff --git a/tilia/ui/cli/timelines/setup.py b/tilia/ui/cli/timelines/setup.py index 1577c979b..ee951c467 100644 --- a/tilia/ui/cli/timelines/setup.py +++ b/tilia/ui/cli/timelines/setup.py @@ -1,4 +1,5 @@ from .add import setup_parser as setup_add_parser +from .beat import setup_parser as setup_beat_parser from .imp import setup_parser as setup_import_parser from .list import setup_parser as setup_list_parser from .move import setup_parser as setup_move_parser @@ -16,6 +17,7 @@ def setup_parser(subparsers): tl_subparser = tl.add_subparsers(dest="timeline_command") setup_add_parser(tl_subparser) + setup_beat_parser(tl_subparser) setup_import_parser(tl_subparser) setup_list_parser(tl_subparser) setup_move_parser(tl_subparser) diff --git a/tilia/ui/consts.py b/tilia/ui/consts.py index f6e53a5f3..e819be85e 100644 --- a/tilia/ui/consts.py +++ b/tilia/ui/consts.py @@ -1 +1,8 @@ TINT_FACTOR_ON_SELECTION = 130 +# Beat timeline time signatures, kept free of Qt so the CLI can use them: +# the height of one row of glyphs, and the height the timeline grows by while +# they are shown (a numerator and a denominator row, plus a margin). +BEAT_TIMELINE_TIME_SIGNATURE_DIGIT_HEIGHT = 14 +BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT = ( + 2 * BEAT_TIMELINE_TIME_SIGNATURE_DIGIT_HEIGHT + 6 +) diff --git a/tilia/ui/dialog_manager.py b/tilia/ui/dialog_manager.py index 7dfeb32c5..f586401dd 100644 --- a/tilia/ui/dialog_manager.py +++ b/tilia/ui/dialog_manager.py @@ -25,7 +25,9 @@ from tilia.ui.timelines.beat.dialogs import ( ask_beat_timeline_fill_method, ask_for_beat_pattern, + ask_for_beat_unit, ) +from tilia.ui.windows.beat_unit import ask_for_beat_unit_scope class DialogManager: @@ -43,6 +45,8 @@ def __init__(self): (Get.FROM_USER_YES_OR_NO, ask_yes_or_no), (Get.FROM_USER_COLOR, ask_for_color), (Get.FROM_USER_BEAT_PATTERN, ask_for_beat_pattern), + (Get.FROM_USER_BEAT_UNIT, ask_for_beat_unit), + (Get.FROM_USER_BEAT_UNIT_SCOPE, ask_for_beat_unit_scope), (Get.FROM_USER_MEDIA_PATH, ask_for_media_file), (Get.FROM_USER_MODE_PARAMS, ask_for_mode_params), (Get.FROM_USER_HARMONY_PARAMS, ask_for_harmony_params), diff --git a/tilia/ui/format.py b/tilia/ui/format.py index ac4223890..0d76f538a 100644 --- a/tilia/ui/format.py +++ b/tilia/ui/format.py @@ -1,3 +1,16 @@ +from fractions import Fraction + + +def format_numerator(numerator: Fraction) -> str: + """A whole number, or a whole number plus a fraction: "4", "4 1/3", "1/2".""" + whole = numerator.numerator // numerator.denominator + remainder = numerator - whole + if not remainder: + return str(whole) + fraction = f"{remainder.numerator}/{remainder.denominator}" + return f"{whole} {fraction}" if whole else fraction + + def format_media_time(audio_time: float | str) -> str: seconds_and_fraction = f"{audio_time % 60:.1f}".zfill(4) minutes = int(float(audio_time) // 60) diff --git a/tilia/ui/strings.py b/tilia/ui/strings.py index 557632cf0..0130c5c4f 100644 --- a/tilia/ui/strings.py +++ b/tilia/ui/strings.py @@ -1,10 +1,37 @@ VALIDATE_BOUNDED_INTEGER = "{0} must be a value between {1} and {2}." BEAT_PATTERN_DIALOG_TITLE = "Beat pattern" -BEAT_PATTERN_DIALOG_PROMPT = "Insert initial beat pattern (can be changed later):" -BEAT_PATTERN_DIALOG_TOOLTIP = """Empty spaces separate measures, line breaks are ignored. -Examples: - - '5' = 5 beats per measure; - - '4 3 3' = a cycle of 4, then 3, then 3 beats per measure.""" +BEAT_PATTERN_DIALOG_PROMPT = ( + "Beats per bar, separated by spaces. The pattern repeats after its last bar." +) +BEAT_PATTERN_DIALOG_TOOLTIP = """Examples: + - '4' = 4 beats in every bar; + - '4 3 3' = a cycle of 4, then 3, then 3 beats per bar; + - '10[4] 3 15[4]' = 10 bars of 4, a bar of 3, then 15 bars of 4; + - '2[4 3[3]]' = 4 3 3 3 4 3 3 3.""" +BEAT_PATTERN_OVERWRITE_TITLE = "Overwrite bars set by hand" +BEAT_PATTERN_OVERWRITE_PROMPT = ( + "This pattern will overwrite bars set by hand (bars {})." + "\nDo you want to continue?" +) +BEAT_UNIT_DIALOG_TITLE = "Beat unit" +BEAT_UNIT_DENOMINATOR_LABEL = "Denominator:" +BEAT_UNIT_UNITS_LABEL = "Units per tap:" +BEAT_UNIT_UNITS_TOOLTIP = """How many denominator units each tapped beat is worth. +Separate taps in a measure with '+'. Examples: + - '1' = one unit per tap (4/4 tapped in 4); + - '3' = three units per tap (6/8 tapped in 2); + - '1/2' = half a unit per tap (2/2 tapped in 4); + - '2+3' = two units, then three (5/4 tapped as 2+3).""" +BEAT_UNIT_SCOPE_ONWARD = "From this measure until the next change" +BEAT_UNIT_SCOPE_MEASURE = "This measure only" +BEAT_UNIT_SCOPE_PROMPT = "Where should this change apply?" +BEAT_UNIT_CONFLICT_TOOLTIP = ( + "This measure has more than one beat unit. The first one applies." +) +BEAT_UNIT_AMBIGUOUS_TOOLTIP = "{} taps under {}: treated as {}." +BEAT_UNIT_ASSUMED_TOOLTIP = ( + "Assumed from the tapped beats. Set the beat unit to confirm." +) PROMPT_CREATE_LEVEL_BELOW_TITLE = "Create level below" PROMPT_CREATE_LEVEL_BELOW_MESSAGE = ( "Node is at already lowest level." diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py new file mode 100644 index 000000000..66afc1706 --- /dev/null +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable + +from PySide6.QtCore import QLineF, QPointF, Qt, QTimer +from PySide6.QtGui import QGuiApplication, QPen +from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsScene + +from tilia.requests import Get, Post, get, post +from tilia.timelines.beat.units import format_units, parse_units +from tilia.ui import commands +from tilia.ui.coords import time_x_converter +from tilia.ui.timelines.base.element import TimelineUIElement +from tilia.ui.timelines.beat.context_menu import BeatUnitContextMenu +from tilia.ui.timelines.beat.time_signature import ( + SELECTED_COLOR, + TIME_SIGNATURE_Y, + TimeSignatureLabel, + TimeSignatureLabelSpec, +) +from tilia.ui.windows.beat_unit import MAX_DENOMINATOR, UnitsValidator +from tilia.ui.windows.inspect import InspectRowKind + +if TYPE_CHECKING: + from tilia.ui.timelines.beat.timeline import BeatTimelineUI + +# How often to check whether the mouse has been released. +_MOUSE_RELEASE_POLL_MS = 20 + + +def _call_once_mouse_is_released(callback: Callable[[], None]) -> None: + """Calls `callback` from the event loop, once no mouse button is held.""" + + def attempt() -> None: + if QGuiApplication.mouseButtons() != Qt.MouseButton.NoButton: + QTimer.singleShot(_MOUSE_RELEASE_POLL_MS, attempt) + return + callback() + + QTimer.singleShot(0, attempt) + + +class BeatUnitUI(TimelineUIElement): + """ + A beat unit has no mark of its own: it is shown, and selected, through + the time signature labels of the measures it governs. + """ + + UPDATE_TRIGGERS = ["denominator", "units", "beat_id", "assumed"] + CONTEXT_MENU_CLASS = BeatUnitContextMenu + + INSPECTOR_FIELDS = [ + ( + "Denominator", + InspectRowKind.SPIN_BOX_ON_FINISH, + lambda: {"min": 1, "max": MAX_DENOMINATOR}, + ), + ( + "Units per tap", + InspectRowKind.SINGLE_LINE_EDIT_ON_FINISH, + lambda: {"validator": UnitsValidator}, + ), + ("Starts at measure", InspectRowKind.LABEL, None), + ("Assumed", InspectRowKind.LABEL, None), + ] + FIELD_NAMES_TO_ATTRIBUTES = { + "Denominator": "denominator", + "Units per tap": "units", + } + + def __init__( + self, + id: int, + timeline_ui: BeatTimelineUI, + scene: QGraphicsScene, + get_data: Callable[[str], Any], + set_data: Callable[[str, Any], None], + **_, + ): + super().__init__( + id=id, + timeline_ui=timeline_ui, + scene=scene, + get_data=get_data, + set_data=set_data, + ) + self.labels: list[TimeSignatureLabel] = [] + self.hover_line: QGraphicsLineItem | None = None + self._is_selected = False + # The measure of the label last clicked, which beat unit commands + # apply to. + self.active_measure_index: int | None = None + # An inspector edit waiting for the mouse to be released, so the same + # edit reported twice asks once. + self._pending_edit: tuple[str, Any] | None = None + + def set_labels(self, specs: list[TimeSignatureLabelSpec]) -> None: + self._remove_labels() + for spec in specs: + label = TimeSignatureLabel( + spec, self.timeline_ui.time_signature_pixmaps, self.on_label_hover + ) + self.scene.addItem(label.body) + self.scene.addItem(label) + self.labels.append(label) + self._update_highlight() + + def _remove_labels(self) -> None: + self._remove_hover_line() + for label in self.labels: + self.scene.removeItem(label.body) + self.scene.removeItem(label) + self.labels = [] + + def get_label(self, measure_index: int) -> TimeSignatureLabel | None: + return next( + (lb for lb in self.labels if lb.measure_index == measure_index), None + ) + + @property + def beat_x(self) -> float | None: + beat = self.timeline_ui.timeline.get_component(self.get_data("beat_id")) + return time_x_converter.get_x_by_time(beat.time) if beat else None + + def on_label_hover(self, label: TimeSignatureLabel, entered: bool) -> None: + self._remove_hover_line() + if not entered or self.beat_x is None: + return + rect = label.rect() + start = QPointF(rect.center().x(), TIME_SIGNATURE_Y) + end = QPointF(self.beat_x, self.timeline_ui.beat_bottom_y) + self.hover_line = QGraphicsLineItem(QLineF(start, end)) + pen = QPen(SELECTED_COLOR, 1) + pen.setStyle(Qt.PenStyle.DotLine) + self.hover_line.setPen(pen) + self.scene.addItem(self.hover_line) + + def _remove_hover_line(self) -> None: + if self.hover_line is not None: + self.scene.removeItem(self.hover_line) + self.hover_line = None + + def child_items(self) -> list: + items = [item for label in self.labels for item in label.items()] + if self.hover_line is not None: + items.append(self.hover_line) + return items + + def selection_triggers(self) -> list: + return list(self.labels) + + def left_click_triggers(self) -> list: + return list(self.labels) + + def right_click_triggers(self) -> list: + return list(self.labels) + + def double_left_click_triggers(self) -> list: + return list(self.labels) + + def _set_active_label(self, item) -> None: + if isinstance(item, TimeSignatureLabel): + self.active_measure_index = item.measure_index + self._update_highlight() + + def _get_highlighted_label(self) -> TimeSignatureLabel | None: + """ + The label the selection is shown on: the one last clicked, or, if it + is gone or none was clicked, the one where the beat unit starts. + """ + if not self.labels: + return None + if self.active_measure_index is not None: + label = self.get_label(self.active_measure_index) + if label is not None: + return label + return self.get_label(self.start_measure_index) or self.labels[0] + + def _update_highlight(self) -> None: + highlighted = self._get_highlighted_label() if self._is_selected else None + for label in self.labels: + label.set_selected(label is highlighted) + + def on_left_click(self, item) -> None: + self._set_active_label(item) + + def on_double_left_click(self, item) -> None: + self._set_active_label(item) + commands.execute("timeline.beat.set_beat_unit") + + def on_right_click(self, x, y, item) -> None: + self._set_active_label(item) + super().on_right_click(x, y, item) + + @property + def start_measure_index(self) -> int: + timeline = self.timeline_ui.timeline + beat = timeline.get_component(self.get_data("beat_id")) + return timeline.get_measure_index(timeline.get_beat_index(beat))[0] + + def get_inspector_dict(self) -> dict: + timeline = self.timeline_ui.timeline + return { + "Denominator": self.get_data("denominator"), + "Units per tap": self.get_data("units"), + "Starts at measure": str( + timeline.measure_numbers[self.start_measure_index] + ), + "Assumed": "Yes" if self.get_data("assumed") else "No", + } + + def on_inspector_edit(self, field_name: str, value) -> None: + """ + Applies an inspector edit to the measure whose label was clicked, + asking first whether it applies there only or until the next change. + """ + attr = self.FIELD_NAMES_TO_ATTRIBUTES[field_name] + if attr == "units": + value = format_units(parse_units(value).units) + if value == self.get_data(attr) or self._pending_edit == (attr, value): + return + + measure_index = ( + self.active_measure_index + if self.active_measure_index is not None + else self.start_measure_index + ) + self._pending_edit = (attr, value) + # Editing often finishes because the user clicked elsewhere. The + # scope question is modal, and opening it inside that mouse press + # keeps the view from ever seeing the release (its selection box + # stays up). Ask once the mouse is released instead. + _call_once_mouse_is_released( + lambda: self._apply_inspector_edit(attr, value, measure_index) + ) + + def _apply_inspector_edit(self, attr: str, value, measure_index: int) -> None: + try: + if self.id not in self.timeline_ui.id_to_element: + return # deleted in the meantime + timeline = self.timeline_ui.timeline + if measure_index >= timeline.measure_count: + return + values = { + "denominator": self.get_data("denominator"), + "units": self.get_data("units"), + } | {attr: value} + + only_this_measure = False + next_index = measure_index + 1 + if ( + next_index < timeline.measure_count + and not timeline.get_measure_meter(next_index).starts_here + ): + accepted, only_this_measure = get(Get.FROM_USER_BEAT_UNIT_SCOPE) + if not accepted: + self._refresh_inspector() + return + + timeline.set_beat_unit( + measure_index, + values["denominator"], + values["units"], + only_this_measure, + ) + post(Post.APP_STATE_RECORD, "beat unit edit via inspect") + self._refresh_inspector() + finally: + self._pending_edit = None + + def _refresh_inspector(self) -> None: + # The click that ended the edit may have selected something else, + # whose inspector must not be replaced by this one. + if self.is_selected(): + self.timeline_ui.post_inspectable_selected_event(self) + + def on_select(self) -> None: + self._is_selected = True + self._update_highlight() + + def on_deselect(self) -> None: + self._is_selected = False + self._update_highlight() + + def update_position(self) -> None: + # Labels are positioned by BeatTimelineUI.update_time_signatures, + # once for all beat units, when the timeline's times change. + pass + + def update_denominator(self) -> None: + self.timeline_ui.update_time_signatures() + + def update_units(self) -> None: + self.timeline_ui.update_time_signatures() + + def update_beat_id(self) -> None: + self.timeline_ui.update_time_signatures() + + def update_assumed(self) -> None: + self.timeline_ui.update_time_signatures() + + def delete(self) -> None: + self._remove_labels() + super().delete() diff --git a/tilia/ui/timelines/beat/context_menu.py b/tilia/ui/timelines/beat/context_menu.py index 1db7143d1..d7e3cfc9a 100644 --- a/tilia/ui/timelines/beat/context_menu.py +++ b/tilia/ui/timelines/beat/context_menu.py @@ -1,3 +1,4 @@ +from tilia.ui.commands import get_qaction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import ( TimelineUIContextMenu, @@ -14,6 +15,7 @@ class BeatContextMenu(TimelineUIElementContextMenu): (MenuItemKind.COMMAND, "timeline.beat.reset_measure_number"), (MenuItemKind.COMMAND, "timeline.beat.distribute"), (MenuItemKind.COMMAND, "timeline.beat.set_amount_in_measure"), + (MenuItemKind.COMMAND, "timeline.beat.set_beat_unit"), (MenuItemKind.SEPARATOR, None), (MenuItemKind.COMMAND, "timeline.component.copy"), (MenuItemKind.COMMAND, "timeline.component.cut"), @@ -23,6 +25,35 @@ class BeatContextMenu(TimelineUIElementContextMenu): ] +class BeatUnitContextMenu(TimelineUIElementContextMenu): + name = "Beat unit" + items = [ + (MenuItemKind.COMMAND, "timeline.beat.set_beat_unit"), + (MenuItemKind.SEPARATOR, None), + (MenuItemKind.COMMAND, "timeline.component.delete"), + ] + + def __init__(self, element): + super().__init__(element) + # A measure can hold several beat units after its barlines move. Only + # the first has a label, so the others are removed from here. + measure_index = element.active_measure_index + timeline = element.timeline_ui.timeline + if ( + measure_index is not None + and measure_index < timeline.measure_count + and timeline.get_measure_meter(measure_index).is_conflicting + ): + self.insertAction( + self.actions()[1], + get_qaction("timeline.beat.remove_other_beat_units"), + ) + + class BeatTimelineUIContextMenu(TimelineUIContextMenu): name = "Beat timeline" - items = [(MenuItemKind, "timeline.set_name")] + items = [ + (MenuItemKind, "timeline.set_name"), + (MenuItemKind.COMMAND, "timeline.beat.set_pattern"), + (MenuItemKind.COMMAND, "timeline.beat.toggle_time_signatures"), + ] diff --git a/tilia/ui/timelines/beat/dialogs.py b/tilia/ui/timelines/beat/dialogs.py index 9c0f55fe4..9c5f08c46 100644 --- a/tilia/ui/timelines/beat/dialogs.py +++ b/tilia/ui/timelines/beat/dialogs.py @@ -1,24 +1,25 @@ -import tilia.errors -from tilia.ui.windows.beat_pattern import AskBeatPattern -from tilia.ui.windows.fill_beat_timeline import BeatTimeline, FillBeatTimeline +from collections.abc import Callable +from tilia.ui.windows.beat_pattern import BeatPatternDialog +from tilia.ui.windows.beat_unit import BeatUnitDialog, BeatUnitInput +from tilia.ui.windows.fill_beat_timeline import BeatTimeline, FillBeatTimeline -def ask_for_beat_pattern(): - def validate_result(): - if not result: - return False - return all([x.isnumeric() for x in result]) - result, accept = AskBeatPattern().ask() +def ask_for_beat_pattern( + initial_text: str = "", + beat_count: int | None = None, + get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, +) -> tuple[bool, str]: + return BeatPatternDialog.ask(initial_text, beat_count, get_overwritten_bars) - if not accept: - return False, [] - elif not validate_result(): - tilia.errors.display(tilia.errors.BEAT_PATTERN_ERROR) - return ask_for_beat_pattern() - else: - return True, list(map(int, result)) +def ask_for_beat_unit( + denominator: int, + units: str, + beat_count: int | None = None, + show_scope: bool = True, +) -> tuple[bool, BeatUnitInput | None]: + return BeatUnitDialog.ask(denominator, units, beat_count, show_scope) def ask_beat_timeline_fill_method() -> tuple[ diff --git a/tilia/ui/timelines/beat/element.py b/tilia/ui/timelines/beat/element.py index 002d87076..08a9cc923 100644 --- a/tilia/ui/timelines/beat/element.py +++ b/tilia/ui/timelines/beat/element.py @@ -11,7 +11,7 @@ from tilia.ui.timelines.base.element import TimelineUIElement from ...coords import time_x_converter -from ...format import format_media_time +from ...format import format_media_time, format_numerator from ...windows.inspect import InspectRowKind from ..copy_paste import CopyAttributes from ..cursors import CursorMixIn @@ -41,6 +41,7 @@ class BeatUI(TimelineUIElement): ("Time", InspectRowKind.LABEL, None), ("Measure", InspectRowKind.LABEL, None), ("Beat", InspectRowKind.LABEL, None), + ("Time signature", InspectRowKind.LABEL, None), ] FIELD_NAMES_TO_ATTRIBUTES: dict[str, str] = {} @@ -118,6 +119,7 @@ def child_items(self): def update_time(self): self.update_position() + self.timeline_ui.update_time_signatures() def update_position(self): self.body.set_position(self.x, self.height) @@ -201,9 +203,17 @@ def on_deselect(self) -> None: self.body.on_deselect() def get_inspector_dict(self) -> dict: + timeline = self.timeline_ui.timeline + beat = self.tl_component + measure_index, _ = timeline.get_measure_index(timeline.get_beat_index(beat)) + meter = timeline.get_measure_meter(measure_index) + time_signature = f"{format_numerator(meter.numerator)}/{meter.denominator}" + if meter.is_assumed: + time_signature += " (assumed)" return { "Time": format_media_time(self.time), "Measure": str(self.get_data("measure")), + "Time signature": time_signature, } diff --git a/tilia/ui/timelines/beat/time_signature.py b/tilia/ui/timelines/beat/time_signature.py new file mode 100644 index 000000000..cd35d4a5c --- /dev/null +++ b/tilia/ui/timelines/beat/time_signature.py @@ -0,0 +1,130 @@ +"""Time signature labels drawn on the beat timeline.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum, auto + +from PySide6.QtCore import QRectF, Qt +from PySide6.QtGui import QColor, QPen +from PySide6.QtWidgets import QGraphicsRectItem, QGraphicsSceneHoverEvent + +from tilia.requests import Get, get +from tilia.ui.consts import ( + BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT, + BEAT_TIMELINE_TIME_SIGNATURE_DIGIT_HEIGHT, +) +from tilia.ui.timelines.time_signature_glyphs import TimeSignatureBody + +# Vertical layout, below the beats and their measure numbers. +TIME_SIGNATURE_Y = 34 +TIME_SIGNATURE_DIGIT_HEIGHT = BEAT_TIMELINE_TIME_SIGNATURE_DIGIT_HEIGHT +# Height added to the timeline while time signatures are shown. +TIME_SIGNATURE_BAND_HEIGHT = BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + +CONFLICT_COLOR = QColor("#cc0000") +AMBIGUOUS_COLOR = QColor("#d98c00") +SELECTED_COLOR = QColor("#1f77b4") +# Time signatures the user hasn't confirmed are drawn faded. +ASSUMED_OPACITY = 0.5 + + +class LabelState(Enum): + NORMAL = auto() + AMBIGUOUS = auto() + CONFLICTING = auto() + + +@dataclass(frozen=True) +class TimeSignatureLabelSpec: + measure_index: int + x: float + numerator: str + denominator: str + state: LabelState + tooltip: str = "" + assumed: bool = False + + +class TimeSignatureLabel(QGraphicsRectItem): + """ + A clickable box around a time signature. The box sits above the glyphs + so clicks land on it, and outlines the label when its beat unit is + selected. + """ + + def __init__( + self, + spec: TimeSignatureLabelSpec, + pixmaps: dict, + on_hover: Callable[[TimeSignatureLabel, bool], None] | None = None, + ): + super().__init__() + self.spec = spec + self.on_hover = on_hover + self.body = TimeSignatureBody( + 0, + 0, + [(spec.numerator, spec.denominator)], + TIME_SIGNATURE_DIGIT_HEIGHT, + pixmaps, + self._get_color(spec.state), + ) + self.body.setZValue(self.zValue() - 1) + if spec.assumed: + self.body.setOpacity(ASSUMED_OPACITY) + self.setBrush(Qt.BrushStyle.NoBrush) + self.set_selected(False) + if spec.tooltip: + self.setToolTip(spec.tooltip) + self.setAcceptHoverEvents(True) + self.set_position(spec.x) + + @staticmethod + def _get_color(state: LabelState) -> QColor | None: + return { + LabelState.NORMAL: None, + LabelState.AMBIGUOUS: AMBIGUOUS_COLOR, + LabelState.CONFLICTING: CONFLICT_COLOR, + }[state] + + @property + def measure_index(self) -> int: + return self.spec.measure_index + + def set_position(self, x: float) -> None: + # Centered on the measure's first beat, but kept out of the view's + # margins, where it would be clipped. + width = self.body.width + left = x - width / 2 + left = min(left, get(Get.RIGHT_MARGIN_X) - width) + left = max(left, get(Get.LEFT_MARGIN_X)) + self.body.set_position(left, TIME_SIGNATURE_Y) + self.setRect( + QRectF( + left - 1, + TIME_SIGNATURE_Y - 1, + self.body.width + 2, + 2 * TIME_SIGNATURE_DIGIT_HEIGHT + 2, + ) + ) + + def set_selected(self, selected: bool) -> None: + if selected: + self.setPen(QPen(SELECTED_COLOR, 1)) + else: + self.setPen(Qt.PenStyle.NoPen) + + def items(self) -> list: + return [self, self.body] + + def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent) -> None: + if self.on_hover: + self.on_hover(self, True) + super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent) -> None: + if self.on_hover: + self.on_hover(self, False) + super().hoverLeaveEvent(event) diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index e5469c4e1..0f9669fda 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -1,17 +1,38 @@ +from __future__ import annotations + import copy +from collections import defaultdict -from tilia.requests import Get, Post, get, listen +import tilia.errors +from tilia.requests import Get, Post, get, listen, post +from tilia.settings import settings +from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.timeline import BeatTimeline +from tilia.timelines.beat.units import MeasureMeter, format_units from tilia.timelines.component_kinds import ComponentKind from tilia.ui import commands +from tilia.ui.coords import time_x_converter +from tilia.ui.format import format_numerator from tilia.ui.menus import BeatMenu from tilia.ui.strings import ( + BEAT_PATTERN_OVERWRITE_PROMPT, + BEAT_PATTERN_OVERWRITE_TITLE, BEAT_TIMELINE_DELETE_EXISTING_BEATS_PROMPT, BEAT_TIMELINE_FILL_TITLE, + BEAT_UNIT_AMBIGUOUS_TOOLTIP, + BEAT_UNIT_ASSUMED_TOOLTIP, + BEAT_UNIT_CONFLICT_TOOLTIP, ) from tilia.ui.timelines.base.timeline import TimelineUI, with_elements +from tilia.ui.timelines.beat.beat_unit import BeatUnitUI from tilia.ui.timelines.beat.context_menu import BeatTimelineUIContextMenu from tilia.ui.timelines.beat.element import BeatUI +from tilia.ui.timelines.beat.time_signature import ( + TIME_SIGNATURE_BAND_HEIGHT, + LabelState, + TimeSignatureLabel, + TimeSignatureLabelSpec, +) from tilia.ui.timelines.beat.toolbar import BeatTimelineToolbar from tilia.ui.timelines.collection.collection import ( TimelineSelector, @@ -21,12 +42,13 @@ from tilia.ui.timelines.copy_paste import ( get_copy_data_from_element, ) +from tilia.ui.timelines.time_signature_glyphs import load_time_signature_pixmaps class BeatTimelineUI(TimelineUI): CONTEXT_MENU_CLASS = BeatTimelineUIContextMenu TOOLBAR_CLASS = BeatTimelineToolbar - ELEMENT_CLASS = BeatUI + ELEMENT_CLASS = [BeatUI, BeatUnitUI] ACCEPTS_HORIZONTAL_ARROWS = True UPDATE_TRIGGERS = TimelineUI.UPDATE_TRIGGERS + [ "beat_pattern", @@ -34,11 +56,16 @@ class BeatTimelineUI(TimelineUI): "beats_that_start_measures", "measures_to_force_display", "beats_in_measure", + "show_time_signatures", ] timeline_class = BeatTimeline menu_class = BeatMenu def __init__(self, *args, **kwargs): + # Time signatures governed by no beat unit (the default) belong to + # no element, so they are kept here. + self._default_time_signature_labels: list[TimeSignatureLabel] = [] + self._time_signature_pixmaps = None super().__init__(*args, **kwargs) listen( self, @@ -97,6 +124,283 @@ def register_commands(cls, collection: TimelineUIs): text="Set amount in measure", ) + commands.register( + "timeline.beat.set_pattern", cls.on_set_pattern, text="Set beat pattern" + ) + + cls.register_timeline_command( + collection, + "set_beat_unit", + cls.on_set_beat_unit, + TimelineSelector.SELECTED, + text="Set beat unit", + ) + + cls.register_timeline_command( + collection, + "remove_other_beat_units", + cls.on_remove_other_beat_units, + TimelineSelector.SELECTED, + text="Remove other beat units in this measure", + ) + + commands.register( + "timeline.beat.toggle_time_signatures", + cls.on_toggle_time_signatures, + text="Show or hide time signatures", + ) + + @property + def beat_uis(self) -> list[BeatUI]: + return [e for e in self.elements if isinstance(e, BeatUI)] + + @property + def beat_unit_uis(self) -> list[BeatUnitUI]: + return [e for e in self.elements if isinstance(e, BeatUnitUI)] + + # Beat unit elements are only reachable through their labels; iterating, + # indexing and counting the timeline UI concern its beats. + def __iter__(self): + return iter(self.beat_uis) + + def __getitem__(self, item): + return self.beat_uis[item] + + def __len__(self): + return len(self.beat_uis) + + def get_next_element(self, element): + beat_uis = self.beat_uis + if element not in beat_uis: + return None + index = beat_uis.index(element) + return beat_uis[index + 1] if index + 1 < len(beat_uis) else None + + def get_previous_element(self, element): + beat_uis = self.beat_uis + if element not in beat_uis: + return None + index = beat_uis.index(element) + return beat_uis[index - 1] if index > 0 else None + + def on_horizontal_arrow_press(self, arrow: str): + if any(isinstance(e, BeatUnitUI) for e in self.selected_elements): + return + super().on_horizontal_arrow_press(arrow) + + @property + def time_signature_pixmaps(self) -> dict: + if self._time_signature_pixmaps is None: + self._time_signature_pixmaps = load_time_signature_pixmaps() + return self._time_signature_pixmaps + + @property + def beat_bottom_y(self) -> float: + return BeatUI.HEIGHT_TALL + + def on_timeline_component_created(self, kind, id, get_data, set_data): + element = super().on_timeline_component_created(kind, id, get_data, set_data) + if kind == ComponentKind.BEAT_UNIT: + self.update_time_signatures() + return element + + def on_timeline_component_deleted(self, id: int): + was_beat_unit = isinstance(self.id_to_element.get(id), BeatUnitUI) + super().on_timeline_component_deleted(id) + if was_beat_unit: + self.update_time_signatures() + + def update_time_on_elements(self) -> None: + super().update_time_on_elements() + # Time signature labels belong to no element, or to beat unit + # elements that are positioned from here, so they are redrawn once. + self.update_time_signatures() + + @staticmethod + def _get_label_state(meter: MeasureMeter) -> LabelState: + if meter.is_conflicting: + return LabelState.CONFLICTING + if meter.is_ambiguous: + return LabelState.AMBIGUOUS + return LabelState.NORMAL + + @staticmethod + def _get_label_tooltip(meter: MeasureMeter) -> str: + lines = [] + if meter.is_conflicting: + lines.append(BEAT_UNIT_CONFLICT_TOOLTIP) + elif meter.is_ambiguous: + lines.append( + BEAT_UNIT_AMBIGUOUS_TOOLTIP.format( + len(meter.beat_values), + format_units(meter.units), + format_units(meter.beat_values), + ) + ) + if meter.is_assumed: + lines.append(BEAT_UNIT_ASSUMED_TOOLTIP) + return "\n".join(lines) + + def get_time_signature_label_specs( + self, + ) -> dict[int | str | None, list[TimeSignatureLabelSpec]]: + """ + Labels to show, by the id of the beat unit that governs them (None + for the default). A label is shown where the time signature changes, + where a beat unit starts, and where a measure needs attention. + """ + specs = defaultdict(list) + if not self.get_data("show_time_signatures"): + return specs + + timeline = self.timeline + beats = timeline.components + starts = getattr(timeline, "beats_that_start_measures", []) + previous = None + for measure_index, meter in enumerate(timeline.measure_meters): + if measure_index >= len(starts) or starts[measure_index] >= len(beats): + break + state = self._get_label_state(meter) + if ( + meter.time_signature != previous + or meter.starts_here + or state != LabelState.NORMAL + ): + specs[meter.beat_unit_id].append( + TimeSignatureLabelSpec( + measure_index=measure_index, + x=time_x_converter.get_x_by_time( + beats[starts[measure_index]].time + ), + numerator=format_numerator(meter.numerator), + denominator=str(meter.denominator), + state=state, + tooltip=self._get_label_tooltip(meter), + assumed=meter.is_assumed, + ) + ) + previous = meter.time_signature + return specs + + def update_time_signatures(self) -> None: + specs = self.get_time_signature_label_specs() + for beat_unit_ui in self.beat_unit_uis: + beat_unit_ui.set_labels(specs.get(beat_unit_ui.id, [])) + + for label in self._default_time_signature_labels: + self.scene.removeItem(label.body) + self.scene.removeItem(label) + self._default_time_signature_labels = [] + for spec in specs.get(None, []): + label = TimeSignatureLabel(spec, self.time_signature_pixmaps) + self.scene.addItem(label.body) + self.scene.addItem(label) + self._default_time_signature_labels.append(label) + + @property + def default_time_signature_labels(self) -> list[TimeSignatureLabel]: + return list(self._default_time_signature_labels) + + @staticmethod + def on_inspector_field_edited( + element, field_name: str, value, inspected_id: int, inspector_id: int + ) -> None: + if isinstance(element, BeatUnitUI): + if inspected_id == element.id: + element.on_inspector_edit(field_name, value) + return + TimelineUI.on_inspector_field_edited( + element, field_name, value, inspected_id, inspector_id + ) + + def update_show_time_signatures(self) -> None: + self.update_time_signatures() + + @staticmethod + @command_callback + def on_toggle_time_signatures(timeline_ui: BeatTimelineUI) -> bool: + collection = get(Get.TIMELINE_COLLECTION) + show = not timeline_ui.get_data("show_time_signatures") + height_change = ( + TIME_SIGNATURE_BAND_HEIGHT if show else -TIME_SIGNATURE_BAND_HEIGHT + ) + collection.set_timeline_data(timeline_ui.id, "show_time_signatures", show) + collection.set_timeline_data( + timeline_ui.id, "height", timeline_ui.get_data("height") + height_change + ) + return True + + def _get_beat_unit_target_measure(self) -> int | None: + """ + The measure a beat unit command applies to: the one whose label was + clicked, or else the measure of the first selected beat. + """ + for element in self.selected_elements: + if ( + isinstance(element, BeatUnitUI) + and element.active_measure_index is not None + ): + return element.active_measure_index + beat_uis = [e for e in self.selected_elements if isinstance(e, BeatUI)] + if beat_uis: + return self._get_measure_indices(beat_uis)[0] + return None + + def on_set_beat_unit( + self, + measure_index: int | None = None, + denominator: int | None = None, + units: str | None = None, + only_this_measure: bool | None = None, + ) -> bool: + timeline = self.timeline + if measure_index is None: + measure_index = self._get_beat_unit_target_measure() + if measure_index is None or not 0 <= measure_index < timeline.measure_count: + return False + + if denominator is None or units is None: + meter = timeline.get_measure_meter(measure_index) + next_index = measure_index + 1 + # Both scopes do the same when the next measure starts its own + # beat unit, or there is none. + show_scope = ( + next_index < timeline.measure_count + and not timeline.get_measure_meter(next_index).starts_here + ) + accepted, result = get( + Get.FROM_USER_BEAT_UNIT, + meter.denominator, + format_units(meter.units), + timeline.beats_in_measure[measure_index], + show_scope, + ) + if not accepted: + return False + denominator, units = result.denominator, result.units + if only_this_measure is None: + only_this_measure = result.only_this_measure + + return timeline.set_beat_unit( + measure_index, denominator, units, bool(only_this_measure) + ) + + def on_remove_other_beat_units(self, measure_index: int | None = None) -> bool: + """ + Keeps only the beat unit that applies in a measure holding several, + i.e. the first one. + """ + if measure_index is None: + measure_index = self._get_beat_unit_target_measure() + if measure_index is None: + return False + + others = self.timeline.get_beat_units_in_measure(measure_index)[1:] + if not others: + return False + self.timeline.delete_components(others) + return True + def _get_measure_indices(self, elements: list[BeatUI]): measure_indices = set() for e in elements: @@ -152,6 +456,39 @@ def on_set_amount_in_measure( self.timeline.set_beat_amount_in_measure(i, amount) return True + @staticmethod + @command_callback + def on_set_pattern( + timeline_ui: "BeatTimelineUI", pattern: str | None = None + ) -> bool: + timeline = timeline_ui.timeline + if pattern is None: + accepted, pattern = get( + Get.FROM_USER_BEAT_PATTERN, + timeline.beat_pattern, + len(timeline), + timeline.get_bars_overwritten_by, + ) + if not accepted: + return False + + result = parse(pattern) + if not result.is_complete: + tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) + return False + + overwritten = timeline.get_bars_overwritten_by(result.bars) + if overwritten and not get( + Get.FROM_USER_YES_OR_NO, + BEAT_PATTERN_OVERWRITE_TITLE, + BEAT_PATTERN_OVERWRITE_PROMPT.format( + ", ".join(str(i + 1) for i in overwritten) + ), + ): + return False + + return timeline.set_beat_pattern(pattern) + def on_add(self, time: float | None = None): if time is None: time = get(Get.SELECTED_TIME) @@ -187,11 +524,25 @@ def on_beat_timeline_fill(): def on_timeline_components_deserialized(self): for beat_ui in self: beat_ui.update_label() + self.update_time_signatures() @classmethod - def get_additional_args_for_creation(cls): - success, beat_pattern = get(Get.FROM_USER_BEAT_PATTERN) - return success, {"beat_pattern": beat_pattern} + def get_additional_args_for_creation(cls, beat_pattern: str | None = None): + if beat_pattern is None: + success, beat_pattern = get(Get.FROM_USER_BEAT_PATTERN) + if not success: + return False, {} + else: + result = parse(beat_pattern) + if not result.is_complete: + tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) + return False, {} + return True, { + "beat_pattern": beat_pattern, + "show_time_signatures": True, + "height": settings.get("beat_timeline", "default_height") + + TIME_SIGNATURE_BAND_HEIGHT, + } def on_settings_updated(self, updated_settings): if "beat_timeline" in updated_settings: @@ -217,9 +568,12 @@ def should_display_measure_number(self, beat_ui): def on_measure_number_change_done(self, start_index: int): for beat_ui in self[start_index:]: beat_ui.update_label() + self.update_time_signatures() def get_copy_data_from_selected_elements(self): - return self.get_copy_data_from_beat_uis(self.selected_elements) + return self.get_copy_data_from_beat_uis( + [e for e in self.selected_elements if isinstance(e, BeatUI)] + ) def get_copy_data_from_beat_uis(self, beat_uis: list[BeatUI]): copy_data = [] @@ -228,9 +582,31 @@ def get_copy_data_from_beat_uis(self, beat_uis: list[BeatUI]): return copy_data - @staticmethod - def get_copy_data_from_beat_ui(beat_ui: BeatUI): - return get_copy_data_from_element(beat_ui, BeatUI.DEFAULT_COPY_ATTRIBUTES) + def get_copy_data_from_beat_ui(self, beat_ui: BeatUI): + data = get_copy_data_from_element(beat_ui, BeatUI.DEFAULT_COPY_ATTRIBUTES) + beat_unit = self.timeline.get_beat_unit_on_beat(beat_ui.id) + if beat_unit: + data["beat_unit"] = { + "denominator": beat_unit.denominator, + "units": beat_unit.units, + "assumed": beat_unit.assumed, + } + return data + + @with_elements + def on_copy_element(self, elements) -> bool: + # Beat units are copied along with the beats they are on. + component_data = self.get_copy_data_from_beat_uis( + [e for e in elements if isinstance(e, BeatUI)] + ) + if not component_data: + return False + + post( + Post.TIMELINE_ELEMENT_COPY_DONE, + {"components": component_data, "timeline_type": self.timeline_class}, + ) + return True def paste_single_into_timeline(self, paste_data: list[dict] | dict): return self.paste_multiple_into_timeline(paste_data) @@ -251,13 +627,17 @@ def create_pasted_beats( paste_data ): # deepcopying so popping won't affect original data beat_time = beat_data["context"].pop("time") + beat_unit_data = beat_data.pop("beat_unit", None) - self.timeline.create_component( + beat, _ = self.timeline.create_component( ComponentKind.BEAT, target_time + (beat_time - reference_time), **beat_data["values"], **beat_data["context"], ) + if beat and beat_unit_data: + # A pasted first beat already has a beat unit of its own. + self.timeline.put_beat_unit_on_beat(beat.id, **beat_unit_data) def update_beat_pattern(self): pass # not implemented @@ -281,6 +661,7 @@ def update_measures_to_force_display(self): def update_beats_in_measure(self): for beat_ui in self: beat_ui.update_is_first_in_measure() + self.update_time_signatures() def beats_that_start_measures(self): for beat_ui in self: diff --git a/tilia/ui/timelines/element_kinds.py b/tilia/ui/timelines/element_kinds.py index 4b546dd53..a0c523afa 100644 --- a/tilia/ui/timelines/element_kinds.py +++ b/tilia/ui/timelines/element_kinds.py @@ -3,6 +3,7 @@ def get_element_class_by_kind(kind: ComponentKind): from tilia.ui.timelines.audiowave.element import AmplitudeBarUI + from tilia.ui.timelines.beat.beat_unit import BeatUnitUI from tilia.ui.timelines.beat.element import BeatUI from tilia.ui.timelines.harmony.elements import HarmonyUI, ModeUI from tilia.ui.timelines.hierarchy.element import HierarchyUI @@ -21,6 +22,7 @@ def get_element_class_by_kind(kind: ComponentKind): ComponentKind.HIERARCHY: HierarchyUI, ComponentKind.MARKER: MarkerUI, ComponentKind.BEAT: BeatUI, + ComponentKind.BEAT_UNIT: BeatUnitUI, ComponentKind.HARMONY: HarmonyUI, ComponentKind.MODE: ModeUI, ComponentKind.AUDIOWAVE: AmplitudeBarUI, diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index f8ee2faa0..869cd969c 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -1,22 +1,213 @@ -from PySide6.QtWidgets import QInputDialog +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtCore import Qt +from PySide6.QtGui import QKeyEvent, QValidator +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QLabel, + QLineEdit, + QVBoxLayout, +) import tilia.ui.strings from tilia.requests import Get, get +from tilia.timelines.beat.pattern import BarRun, ParseResult, group_runs, parse +from tilia.ui.windows.pattern_preview import PatternPreview, PreviewContent + +_PATTERN_CHARACTERS = set("0123456789[]") + + +class BeatPatternValidator(QValidator): + """ + Blocks characters that can never be part of a pattern, and reports text + that could still become valid as Intermediate so the user can keep typing. + """ + + def validate(self, text: str, pos: int) -> tuple[QValidator.State, str, int]: + if any(c not in _PATTERN_CHARACTERS and not c.isspace() for c in text): + return QValidator.State.Invalid, text, pos + if parse(text).is_complete: + return QValidator.State.Acceptable, text, pos + return QValidator.State.Intermediate, text, pos + + +class PatternLineEdit(QLineEdit): + """ + Pairs brackets as they are typed: "[" inserts "[]" with the cursor + between them (or wraps the selection), "]" steps over a closing bracket + that is already there, and Backspace between an empty pair removes both. + """ + + def keyPressEvent(self, event: QKeyEvent) -> None: + text = self.text() + cursor = self.cursorPosition() + + if event.text() == "[": + if self.hasSelectedText(): + start = self.selectionStart() + selected = self.selectedText() + self.insert(f"[{selected}]") + self.setCursorPosition(start + len(selected) + 2) + else: + self.insert("[]") + self.setCursorPosition(cursor + 1) + return + + if ( + event.text() == "]" + and not self.hasSelectedText() + and text[cursor : cursor + 1] == "]" + ): + self.setCursorPosition(cursor + 1) + return + + if ( + event.key() == Qt.Key.Key_Backspace + and not self.hasSelectedText() + and cursor > 0 + and text[cursor - 1 : cursor + 1] == "[]" + ): + self.setText(text[: cursor - 1] + text[cursor + 1 :]) + self.setCursorPosition(cursor - 1) + return + + super().keyPressEvent(event) + + +def _describe_bars(run: BarRun) -> str: + if run.count == 1: + return str(run.start + 1) + return f"{run.start + 1}–{run.start + run.count}" + + +def _describe_bar_numbers(bars: list[int]) -> str: + numbers = ", ".join(str(bar + 1) for bar in bars) + return ("Bar " if len(bars) == 1 else "Bars ") + numbers + +def get_pattern_preview( + result: ParseResult, + beat_count: int | None = None, + overwritten_bars: list[int] | None = None, +) -> PreviewContent: + """ + The bars a pattern expands to, grouped where consecutive bars are equal. + `overwritten_bars` are 0-based indices of the timeline's bars set by hand + that applying the pattern would change; their rows are highlighted. + """ + if not result.is_complete: + if result.position is None or result.error.startswith("Enter"): + return PreviewContent(error=result.error) + return PreviewContent( + error=f"{result.error} (at character {result.position + 1})" + ) + + runs = group_runs(result.bars) + pattern_length = len(result.bars) + overwritten_in_pattern = {bar % pattern_length for bar in overwritten_bars or []} + highlighted = frozenset( + index + for index, run in enumerate(runs) + if any( + run.start <= bar < run.start + run.count for bar in overwritten_in_pattern + ) + ) + + total_beats = sum(result.bars) + lines = [ + f"{pattern_length} bar{'' if pattern_length == 1 else 's'}, " + f"{total_beats} beat{'' if total_beats == 1 else 's'}, " + "then the pattern repeats from bar 1." + ] + if beat_count is not None: + lines.append(f"{beat_count} beat{'' if beat_count == 1 else 's'} tapped.") + if overwritten_bars: + lines.append( + f"{_describe_bar_numbers(overwritten_bars)} " + f"{'was' if len(overwritten_bars) == 1 else 'were'} " + "set by hand and will be overwritten." + ) -class AskBeatPattern(QInputDialog): - def __init__(self): - super().__init__() - self.setOption(QInputDialog.InputDialogOption.UsePlainTextEditForTextInput) + return PreviewContent( + headers=["Bars", "Beats per bar"], + rows=[(_describe_bars(run), str(run.beats)) for run in runs], + footer="\n".join(lines), + highlighted_rows=highlighted, + ) - def ask(self): - result, accept = self.getMultiLineText( + +class BeatPatternDialog(QDialog): + def __init__( + self, + initial_text: str = "", + beat_count: int | None = None, + get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, + ): + super().__init__( get(Get.MAIN_WINDOW), - tilia.ui.strings.BEAT_PATTERN_DIALOG_TITLE, - tilia.ui.strings.BEAT_PATTERN_DIALOG_PROMPT, + Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, + ) + self.setWindowTitle(tilia.ui.strings.BEAT_PATTERN_DIALOG_TITLE) + self._beat_count = beat_count + self._get_overwritten_bars = get_overwritten_bars + + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(5, 5, 5, 5) + + prompt = QLabel(tilia.ui.strings.BEAT_PATTERN_DIALOG_PROMPT) + + self.line_edit = PatternLineEdit(initial_text) + self.line_edit.setValidator(BeatPatternValidator(self.line_edit)) + self.line_edit.setToolTip(tilia.ui.strings.BEAT_PATTERN_DIALOG_TOOLTIP) + + self.preview = PatternPreview(self) + + self.button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, ) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + + self.layout().addWidget(prompt) + self.layout().addWidget(self.line_edit) + self.layout().addWidget(self.preview) + self.layout().addWidget(self.button_box) + + self.line_edit.textChanged.connect(self._on_text_changed) + self._on_text_changed(initial_text) - if accept: - result = result.split() + @property + def ok_button(self): + return self.button_box.button(QDialogButtonBox.StandardButton.Ok) + + @property + def text(self) -> str: + return self.line_edit.text().strip() + + def _on_text_changed(self, text: str) -> None: + result = parse(text) + overwritten = ( + self._get_overwritten_bars(result.bars) + if result.is_complete and self._get_overwritten_bars + else [] + ) + self.preview.set_content( + get_pattern_preview(result, self._beat_count, overwritten) + ) + self.ok_button.setEnabled(result.is_complete) - return result, accept + @classmethod + def ask( + cls, + initial_text: str = "", + beat_count: int | None = None, + get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, + ) -> tuple[bool, str]: + dialog = cls(initial_text, beat_count, get_overwritten_bars) + if dialog.exec() == QDialog.DialogCode.Accepted: + return True, dialog.text + return False, "" diff --git a/tilia/ui/windows/beat_unit.py b/tilia/ui/windows/beat_unit.py new file mode 100644 index 000000000..dd9fb42cc --- /dev/null +++ b/tilia/ui/windows/beat_unit.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction + +from PySide6.QtCore import Qt +from PySide6.QtGui import QValidator +from PySide6.QtWidgets import ( + QButtonGroup, + QDialog, + QDialogButtonBox, + QFormLayout, + QLineEdit, + QMessageBox, + QRadioButton, + QSpinBox, + QVBoxLayout, +) + +import tilia.ui.strings +from tilia.requests import Get, get +from tilia.timelines.beat.units import ( + fit_units, + format_units, + is_fit_ambiguous, + parse_units, +) +from tilia.ui.format import format_numerator +from tilia.ui.windows.pattern_preview import PatternPreview, PreviewContent + +MAX_DENOMINATOR = 128 + +_UNITS_CHARACTERS = set("0123456789/+") + + +@dataclass(frozen=True) +class BeatUnitInput: + denominator: int + units: str + only_this_measure: bool + + +class UnitsValidator(QValidator): + """Blocks characters that can never be part of units; see parse_units.""" + + def validate(self, text: str, pos: int) -> tuple[QValidator.State, str, int]: + if any(c not in _UNITS_CHARACTERS and not c.isspace() for c in text): + return QValidator.State.Invalid, text, pos + if parse_units(text).is_valid: + return QValidator.State.Acceptable, text, pos + return QValidator.State.Intermediate, text, pos + + +def get_units_preview( + denominator: int, units_text: str, beat_count: int | None +) -> PreviewContent: + """ + What each tap in the units pattern is worth, with a footer on how the + pattern repeats and the time signature the measure would get. + """ + result = parse_units(units_text) + if not result.is_valid: + return PreviewContent(error=result.error) + + rows = [ + ( + str(index + 1), + format_numerator(unit), + _describe_quarters(unit * 4 / denominator), + ) + for index, unit in enumerate(result.units) + ] + if len(result.units) == 1: + lines = ["Every tap is worth the same."] + else: + lines = [f"Repeats every {len(result.units)} taps, from tap 1 in each measure."] + if beat_count is not None: + values = fit_units(result.units, beat_count) + numerator = format_numerator(sum(values, Fraction(0))) + # "4 1/3/4" would be unreadable. + separator = "/" if numerator.isdigit() else " over " + lines.append( + f"This measure ({beat_count} tap{'' if beat_count == 1 else 's'}) " + f"becomes {numerator}{separator}{denominator}." + ) + if is_fit_ambiguous(result.units, beat_count): + lines.append( + tilia.ui.strings.BEAT_UNIT_AMBIGUOUS_TOOLTIP.format( + beat_count, format_units(result.units), format_units(values) + ) + ) + return PreviewContent( + headers=["Tap", f"Units (1/{denominator})", "Length"], + rows=rows, + footer="\n".join(lines), + ) + + +def _describe_quarters(quarters: Fraction) -> str: + return f"{format_numerator(quarters)} quarter" + ("s" if quarters > 1 else "") + + +class BeatUnitDialog(QDialog): + def __init__( + self, + denominator: int, + units: str, + beat_count: int | None = None, + show_scope: bool = True, + ): + super().__init__( + get(Get.MAIN_WINDOW), + Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, + ) + self.setWindowTitle(tilia.ui.strings.BEAT_UNIT_DIALOG_TITLE) + self._beat_count = beat_count + + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(5, 5, 5, 5) + + form = QFormLayout() + self.denominator_edit = QSpinBox() + self.denominator_edit.setRange(1, MAX_DENOMINATOR) + self.denominator_edit.setValue(denominator) + self.units_edit = QLineEdit(units) + self.units_edit.setValidator(UnitsValidator(self.units_edit)) + self.units_edit.setToolTip(tilia.ui.strings.BEAT_UNIT_UNITS_TOOLTIP) + form.addRow(tilia.ui.strings.BEAT_UNIT_DENOMINATOR_LABEL, self.denominator_edit) + form.addRow(tilia.ui.strings.BEAT_UNIT_UNITS_LABEL, self.units_edit) + + self.preview = PatternPreview(self) + + self.onward_option = QRadioButton(tilia.ui.strings.BEAT_UNIT_SCOPE_ONWARD) + self.measure_option = QRadioButton(tilia.ui.strings.BEAT_UNIT_SCOPE_MEASURE) + self._scope = QButtonGroup(self) + self._scope.addButton(self.onward_option) + self._scope.addButton(self.measure_option) + self.onward_option.setChecked(True) + self.onward_option.setVisible(show_scope) + self.measure_option.setVisible(show_scope) + + self.button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + + self.layout().addLayout(form) + self.layout().addWidget(self.preview) + self.layout().addWidget(self.onward_option) + self.layout().addWidget(self.measure_option) + self.layout().addWidget(self.button_box) + + self.denominator_edit.valueChanged.connect(self._on_input_changed) + self.units_edit.textChanged.connect(self._on_input_changed) + self._on_input_changed() + + @property + def ok_button(self): + return self.button_box.button(QDialogButtonBox.StandardButton.Ok) + + @property + def result_input(self) -> BeatUnitInput: + return BeatUnitInput( + self.denominator_edit.value(), + format_units(parse_units(self.units_edit.text()).units), + self.measure_option.isChecked(), + ) + + def _on_input_changed(self, *_) -> None: + self.preview.set_content( + get_units_preview( + self.denominator_edit.value(), + self.units_edit.text(), + self._beat_count, + ) + ) + self.ok_button.setEnabled(parse_units(self.units_edit.text()).is_valid) + + @classmethod + def ask( + cls, + denominator: int, + units: str, + beat_count: int | None = None, + show_scope: bool = True, + ) -> tuple[bool, BeatUnitInput | None]: + dialog = cls(denominator, units, beat_count, show_scope) + if dialog.exec() == QDialog.DialogCode.Accepted: + return True, dialog.result_input + return False, None + + +def ask_for_beat_unit_scope() -> tuple[bool, bool]: + """ + Asks where an edit made in the inspector applies. Returns whether the + user answered, and whether the edit applies to this measure only. + """ + box = QMessageBox(get(Get.MAIN_WINDOW)) + box.setWindowTitle(tilia.ui.strings.BEAT_UNIT_DIALOG_TITLE) + box.setText(tilia.ui.strings.BEAT_UNIT_SCOPE_PROMPT) + onward = box.addButton( + tilia.ui.strings.BEAT_UNIT_SCOPE_ONWARD, QMessageBox.ButtonRole.AcceptRole + ) + measure = box.addButton( + tilia.ui.strings.BEAT_UNIT_SCOPE_MEASURE, QMessageBox.ButtonRole.AcceptRole + ) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(onward) + box.exec() + clicked = box.clickedButton() + if clicked not in (onward, measure): + return False, False + return True, clicked is measure diff --git a/tilia/ui/windows/pattern_preview.py b/tilia/ui/windows/pattern_preview.py new file mode 100644 index 000000000..0c9534ddd --- /dev/null +++ b/tilia/ui/windows/pattern_preview.py @@ -0,0 +1,99 @@ +""" +A scrollable table previewing what a typed pattern expands to, with a footer +about how it repeats. Used by the beat pattern and beat unit dialogs. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QHeaderView, + QLabel, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +# The table scrolls past this height rather than growing the dialog. +MAX_TABLE_HEIGHT = 160 +HIGHLIGHT_COLOR = QColor("#ffe2b3") + + +@dataclass(frozen=True) +class PreviewContent: + headers: list[str] = field(default_factory=list) + rows: list[tuple[str, ...]] = field(default_factory=list) + footer: str = "" + # Shown instead of the table, e.g. while the pattern is half-typed. + error: str = "" + highlighted_rows: frozenset[int] = frozenset() + + +class PatternPreview(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + + self.table = QTableWidget(self) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.table.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.table.verticalHeader().setVisible(False) + self.table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Stretch + ) + self.table.setMaximumHeight(MAX_TABLE_HEIGHT) + + self.footer = QLabel(self) + self.footer.setTextFormat(Qt.TextFormat.PlainText) + self.footer.setWordWrap(True) + + self.error = QLabel(self) + self.error.setTextFormat(Qt.TextFormat.PlainText) + self.error.setWordWrap(True) + + self.layout().addWidget(self.table) + self.layout().addWidget(self.footer) + self.layout().addWidget(self.error) + + def set_content(self, content: PreviewContent) -> None: + self.error.setText(content.error) + self.error.setVisible(bool(content.error)) + self.table.setVisible(not content.error) + self.footer.setVisible(not content.error) + if content.error: + return + + self.table.clear() + self.table.setColumnCount(len(content.headers)) + self.table.setHorizontalHeaderLabels(content.headers) + self.table.setRowCount(len(content.rows)) + for row_index, row in enumerate(content.rows): + for column_index, text in enumerate(row): + item = QTableWidgetItem(text) + if row_index in content.highlighted_rows: + item.setBackground(HIGHLIGHT_COLOR) + self.table.setItem(row_index, column_index, item) + self.footer.setText(content.footer) + + def rows(self) -> list[tuple[str, ...]]: + return [ + tuple( + self.table.item(row, column).text() + for column in range(self.table.columnCount()) + ) + for row in range(self.table.rowCount()) + ] + + def highlighted_rows(self) -> set[int]: + return { + row + for row in range(self.table.rowCount()) + if self.table.item(row, 0).background().color() == HIGHLIGHT_COLOR + } From 308a66e7b15ac84757073397b758bd0c65512619 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:58:30 +0200 Subject: [PATCH 15/56] test: answer beat timeline prompts through their dialogs The remaining beat timeline UI tests answered "Set measure number", "Set amount in measure" and "Fill with beats" by serving the Get.FROM_USER_* request, skipping the dialog code. They now patch the dialogs, as the rest of the file does, so the prompts' own code is exercised (#605). --- .../timelines/beat/test_beat_timeline_ui.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 1f6aee6ca..039cc7cb9 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -616,7 +616,7 @@ class TestSetMeasureNumber: @staticmethod def _set_measure_number(number=DUMMY_MEASURE_NUMBER): """Assumes there a beat in the measure is selected""" - with Serve(Get.FROM_USER_INT, (True, number)): + with patch_ask_for_int_dialog(True, number): commands.execute("timeline.beat.set_measure_number") def test_set_measure_number_single_measure(self, beat_tlui): @@ -835,7 +835,8 @@ def test_set_beat_amount_in_measure(self, beat_tlui): beat_tlui.select_element(beat_tlui[0]) beat_tlui.timeline.set_beat_amount_in_measure = MagicMock() - commands.execute("timeline.beat.set_amount_in_measure", amount=11) + with patch_ask_for_int_dialog(True, 11): + commands.execute("timeline.beat.set_amount_in_measure") beat_tlui.timeline.set_beat_amount_in_measure.assert_called_with(0, 11) @@ -848,7 +849,8 @@ def test_updates_measure_numbers(self, beat_tlui): beat_tlui.select_element(beat_tlui[0]) - commands.execute("timeline.beat.set_amount_in_measure", amount=2) + with patch_ask_for_int_dialog(True, 2): + commands.execute("timeline.beat.set_amount_in_measure") assert [get_displayed_measure_number(b) for b in beat_tlui] == ["1", "", "2"] @@ -882,9 +884,8 @@ def setup(self, beat_tlui): post(Post.APP_STATE_RECORD, "test") def test_by_amount(self, beat_tlui): - with Serve( - Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD, - (True, (beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100)), + with patch_fill_beat_timeline_dialog( + beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100 ): with undoable(): commands.execute("timeline.beat.fill") @@ -894,9 +895,8 @@ def test_by_amount(self, beat_tlui): def test_by_interval(self, beat_tlui, tilia_state): interval = 0.5 amount = int(tilia_state.duration / interval) - with Serve( - Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD, - (True, (beat_tlui.timeline, BeatTimeline.FillMethod.BY_INTERVAL, interval)), + with patch_fill_beat_timeline_dialog( + beat_tlui.timeline, BeatTimeline.FillMethod.BY_INTERVAL, interval ): with undoable(): commands.execute("timeline.beat.fill") @@ -907,8 +907,9 @@ def test_by_interval(self, beat_tlui, tilia_state): def test_accept_delete_existing_beats(self, beat_tlui): commands.execute("timeline.beat.add") - response = (True, (beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100)) - with Serve(Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD, response): + with patch_fill_beat_timeline_dialog( + beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100 + ): with patch_yes_or_no_dialog(True): with undoable(): commands.execute("timeline.beat.fill") @@ -917,8 +918,9 @@ def test_accept_delete_existing_beats(self, beat_tlui): def test_reject_delete_existing_beats(self, beat_tlui): beat_tlui.create_beat(0) - response = (True, (beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100)) - with Serve(Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD, response): + with patch_fill_beat_timeline_dialog( + beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100 + ): with patch_yes_or_no_dialog(False): commands.execute("timeline.beat.fill") From d9d280abb526770f803b1e0a6354ac7e6fc8580f Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 7 Oct 2026 11:21:35 +0200 Subject: [PATCH 16/56] fix: reject beat patterns the parser could not handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser matched digits with str.isdigit(), which also accepts characters such as "²" that int() then rejects, so parse() raised ValueError instead of returning INVALID. Patterns reach it from the CLI, from saved files and from validate_beat_pattern, not only from the dialog whose validator filters input. Deeply nested groups overflowed the recursion, and numbers past int()'s digit limit raised as well. Digits are now ASCII only, groups nest at most 32 deep and numbers have at most 9 significant digits; each is reported as an invalid pattern. --- tests/timelines/beat/test_pattern.py | 35 ++++++++++++++++++++++++++++ tilia/timelines/beat/pattern.py | 28 +++++++++++++++++++--- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/tests/timelines/beat/test_pattern.py b/tests/timelines/beat/test_pattern.py index 7054c3239..1f566cf54 100644 --- a/tests/timelines/beat/test_pattern.py +++ b/tests/timelines/beat/test_pattern.py @@ -2,6 +2,8 @@ from tilia.timelines.beat.pattern import ( MAX_BARS, + MAX_DEPTH, + MAX_DIGITS, BarRun, ParseStatus, group_runs, @@ -91,6 +93,39 @@ def test_too_many_bars(self, text): assert result.status == ParseStatus.INVALID assert "more than" in result.error + @pytest.mark.parametrize("text", ["²", "4 ²", "①[4]", "٣"]) + def test_non_ascii_digits(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error.startswith("Unexpected") + + @pytest.mark.parametrize("text", ["9" * 5000, f"{'9' * 10}[4]"]) + def test_too_many_digits(self, text): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == f"Numbers can have at most {MAX_DIGITS} digits." + assert result.position == 0 + + def test_leading_zeros_do_not_count_as_digits(self): + assert parse("0" * 20 + "4").bars == [4] + + def test_max_depth_is_allowed(self): + text = "[" * MAX_DEPTH + "4" + "]" * MAX_DEPTH + assert parse(text).bars == [4] + + @pytest.mark.parametrize("closed", [True, False]) + def test_too_deep(self, closed): + depth = MAX_DEPTH + 1 + text = "[" * depth + "4" + ("]" * depth if closed else "") + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == f"Groups can be nested at most {MAX_DEPTH} deep." + assert result.position == MAX_DEPTH + + def test_very_deep_nesting_does_not_recurse(self): + result = parse("[" * 5000 + "4" + "]" * 5000) + assert result.status == ParseStatus.INVALID + class TestGroupRuns: def test_empty(self): diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py index a92cf2739..733fd8c27 100644 --- a/tilia/timelines/beat/pattern.py +++ b/tilia/timelines/beat/pattern.py @@ -20,6 +20,15 @@ # Guards against inputs like "999999[999999[4]]" expanding into a list that # would exhaust memory. Far above the bar count of any real piece. MAX_BARS = 100_000 +# Groups are parsed recursively, so nesting is capped well below Python's +# recursion limit. Far deeper than any pattern worth writing. +MAX_DEPTH = 32 +# Significant digits in a number. Any more and the number is far above +# MAX_BARS, and int() refuses strings past a few thousand digits. +MAX_DIGITS = 9 +# Only ASCII digits: str.isdigit() also accepts characters such as "²" that +# int() rejects. +_DIGITS = frozenset("0123456789") class ParseStatus(Enum): @@ -68,6 +77,7 @@ class _Parser: def __init__(self, text: str): self.text = text self.pos = 0 + self.depth = 0 def at_end(self) -> bool: return self.pos >= len(self.text) @@ -75,15 +85,21 @@ def at_end(self) -> bool: def peek(self) -> str: return self.text[self.pos] + def at_digit(self) -> bool: + return not self.at_end() and self.peek() in _DIGITS + def skip_whitespace(self) -> None: while not self.at_end() and self.peek().isspace(): self.pos += 1 def parse_int(self) -> int: start = self.pos - while not self.at_end() and self.peek().isdigit(): + while self.at_digit(): self.pos += 1 - return int(self.text[start : self.pos]) + digits = self.text[start : self.pos].lstrip("0") + if len(digits) > MAX_DIGITS: + raise _Invalid(f"Numbers can have at most {MAX_DIGITS} digits.", start) + return int(digits or "0") def parse_items(self, open_position: int | None = None) -> list[int]: """ @@ -118,13 +134,19 @@ def parse_items(self, open_position: int | None = None) -> list[int]: def parse_item(self) -> list[int]: start = self.pos - count = self.parse_int() if self.peek().isdigit() else None + count = self.parse_int() if self.at_digit() else None self.skip_whitespace() if not self.at_end() and self.peek() == "[": open_position = self.pos + if self.depth >= MAX_DEPTH: + raise _Invalid( + f"Groups can be nested at most {MAX_DEPTH} deep.", open_position + ) self.pos += 1 + self.depth += 1 group = self.parse_items(open_position) + self.depth -= 1 self.pos += 1 # the closing "]" repeats = 1 if count is None else count if repeats < 1: From b9a4f82251ba00bb750eb50450d4e76d530e38ce Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 7 Oct 2026 11:22:42 +0200 Subject: [PATCH 17/56] fix: keep the last gap's tempo past the last beat The last beat has no next beat, so its seconds-per-quarter reused the gap before it but divided it by the last beat's own quarter length. That gap lasted the previous beat's length. With uneven beat values (2+3, or a last beat worth more or less than the one before), conversions at and past the last beat used the wrong tempo. The last entry now repeats the previous one. --- tests/timelines/beat/test_quarter_stamps.py | 11 +++++++++++ tilia/timelines/beat/timeline.py | 16 ++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/timelines/beat/test_quarter_stamps.py b/tests/timelines/beat/test_quarter_stamps.py index 21c763f9e..26f9ec03b 100644 --- a/tests/timelines/beat/test_quarter_stamps.py +++ b/tests/timelines/beat/test_quarter_stamps.py @@ -110,6 +110,17 @@ def test_uneven_beat_lengths(self, beat_tl): assert beat_tl.get_quarter_stamp_by_time(0.5) == pytest.approx(1) assert beat_tl.get_quarter_stamp_by_time(1.5) == pytest.approx(3.5) + def test_past_last_beat_keeps_last_gap_tempo(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, [0, 1, 2]) + add_beat_unit(beat_tl, 0, 4, "2+3") + + # The last gap lasts 3 quarters over 1 s. The last beat is worth 2 + # quarters, which must not change that tempo. + assert beat_tl.quarter_stamps == [0, 2, 5] + assert beat_tl.get_quarter_stamp_by_time(2.5) == pytest.approx(6.5) + assert beat_tl.get_time_by_quarter_stamp(6.5) == pytest.approx(2.5) + @pytest.mark.parametrize("times", [[], [5]]) def test_needs_two_beats(self, beat_tl, times): add_beats(beat_tl, times) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index c07814ba0..e98512a07 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -709,14 +709,14 @@ def get_quarter_stamp_of_beat(self, beat: Beat) -> Fraction: def _build_time_stamp_table(self) -> TimeStampTable: times = [beat.time for beat in self.components] lengths = self.beat_quarter_lengths - seconds_per_quarter = [] - for index in range(len(times)): - # Past the last beat, its length is spread over the last gap. - if index < len(times) - 1: - gap = times[index + 1] - times[index] - else: - gap = times[index] - times[index - 1] - seconds_per_quarter.append(gap / float(lengths[index])) + seconds_per_quarter = [ + (times[index + 1] - times[index]) / float(lengths[index]) + for index in range(len(times) - 1) + ] + # The last beat has no next beat to measure against, so the tempo of + # the gap before it carries on past it. Its own length doesn't enter: + # that gap lasted the previous beat's length, not the last one's. + seconds_per_quarter.append(seconds_per_quarter[-1]) return TimeStampTable( times=times, stamps=[float(stamp) for stamp in self.quarter_stamps], From fdd0bc32867a1ec7116965314dacc3d91a4ee0a7 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 7 Oct 2026 11:23:56 +0200 Subject: [PATCH 18/56] fix: hash beat units in beat order, not creation order The component manager keeps beat units in an append-only list, and hashed and serialized them in that order. Equal states reached by different paths then hashed differently: deleting the first measure's beat unit re-creates it with the same values, but after the others, so the file looked modified. Beat units are now hashed and serialized in the order of the beats they are on. --- tests/timelines/beat/test_beat_units.py | 12 ++++++++++++ tilia/timelines/beat/timeline.py | 13 +++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 32844f1d7..21d3fedbb 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -472,3 +472,15 @@ def test_hash_changes_with_beat_units(self, beat_tl): add_beat_unit(beat_tl, 0, 8, "1") assert beat_tl.component_manager.hash_components() != before + + def test_hash_does_not_depend_on_creation_order(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 4) + add_beat_unit(beat_tl, 2, 8, "3") + before = beat_tl.component_manager.hash_components() + + # Recreated with the same default values, but after the other one. + beat_tl.delete_components([first_beat_unit(beat_tl)]) + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (2, 8, "3")] + + assert beat_tl.component_manager.hash_components() == before diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index e98512a07..3801c8fde 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -315,15 +315,20 @@ def clear(self): if was_computing: self.timeline.refresh_measures() - def hash_components(self): + def _get_beats_and_beat_units(self) -> list[TC]: + # Beat units in the order of their beats, not of creation, so equal + # states hash and serialize alike however they were reached. + return self._components + self.timeline.beat_units + + def hash_components(self) -> str: str_to_hash = "" - for component in self._components + self._beat_units: + for component in self._get_beats_and_beat_units(): str_to_hash += component.hash + "|" return hash_function(str_to_hash) - def serialize_components(self): - return serialize.serialize_components(self._components + self._beat_units) + def serialize_components(self) -> dict[int | str, dict[str, Any]]: + return serialize.serialize_components(self._get_beats_and_beat_units()) @staticmethod def _split_beat_units( From 083bff2a4aacd34667bc58eea243418ae7223018 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 7 Oct 2026 11:26:21 +0200 Subject: [PATCH 19/56] fix: cap beat unit denominators at 128 everywhere The dialog and inspector spin boxes stopped at 128, but the model, CSV import and CLI accepted any positive denominator. Opening a unit with a larger one clamped it in the spin box, so pressing OK, or Enter in the inspector, silently rewrote it to 128. MAX_DENOMINATOR now lives in tilia.timelines.beat.units and validate_denominator enforces it on creation, on set_data, in set_beat_unit, and in CSV and CLI input. --- tests/parsers/csv/test_beats_from_csv.py | 5 +++-- tests/timelines/beat/test_beat_units.py | 9 +++++++-- .../cli/timelines/test_cli_timelines_beat_units.py | 1 + tilia/parsers/csv/beat.py | 7 +++++-- tilia/timelines/beat/components.py | 6 +++--- tilia/timelines/beat/timeline.py | 5 +++-- tilia/timelines/beat/units.py | 11 +++++++++++ tilia/ui/cli/timelines/beat.py | 13 ++++++++++--- tilia/ui/timelines/beat/beat_unit.py | 4 ++-- tilia/ui/windows/beat_unit.py | 3 +-- 10 files changed, 46 insertions(+), 18 deletions(-) diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 0fb3aeaea..518b0933a 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -164,12 +164,13 @@ def test_only_one_of_the_columns(self, beat_tl): assert beat_unit.beat_id == beat_tl[0].id def test_invalid_values_are_reported(self, beat_tl): - data = "time,denominator,units\n0,0,1\n1,4,2+\n2,8,1" + data = "time,denominator,units\n0,0,1\n1,4,2+\n2,8,1\n3,129,1" success, errors = _import_with_patch(beat_tl, data) - assert_in_errors("denominator", errors) + assert_in_errors("'0' is not a valid denominator", errors) assert_in_errors("2+", errors) + assert_in_errors("'129' is not a valid denominator", errors) # The first beat keeps its default beat unit; only the valid row adds one. values = [(u.denominator, u.units) for u in beat_tl.beat_units] assert values == [(4, "1"), (8, "1")] diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 21d3fedbb..9663afce0 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -3,6 +3,7 @@ import pytest from tilia.timelines.beat.units import ( + MAX_DENOMINATOR, fit_units, format_units, is_fit_ambiguous, @@ -229,7 +230,10 @@ def test_two_on_the_same_beat_fails(self, beat_tl): assert beat_unit is None assert error - @pytest.mark.parametrize("denominator, units", [(0, "1"), (4, "0"), (4, "a")]) + @pytest.mark.parametrize( + "denominator, units", + [(0, "1"), (MAX_DENOMINATOR + 1, "1"), (4, "0"), (4, "a")], + ) def test_invalid_values_fail(self, beat_tl, denominator, units): add_beats(beat_tl, 2) beat_unit, error = beat_tl.create_component( @@ -356,7 +360,8 @@ def test_this_measure_only_on_the_last_measure(self, beat_tl): assert len(beat_tl.beat_units) == 1 @pytest.mark.parametrize( - "measure_index, denominator, units", [(5, 4, "1"), (-1, 4, "1"), (0, 0, "1")] + "measure_index, denominator, units", + [(5, 4, "1"), (-1, 4, "1"), (0, 0, "1"), (0, MAX_DENOMINATOR + 1, "1")], ) def test_invalid(self, beat_tl, measure_index, denominator, units): add_beats(beat_tl, 4) diff --git a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py b/tests/ui/cli/timelines/test_cli_timelines_beat_units.py index 87b3d296b..ed1eeb087 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py +++ b/tests/ui/cli/timelines/test_cli_timelines_beat_units.py @@ -53,6 +53,7 @@ def test_fractional_and_grouped_units(self, cli, beat_tl): "-m 9 -d 4 -u 1", "--measure-index 9 -d 4 -u 1", "-m 1 -d 0 -u 1", + "-m 1 -d 129 -u 1", "-m 1 -d 4 -u 2+", "-m 1 -d 4 -u 1.5", ], diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index b8d51dceb..82177043b 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -11,8 +11,10 @@ from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, DEFAULT_UNITS, + MAX_DENOMINATOR, format_units, parse_units, + validate_denominator, ) from tilia.timelines.component_kinds import ComponentKind @@ -180,9 +182,10 @@ def _parse_beat_unit_values( denominator = int(row_values["denominator"]) except ValueError: denominator = 0 - if denominator < 1: + if not validate_denominator(denominator): return (denominator, units), ( - f"'{row_values['denominator']}' is not a valid denominator" + f"'{row_values['denominator']}' is not a valid denominator " + f"(1 to {MAX_DENOMINATOR})" ) if "units" in row_values: result = parse_units(row_values["units"]) diff --git a/tilia/timelines/beat/components.py b/tilia/timelines/beat/components.py index 6d2fdae7d..ee38ad62b 100644 --- a/tilia/timelines/beat/components.py +++ b/tilia/timelines/beat/components.py @@ -7,7 +7,6 @@ from tilia.timelines.base.metric_position import MetricPosition from tilia.timelines.base.validators import ( validate_bool, - validate_positive_integer, validate_read_only, validate_time, ) @@ -16,6 +15,7 @@ DEFAULT_UNITS, format_units, parse_units, + validate_denominator, ) from tilia.timelines.component_kinds import ComponentKind @@ -113,7 +113,7 @@ class BeatUnit(TimelineComponent): "timeline": validate_read_only, "id": validate_read_only, "beat_id": validate_component_id, - "denominator": validate_positive_integer, + "denominator": validate_denominator, "units": validate_units, "assumed": validate_bool, } @@ -182,7 +182,7 @@ def validate_creation( return False, f"No beat with id {beat_id}." if beat_id in taken_beat_ids: return False, f"Beat {beat_id} already has a beat unit." - if not validate_positive_integer(denominator): + if not validate_denominator(denominator): return False, f"Invalid denominator: {denominator}." if not validate_units(units): return False, f"Invalid units: {units}." diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 3801c8fde..1827eab00 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -23,7 +23,7 @@ TimelineComponentManager, TimelineFlag, ) -from tilia.timelines.base.validators import validate_bool, validate_positive_integer +from tilia.timelines.base.validators import validate_bool from tilia.timelines.beat.components import Beat, BeatUnit from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.units import ( @@ -34,6 +34,7 @@ format_units, is_fit_ambiguous, parse_units, + validate_denominator, ) from tilia.timelines.beat.validators import ( validate_beat_pattern, @@ -830,7 +831,7 @@ def set_beat_unit( as set by the user even when unchanged, which confirms assumed ones. """ result = parse_units(units) - if not result.is_valid or not validate_positive_integer(denominator): + if not result.is_valid or not validate_denominator(denominator): return False if not 0 <= measure_index < self.measure_count: return False diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 514d69526..7295d85d6 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -15,10 +15,21 @@ DEFAULT_DENOMINATOR = 4 DEFAULT_UNITS = "1" +# Far above any denominator used in notation. Enforced wherever a beat unit +# is set, so the dialog and inspector, which can't show more, never meet one. +MAX_DENOMINATOR = 128 _UNIT_CHARACTERS = set("0123456789/") +def validate_denominator(value: int) -> bool: + return ( + isinstance(value, int) + and not isinstance(value, bool) + and 1 <= value <= MAX_DENOMINATOR + ) + + @dataclass(frozen=True) class UnitsParseResult: units: list[Fraction] diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index 31761981a..8a646087e 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -7,7 +7,11 @@ from tilia.requests import Get, get from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.timeline import BeatTimeline -from tilia.timelines.beat.units import parse_units +from tilia.timelines.beat.units import ( + MAX_DENOMINATOR, + parse_units, + validate_denominator, +) from tilia.ui.cli import io from tilia.ui.cli.timelines.utils import ( get_timeline_by_name, @@ -249,8 +253,11 @@ def set_unit(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: if not result.is_valid: io.error(f"Invalid units '{namespace.units}': {result.error}") return - if namespace.denominator < 1: - io.error(f"Invalid denominator: {namespace.denominator}.") + if not validate_denominator(namespace.denominator): + io.error( + f"Invalid denominator: {namespace.denominator}. " + f"Use 1 to {MAX_DENOMINATOR}." + ) return timeline.set_beat_unit( diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py index 66afc1706..277f77859 100644 --- a/tilia/ui/timelines/beat/beat_unit.py +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -7,7 +7,7 @@ from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsScene from tilia.requests import Get, Post, get, post -from tilia.timelines.beat.units import format_units, parse_units +from tilia.timelines.beat.units import MAX_DENOMINATOR, format_units, parse_units from tilia.ui import commands from tilia.ui.coords import time_x_converter from tilia.ui.timelines.base.element import TimelineUIElement @@ -18,7 +18,7 @@ TimeSignatureLabel, TimeSignatureLabelSpec, ) -from tilia.ui.windows.beat_unit import MAX_DENOMINATOR, UnitsValidator +from tilia.ui.windows.beat_unit import UnitsValidator from tilia.ui.windows.inspect import InspectRowKind if TYPE_CHECKING: diff --git a/tilia/ui/windows/beat_unit.py b/tilia/ui/windows/beat_unit.py index dd9fb42cc..3186792b5 100644 --- a/tilia/ui/windows/beat_unit.py +++ b/tilia/ui/windows/beat_unit.py @@ -20,6 +20,7 @@ import tilia.ui.strings from tilia.requests import Get, get from tilia.timelines.beat.units import ( + MAX_DENOMINATOR, fit_units, format_units, is_fit_ambiguous, @@ -28,8 +29,6 @@ from tilia.ui.format import format_numerator from tilia.ui.windows.pattern_preview import PatternPreview, PreviewContent -MAX_DENOMINATOR = 128 - _UNITS_CHARACTERS = set("0123456789/+") From 88ea22abd1688cf45105950742ea5c7d764cba7b Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 7 Oct 2026 11:28:31 +0200 Subject: [PATCH 20/56] refactor: share the time signature band logic between UI and CLI Showing or hiding a beat timeline's time signatures, and the height a new beat timeline gets for them, were written out three times: in the context-menu command, in `timelines beat time-signatures` and in `timelines add beat`, plus the creation arguments the Qt UI passes. They now live in tilia.ui.timelines.beat_time_signatures, which is free of Qt so the CLI can use it. --- tilia/ui/cli/timelines/add.py | 11 +----- tilia/ui/cli/timelines/beat.py | 13 +------ tilia/ui/timelines/beat/timeline.py | 24 ++++-------- tilia/ui/timelines/beat_time_signatures.py | 43 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 tilia/ui/timelines/beat_time_signatures.py diff --git a/tilia/ui/cli/timelines/add.py b/tilia/ui/cli/timelines/add.py index 3fcb42a07..69bad97e3 100644 --- a/tilia/ui/cli/timelines/add.py +++ b/tilia/ui/cli/timelines/add.py @@ -2,7 +2,6 @@ import tilia.errors from tilia.requests import Get, get -from tilia.settings import settings from tilia.timelines.base.timeline import Timeline from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.timeline import BeatTimeline @@ -12,7 +11,7 @@ from tilia.timelines.score.timeline import ScoreTimeline from tilia.ui.cli import io from tilia.ui.cli.io import output -from tilia.ui.consts import BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT +from tilia.ui.timelines.beat_time_signatures import get_creation_args def setup_parser(subparser): @@ -133,13 +132,7 @@ def add(namespace: argparse.Namespace): return kwargs["beat_pattern"] = pattern if tl_type is BeatTimeline: - # New beat timelines show time signatures, with room for them. - kwargs["show_time_signatures"] = True - if kwargs.get("height") is None: - kwargs["height"] = ( - settings.get("beat_timeline", "default_height") - + BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT - ) + kwargs |= get_creation_args(kwargs.get("height")) output(f"Adding timeline with {kind=}, {name=}") diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index 8a646087e..b6deb0dc6 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -4,7 +4,6 @@ from collections.abc import Callable from functools import wraps -from tilia.requests import Get, get from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.beat.units import ( @@ -17,8 +16,8 @@ get_timeline_by_name, get_timeline_by_ordinal, ) -from tilia.ui.consts import BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT from tilia.ui.format import format_numerator +from tilia.ui.timelines.beat_time_signatures import set_time_signatures_shown def setup_parser(subparser): @@ -314,12 +313,4 @@ def list_units(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: def set_time_signatures_visibility( timeline: BeatTimeline, namespace: argparse.Namespace ) -> None: - show = namespace.action == "show" - if show == timeline.show_time_signatures: - return - collection = get(Get.TIMELINE_COLLECTION) - height_change = BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT - if not show: - height_change = -height_change - collection.set_timeline_data(timeline.id, "show_time_signatures", show) - collection.set_timeline_data(timeline.id, "height", timeline.height + height_change) + set_time_signatures_shown(timeline.id, namespace.action == "show") diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 0f9669fda..5188e8c8c 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -5,7 +5,6 @@ import tilia.errors from tilia.requests import Get, Post, get, listen, post -from tilia.settings import settings from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.beat.units import MeasureMeter, format_units @@ -28,12 +27,15 @@ from tilia.ui.timelines.beat.context_menu import BeatTimelineUIContextMenu from tilia.ui.timelines.beat.element import BeatUI from tilia.ui.timelines.beat.time_signature import ( - TIME_SIGNATURE_BAND_HEIGHT, LabelState, TimeSignatureLabel, TimeSignatureLabelSpec, ) from tilia.ui.timelines.beat.toolbar import BeatTimelineToolbar +from tilia.ui.timelines.beat_time_signatures import ( + get_creation_args, + set_time_signatures_shown, +) from tilia.ui.timelines.collection.collection import ( TimelineSelector, TimelineUIs, @@ -319,16 +321,9 @@ def update_show_time_signatures(self) -> None: @staticmethod @command_callback def on_toggle_time_signatures(timeline_ui: BeatTimelineUI) -> bool: - collection = get(Get.TIMELINE_COLLECTION) - show = not timeline_ui.get_data("show_time_signatures") - height_change = ( - TIME_SIGNATURE_BAND_HEIGHT if show else -TIME_SIGNATURE_BAND_HEIGHT - ) - collection.set_timeline_data(timeline_ui.id, "show_time_signatures", show) - collection.set_timeline_data( - timeline_ui.id, "height", timeline_ui.get_data("height") + height_change + return set_time_signatures_shown( + timeline_ui.id, not timeline_ui.get_data("show_time_signatures") ) - return True def _get_beat_unit_target_measure(self) -> int | None: """ @@ -537,12 +532,7 @@ def get_additional_args_for_creation(cls, beat_pattern: str | None = None): if not result.is_complete: tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) return False, {} - return True, { - "beat_pattern": beat_pattern, - "show_time_signatures": True, - "height": settings.get("beat_timeline", "default_height") - + TIME_SIGNATURE_BAND_HEIGHT, - } + return True, {"beat_pattern": beat_pattern} | get_creation_args() def on_settings_updated(self, updated_settings): if "beat_timeline" in updated_settings: diff --git a/tilia/ui/timelines/beat_time_signatures.py b/tilia/ui/timelines/beat_time_signatures.py new file mode 100644 index 000000000..de753dd65 --- /dev/null +++ b/tilia/ui/timelines/beat_time_signatures.py @@ -0,0 +1,43 @@ +""" +Showing a beat timeline's time signatures, which also grows the timeline by +the band they are drawn in. Free of Qt, so the CLI shares it with the Qt UI. +""" + +from __future__ import annotations + +from typing import Any + +from tilia.requests import Get, get +from tilia.settings import settings +from tilia.ui.consts import BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + + +def get_creation_args(height: int | None = None) -> dict[str, Any]: + """ + Arguments that give a new beat timeline its time signatures and room for + them. Without `height`, it is the default height plus the band. + """ + if height is None: + height = ( + settings.get("beat_timeline", "default_height") + + BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + ) + return {"show_time_signatures": True, "height": height} + + +def set_time_signatures_shown(timeline_id: int | str, show: bool) -> bool: + """ + Shows or hides a beat timeline's time signatures, growing or shrinking it + by their band. Returns whether anything changed. + """ + collection = get(Get.TIMELINE_COLLECTION) + timeline = collection.get_timeline(timeline_id) + if timeline.show_time_signatures == show: + return False + + height_change = BEAT_TIMELINE_TIME_SIGNATURE_BAND_HEIGHT + if not show: + height_change = -height_change + collection.set_timeline_data(timeline_id, "show_time_signatures", show) + collection.set_timeline_data(timeline_id, "height", timeline.height + height_change) + return True From b2a1e7755f986567b4d7615f18d13f91ee222294 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 7 Oct 2026 11:31:32 +0200 Subject: [PATCH 21/56] style: annotate the beat unit code added on this branch CLAUDE.md asks for type hints on every parameter and return in production code. Ruff's ANN rules flagged 60 omissions on lines this branch adds: overridden element and timeline UI methods, dialog and preview constructors, the CLI parser setup functions and the get_additional_args_for_creation classmethods. --- tilia/timelines/beat/components.py | 10 +++--- tilia/timelines/beat/pattern.py | 6 ++-- tilia/ui/cli/timelines/beat.py | 10 +++--- tilia/ui/timelines/beat/beat_unit.py | 18 +++++----- tilia/ui/timelines/beat/context_menu.py | 9 ++++- tilia/ui/timelines/beat/time_signature.py | 2 +- tilia/ui/timelines/beat/timeline.py | 39 ++++++++++++++------- tilia/ui/timelines/collection/collection.py | 2 +- tilia/ui/timelines/pdf/timeline.py | 4 ++- tilia/ui/timelines/time_signature_glyphs.py | 2 +- tilia/ui/windows/beat_pattern.py | 5 +-- tilia/ui/windows/beat_unit.py | 5 +-- tilia/ui/windows/pattern_preview.py | 2 +- 13 files changed, 71 insertions(+), 43 deletions(-) diff --git a/tilia/timelines/beat/components.py b/tilia/timelines/beat/components.py index ee38ad62b..ba9fc01d0 100644 --- a/tilia/timelines/beat/components.py +++ b/tilia/timelines/beat/components.py @@ -2,7 +2,7 @@ import math from fractions import Fraction -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from tilia.timelines.base.metric_position import MetricPosition from tilia.timelines.base.validators import ( @@ -127,7 +127,7 @@ def __init__( units: str = DEFAULT_UNITS, assumed: bool = False, **_, - ): + ) -> None: self.beat_id = beat_id self.denominator = denominator self.assumed = assumed @@ -137,17 +137,17 @@ def __init__( super().__init__(timeline, id) - def __str__(self): + def __str__(self) -> str: assumed = ", assumed" if self.assumed else "" return ( f"BeatUnit({self.denominator} / {self.units} on beat {self.beat_id}" f"{assumed})" ) - def __repr__(self): + def __repr__(self) -> str: return str(self) - def set_data(self, attr: str, value): + def set_data(self, attr: str, value: Any) -> tuple[Any, bool]: if attr == "units" and validate_units(value): value = format_units(parse_units(value).units) return super().set_data(attr, value) diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py index 733fd8c27..236c76e56 100644 --- a/tilia/timelines/beat/pattern.py +++ b/tilia/timelines/beat/pattern.py @@ -60,21 +60,21 @@ class BarRun: class _Incomplete(Exception): - def __init__(self, message: str, position: int): + def __init__(self, message: str, position: int) -> None: super().__init__(message) self.message = message self.position = position class _Invalid(Exception): - def __init__(self, message: str, position: int): + def __init__(self, message: str, position: int) -> None: super().__init__(message) self.message = message self.position = position class _Parser: - def __init__(self, text: str): + def __init__(self, text: str) -> None: self.text = text self.pos = 0 self.depth = 0 diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index b6deb0dc6..dbc0ac010 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -20,7 +20,7 @@ from tilia.ui.timelines.beat_time_signatures import set_time_signatures_shown -def setup_parser(subparser): +def setup_parser(subparser: argparse._SubParsersAction) -> None: beat_parser = subparser.add_parser( "beat", exit_on_error=False, @@ -33,7 +33,7 @@ def setup_parser(subparser): _setup_time_signatures_parser(beat_subp) -def _setup_pattern_parser(subparser): +def _setup_pattern_parser(subparser: argparse._SubParsersAction) -> None: pattern_parser = subparser.add_parser( "pattern", exit_on_error=False, @@ -54,7 +54,7 @@ def _add_target_args(parser: argparse.ArgumentParser) -> None: ) -def _setup_pattern_set(subparser): +def _setup_pattern_set(subparser: argparse._SubParsersAction) -> None: parser = subparser.add_parser( "set", exit_on_error=False, @@ -82,7 +82,7 @@ def _setup_pattern_set(subparser): parser.set_defaults(func=set_pattern) -def _setup_unit_parser(subparser): +def _setup_unit_parser(subparser: argparse._SubParsersAction) -> None: unit_parser = subparser.add_parser( "unit", exit_on_error=False, @@ -140,7 +140,7 @@ def _setup_unit_parser(subparser): parser.set_defaults(func=list_units) -def _setup_time_signatures_parser(subparser): +def _setup_time_signatures_parser(subparser: argparse._SubParsersAction) -> None: parser = subparser.add_parser( "time-signatures", exit_on_error=False, diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py index 277f77859..88d92d46a 100644 --- a/tilia/ui/timelines/beat/beat_unit.py +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QLineF, QPointF, Qt, QTimer from PySide6.QtGui import QGuiApplication, QPen -from PySide6.QtWidgets import QGraphicsLineItem, QGraphicsScene +from PySide6.QtWidgets import QGraphicsItem, QGraphicsLineItem, QGraphicsScene from tilia.requests import Get, Post, get, post from tilia.timelines.beat.units import MAX_DENOMINATOR, format_units, parse_units @@ -76,7 +76,7 @@ def __init__( get_data: Callable[[str], Any], set_data: Callable[[str, Any], None], **_, - ): + ) -> None: super().__init__( id=id, timeline_ui=timeline_ui, @@ -158,7 +158,7 @@ def right_click_triggers(self) -> list: def double_left_click_triggers(self) -> list: return list(self.labels) - def _set_active_label(self, item) -> None: + def _set_active_label(self, item: QGraphicsItem) -> None: if isinstance(item, TimeSignatureLabel): self.active_measure_index = item.measure_index self._update_highlight() @@ -181,14 +181,14 @@ def _update_highlight(self) -> None: for label in self.labels: label.set_selected(label is highlighted) - def on_left_click(self, item) -> None: + def on_left_click(self, item: QGraphicsItem) -> None: self._set_active_label(item) - def on_double_left_click(self, item) -> None: + def on_double_left_click(self, item: QGraphicsItem) -> None: self._set_active_label(item) commands.execute("timeline.beat.set_beat_unit") - def on_right_click(self, x, y, item) -> None: + def on_right_click(self, x: int, y: int, item: QGraphicsItem) -> None: self._set_active_label(item) super().on_right_click(x, y, item) @@ -209,7 +209,7 @@ def get_inspector_dict(self) -> dict: "Assumed": "Yes" if self.get_data("assumed") else "No", } - def on_inspector_edit(self, field_name: str, value) -> None: + def on_inspector_edit(self, field_name: str, value: int | str) -> None: """ Applies an inspector edit to the measure whose label was clicked, asking first whether it applies there only or until the next change. @@ -234,7 +234,9 @@ def on_inspector_edit(self, field_name: str, value) -> None: lambda: self._apply_inspector_edit(attr, value, measure_index) ) - def _apply_inspector_edit(self, attr: str, value, measure_index: int) -> None: + def _apply_inspector_edit( + self, attr: str, value: int | str, measure_index: int + ) -> None: try: if self.id not in self.timeline_ui.id_to_element: return # deleted in the meantime diff --git a/tilia/ui/timelines/beat/context_menu.py b/tilia/ui/timelines/beat/context_menu.py index d7e3cfc9a..8870a2836 100644 --- a/tilia/ui/timelines/beat/context_menu.py +++ b/tilia/ui/timelines/beat/context_menu.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from tilia.ui.commands import get_qaction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import ( @@ -5,6 +9,9 @@ TimelineUIElementContextMenu, ) +if TYPE_CHECKING: + from tilia.ui.timelines.beat.beat_unit import BeatUnitUI + class BeatContextMenu(TimelineUIElementContextMenu): name = "Beat" @@ -33,7 +40,7 @@ class BeatUnitContextMenu(TimelineUIElementContextMenu): (MenuItemKind.COMMAND, "timeline.component.delete"), ] - def __init__(self, element): + def __init__(self, element: BeatUnitUI) -> None: super().__init__(element) # A measure can hold several beat units after its barlines move. Only # the first has a label, so the others are removed from here. diff --git a/tilia/ui/timelines/beat/time_signature.py b/tilia/ui/timelines/beat/time_signature.py index cd35d4a5c..758b0575a 100644 --- a/tilia/ui/timelines/beat/time_signature.py +++ b/tilia/ui/timelines/beat/time_signature.py @@ -59,7 +59,7 @@ def __init__( spec: TimeSignatureLabelSpec, pixmaps: dict, on_hover: Callable[[TimeSignatureLabel, bool], None] | None = None, - ): + ) -> None: super().__init__() self.spec = spec self.on_hover = on_hover diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 5188e8c8c..623f4d435 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -2,6 +2,8 @@ import copy from collections import defaultdict +from collections.abc import Callable, Iterator +from typing import Any import tilia.errors from tilia.requests import Get, Post, get, listen, post @@ -22,6 +24,7 @@ BEAT_UNIT_ASSUMED_TOOLTIP, BEAT_UNIT_CONFLICT_TOOLTIP, ) +from tilia.ui.timelines.base.element import TimelineUIElement from tilia.ui.timelines.base.timeline import TimelineUI, with_elements from tilia.ui.timelines.beat.beat_unit import BeatUnitUI from tilia.ui.timelines.beat.context_menu import BeatTimelineUIContextMenu @@ -162,30 +165,30 @@ def beat_unit_uis(self) -> list[BeatUnitUI]: # Beat unit elements are only reachable through their labels; iterating, # indexing and counting the timeline UI concern its beats. - def __iter__(self): + def __iter__(self) -> Iterator[BeatUI]: return iter(self.beat_uis) - def __getitem__(self, item): + def __getitem__(self, item: int | slice) -> BeatUI | list[BeatUI]: return self.beat_uis[item] - def __len__(self): + def __len__(self) -> int: return len(self.beat_uis) - def get_next_element(self, element): + def get_next_element(self, element: TimelineUIElement) -> BeatUI | None: beat_uis = self.beat_uis if element not in beat_uis: return None index = beat_uis.index(element) return beat_uis[index + 1] if index + 1 < len(beat_uis) else None - def get_previous_element(self, element): + def get_previous_element(self, element: TimelineUIElement) -> BeatUI | None: beat_uis = self.beat_uis if element not in beat_uis: return None index = beat_uis.index(element) return beat_uis[index - 1] if index > 0 else None - def on_horizontal_arrow_press(self, arrow: str): + def on_horizontal_arrow_press(self, arrow: str) -> None: if any(isinstance(e, BeatUnitUI) for e in self.selected_elements): return super().on_horizontal_arrow_press(arrow) @@ -200,13 +203,19 @@ def time_signature_pixmaps(self) -> dict: def beat_bottom_y(self) -> float: return BeatUI.HEIGHT_TALL - def on_timeline_component_created(self, kind, id, get_data, set_data): + def on_timeline_component_created( + self, + kind: ComponentKind, + id: int, + get_data: Callable[[str], Any], + set_data: Callable[[str, Any], None], + ) -> TimelineUIElement: element = super().on_timeline_component_created(kind, id, get_data, set_data) if kind == ComponentKind.BEAT_UNIT: self.update_time_signatures() return element - def on_timeline_component_deleted(self, id: int): + def on_timeline_component_deleted(self, id: int) -> None: was_beat_unit = isinstance(self.id_to_element.get(id), BeatUnitUI) super().on_timeline_component_deleted(id) if was_beat_unit: @@ -305,7 +314,11 @@ def default_time_signature_labels(self) -> list[TimeSignatureLabel]: @staticmethod def on_inspector_field_edited( - element, field_name: str, value, inspected_id: int, inspector_id: int + element: TimelineUIElement, + field_name: str, + value: Any, + inspected_id: int, + inspector_id: int, ) -> None: if isinstance(element, BeatUnitUI): if inspected_id == element.id: @@ -522,7 +535,9 @@ def on_timeline_components_deserialized(self): self.update_time_signatures() @classmethod - def get_additional_args_for_creation(cls, beat_pattern: str | None = None): + def get_additional_args_for_creation( + cls, beat_pattern: str | None = None + ) -> tuple[bool, dict[str, Any]]: if beat_pattern is None: success, beat_pattern = get(Get.FROM_USER_BEAT_PATTERN) if not success: @@ -572,7 +587,7 @@ def get_copy_data_from_beat_uis(self, beat_uis: list[BeatUI]): return copy_data - def get_copy_data_from_beat_ui(self, beat_ui: BeatUI): + def get_copy_data_from_beat_ui(self, beat_ui: BeatUI) -> dict[str, Any]: data = get_copy_data_from_element(beat_ui, BeatUI.DEFAULT_COPY_ATTRIBUTES) beat_unit = self.timeline.get_beat_unit_on_beat(beat_ui.id) if beat_unit: @@ -584,7 +599,7 @@ def get_copy_data_from_beat_ui(self, beat_ui: BeatUI): return data @with_elements - def on_copy_element(self, elements) -> bool: + def on_copy_element(self, elements: list[TimelineUIElement]) -> bool: # Beat units are copied along with the beats they are on. component_data = self.get_copy_data_from_beat_uis( [e for e in elements if isinstance(e, BeatUI)] diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index 9864f0d17..3d9689131 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -379,7 +379,7 @@ def on_timelines_clear(self): def on_timeline_add( self, cls: type[Timeline], name: str | None = None, **creation_args: Any - ): + ) -> bool | None: """ `creation_args` are values the timeline kind would otherwise prompt for (e.g. `beat_pattern`, `path`), passed to its UI class's diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index 0e9ddcf90..a573884c6 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -55,7 +55,9 @@ def __init__( listen(self, Post.PLAYER_CURRENT_TIME_CHANGED, self.on_media_time_change) @classmethod - def get_additional_args_for_creation(cls, path: str | None = None): + def get_additional_args_for_creation( + cls, path: str | None = None + ) -> tuple[bool, dict[str, str]]: if path is None: success, path = get(Get.FROM_USER_PDF_PATH) return success, {"path": path} diff --git a/tilia/ui/timelines/time_signature_glyphs.py b/tilia/ui/timelines/time_signature_glyphs.py index 99f6602f9..1927234ff 100644 --- a/tilia/ui/timelines/time_signature_glyphs.py +++ b/tilia/ui/timelines/time_signature_glyphs.py @@ -70,7 +70,7 @@ def __init__( digit_height: int, pixmaps: dict[str, QPixmap], color: QColor | None = None, - ): + ) -> None: super().__init__() self.pixmaps = pixmaps self.color = color diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index 869cd969c..ab374a3e9 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -9,6 +9,7 @@ QDialogButtonBox, QLabel, QLineEdit, + QPushButton, QVBoxLayout, ) @@ -145,7 +146,7 @@ def __init__( initial_text: str = "", beat_count: int | None = None, get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, - ): + ) -> None: super().__init__( get(Get.MAIN_WINDOW), Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, @@ -181,7 +182,7 @@ def __init__( self._on_text_changed(initial_text) @property - def ok_button(self): + def ok_button(self) -> QPushButton: return self.button_box.button(QDialogButtonBox.StandardButton.Ok) @property diff --git a/tilia/ui/windows/beat_unit.py b/tilia/ui/windows/beat_unit.py index 3186792b5..8c40ed091 100644 --- a/tilia/ui/windows/beat_unit.py +++ b/tilia/ui/windows/beat_unit.py @@ -12,6 +12,7 @@ QFormLayout, QLineEdit, QMessageBox, + QPushButton, QRadioButton, QSpinBox, QVBoxLayout, @@ -106,7 +107,7 @@ def __init__( units: str, beat_count: int | None = None, show_scope: bool = True, - ): + ) -> None: super().__init__( get(Get.MAIN_WINDOW), Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, @@ -156,7 +157,7 @@ def __init__( self._on_input_changed() @property - def ok_button(self): + def ok_button(self) -> QPushButton: return self.button_box.button(QDialogButtonBox.StandardButton.Ok) @property diff --git a/tilia/ui/windows/pattern_preview.py b/tilia/ui/windows/pattern_preview.py index 0c9534ddd..52bebd5f8 100644 --- a/tilia/ui/windows/pattern_preview.py +++ b/tilia/ui/windows/pattern_preview.py @@ -35,7 +35,7 @@ class PreviewContent: class PatternPreview(QWidget): - def __init__(self, parent: QWidget | None = None): + def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) From 5b931f7305019bc43760a7964cf40dfd7d87df3a Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:12:05 +0200 Subject: [PATCH 22/56] style: type MeasureMeter.beat_unit_id as the string id it holds App.get_id returns component ids as strings, so the governing beat unit's id is a str at runtime; ints only appear in tests. The docstring now also says what the field holds in tilia_core's copy of units.py: the id of the beat that carries the governing unit, since tilia_core reads a .tla beat unit as part of its beat. Co-Authored-By: Claude Opus 5.5 --- tilia/timelines/beat/units.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 7295d85d6..675984018 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -95,15 +95,19 @@ def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool: @dataclass(frozen=True) class MeasureMeter: """ - The meter a measure ends up with. `beat_unit_id` is the beat unit that - governs the measure, or None where the default applies. `is_assumed` is - True when its values weren't set by the user (see BeatUnit.assumed). + The meter a measure ends up with. `beat_unit_id` names the beat unit + that governs the measure, or is None where the default applies. In the + app it is that BeatUnit component's own id. In tilia_core's copy of this + module it is the id of the beat that carries the governing unit, since + tilia_core reads a .tla beat unit as part of its beat, without an id of + its own. `is_assumed` is True when its values weren't set by the user + (see BeatUnit.assumed). """ denominator: int units: list[Fraction] beat_values: list[Fraction] - beat_unit_id: int | None + beat_unit_id: str | None starts_here: bool is_ambiguous: bool is_conflicting: bool From 060b2abe658dd08064827d9ccf2f77b4d7ea7517 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:15:17 +0200 Subject: [PATCH 23/56] fix: make MeasureMeter hashable MeasureMeter is frozen but held its units and beat values in lists, so hash(meter) raised TypeError and a set of meters, or a cache keyed on one, couldn't be built; the lists could also still be changed in place. Both fields are now tuples. format_units takes any sequence, since the time signature labels pass it a meter's fields. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 18 ++++++++++++++---- tilia/timelines/beat/timeline.py | 4 ++-- tilia/timelines/beat/units.py | 7 ++++--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 9663afce0..738e15db6 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -160,7 +160,7 @@ def test_ambiguous_fit(self, beat_tl): add_beat_unit(beat_tl, 0, 4, "2+3") meters = beat_tl.measure_meters - assert [m.beat_values for m in meters] == [[2, 3], [2, 3, 2]] + assert [m.beat_values for m in meters] == [(2, 3), (2, 3, 2)] assert [m.is_ambiguous for m in meters] == [False, True] def test_short_measures_take_the_start_of_the_pattern(self, beat_tl): @@ -169,7 +169,7 @@ def test_short_measures_take_the_start_of_the_pattern(self, beat_tl): add_beat_unit(beat_tl, 0, 4, "1+2+3") meters = beat_tl.measure_meters - assert [m.beat_values for m in meters] == [[1, 2]] * 3 + assert [m.beat_values for m in meters] == [(1, 2)] * 3 assert all(m.is_ambiguous for m in meters) def test_pickup_also_takes_the_start_of_the_pattern(self, beat_tl): @@ -177,8 +177,8 @@ def test_pickup_also_takes_the_start_of_the_pattern(self, beat_tl): add_beats(beat_tl, 3) add_beat_unit(beat_tl, 0, 4, "3+2") - assert beat_tl.get_measure_meter(0).beat_values == [3] - assert beat_tl.get_measure_meter(1).beat_values == [3, 2] + assert beat_tl.get_measure_meter(0).beat_values == (3,) + assert beat_tl.get_measure_meter(1).beat_values == (3, 2) def test_two_beat_units_in_a_measure_first_wins(self, beat_tl): add_beats(beat_tl, 8) @@ -202,6 +202,16 @@ def test_beat_unit_follows_its_beat_when_barlines_move(self, beat_tl): assert time_signatures(beat_tl) == [(2, 4), (2, 4), (2, 4), (6, 8)] + def test_meters_can_be_hashed(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + + first, second, third = beat_tl.measure_meters + + # The last two are both 2/4 under the first measure's beat unit. + assert hash(second) == hash(third) + assert set(beat_tl.measure_meters) == {first, second} + def test_cache_is_refreshed_when_units_change(self, beat_tl): add_beats(beat_tl, 4) beat_unit = add_beat_unit(beat_tl, 0, 4, "1") diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 1827eab00..0a5aa44fd 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -786,8 +786,8 @@ def _compute_measure_meters(self) -> list[MeasureMeter]: meters.append( MeasureMeter( denominator=denominator, - units=units, - beat_values=fit_units(units, beat_count), + units=tuple(units), + beat_values=tuple(fit_units(units, beat_count)), beat_unit_id=governing.id if governing else None, starts_here=bool(in_measure), is_ambiguous=is_fit_ambiguous(units, beat_count), diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 675984018..d4baf462f 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -10,6 +10,7 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from fractions import Fraction @@ -68,7 +69,7 @@ def parse_units(text: str) -> UnitsParseResult: return UnitsParseResult(units) -def format_units(units: list[Fraction]) -> str: +def format_units(units: Sequence[Fraction]) -> str: """Writes units in canonical form, e.g. [2, 3] as "2+3".""" return "+".join(str(unit) for unit in units) @@ -105,8 +106,8 @@ class MeasureMeter: """ denominator: int - units: list[Fraction] - beat_values: list[Fraction] + units: tuple[Fraction, ...] + beat_values: tuple[Fraction, ...] beat_unit_id: str | None starts_here: bool is_ambiguous: bool From 4d9acff6993d011e964727d4cee79dcd41a39aa0 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:17:21 +0200 Subject: [PATCH 24/56] fix: keep quarter lengths exact for whole-number beat values MeasureMeter.quarter_length computed beat_value * 4 / denominator, which is a float when beat_value is an int: quarter_length(1) under a denominator of 6 gave 0.666..., not 2/3. Beat values from the timeline are always Fractions, so nothing reached this yet. The value is now converted to a Fraction first, so the result is exact whatever it is given. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 10 ++++++++++ tilia/timelines/beat/units.py | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 738e15db6..7db754e21 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -212,6 +212,16 @@ def test_meters_can_be_hashed(self, beat_tl): assert hash(second) == hash(third) assert set(beat_tl.measure_meters) == {first, second} + @pytest.mark.parametrize("beat_value", [1, F(1)]) + def test_quarter_length_is_exact(self, beat_tl, beat_value): + add_beats(beat_tl, 3) + add_beat_unit(beat_tl, 0, 6, "1") + + length = beat_tl.get_measure_meter(0).quarter_length(beat_value) + + assert isinstance(length, F) + assert length == F(2, 3) + def test_cache_is_refreshed_when_units_change(self, beat_tl): add_beats(beat_tl, 4) beat_unit = add_beat_unit(beat_tl, 0, 4, "1") diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index d4baf462f..6f1a07bb2 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -122,5 +122,6 @@ def numerator(self) -> Fraction: def time_signature(self) -> tuple[Fraction, int]: return self.numerator, self.denominator - def quarter_length(self, beat_value: Fraction) -> Fraction: - return beat_value * 4 / self.denominator + def quarter_length(self, beat_value: Fraction | int) -> Fraction: + # An int divided by the denominator would give a float. + return Fraction(beat_value) * 4 / self.denominator From c8a39bb1f885e1cb2b1a399fe0fb969f5de16c9b Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:19:15 +0200 Subject: [PATCH 25/56] fix: refuse units longer than 32 characters parse_units passed each unit to Fraction(), and whether that reads a very long integer depends on the Python version: 3.10.7 and later refuse more than 4300 digits, earlier versions read any length. The same file then got different meters on different Pythons, and the error repeated the whole unit. A unit of more than 32 characters, far above anything written in notation, is now refused before Fraction() sees it, with the error any unreadable unit gets. The message shows at most its first 32 characters, followed by an ellipsis. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 37 +++++++++++++++++++++++++ tilia/timelines/beat/units.py | 13 +++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 7db754e21..4fff47468 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -1,9 +1,11 @@ +import sys from fractions import Fraction as F import pytest from tilia.timelines.beat.units import ( MAX_DENOMINATOR, + MAX_UNIT_LENGTH, fit_units, format_units, is_fit_ambiguous, @@ -39,6 +41,41 @@ def test_invalid(self, text): assert result.error assert result.units == [] + def test_longest_unit_is_read(self): + assert parse_units("1" * MAX_UNIT_LENGTH).is_valid + + def test_longer_unit_is_refused_and_cut_short_in_the_message(self): + result = parse_units("1" * (MAX_UNIT_LENGTH + 1)) + + assert not result.is_valid + assert result.units == [] + assert result.error == ( + f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction " + "such as 1/2." + ) + + @pytest.mark.skipif( + not hasattr(sys, "set_int_max_str_digits"), + reason="this Python has no limit on the digits int() reads", + ) + @pytest.mark.parametrize("digit_limit", [0, 4300]) + def test_very_long_unit_is_refused_whatever_the_digit_limit(self, digit_limit): + # Python 3.10.7 and later refuse to read an integer of more than 4300 + # digits by default; earlier versions read any length, as 0 does. + before = sys.get_int_max_str_digits() + sys.set_int_max_str_digits(digit_limit) + try: + result = parse_units("1" * 5000) + finally: + sys.set_int_max_str_digits(before) + + assert not result.is_valid + assert result.units == [] + assert result.error == ( + f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction " + "such as 1/2." + ) + def test_format(self): assert format_units([F(2), F(3)]) == "2+3" assert format_units([F(1, 3)]) == "1/3" diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 6f1a07bb2..3ca97a740 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -19,6 +19,10 @@ # Far above any denominator used in notation. Enforced wherever a beat unit # is set, so the dialog and inspector, which can't show more, never meet one. MAX_DENOMINATOR = 128 +# Far above any unit written in notation, and far below the 4300 digits +# Python 3.10.7 and later refuse to read as an int (earlier versions have no +# limit), so a unit reads the same on every Python. +MAX_UNIT_LENGTH = 32 _UNIT_CHARACTERS = set("0123456789/") @@ -51,12 +55,15 @@ def parse_units(text: str) -> UnitsParseResult: item = item.strip() if not item: return UnitsParseResult([], "Missing unit around '+'.") + too_long = len(item) > MAX_UNIT_LENGTH + shown = item[:MAX_UNIT_LENGTH] + "…" if too_long else item not_a_fraction = UnitsParseResult( - [], f"'{item}' is not a whole number or fraction such as 1/2." + [], f"'{shown}' is not a whole number or fraction such as 1/2." ) # Fraction() also accepts decimals, signs and exponents; units are - # written the way they are in notation. - if not set(item) <= _UNIT_CHARACTERS: + # written the way they are in notation. A longer unit is refused + # before Fraction() sees it, which may or may not read it. + if too_long or not set(item) <= _UNIT_CHARACTERS: return not_a_fraction try: unit = Fraction(item) From 273f7c0c8e4ed0e89408485e274ea6ddfde7e9d7 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:22:51 +0200 Subject: [PATCH 26/56] fix: mark the first measure's copied beat unit as assumed When the first measure has no beat unit, as after adding a pickup before the first beat or when opening a file without one there, _ensure_first_beat_unit copies the next beat unit's denominator and units onto the first beat. It also copied that unit's assumed flag, so the measures before it read as set by the user. Nobody set a time signature on them: the copy is now always assumed, as the default unit it replaces would be. Co-Authored-By: Claude Opus 5.5 --- .../timelines/beat/test_beat_timeline_ui.py | 49 ++++++++++++++++++- tilia/timelines/beat/timeline.py | 11 +++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 039cc7cb9..60dfabc4a 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -2086,7 +2086,7 @@ def test_deleting_the_first_beat_unit_makes_it_assumed_again(self, tluis): assert assumed_flags(tluis[0].timeline) == [True] - def test_pickup_copies_the_flag_of_the_next_beat_unit(self, tluis): + def test_pickup_copy_of_the_next_beat_unit_is_assumed(self, tluis): add_beat_timeline_with_pattern("1 2") add_beats(range(1, 5)) set_beat_unit(tluis[0], 0, 8, "3") @@ -2095,7 +2095,13 @@ def test_pickup_copies_the_flag_of_the_next_beat_unit(self, tluis): commands.execute("media.seek", 0) commands.execute("timeline.beat.add") - assert assumed_flags(tluis[0].timeline) == [False, False] + # It gets the next beat unit's values, but nobody set them there. + timeline = tluis[0].timeline + assert assumed_flags(timeline) == [True, False] + assert [(u.denominator, u.units) for u in timeline.beat_units] == [ + (8, "3"), + (8, "3"), + ] def test_beat_inspector_says_assumed(self, qtui, tluis): add_beat_timeline_with_pattern("2") @@ -2130,6 +2136,45 @@ def test_file_from_before_beat_units_loads_assumed(self, resources, tluis): assert assumed_flags(tluis[0].timeline) == [True] + def test_file_without_a_first_beat_unit_loads_its_copy_assumed( + self, tluis, tmp_path + ): + # TiLiA always keeps a beat unit in the first measure, so it can't + # write such a file: remove that beat unit from a saved one. + add_beat_timeline_with_pattern("2") + add_beats(range(6)) + set_beat_unit(tluis[0], 2, 8, "3") + first_beat_id = tluis[0].timeline[0].id + path = save_tilia_to_tmp_path(tmp_path) + with open(path, encoding="utf-8") as f: + data = json.load(f) + components = next( + tl["components"] + for tl in data["timelines"].values() + if tl["kind"] == "Beat" + ) + (first_unit_id,) = [ + id + for id, component in components.items() + if component["kind"] == "BEAT_UNIT" + and component["beat_id"] == first_beat_id + ] + del components[first_unit_id] + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + post(Post.APP_CLEAR) + + commands.execute("file.open", path=path) + + # The first measure gets a copy of the beat unit on bar 3, but nobody + # set a time signature on the bars before it. + meters = tluis[0].timeline.measure_meters + assert [(m.time_signature, m.is_assumed) for m in meters] == [ + ((6, 8), True), + ((6, 8), True), + ((6, 8), False), + ] + def test_pasted_beat_unit_keeps_its_flag(self, tluis): add_beat_timeline_with_pattern("2") add_beats(range(4)) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 0a5aa44fd..b7bcd6a4e 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -1021,8 +1021,10 @@ def _ensure_first_beat_unit(self, copy_next: bool = True) -> None: When one is missing, e.g. because a pickup was added before the old first beat, it copies the next beat unit's values (a pickup in a 6/8 piece starts as 6/8); with no other beat units, or `copy_next` False, - it gets the default. Skipped while beats are being changed in bulk, - which recalculates and calls this once at the end. + it gets the default. Either way it is assumed: nobody set a time + signature on the measures before the next beat unit. Skipped while + beats are being changed in bulk, which recalculates and calls this + once at the end. """ manager = self.component_manager if ( @@ -1040,15 +1042,14 @@ def _ensure_first_beat_unit(self, copy_next: bool = True) -> None: beat_units = self.beat_units if copy_next and beat_units: denominator, units = beat_units[0].denominator, beat_units[0].units - assumed = beat_units[0].assumed else: - denominator, units, assumed = DEFAULT_DENOMINATOR, DEFAULT_UNITS, True + denominator, units = DEFAULT_DENOMINATOR, DEFAULT_UNITS self.create_component( ComponentKind.BEAT_UNIT, beat_id=self[0].id, denominator=denominator, units=units, - assumed=assumed, + assumed=True, ) def put_beat_unit_on_beat( From b7df5cc20c5cf8f54b4d6d50f87c68f42f175505 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:28:20 +0200 Subject: [PATCH 27/56] fix: name the governing beat unit by the beat that carries it MeasureMeter.beat_unit_id was the BeatUnit component's own id, while tilia-core's copy of units.py, which reads a .tla beat unit as part of its beat, gives the id of that beat. It is now the beat's id here too: a beat holds at most one unit, so its id names the unit. Its three users change with it. _put_beat_unit uses the id as it is, `beat unit remove` deletes get_beat_unit_on_beat(...), and the time signature labels are keyed by the unit's beat. With the component's id gone, the CLI would otherwise have deleted the beat itself once the app uses the core's meters; its test now checks that no beat goes. The docstring gives the field one meaning. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 8 +++++--- .../cli/timelines/test_cli_timelines_beat_units.py | 2 ++ tilia/timelines/beat/timeline.py | 4 ++-- tilia/timelines/beat/units.py | 13 ++++++------- tilia/ui/cli/timelines/beat.py | 2 +- tilia/ui/timelines/beat/timeline.py | 6 +++--- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 4fff47468..5ce9d2335 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -154,7 +154,7 @@ def test_first_beat_gets_the_default_beat_unit(self, beat_tl): assert time_signatures(beat_tl) == [(4, 4), (3, 4)] assert beat_unit_values(beat_tl) == [(0, 4, "1")] first = first_beat_unit(beat_tl) - assert [m.beat_unit_id for m in beat_tl.measure_meters] == [first.id] * 2 + assert [m.beat_unit_id for m in beat_tl.measure_meters] == [first.beat_id] * 2 assert [m.starts_here for m in beat_tl.measure_meters] == [True, False] def test_empty_timeline(self, beat_tl): @@ -169,7 +169,9 @@ def test_beat_unit_applies_until_next_change(self, beat_tl): meters = beat_tl.measure_meters assert [m.starts_here for m in meters] == [True, True, False, False] first = first_beat_unit(beat_tl) - assert [m.beat_unit_id for m in meters] == [first.id] + [beat_unit.id] * 3 + assert [m.beat_unit_id for m in meters] == [first.beat_id] + [ + beat_unit.beat_id + ] * 3 def test_beat_unit_on_a_later_beat_applies_from_its_measure(self, beat_tl): add_beats(beat_tl, 8) @@ -224,7 +226,7 @@ def test_two_beat_units_in_a_measure_first_wins(self, beat_tl): meter = beat_tl.get_measure_meter(1) assert meter.is_conflicting - assert meter.beat_unit_id == first.id + assert meter.beat_unit_id == first.beat_id assert meter.denominator == 8 def test_beat_unit_follows_its_beat_when_barlines_move(self, beat_tl): diff --git a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py b/tests/ui/cli/timelines/test_cli_timelines_beat_units.py index ed1eeb087..06d426cc1 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py +++ b/tests/ui/cli/timelines/test_cli_timelines_beat_units.py @@ -76,10 +76,12 @@ def test_repeated_measure_number_is_refused(self, cli, beat_tl): class TestRemoveUnit: def test_remove(self, cli, beat_tl): cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + beat_count = len(beat_tl) cli.parse_and_run("timelines beat unit remove -o 1 -m 2") assert_only_the_default_beat_unit(beat_tl) + assert len(beat_tl) == beat_count def test_no_unit_at_measure(self, cli, beat_tl): cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index b7bcd6a4e..0a41e2dbc 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -788,7 +788,7 @@ def _compute_measure_meters(self) -> list[MeasureMeter]: denominator=denominator, units=tuple(units), beat_values=tuple(fit_units(units, beat_count)), - beat_unit_id=governing.id if governing else None, + beat_unit_id=governing.beat_id if governing else None, starts_here=bool(in_measure), is_ambiguous=is_fit_ambiguous(units, beat_count), is_conflicting=len(in_measure) > 1, @@ -861,7 +861,7 @@ def _put_beat_unit( ) -> None: meter = self.get_measure_meter(measure_index) if meter.starts_here: - beat_id = self.get_component(meter.beat_unit_id).beat_id + beat_id = meter.beat_unit_id else: beat_id = self[self.beats_that_start_measures[measure_index]].id self.put_beat_unit_on_beat(beat_id, denominator, units, assumed) diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 3ca97a740..ead86ee48 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -103,13 +103,12 @@ def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool: @dataclass(frozen=True) class MeasureMeter: """ - The meter a measure ends up with. `beat_unit_id` names the beat unit - that governs the measure, or is None where the default applies. In the - app it is that BeatUnit component's own id. In tilia_core's copy of this - module it is the id of the beat that carries the governing unit, since - tilia_core reads a .tla beat unit as part of its beat, without an id of - its own. `is_assumed` is True when its values weren't set by the user - (see BeatUnit.assumed). + The meter a measure ends up with. `beat_unit_id` is the id of the beat + that carries the beat unit governing the measure (a beat holds at most + one), or None where no unit set on a beat governs it: in tilia_core, the + measures before the first unit, which take its values, assumed, and every + measure when there is none. `is_assumed` is True when its values weren't + set by the user (see BeatUnit.assumed). """ denominator: int diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index dbc0ac010..4a5d07925 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -276,7 +276,7 @@ def remove_unit(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: if not meter.starts_here: io.error("No beat unit starts at that measure.") return - timeline.delete_components([timeline.get_component(meter.beat_unit_id)]) + timeline.delete_components([timeline.get_beat_unit_on_beat(meter.beat_unit_id)]) @with_timeline diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 623f4d435..390c7360a 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -256,8 +256,8 @@ def get_time_signature_label_specs( self, ) -> dict[int | str | None, list[TimeSignatureLabelSpec]]: """ - Labels to show, by the id of the beat unit that governs them (None - for the default). A label is shown where the time signature changes, + Labels to show, by the id of the beat that carries the beat unit + governing them (None where no unit set on a beat does). A label is shown where the time signature changes, where a beat unit starts, and where a measure needs attention. """ specs = defaultdict(list) @@ -296,7 +296,7 @@ def get_time_signature_label_specs( def update_time_signatures(self) -> None: specs = self.get_time_signature_label_specs() for beat_unit_ui in self.beat_unit_uis: - beat_unit_ui.set_labels(specs.get(beat_unit_ui.id, [])) + beat_unit_ui.set_labels(specs.get(beat_unit_ui.get_data("beat_id"), [])) for label in self._default_time_signature_labels: self.scene.removeItem(label.body) From c5c9356e8999f760b7f9569219c1300ce63a0778 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:29:35 +0200 Subject: [PATCH 28/56] fix: say that a unit is too long, not that it isn't a number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A unit of more than MAX_UNIT_LENGTH characters was refused with the error of any unreadable unit, "'1111…' is not a whole number or fraction such as 1/2.", even when it was a whole number. It now says what is wrong: "'1111…' is longer than 32 characters." Units within the limit get the same messages as before. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 6 ++---- tilia/timelines/beat/units.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 5ce9d2335..150bcd906 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -50,8 +50,7 @@ def test_longer_unit_is_refused_and_cut_short_in_the_message(self): assert not result.is_valid assert result.units == [] assert result.error == ( - f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction " - "such as 1/2." + f"'{'1' * MAX_UNIT_LENGTH}…' is longer than {MAX_UNIT_LENGTH} characters." ) @pytest.mark.skipif( @@ -72,8 +71,7 @@ def test_very_long_unit_is_refused_whatever_the_digit_limit(self, digit_limit): assert not result.is_valid assert result.units == [] assert result.error == ( - f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction " - "such as 1/2." + f"'{'1' * MAX_UNIT_LENGTH}…' is longer than {MAX_UNIT_LENGTH} characters." ) def test_format(self): diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index ead86ee48..cbf2cf56d 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -55,15 +55,19 @@ def parse_units(text: str) -> UnitsParseResult: item = item.strip() if not item: return UnitsParseResult([], "Missing unit around '+'.") - too_long = len(item) > MAX_UNIT_LENGTH - shown = item[:MAX_UNIT_LENGTH] + "…" if too_long else item + if len(item) > MAX_UNIT_LENGTH: + # Refused before Fraction() sees it, which may or may not read it. + return UnitsParseResult( + [], + f"'{item[:MAX_UNIT_LENGTH]}…' is longer than {MAX_UNIT_LENGTH} " + "characters.", + ) not_a_fraction = UnitsParseResult( - [], f"'{shown}' is not a whole number or fraction such as 1/2." + [], f"'{item}' is not a whole number or fraction such as 1/2." ) # Fraction() also accepts decimals, signs and exponents; units are - # written the way they are in notation. A longer unit is refused - # before Fraction() sees it, which may or may not read it. - if too_long or not set(item) <= _UNIT_CHARACTERS: + # written the way they are in notation. + if not set(item) <= _UNIT_CHARACTERS: return not_a_fraction try: unit = Fraction(item) From 05b4184a742a88b9ae6942bdf726e1ae59d68727 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:30:11 +0200 Subject: [PATCH 29/56] fix: keep a MeasureMeter's units and beat values as tuples however it is built MeasureMeter is frozen and its units and beat_values are typed as tuples, but it held tuples only because _compute_measure_meters passed them. Built from lists, a meter could not be hashed and compared unequal to the same meter built from tuples. It now turns both into tuples itself, in __post_init__. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 25 +++++++++++++++++++++++++ tilia/timelines/beat/units.py | 5 +++++ 2 files changed, 30 insertions(+) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 150bcd906..0dd48052a 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -6,6 +6,7 @@ from tilia.timelines.beat.units import ( MAX_DENOMINATOR, MAX_UNIT_LENGTH, + MeasureMeter, fit_units, format_units, is_fit_ambiguous, @@ -114,6 +115,30 @@ def test_ambiguous(self, units, beat_count, ambiguous): assert is_fit_ambiguous(units, beat_count) == ambiguous +def make_meter(**fields): + values = dict( + denominator=4, + units=(F(1),), + beat_values=(F(1), F(1)), + beat_unit_id=None, + starts_here=True, + is_ambiguous=False, + is_conflicting=False, + is_assumed=True, + ) + return MeasureMeter(**(values | fields)) + + +class TestMeasureMeter: + def test_built_from_lists_it_holds_tuples(self): + from_lists = make_meter(units=[F(1)], beat_values=[F(1), F(1)]) + + assert from_lists.units == (F(1),) + assert from_lists.beat_values == (F(1), F(1)) + assert hash(from_lists) == hash(make_meter()) + assert from_lists == make_meter() + + def add_beats(beat_tl, count, start=0): for i in range(count): beat_tl.create_beat(start + i) diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index cbf2cf56d..5c713e681 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -124,6 +124,11 @@ class MeasureMeter: is_conflicting: bool is_assumed: bool + def __post_init__(self) -> None: + # Tuples, whatever the caller passes, so that a meter can be hashed. + object.__setattr__(self, "units", tuple(self.units)) + object.__setattr__(self, "beat_values", tuple(self.beat_values)) + @property def numerator(self) -> Fraction: return sum(self.beat_values, Fraction(0)) From 150861b6659fa8395d977b5b58cf56f073faa639 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:30:43 +0200 Subject: [PATCH 30/56] fix: make only a whole number exact in quarter_length quarter_length passed its argument to Fraction() so that an int stays exact, but Fraction() also reads text and floats: quarter_length("1/2") returned a value, and quarter_length(0.1) the float's exact binary value. Only an int is now made a Fraction, so text raises TypeError as before. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 4 ++++ tilia/timelines/beat/units.py | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 0dd48052a..63fa99d68 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -138,6 +138,10 @@ def test_built_from_lists_it_holds_tuples(self): assert hash(from_lists) == hash(make_meter()) assert from_lists == make_meter() + def test_quarter_length_reads_no_text(self): + with pytest.raises(TypeError): + make_meter().quarter_length("1/2") + def add_beats(beat_tl, count, start=0): for i in range(count): diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 5c713e681..537e3be97 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -138,5 +138,8 @@ def time_signature(self) -> tuple[Fraction, int]: return self.numerator, self.denominator def quarter_length(self, beat_value: Fraction | int) -> Fraction: - # An int divided by the denominator would give a float. - return Fraction(beat_value) * 4 / self.denominator + # An int divided by the denominator would give a float. Only an int + # is made a Fraction: Fraction() would also read text and floats. + if isinstance(beat_value, int): + beat_value = Fraction(beat_value) + return beat_value * 4 / self.denominator From ec8f5d3201f9a0d9f3b16e4d7e826c98813fabcd Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:06:57 +0200 Subject: [PATCH 31/56] fix: refuse fractions in units with a denominator above 128 A unit could be any fraction of up to MAX_UNIT_LENGTH characters, such as 1/100000000000000000000000000001. Quarter stamps are exact sums of the beats' lengths, so a file whose measures each bring a new large denominator made the stamps' denominators grow by some 30 digits a measure: building them for a few thousand measures took seconds, and from about 150 measures on a stamp had more digits than Python writes as text. parse_units now refuses a fraction whose denominator, in lowest terms, is above MAX_DENOMINATOR (128), the limit of a beat unit's own denominator, with the message of any unreadable unit: "'1/129' is not a whole number or fraction such as 1/2." Whole numbers are unchanged. Every sum of units then has a denominator that divides one fixed number. Part of TimeLineAnnotator/project-management#247 Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 44 +++++++++++- tests/timelines/beat/test_quarter_stamps.py | 80 +++++++++++++++++++++ tilia/timelines/beat/units.py | 9 ++- 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 63fa99d68..05e0fa6ba 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -42,6 +42,48 @@ def test_invalid(self, text): assert result.error assert result.units == [] + @pytest.mark.parametrize( + "text, units", + [ + ("1/128", [F(1, 128)]), + ("127/128", [F(127, 128)]), + ("1+1/128", [F(1), F(1, 128)]), + # The limit is on the fraction in lowest terms, as it is stored. + ("2/256", [F(1, 128)]), + ], + ) + def test_denominator_up_to_the_limit_is_read(self, text, units): + result = parse_units(text) + assert result.is_valid + assert result.units == units + + @pytest.mark.parametrize( + "text, item", + [ + ("1/129", "1/129"), + ("3/7919", "3/7919"), + ("1+1/129", "1/129"), + ("1/" + str(10**29 + 1), "1/" + str(10**29 + 1)), + ], + ) + def test_larger_denominator_is_refused(self, text, item): + result = parse_units(text) + + assert not result.is_valid + assert result.units == [] + assert ( + result.error == f"'{item}' is not a whole number or fraction such as 1/2." + ) + + @pytest.mark.parametrize( + "text, units", + [("129", [F(129)]), ("7919", [F(7919)]), ("258/2", [F(129)])], + ) + def test_whole_numbers_have_no_denominator_to_limit(self, text, units): + result = parse_units(text) + assert result.is_valid + assert result.units == units + def test_longest_unit_is_read(self): assert parse_units("1" * MAX_UNIT_LENGTH).is_valid @@ -318,7 +360,7 @@ def test_two_on_the_same_beat_fails(self, beat_tl): @pytest.mark.parametrize( "denominator, units", - [(0, "1"), (MAX_DENOMINATOR + 1, "1"), (4, "0"), (4, "a")], + [(0, "1"), (MAX_DENOMINATOR + 1, "1"), (4, "0"), (4, "a"), (4, "1/129")], ) def test_invalid_values_fail(self, beat_tl, denominator, units): add_beats(beat_tl, 2) diff --git a/tests/timelines/beat/test_quarter_stamps.py b/tests/timelines/beat/test_quarter_stamps.py index 26f9ec03b..150d60dfe 100644 --- a/tests/timelines/beat/test_quarter_stamps.py +++ b/tests/timelines/beat/test_quarter_stamps.py @@ -1,9 +1,12 @@ +import math from fractions import Fraction as F +from time import perf_counter from unittest.mock import patch import pytest from tilia.timelines.beat.timeline import BeatTimeline +from tilia.timelines.beat.units import MAX_DENOMINATOR def add_beats(beat_tl, times): @@ -155,3 +158,80 @@ def test_table_is_rebuilt_after_a_change(self, beat_tl): beat_tl.set_component_data(beat_tl[3].id, "time", 4) assert beat_tl.get_quarter_stamp_by_time(4) == pytest.approx(3) + + +def load_two_tap_measures(beat_tl, units: list[str]) -> None: + """ + Loads, as from a file, a measure of two taps for each of `units`, with a + beat unit of 4 / that text on its first beat. + """ + beat_tl.set_data("beat_pattern", [2]) + step = 50 / len(units) # Within the tests' 100 s of media. + components = {} + for index, text in enumerate(units): + first_beat_id = str(1_000_000 + 2 * index) + for tap in range(2): + components[str(1_000_000 + 2 * index + tap)] = { + "time": (2 * index + tap) * step, + "kind": "BEAT", + } + components[str(2_000_000 + index)] = { + "beat_id": first_beat_id, + "denominator": 4, + "units": text, + "assumed": False, + "kind": "BEAT_UNIT", + } + beat_tl.component_manager.deserialize_components(components) + + +class TestLargeDenominators: + """ + A beat's quarter stamp is the exact sum of the lengths before it, so it + takes in every denominator the units before it bring. Units such as + 1/(10**29 + i) in measure i would make that sum's denominator grow by + some 30 digits a measure: building the stamps of a few thousand measures + would take seconds, and from about 150 measures on a stamp would have + more digits than Python writes as text. A unit's denominator is at most + MAX_DENOMINATOR, so every stamp's denominator divides a fixed number. + """ + + MEASURE_COUNT = 1000 + TIME_LIMIT = 1 # Seconds; building the stamps takes a few milliseconds. + + def build_stamps(self, beat_tl) -> tuple[list[F], float]: + start = perf_counter() + stamps = beat_tl.quarter_stamps + beat_tl.get_quarter_stamp_by_time(0) + return stamps, perf_counter() - start + + def test_a_file_with_larger_denominators_loads_without_them( + self, beat_tl, tilia_errors + ): + load_two_tap_measures( + beat_tl, [f"1/{10**29 + i}" for i in range(self.MEASURE_COUNT)] + ) + + stamps, elapsed = self.build_stamps(beat_tl) + + # Only the beat unit TiLiA gives the first beat is left. + assert [(u.denominator, u.units, u.assumed) for u in beat_tl.beat_units] == [ + (4, "1", True) + ] + assert stamps == list(range(2 * self.MEASURE_COUNT)) + assert elapsed < self.TIME_LIMIT + tilia_errors.assert_in_error_message(f"Invalid units: 1/{10**29}.") + + def test_stamps_stay_small_with_every_denominator_allowed(self, beat_tl): + load_two_tap_measures( + beat_tl, + [f"1/{i % MAX_DENOMINATOR + 1}" for i in range(self.MEASURE_COUNT)], + ) + + stamps, elapsed = self.build_stamps(beat_tl) + + assert len(beat_tl.beat_units) == self.MEASURE_COUNT + every_denominator = math.lcm(*range(1, MAX_DENOMINATOR + 1)) + assert all(every_denominator % stamp.denominator == 0 for stamp in stamps) + assert str(stamps[-1]) + assert elapsed < self.TIME_LIMIT diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 537e3be97..2ef3be79c 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -4,7 +4,8 @@ A beat unit is a denominator plus a units pattern, the number of denominator units each tapped beat in a measure is worth. Units are written as a "+"-separated list of whole numbers or fractions: "1", "3", "2+3", -"1/2", "1/3". The time signature of a measure is calculated from its beat +"1/2", "1/3". A fraction's denominator, in lowest terms, is at most +MAX_DENOMINATOR. The time signature of a measure is calculated from its beat unit and number of tapped beats, and is never stored. """ @@ -18,6 +19,7 @@ DEFAULT_UNITS = "1" # Far above any denominator used in notation. Enforced wherever a beat unit # is set, so the dialog and inspector, which can't show more, never meet one. +# Also the largest denominator of a fraction inside units (see parse_units). MAX_DENOMINATOR = 128 # Far above any unit written in notation, and far below the 4300 digits # Python 3.10.7 and later refuse to read as an int (earlier versions have no @@ -73,6 +75,11 @@ def parse_units(text: str) -> UnitsParseResult: unit = Fraction(item) except (ValueError, ZeroDivisionError): return not_a_fraction + # The limit keeps the denominator of any sum of units, such as a + # running count of quarter notes, a divisor of one fixed number. + # Without it, each new one (1/7919, 1/7920...) would make it longer. + if unit.denominator > MAX_DENOMINATOR: + return not_a_fraction if unit <= 0: return UnitsParseResult([], "Units must be greater than 0.") units.append(unit) From cec45da31f570de01961a06166567c90280f2588 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:14:41 +0100 Subject: [PATCH 32/56] fix: keep beat units on their own beats when duplicating a beat timeline Duplicating a timeline gives its components fresh ids, but a beat unit names its beat by id, so the copy's beat units still pointed at the original's beats. Loading them failed with "No beat with id ...", and the copy kept only the assumed beat unit on its first beat. Timelines.duplicate_timeline now passes its old-to-new id map to Timeline.update_component_references, which BeatTimeline overrides to repoint beat_id. The duplication tests compare a beat unit's beat_id as the position of the beat it names, and leave out the hashes, which include it. Co-Authored-By: Claude Opus 5.5 --- tests/timelines/beat/test_beat_units.py | 14 ++++++++++++++ tests/ui/windows/test_manage_timelines.py | 19 ++++++++++++++++--- tilia/timelines/base/timeline.py | 11 +++++++++++ tilia/timelines/beat/timeline.py | 10 ++++++++++ tilia/timelines/collection/collection.py | 4 +++- 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 05e0fa6ba..4e0712c7c 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -598,6 +598,20 @@ def test_serialized_components_include_beat_units(self, beat_tl): "hash": beat_unit.hash, } + def test_duplicate_keeps_beat_units_on_its_own_beats( + self, beat_tl, tls, tilia_errors + ): + add_beats(beat_tl, 8) + add_beat_unit(beat_tl, 4, 8, "3") + + duplicate = tls.duplicate_timeline(beat_tl, "copy") + + tilia_errors.assert_no_error() + beat_ids = {beat.id for beat in duplicate} + assert all(u.beat_id in beat_ids for u in duplicate.beat_units) + assert beat_unit_values(duplicate) == [(0, 4, "1"), (4, 8, "3")] + assert [u.assumed for u in duplicate.beat_units] == [True, False] + def test_hash_changes_with_beat_units(self, beat_tl): add_beats(beat_tl, 4) before = beat_tl.component_manager.hash_components() diff --git a/tests/ui/windows/test_manage_timelines.py b/tests/ui/windows/test_manage_timelines.py index 16f0f1fa4..c7928d8f8 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -318,11 +318,24 @@ def test_deleting_non_last_row_preserves_row_index(self, tluis): def get_comparable_state(timeline: Timeline) -> dict: - """Timeline state without the attributes a duplicate must not share.""" + """ + Timeline state without the attributes a duplicate must not share. + + A duplicate's components get fresh ids, so an id that a component holds + (a beat unit's `beat_id`) is compared as the position of the component + it names, and the hashes, which include such ids, are left out. + """ state = timeline.get_state() - for attr in ("name", "ordinal", "hash"): + for attr in ("name", "ordinal", "hash", "components_hash"): state.pop(attr, None) - state["components"] = list(state["components"].values()) + positions = {id: i for i, id in enumerate(state["components"])} + components = [] + for data in state["components"].values(): + data = {attr: value for attr, value in data.items() if attr != "hash"} + if "beat_id" in data: + data["beat_id"] = positions[data["beat_id"]] + components.append(data) + state["components"] = components return state diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index c77d6a404..22a1731cc 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -274,6 +274,17 @@ def crop(self, length: float) -> None: def deserialize_components(self, components: dict[int, dict[str]]): return self.component_manager.deserialize_components(components) + @staticmethod + def update_component_references( + components: dict[int | str, dict[str, Any]], + new_ids: dict[int | str, int | str], + ) -> None: + """ + Points serialized components that refer to other components by id + to the ids those components get in `new_ids`, for a copy of the + timeline whose components get fresh ids. + """ + def _get_base_state(self) -> dict: """Returns a dict with serializable timeline attributes, excluding components.""" state = {"kind": self.type_name()} diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 0a41e2dbc..2f4af2ce1 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -414,6 +414,16 @@ class BeatTimeline(Timeline): COMPONENT_MANAGER_CLASS = BeatTLComponentManager FLAGS = [TimelineFlag.COMPONENTS_COPYABLE, TimelineFlag.COMPONENTS_IMPORTABLE] + @staticmethod + def update_component_references( + components: dict[int | str, dict[str, Any]], + new_ids: dict[int | str, int | str], + ) -> None: + # A beat unit names the beat it is on by its id. + for data in components.values(): + if data["kind"] == ComponentKind.BEAT_UNIT.name: + data["beat_id"] = new_ids.get(data["beat_id"], data["beat_id"]) + def __init__( self, beat_pattern: str | list[int] | None = None, diff --git a/tilia/timelines/collection/collection.py b/tilia/timelines/collection/collection.py index 804b78794..0b75f83fe 100644 --- a/tilia/timelines/collection/collection.py +++ b/tilia/timelines/collection/collection.py @@ -190,7 +190,9 @@ def duplicate_timeline(self, timeline: Timeline, name: str) -> Timeline: # Serialized components are sorted by id, which reflects creation # order. Keep it, as some kinds depend on it (e.g. score notes need # their staff to exist). - components = {get(Get.ID): data for data in components.values()} + new_ids = {id: get(Get.ID) for id in components} + components = {new_ids[id]: data for id, data in components.items()} + timeline.update_component_references(components, new_ids) duplicate = self.create_timeline(kind, components=components, **state) if not components and len(duplicate): From 78c5983a0e88b7e0ea8631058a29eb257ccca6ac Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:26:16 +0100 Subject: [PATCH 33/56] feat: keep measures on the beats that start them A beat timeline kept its measures in three lists by measure index (beats_in_measure, measure_numbers, measures_to_force_display), which had to be kept in step with the beats on every add, delete, crop, clear and undo, while beat units sat on beats by id. The two drifted apart: deleting a beat moved every later barline but left the time signatures on their beats. Now the beat that starts a measure carries it: is_first_in_measure, plus the number set on it by hand and whether that number is always shown. The measures are read from the beats (tilia/timelines/beat/measures.py) and cached until the beats change, so nothing kept by index can drift. The old names remain as read-only properties. What a beat edit does: - a beat added after the last beat starts a measure once the last one has the beats the pattern gives it, as before; - a beat added before the first starts a measure of its own, numbered one less than the first (a pickup is 0); - a beat added anywhere else joins the measure it falls in, and deleting a beat changes only its measure; a deleted first beat hands its measure, number and beat unit to the beat after it. No other barline moves. "Set amount in measure" and "Set beat pattern" remeasure the beats after them: the later measures keep their beat counts, and each keeps its number and beat unit as it moves. Files from before keep loading: their lists are fitted to the beats as TiLiA fitted them, then marked on the beats. CSV import marks the measures it finds on the beats it creates. --- tests/parsers/csv/test_markers_from_csv.py | 2 +- .../parsers/score/test_score_from_musicxml.py | 4 +- tests/timelines/beat/test_beat_timeline.py | 156 +-- tests/timelines/beat/test_beat_units.py | 26 +- .../timelines/test_cli_timelines_import.py | 2 +- .../timelines/beat/test_beat_timeline_ui.py | 222 ++++- tests/ui/timelines/score/test_musicxml.py | 2 +- .../timelines/score/test_score_timeline_ui.py | 12 +- tests/ui/windows/test_svg_viewer.py | 2 +- tilia/parsers/csv/beat.py | 96 +- tilia/timelines/beat/components.py | 56 +- tilia/timelines/beat/measures.py | 182 ++++ tilia/timelines/beat/timeline.py | 893 +++++++++--------- tilia/timelines/beat/validators.py | 4 - tilia/ui/timelines/beat/timeline.py | 34 +- 15 files changed, 1059 insertions(+), 634 deletions(-) create mode 100644 tilia/timelines/beat/measures.py diff --git a/tests/parsers/csv/test_markers_from_csv.py b/tests/parsers/csv/test_markers_from_csv.py index 939a9c8f3..f6afcba1b 100644 --- a/tests/parsers/csv/test_markers_from_csv.py +++ b/tests/parsers/csv/test_markers_from_csv.py @@ -60,7 +60,7 @@ def test_markers_by_measure_from_csv_multiple_measures_with_number( beat_tlui.create_beat(time=2) beat_tlui.create_beat(time=3) - beat_tl.measure_numbers = [1, 1, 1] + beat_tl.set_measure_numbers([1, 1, 1]) beat_tl.recalculate_measures() diff --git a/tests/parsers/score/test_score_from_musicxml.py b/tests/parsers/score/test_score_from_musicxml.py index 5c6599d0b..6611620ea 100644 --- a/tests/parsers/score/test_score_from_musicxml.py +++ b/tests/parsers/score/test_score_from_musicxml.py @@ -242,7 +242,7 @@ def test_changing_attributes(score_tl, beat_tl, tmp_path): beat_tl.set_data("beat_pattern", [1]) for i in range(6): beat_tl.create_beat(i) - beat_tl.measure_numbers = [1, 3, 0, 2, 4, 5] + beat_tl.set_measure_numbers([1, 3, 0, 2, 4, 5]) beat_tl.recalculate_measures() _import_with_patch(score_tl, beat_tl, example, tmp_path) @@ -656,7 +656,7 @@ def test_notes_in_a_measure_the_beat_timeline_skips_are_not_imported( beat_tl.set_data("beat_pattern", [1]) for i in range(4): beat_tl.create_beat(i) - beat_tl.measure_numbers = [1, 3, 4, 5] + beat_tl.set_measure_numbers([1, 3, 4, 5]) beat_tl.recalculate_measures() success, errors = _import_with_patch( diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 139d61304..b4729ca28 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - import pytest from tests.mock import patch_yes_or_no_dialog @@ -27,9 +25,7 @@ def test_create_beat_at_time_bigger_than_media_duration_fails( commands.execute("timeline.beat.add") assert len(beat_tlui) == 0 - def test_create_beat_at_middle_updates_next_beats_is_first_in_measure( - self, beat_tlui - ): + def test_create_beat_at_middle_joins_the_measure_it_falls_in(self, beat_tlui): beat_tlui.timeline.beat_pattern = [2] commands.execute("media.seek", 0) commands.execute("timeline.beat.add") @@ -41,10 +37,24 @@ def test_create_beat_at_middle_updates_next_beats_is_first_in_measure( commands.execute("media.seek", 5) commands.execute("timeline.beat.add") - assert beat_tlui[0].get_data("is_first_in_measure") is True - assert beat_tlui[1].get_data("is_first_in_measure") is False - assert beat_tlui[2].get_data("is_first_in_measure") is True - assert beat_tlui[3].get_data("is_first_in_measure") is False + assert [b.get_data("is_first_in_measure") for b in beat_tlui] == [ + True, + False, + False, + True, + ] + assert beat_tlui.timeline.beats_in_measure == [3, 1] + + def test_create_beat_before_the_first_starts_measure_zero(self, beat_tlui): + for time in [10, 11, 12]: + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + + commands.execute("media.seek", 5) + commands.execute("timeline.beat.add") + + assert beat_tlui.timeline.beats_in_measure == [1, 3] + assert beat_tlui.timeline.measure_numbers == [0, 1] def test_recalculate_measures_added_one_measure(self, beat_tl): beat_tl.create_beat(time=1) @@ -157,11 +167,6 @@ def test_recalculate_measures_added_two_measures_to_incomplete_measure( assert beat_tl.beats_in_measure == [2, 2, 1] assert beat_tl.measure_numbers == [1, 2, 3] - def test_reduce_beats_in_measure_more_than_available(self, beat_tl): - beat_tl.beats_in_measure = [4, 3, 2] - beat_tl.reduce_beats_in_measure(13) - assert beat_tl.beats_in_measure == [] - def test_recalculate_measures_removed_one_measure(self, beat_tl): beat_tl.create_beat(time=1) beat_tl.create_beat(time=2) @@ -240,74 +245,88 @@ def test_recalculate_measure_first_beat_from_last_measure(self, beat_tl): assert beat_tl.beats_in_measure == [2, 1] assert beat_tl.measure_numbers == [1, 2] + @staticmethod + def _add_measures(beat_tl, count): + beat_tl.set_data("beat_pattern", [1]) + for time in range(count): + beat_tl.create_beat(time=time + 1) + def test_change_measure_number_case1(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(1, 10) assert beat_tl.measure_numbers == [1, 10, 11, 12] assert beat_tl.measures_to_force_display == [1] def test_change_measure_number_case2(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(2, 10) assert beat_tl.measure_numbers == [1, 2, 10, 11] assert beat_tl.measures_to_force_display == [2] - def test_propagate_number_change_stops_at_forced_display(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4, 5, 6] - beat_tl.measures_to_force_display = [4] + def test_number_change_stops_at_a_measure_numbered_by_hand(self, beat_tl): + self._add_measures(beat_tl, 6) + beat_tl.set_measure_number(4, 5) beat_tl.set_measure_number(2, 10) assert beat_tl.measure_numbers == [1, 2, 10, 11, 5, 6] - assert beat_tl.measures_to_force_display == [4, 2] + assert beat_tl.measures_to_force_display == [2, 4] def test_set_measure_number_twice_forces_display_once(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(1, 10) beat_tl.set_measure_number(1, 10) assert beat_tl.measures_to_force_display == [1] def test_reset_measure_number_unforces_display_after_repeated_sets(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(1, 10) beat_tl.set_measure_number(1, 20) beat_tl.set_measure_number(1, 30) beat_tl.reset_measure_number(1) assert beat_tl.measures_to_force_display == [] - def test_reduce_measure_numbers_drops_forced_display_out_of_range(self, beat_tl): - beat_tl.set_data("beat_pattern", [1]) - for i in range(5): - beat_tl.create_beat(time=i + 1) - beat_tl.recalculate_measures() + def test_deleting_measures_drops_their_forced_display(self, beat_tl): + self._add_measures(beat_tl, 5) - # forcing a later measure first leaves the list unsorted beat_tl.set_measure_number(4, 40) beat_tl.set_measure_number(1, 10) - assert beat_tl.measures_to_force_display == [4, 1] + assert beat_tl.measures_to_force_display == [1, 4] beat_tl.delete_components([beat_tl[2], beat_tl[3], beat_tl[4]]) - beat_tl.recalculate_measures() assert beat_tl.measure_count == 2 assert beat_tl.measures_to_force_display == [1] def test_reset_measure_number_first_measure(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(0, 10) beat_tl.reset_measure_number(0) assert beat_tl.measure_numbers == [1, 2, 3, 4] def test_reset_measure_number_middle_measure(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(1, 10) beat_tl.reset_measure_number(1) assert beat_tl.measure_numbers == [1, 2, 3, 4] def test_reset_measure_number_last_measure(self, beat_tl): - beat_tl.measure_numbers = [1, 2, 3, 4] + self._add_measures(beat_tl, 4) beat_tl.set_measure_number(3, 10) beat_tl.reset_measure_number(3) assert beat_tl.measure_numbers == [1, 2, 3, 4] + def test_set_measure_numbers_with_a_repeat(self, beat_tl): + self._add_measures(beat_tl, 6) + beat_tl.set_measure_numbers([1, 2, 3, 1, 2, 3]) + assert beat_tl.measure_numbers == [1, 2, 3, 1, 2, 3] + assert [b.measure_number_override for b in beat_tl] == [ + None, + None, + None, + 1, + None, + None, + ] + def test_get_times_by_measure_no_beats(self, beat_tl): with pytest.raises(ValueError): beat_tl.get_time_by_measure(0) @@ -425,8 +444,7 @@ def test_get_times_by_fraction_in_repeat_measure_number(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) for t in range(1, 13): beat_tl.create_beat(time=t) - beat_tl.measure_numbers = [1, 2, 3, 1, 2, 1] - beat_tl.recalculate_measures() + beat_tl.set_measure_numbers([1, 2, 3, 1, 2, 1]) assert beat_tl.get_time_by_measure(1) == [1, 7, 11] assert beat_tl.get_time_by_measure(2, 0.5) == [4, 10] @@ -436,20 +454,32 @@ def test_get_times_by_fraction_in_repeat_measure_number(self, beat_tl): assert beat_tl.get_time_by_measure(3, 1.0) == [] assert beat_tl.get_time_by_measure(3, 1.0, True) == [7] - def test_delete_beat_updates_is_first_in_measure_of_subsequent_beats(self, beat_tl): + def test_delete_beat_leaves_the_other_measures_as_they_are(self, beat_tl): beat_tl.beat_pattern = [2] - beat_tl.create_beat(0) - beat_tl.create_beat(1) - beat_tl.create_beat(2) - beat_tl.create_beat(3) - beat_tl.create_beat(4) + for time in range(5): + beat_tl.create_beat(time) beat_tl.delete_components([beat_tl[1]]) - assert beat_tl[0].get_data("is_first_in_measure") - assert not beat_tl[1].get_data("is_first_in_measure") - assert beat_tl[2].get_data("is_first_in_measure") - assert not beat_tl[3].get_data("is_first_in_measure") + assert [b.get_data("is_first_in_measure") for b in beat_tl] == [ + True, + True, + False, + True, + ] + assert beat_tl.beats_in_measure == [1, 2, 1] + + def test_delete_first_beat_of_a_measure_hands_it_to_the_next(self, beat_tl): + beat_tl.beat_pattern = [2] + for time in range(5): + beat_tl.create_beat(time) + beat_tl.set_measure_number(1, 10) + + beat_tl.delete_components([beat_tl[2]]) + + assert beat_tl.beats_in_measure == [2, 1, 1] + assert beat_tl.measure_numbers == [1, 10, 11] + assert beat_tl[2].measure_number_override == 10 @pytest.mark.timeout(5) def test_open_file_with_many_beats(self, resources, tilia): @@ -520,36 +550,32 @@ def test_undo_redo_delete_all_beats(self, beat_tlui): with undoable(): commands.execute("timeline.component.delete") - def test_delete_keeps_an_outer_suppression_in_place(self, beat_tlui): - # BeatTLComponentManager.restore_state suppresses recomputation across a - # whole delete-then-create pass, and reaches delete_components in the - # middle of it. Restoring a hardcoded True here would re-enable the - # recomputation for every beat the restore re-creates afterwards. + def test_delete_keeps_an_outer_bulk_change_in_place(self, beat_tlui): + # BeatTLComponentManager.restore_state makes a whole delete-then-create + # pass a bulk change, and reaches delete_components in the middle of + # it. Ending the bulk change there would place every beat the restore + # re-creates afterwards as if a user had added it. self._add_beats(range(8)) timeline = beat_tlui.timeline component_manager = timeline.component_manager - with component_manager.suppressing_is_first_in_measure(): + with component_manager.bulk(): timeline.delete_components(list(timeline.components)[4:]) - assert component_manager.compute_is_first_in_measure is False + assert component_manager.is_bulk is True - assert component_manager.compute_is_first_in_measure is True + assert component_manager.is_bulk is False - def test_delete_restores_the_flag_when_a_deletion_raises(self, beat_tlui): - # Leaving the flag off would silently stop every beat created later in - # the session from getting any measure data. - self._add_beats(range(8)) - timeline = beat_tlui.timeline - component_manager = timeline.component_manager + def test_bulk_change_ends_when_its_body_raises(self, beat_tlui): + # Leaving the flag on would stop every beat created later in the + # session from being placed in a measure. + component_manager = beat_tlui.timeline.component_manager - with patch.object( - component_manager, "delete_component", side_effect=ValueError - ): - with pytest.raises(ValueError): - timeline.delete_components(list(timeline.components)) + with pytest.raises(ValueError): + with component_manager.bulk(): + raise ValueError - assert component_manager.compute_is_first_in_measure is True + assert component_manager.is_bulk is False def test_crop_recalculates_measures(self, beat_tlui, tilia_state): beat_tlui.timeline.set_data("beat_pattern", [2, 3]) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 4e0712c7c..1895a7162 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -298,17 +298,32 @@ def test_two_beat_units_in_a_measure_first_wins(self, beat_tl): assert meter.beat_unit_id == first.beat_id assert meter.denominator == 8 - def test_beat_unit_follows_its_beat_when_barlines_move(self, beat_tl): + def test_inserted_beats_leave_later_measures_as_they_are(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) add_beat_unit(beat_tl, 4, 8, "3") assert time_signatures(beat_tl) == [(2, 4), (2, 4), (6, 8)] - # A forgotten bar's beats, before the beat unit. + # A forgotten bar's beats join the measure they fall in, and the + # beat unit stays on the measure it starts. beat_tl.create_beat(0.25) beat_tl.create_beat(0.5) - assert time_signatures(beat_tl) == [(2, 4), (2, 4), (2, 4), (6, 8)] + assert time_signatures(beat_tl) == [(4, 4), (2, 4), (6, 8)] + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (6, 8, "3")] + + def test_remeasuring_keeps_each_measures_beat_unit(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 8) + add_beat_unit(beat_tl, 4, 8, "3") + + # The measures after the first keep their beat counts, so their first + # beats move, and each beat unit moves with its measure. + beat_tl.set_beat_amount_in_measure(0, 3) + + assert beat_tl.beats_in_measure == [3, 2, 2, 1] + assert time_signatures(beat_tl) == [(3, 4), (2, 4), (6, 8), (3, 8)] + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (5, 8, "3")] def test_meters_can_be_hashed(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) @@ -404,14 +419,15 @@ def test_pickup_copies_the_next_beat_unit(self, beat_tl): assert beat_unit_values(beat_tl) == [(0, 8, "3"), (1, 8, "3")] assert time_signatures(beat_tl)[0] == (3, 8) - def test_pickup_in_the_same_measure_needs_no_new_beat_unit(self, beat_tl): + def test_pickup_leaves_the_other_measures_as_they_are(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 4, start=1) add_beat_unit(beat_tl, 0, 8, "3") beat_tl.create_beat(0) - assert beat_unit_values(beat_tl) == [(1, 8, "3")] + assert beat_tl.beats_in_measure == [1, 2, 2] + assert beat_unit_values(beat_tl) == [(0, 8, "3"), (1, 8, "3")] def test_deleting_the_first_beat_keeps_the_values(self, beat_tl): beat_tl.set_data("beat_pattern", [1]) diff --git a/tests/ui/cli/timelines/test_cli_timelines_import.py b/tests/ui/cli/timelines/test_cli_timelines_import.py index c79c68d21..e44606ac6 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_import.py +++ b/tests/ui/cli/timelines/test_cli_timelines_import.py @@ -175,7 +175,7 @@ def test_score(self, cli, tls, beat_tl, score_tl, tmp_path, tilia_errors): beat_tl.create_beat(4) # A 0th measure is necessary to import the pickup measure - beat_tl.measure_numbers = [0, 1, 2, 3] + beat_tl.set_measure_numbers([0, 1, 2, 3]) beat_tl.recalculate_measures() cli.parse_and_run( diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 60dfabc4a..1efff5503 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -48,6 +48,61 @@ from tilia.ui.windows import WindowKind +def save_as_file_with_measure_lists( + tmp_path, + beats_in_measure: list[int] | None = None, + measure_numbers: list[int] | None = None, + measures_to_force_display: list[int] | None = None, +) -> str: + """ + Saves the current state as a file from before measures were marked on + their beats, which kept them in lists by measure on the beat timeline. A + list left out is written as the measures the timeline has now. + """ + path = save_tilia_to_tmp_path(tmp_path, "measure_lists") + with open(path, encoding="utf-8") as file: + data = json.load(file) + for timeline in data["timelines"].values(): + if timeline["kind"] != "BEAT_TIMELINE" and timeline["kind"] != "Beat": + continue + starts, numbers, always_shown = [], [], [] + for component in timeline["components"].values(): + if component["kind"] != "BEAT": + continue + if component.pop("is_first_in_measure"): + starts.append(component["time"]) + override = component["measure_number_override"] + numbers.append( + override if override is not None else (numbers or [0])[-1] + 1 + ) + if component["measure_number_always_shown"]: + always_shown.append(len(numbers) - 1) + component.pop("measure_number_override") + component.pop("measure_number_always_shown") + times = sorted( + c["time"] for c in timeline["components"].values() if c["kind"] == "BEAT" + ) + indices = [times.index(t) for t in starts] + [len(times)] + counts = [ + end - start for start, end in zip(indices[:-1], indices[1:], strict=True) + ] + timeline["beats_in_measure"] = ( + counts if beats_in_measure is None else beats_in_measure + ) + timeline["measure_numbers"] = ( + numbers if measure_numbers is None else measure_numbers + ) + timeline["measures_to_force_display"] = ( + always_shown + if measures_to_force_display is None + else measures_to_force_display + ) + with open(path, "w", encoding="utf-8") as file: + json.dump(data, file) + post(Post.APP_CLEAR) + return path + + def get_displayed_measure_number(beat_ui): if beat_ui.label.isVisible(): return beat_ui.label.toPlainText() @@ -88,13 +143,10 @@ def test_file_with_measures_to_force_display( for i in range(8): beat_tlui.create_beat(i) - beat_tlui.timeline.measures_to_force_display = [1, 3, 5, 7] + for i in [1, 3, 5, 7]: + beat_tlui.timeline.force_display_measure_number(i) - tmp_file = tmp_path / "test.tla" - - post(Post.REQUEST_SAVE_TO_PATH, tmp_file) - - commands.execute("file.open", tmp_file) + save_and_reopen(tmp_path) assert [x.label.toPlainText() for x in tluis[0]] == [ "1", @@ -110,18 +162,17 @@ def test_file_with_measures_to_force_display( def test_duplicate_and_out_of_range_forced_measures_are_dropped( self, beat_tlui, tluis, tmp_path ): - # Earlier versions could save such a list; the current one can't - # produce it, so it is set directly. + # Files from before measures were marked on their beats could save + # such a list. settings.set("beat_timeline", "display_measure_periodicity", 4) beat_tlui.timeline.beat_pattern = [1] for i in range(4): commands.execute("media.seek", i) commands.execute("timeline.beat.add") - beat_tlui.timeline.measures_to_force_display = [1, 6, 1, 2] - - tmp_file = tmp_path / "test.tla" - post(Post.REQUEST_SAVE_TO_PATH, tmp_file) - commands.execute("file.open", tmp_file) + path = save_as_file_with_measure_lists( + tmp_path, measures_to_force_display=[1, 6, 1, 2] + ) + commands.execute("file.open", path) reopened = tluis[0] reopened.select_element(reopened[1]) @@ -136,6 +187,110 @@ def test_duplicate_and_out_of_range_forced_measures_are_dropped( assert get_displayed_measure_number(reopened[6]) == "" +class TestFileWithMeasureLists: + """Files from before measures were marked on their beats keep them in + lists by measure, fitted to the beats when the file is opened.""" + + @staticmethod + def open_file_with(tmp_path, times, **lists): + for time in times: + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + commands.execute( + "file.open", save_as_file_with_measure_lists(tmp_path, **lists) + ) + + def test_measures_are_marked_on_their_first_beats(self, beat_tlui, tluis, tmp_path): + self.open_file_with( + tmp_path, range(8), beats_in_measure=[3, 3, 2], measure_numbers=[1, 2, 3] + ) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [3, 3, 2] + assert [b.is_first_in_measure for b in timeline] == [ + True, + False, + False, + True, + False, + False, + True, + False, + ] + + def test_numbers_that_break_the_count_are_kept(self, beat_tlui, tluis, tmp_path): + self.open_file_with( + tmp_path, + range(6), + beats_in_measure=[2, 2, 2], + measure_numbers=[1, 2, 1], + ) + + timeline = tluis[0].timeline + assert timeline.measure_numbers == [1, 2, 1] + assert [b.measure_number_override for b in timeline] == [ + None, + None, + None, + None, + 1, + None, + ] + + def test_missing_measures_are_made_from_the_pattern( + self, beat_tlui, tluis, tmp_path + ): + self.open_file_with( + tmp_path, range(10), beats_in_measure=[3], measure_numbers=[5] + ) + + timeline = tluis[0].timeline + # The incomplete last measure is filled first. + assert timeline.beats_in_measure == [4, 4, 2] + assert timeline.measure_numbers == [5, 6, 7] + + def test_surplus_measures_are_dropped(self, beat_tlui, tluis, tmp_path): + self.open_file_with( + tmp_path, + range(5), + beats_in_measure=[4, 4, 4], + measure_numbers=[1, 2, 3], + ) + + assert tluis[0].timeline.beats_in_measure == [4, 1] + + def test_forced_display_is_kept(self, beat_tlui, tluis, tmp_path): + settings.set("beat_timeline", "display_measure_periodicity", 10) + self.open_file_with( + tmp_path, + range(4), + beats_in_measure=[1, 1, 1, 1], + measure_numbers=[1, 2, 3, 4], + measures_to_force_display=[2], + ) + + assert [get_displayed_measure_number(b) for b in tluis[0]] == [ + "1", + "", + "3", + "", + ] + + def test_saving_again_keeps_the_measures(self, beat_tlui, tluis, tmp_path): + self.open_file_with( + tmp_path, + range(6), + beats_in_measure=[1, 3, 2], + measure_numbers=[0, 1, 2], + ) + + save_and_reopen(tmp_path) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [1, 3, 2] + assert timeline.measure_numbers == [0, 1, 2] + + def add_beat_timeline_with_pattern(pattern: str) -> None: commands.execute("timelines.add.beat", name="", beat_pattern=pattern) @@ -363,7 +518,7 @@ def test_delete(self, beat_tlui): def test_delete_update_next_measures_numbers(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] - beat_tlui.timeline.measures_to_force_display = [0, 1, 2] + settings.set("beat_timeline", "display_measure_periodicity", 1) beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.create_beat(2) @@ -374,9 +529,9 @@ def test_delete_update_next_measures_numbers(self, beat_tlui): assert get_displayed_measure_number(beat_tlui[0]) == "1" assert get_displayed_measure_number(beat_tlui[1]) == "2" - def test_create_update_next_measures_numbers(self, beat_tlui): + def test_create_in_a_measure_keeps_the_next_measures_numbers(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] - beat_tlui.timeline.measures_to_force_display = [0, 1, 2, 3] + settings.set("beat_timeline", "display_measure_periodicity", 1) beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.create_beat(2) @@ -384,11 +539,26 @@ def test_create_update_next_measures_numbers(self, beat_tlui): commands.execute("media.seek", 0.5) commands.execute("timeline.beat.add") + assert [get_displayed_measure_number(beat) for beat in beat_tlui] == [ + "1", + "", + "2", + "3", + ] + + def test_create_after_the_last_beat_numbers_the_new_measure(self, beat_tlui): + beat_tlui.timeline.beat_pattern = [1] + settings.set("beat_timeline", "display_measure_periodicity", 1) + beat_tlui.create_beat(0) + beat_tlui.create_beat(1) + + commands.execute("media.seek", 2) + commands.execute("timeline.beat.add") + assert [get_displayed_measure_number(beat) for beat in beat_tlui] == [ "1", "2", "3", - "4", ] @@ -842,7 +1012,7 @@ def test_set_beat_amount_in_measure(self, beat_tlui): def test_updates_measure_numbers(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] - beat_tlui.timeline.measures_to_force_display = [0, 1, 2] + settings.set("beat_timeline", "display_measure_periodicity", 1) beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.create_beat(2) @@ -998,7 +1168,7 @@ def test_undo_redo_delete_beat(self, beat_tlui, tluis): def test_undo_redo_updates_displayed_measure_numbers(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] - beat_tlui.timeline.measures_to_force_display = [0, 1, 2] + settings.set("beat_timeline", "display_measure_periodicity", 1) beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.create_beat(2) @@ -2194,15 +2364,17 @@ def test_pasted_beat_unit_keeps_its_flag(self, tluis): class TestConflictingBeatUnits: @staticmethod def make_conflict(tluis): - """Two beat units end up in one measure after a beat is deleted.""" + """ + Two beat units in one measure, as a CSV import that gives a beat unit + to a beat that doesn't start a measure leaves them. + """ add_beat_timeline_with_pattern("2") add_beats(range(6)) set_beat_unit(tluis[0], 0, 8, "1") - set_beat_unit(tluis[0], 1, 2, "1") - tluis[0].select_element(tluis[0][1]) - commands.execute("timeline.component.delete") - tluis[0].deselect_all_elements() - assert tluis[0].timeline.get_measure_meter(0).is_conflicting + timeline = tluis[0].timeline + timeline.put_beat_unit_on_beat(timeline[1].id, 2, "1") + post(Post.APP_STATE_RECORD, "conflicting beat units") + assert timeline.get_measure_meter(0).is_conflicting @staticmethod def right_click_label(timeline_ui, label): diff --git a/tests/ui/timelines/score/test_musicxml.py b/tests/ui/timelines/score/test_musicxml.py index 712eede7a..f79815004 100644 --- a/tests/ui/timelines/score/test_musicxml.py +++ b/tests/ui/timelines/score/test_musicxml.py @@ -31,7 +31,7 @@ def test_user_accepts(self, qtui, score_tlui, beat_tl, tmp_path, tilia_state): assert len(time_signatures) == 1 assert beat_tl.measure_numbers[0] == 0 - assert beat_tl._beats_in_measure[0] == 1 + assert beat_tl.beats_in_measure[0] == 1 def test_user_accepts_but_no_space_for_measure( self, qtui, score_tlui, beat_tl, tmp_path, tilia_state diff --git a/tests/ui/timelines/score/test_score_timeline_ui.py b/tests/ui/timelines/score/test_score_timeline_ui.py index db47bc14f..779ff14a1 100644 --- a/tests/ui/timelines/score/test_score_timeline_ui.py +++ b/tests/ui/timelines/score/test_score_timeline_ui.py @@ -126,7 +126,7 @@ def test_attribute_positions(qtui, score_tl, beat_tl, tmp_path): beat_tl.beat_pattern = [1] for i in range(0, 3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [0, 1, 2] + beat_tl.set_measure_numbers([0, 1, 2]) beat_tl.recalculate_measures() notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) @@ -138,7 +138,7 @@ def test_attribute_positions_without_measure_zero(qtui, score_tl, beat_tl, tmp_p beat_tl.beat_pattern = [1] for i in range(1, 3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [1, 2] + beat_tl.set_measure_numbers([1, 2]) beat_tl.recalculate_measures() with patch_yes_or_no_dialog(False): @@ -151,7 +151,7 @@ def test_correct_clef_to_staff(qtui, score_tl, beat_tl): beat_tl.beat_pattern = [1] for i in range(1, 3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [1, 2] + beat_tl.set_measure_numbers([1, 2]) beat_tl.recalculate_measures() with patch_yes_or_no_dialog(False): @@ -252,7 +252,7 @@ def test_symbols_do_not_collide_with_staff_without_notes( beat_tl.beat_pattern = [1] for i in range(1, 3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [1, 2] + beat_tl.set_measure_numbers([1, 2]) beat_tl.recalculate_measures() with ( @@ -720,7 +720,7 @@ def test_reload_file_places_notes_in_same_position(qtui, score_tl, beat_tl, tmp_ beat_tl.beat_pattern = [1] for i in range(0, 3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [0, 1, 2] + beat_tl.set_measure_numbers([0, 1, 2]) beat_tl.recalculate_measures() notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) @@ -756,7 +756,7 @@ def test_import_score_then_delete_all_beats_does_not_crash( beat_tl.beat_pattern = [1] for i in range(0, 3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [0, 1, 2] + beat_tl.set_measure_numbers([0, 1, 2]) beat_tl.recalculate_measures() notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) diff --git a/tests/ui/windows/test_svg_viewer.py b/tests/ui/windows/test_svg_viewer.py index 1ef0f9f0b..1ffc8e540 100644 --- a/tests/ui/windows/test_svg_viewer.py +++ b/tests/ui/windows/test_svg_viewer.py @@ -157,7 +157,7 @@ def scored_beat_tl(beat_tl): beat_tl.beat_pattern = [1] for i in range(3): beat_tl.create_beat(i) - beat_tl.measure_numbers = [0, 1, 2] + beat_tl.set_measure_numbers([0, 1, 2]) beat_tl.recalculate_measures() return beat_tl diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index 82177043b..7709fcd05 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -6,6 +6,7 @@ get_column_not_found_error_message, get_params_indices, ) +from tilia.timelines.beat.measures import get_extension_from_beat_pattern from tilia.timelines.beat.pattern import format_bars from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.beat.units import ( @@ -40,6 +41,7 @@ def beats_from_csv( """ errors = [] + measure_marks = None params = ["is_first_in_measure", "measure", "time"] optional_params = ["is_first_in_measure", "measure"] parsers = [str, int, float] @@ -134,44 +136,78 @@ def check_params() -> bool: beats_in_measure.append(current_beat) measure_numbers.append(current_measure) timeline.set_data("beat_pattern", format_bars(beats_in_measure)) - timeline.set_data("measure_numbers", measure_numbers) - timeline.set_data("measures_to_force_display", measures_to_force_display) + measure_marks = _get_measure_marks( + beats_in_measure, measure_numbers, measures_to_force_display + ) with TiliaCSVReader(path, file_kwargs, reader_kwargs) as reader: beat_unit_indices = get_params_indices(["denominator", "units"], next(reader)) - denominator, units = DEFAULT_DENOMINATOR, DEFAULT_UNITS - for row in reader: - if not row: - continue + rows = [row for row in reader if row] - constructor_kwargs = {} - index = params_to_indices["time"] - constructor_kwargs["time"] = float(row[index]) + if measure_marks is None: + counts = get_extension_from_beat_pattern(timeline.beat_pattern_bars, len(rows)) + measure_marks = _get_measure_marks(counts, list(range(1, len(counts) + 1)), []) + created = [] + with timeline.component_manager.bulk(): + for row_index, row in enumerate(rows): + override, always_shown = measure_marks.get(row_index, (None, False)) component, fail_reason = timeline.create_component( - ComponentKind.BEAT, **constructor_kwargs + ComponentKind.BEAT, + time=float(row[params_to_indices["time"]]), + is_first_in_measure=row_index in measure_marks, + measure_number_override=override, + measure_number_always_shown=always_shown, ) - if not component: + if component: + created.append((component, row)) + else: errors.append(fail_reason) - - timeline.recalculate_measures() - - row_values = { - param: row[index].strip() - for param, index in beat_unit_indices.items() - if index < len(row) and row[index].strip() - } - if component and row_values: - beat_unit_values, error = _parse_beat_unit_values( - row_values, denominator, units - ) - if error: - errors.append(f"{row}: {error}") - continue - denominator, units = beat_unit_values - # The first beat already has a beat unit of its own. - timeline.put_beat_unit_on_beat(component.id, denominator, units) - return True, errors + # Gives the first beat its default beat unit, which a row of the file can + # then edit. + timeline.refresh_measures() + + denominator, units = DEFAULT_DENOMINATOR, DEFAULT_UNITS + for component, row in created: + row_values = { + param: row[index].strip() + for param, index in beat_unit_indices.items() + if index < len(row) and row[index].strip() + } + if not row_values: + continue + beat_unit_values, error = _parse_beat_unit_values( + row_values, denominator, units + ) + if error: + errors.append(f"{row}: {error}") + continue + denominator, units = beat_unit_values + timeline.put_beat_unit_on_beat(component.id, denominator, units) + + return True, errors + + +def _get_measure_marks( + beats_in_measure: list[int], + measure_numbers: list[int], + measures_to_force_display: list[int], +) -> dict[int, tuple[int | None, bool]]: + """ + The rows that start measures, with the number each of them gets where it + isn't the previous measure's plus one, and whether it is always shown. + """ + marks = {} + row_index = 0 + for measure_index, count in enumerate(beats_in_measure): + number = measure_numbers[measure_index] + expected = measure_numbers[measure_index - 1] + 1 if measure_index else 1 + marks[row_index] = ( + None if number == expected else number, + measure_index in measures_to_force_display, + ) + row_index += count + return marks def _parse_beat_unit_values( diff --git a/tilia/timelines/beat/components.py b/tilia/timelines/beat/components.py index ba9fc01d0..3833f1b7f 100644 --- a/tilia/timelines/beat/components.py +++ b/tilia/timelines/beat/components.py @@ -28,25 +28,50 @@ ) +def validate_measure_number_override(value: int | None) -> bool: + return value is None or (isinstance(value, int) and not isinstance(value, bool)) + + class Beat(PointLikeTimelineComponent): - SERIALIZABLE = ["time"] + """ + A tapped beat. A beat that starts a measure carries `is_first_in_measure` + and the measure's own data: the number set on it by hand, if any (None + means one more than the measure before), and whether that number is + always shown. See `tilia.timelines.beat.measures`. + """ + + SERIALIZABLE = [ + "time", + "is_first_in_measure", + "measure_number_override", + "measure_number_always_shown", + ] ORDERING_ATTRS = ("time",) KIND = ComponentKind.BEAT - validators = {"time": validate_time, "is_first_in_measure": validate_bool} + validators = { + "time": validate_time, + "is_first_in_measure": validate_bool, + "measure_number_override": validate_measure_number_override, + "measure_number_always_shown": validate_bool, + } def __init__( self, timeline: BeatTimeline, id: int, time: float, + is_first_in_measure: bool = False, + measure_number_override: int | None = None, + measure_number_always_shown: bool = False, comments="", **_, ): self.time = time + self.is_first_in_measure = is_first_in_measure + self.measure_number_override = measure_number_override + self.measure_number_always_shown = measure_number_always_shown self.comments = comments - self.is_first_in_measure = False - self._cached_metric_position = None super().__init__(timeline, id) @@ -56,25 +81,16 @@ def __str__(self): def __repr__(self): return f"Beat({self.time})" - def clear_cached_metric_position(self): - self._cached_metric_position = None + @classmethod + def get_export_attributes(cls) -> list[str]: + # The measure data is exported through "measure", the number the + # beat's measure ends up with. + return ["time", "measure", "beat"] @property def metric_position(self) -> MetricPosition: - if self._cached_metric_position is None: - self.timeline: BeatTimeline - beat_index = self.timeline.get_beat_index(self) - measure_index, index_in_measure = self.timeline.get_measure_index( - beat_index - ) - - self._cached_metric_position = MetricPosition( - self.timeline.measure_numbers[measure_index], - index_in_measure + 1, - self.timeline.beats_in_measure[measure_index], - ) - - return self._cached_metric_position + self.timeline: BeatTimeline + return self.timeline.get_metric_position_of_beat(self) @property def measure_number(self): diff --git a/tilia/timelines/beat/measures.py b/tilia/timelines/beat/measures.py new file mode 100644 index 000000000..bbb4c9688 --- /dev/null +++ b/tilia/timelines/beat/measures.py @@ -0,0 +1,182 @@ +""" +A beat timeline's measures, read from the beats that start them. + +Each beat that starts a measure carries `is_first_in_measure`, and the +measure's own data: a number set by hand (`measure_number_override`) and +whether that number is always shown. A measure's beats are the beats from its +first beat to the next one's, so no count is stored and nothing kept by +measure index can drift from the beats. `Measures` is the table built from +them; the timeline caches it until its beats change. +""" + +from __future__ import annotations + +import itertools +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tilia.timelines.beat.components import Beat + + +@dataclass(frozen=True) +class Measures: + # The index of the first beat of each measure. + starts: list[int] + counts: list[int] + numbers: list[int] + # Indices of measures whose number is always shown. + always_shown: list[int] + # The measure each beat is in, by beat index. + measure_of_beat: list[int] + + @property + def count(self) -> int: + return len(self.starts) + + +def build_measures(beats: Sequence[Beat]) -> Measures: + """ + The measures of `beats`, in time order. The first beat always starts a + measure, whether or not it is marked. A measure without a number of its + own is numbered one more than the measure before, and the first one 1. + """ + starts: list[int] = [] + numbers: list[int] = [] + always_shown: list[int] = [] + measure_of_beat: list[int] = [] + for index, beat in enumerate(beats): + if index == 0 or beat.is_first_in_measure: + if beat.measure_number_override is not None: + number = beat.measure_number_override + else: + number = numbers[-1] + 1 if numbers else 1 + if beat.measure_number_always_shown: + always_shown.append(len(starts)) + starts.append(index) + numbers.append(number) + measure_of_beat.append(len(starts) - 1) + + ends = starts[1:] + [len(beats)] if starts else [] + counts = [end - start for start, end in zip(starts, ends, strict=True)] + return Measures(starts, counts, numbers, always_shown, measure_of_beat) + + +def get_extension_from_beat_pattern( + beat_pattern: list[int], + amount: int, + start_index: int = 0, + beats_on_starting_measure: int = 0, +) -> list[int]: + """ + The measure lengths that `amount` more beats make, following the pattern + cyclically from `start_index`. With `beats_on_starting_measure`, the + first entry tops up a measure that already has that many beats. + """ + if amount == 0: + return [] + + if start_index < 0: + raise ValueError(f"Start index must be a positive value. Got {start_index}") + + beat_pattern_cycle = itertools.cycle(beat_pattern) + + # iterate up to start index + for _ in range(start_index): + next(beat_pattern_cycle) + + if beats_on_starting_measure: + beats = next(beat_pattern_cycle) + if beats_on_starting_measure < beats: + diff = beats - beats_on_starting_measure + extension = [min(diff, amount)] + remaining_beats = amount - diff + else: + # A measure may hold more beats than the beat pattern + # prescribes for it, as "Set amount in measure" sets an + # arbitrary amount. There is nothing left to fill in that + # case, so the extension starts a new measure. + extension = [] + remaining_beats = amount + else: + extension = [] + remaining_beats = amount + + if remaining_beats > 0: + for beats in beat_pattern_cycle: + if remaining_beats > beats: + remaining_beats -= beats + extension.append(beats) + else: + extension.append(remaining_beats) + break + + return extension + + +def fit_legacy_lists( + beat_count: int, + pattern: list[int], + beats_in_measure: list[int], + measure_numbers: list[int], +) -> tuple[list[int], list[int]]: + """ + Fits the lists by measure that files from before measures were marked on + their beats store, `beats_in_measure` and `measure_numbers`, to + `beat_count` beats, as TiLiA did when it loaded them: missing measures + are made from the pattern and numbered on from the last, and surplus ones + are dropped from the end. + """ + counts = list(beats_in_measure) + delta = beat_count - sum(counts) + if delta > 0: + counts = _extend_legacy_counts(counts, pattern, delta) + elif delta < 0: + counts = _reduce_legacy_counts(counts, -delta) + + numbers = list(measure_numbers[: len(counts)]) + while len(numbers) < len(counts): + numbers.append(numbers[-1] + 1 if numbers else 1) + return counts, numbers + + +def _extend_legacy_counts( + counts: list[int], pattern: list[int], amount: int +) -> list[int]: + if not counts: + return get_extension_from_beat_pattern(pattern, amount) + + measure_count = len(counts) + start_index = (measure_count % len(pattern) - 1) % measure_count + beats_on_starting_measure = counts[-1] + if start_index == -1 and beats_on_starting_measure == pattern[-1]: + beats_on_starting_measure = 0 + start_index = 0 + extension = get_extension_from_beat_pattern( + pattern, + amount, + start_index=start_index, + beats_on_starting_measure=beats_on_starting_measure, + ) + + is_last_measure_complete = counts[-1] >= pattern[(measure_count % len(pattern)) - 1] + if is_last_measure_complete: + return counts + extension + return counts[:-1] + [counts[-1] + extension[0]] + extension[1:] + + +def _reduce_legacy_counts(counts: list[int], amount: int) -> list[int]: + counts = counts.copy() + remaining_beats = amount + for beats in reversed(counts.copy()): + if beats < remaining_beats: + counts.pop(-1) + remaining_beats -= beats + elif beats > remaining_beats: + counts[-1] -= remaining_beats + break + else: + counts.pop(-1) + break + return counts diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 2f4af2ce1..d4e3ced55 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -16,7 +16,7 @@ from tilia.requests import Get, LongOperation, Post, get, long_operation, post from tilia.settings import settings from tilia.timelines import serialize -from tilia.timelines.base.component.pointlike import crop_pointlike +from tilia.timelines.base.metric_position import MetricPosition from tilia.timelines.base.timeline import ( TC, Timeline, @@ -25,6 +25,12 @@ ) from tilia.timelines.base.validators import validate_bool from tilia.timelines.beat.components import Beat, BeatUnit +from tilia.timelines.beat.measures import ( + Measures, + build_measures, + fit_legacy_lists, + get_extension_from_beat_pattern, +) from tilia.timelines.beat.pattern import parse from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, @@ -36,10 +42,7 @@ parse_units, validate_denominator, ) -from tilia.timelines.beat.validators import ( - validate_beat_pattern, - validate_integer_list, -) +from tilia.timelines.beat.validators import validate_beat_pattern from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.hash_timelines import hash_function @@ -62,59 +65,40 @@ class BeatTLComponentManager(TimelineComponentManager): def __init__(self, timeline: BeatTimeline): super().__init__(timeline, [ComponentKind.BEAT, ComponentKind.BEAT_UNIT]) self.timeline = cast(BeatTimeline, self.timeline) - self.compute_is_first_in_measure = True - self.compute_metric_fraction_dict = True # Beat units are kept apart from beats, so everything that treats the # timeline's components as its beats (len, indexing, iteration) keeps # doing so. self._beat_units: list[BeatUnit] = [] - # Set while undo/redo rebuilds components, when beat units are - # restored from the snapshot rather than moved off deleted beats. + # Set while undo/redo rebuilds components from a snapshot, which + # already says which beats start measures and where beat units are. self.is_restoring = False + # Set while beats are changed in bulk (loading, filling, cropping). + # A beat created then is not placed in a measure by the rules for a + # single beat, and the UI relabels the beats once at the end. + self.is_bulk = False # Bumped on every change to beats or beat units. Caches built from - # them (quarter stamps, the time/stamp table) rebuild when it moves, - # so a change can't leave them stale by missing an invalidation. + # them (the measures, quarter stamps, the time/stamp table) rebuild + # when it moves, so a change can't leave them stale by missing an + # invalidation. self.version = 0 def bump_version(self) -> None: self.version += 1 @contextmanager - def suppressing_is_first_in_measure(self) -> Iterator[bool]: - """ - Suppress per-beat measure recomputation for the duration of the block. - - Yields the value the flag had on entry, so a caller can tell whether it - owns the recomputation or an outer block will do it once at the end. - Restoring that value rather than a literal True is what keeps nested - blocks correct; the try/finally keeps a raising body from leaving the - flag off for the rest of the timeline's life, which would silently stop - every beat created afterwards from getting any measure data. - """ - was_computing = self.compute_is_first_in_measure - self.compute_is_first_in_measure = False - try: - yield was_computing - finally: - self.compute_is_first_in_measure = was_computing - - @contextmanager - def suppressing_metric_fraction_dict(self) -> Iterator[bool]: + def bulk(self) -> Iterator[bool]: """ - Suppress per-beat rebuilds of the metric-fraction dicts for the block. - - Same contract as suppressing_is_first_in_measure: restore what was - found, and restore it even when the body raises. Leaving this flag off - would stop set_component_data from ever rebuilding the dicts again, so - every later get_time_by_measure and by-measure import would silently - read stale time-to-measure data. + Marks the block as a bulk change (see `is_bulk`). Yields the value + the flag had on entry, so a caller can tell whether it owns the final + refresh or an outer block will do it. The flag is restored even when + the body raises. """ - was_computing = self.compute_metric_fraction_dict - self.compute_metric_fraction_dict = False + was_bulk = self.is_bulk + self.is_bulk = True try: - yield was_computing + yield was_bulk finally: - self.compute_metric_fraction_dict = was_computing + self.is_bulk = was_bulk @property def beat_times(self): @@ -141,45 +125,15 @@ def _remove_from_components_set(self, component: TC) -> None: else: super()._remove_from_components_set(component) - def update_is_first_in_measure_of_subsequent_beats(self, start_index): - with self.suppressing_metric_fraction_dict(): - beats_that_start_measure = set(self.timeline.beats_that_start_measures) - for i, beat in enumerate(self.timeline[start_index:]): - is_first_in_measure = start_index + i in beats_that_start_measure - if is_first_in_measure != beat.is_first_in_measure: - self.timeline.set_component_data( - beat.id, - "is_first_in_measure", - is_first_in_measure, - ) - - self.timeline.update_metric_fraction_dicts() - def create_component( self, kind: ComponentKind, timeline, id, *args, **kwargs ) -> tuple[bool, TC | None, str]: - success, beat, reason = super().create_component( + success, component, reason = super().create_component( kind, timeline, id, *args, **kwargs ) if success: self.bump_version() - - if success and kind == ComponentKind.BEAT_UNIT: - self.timeline.on_beat_units_changed() - elif success: - if self.compute_is_first_in_measure: - self.timeline.recalculate_measures() - beat.is_first_in_measure = self.timeline.is_first_in_measure(beat) - beat_index = self.get_components().index(beat) + 1 - self.update_is_first_in_measure_of_subsequent_beats(beat_index) - measure_index = self.timeline.get_measure_index(beat_index)[0] - post( - Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, - self.timeline.id, - measure_index - 1, - ) - - return success, beat, reason + return success, component, reason def _validate_component_creation( self, @@ -197,75 +151,89 @@ def _validate_component_creation( time = args[0] if args else kwargs["time"] return Beat.validate_creation(time, self.beat_times) - def _get_beat_unit_destination(self, beat: Beat) -> Beat | None: + def move_measure_start(self, beat: Beat, destination: Beat) -> None: + """ + Moves the start of the measure that `beat` starts to `destination`, + with its number, its display and its beat unit. + """ + for attr in ("measure_number_override", "measure_number_always_shown"): + value = getattr(beat, attr) + if value != getattr(destination, attr): + self.set_component_data(destination.id, attr, value) + if not destination.is_first_in_measure: + self.set_component_data(destination.id, "is_first_in_measure", True) + if beat.is_first_in_measure: + self.set_component_data(beat.id, "is_first_in_measure", False) + if beat.measure_number_override is not None: + self.set_component_data(beat.id, "measure_number_override", None) + if beat.measure_number_always_shown: + self.set_component_data(beat.id, "measure_number_always_shown", False) + + beat_unit = self.get_beat_unit_on_beat(beat.id) + if beat_unit is None: + return + if self.get_beat_unit_on_beat(destination.id): + self.delete_component(beat_unit) + else: + self.set_component_data(beat_unit.id, "beat_id", destination.id) + + def _on_beat_deleted(self, beat: Beat) -> None: """ - Where a beat unit goes when the beat it is on is deleted: the first - other beat of the same measure, or, if that was the measure's only - beat, the first beat of the next measure, so the measures it governed - keep it. None if there is no such beat. + Keeps the other beats' measures as they are. A deleted beat that + starts a measure hands the measure to the beat after it; if it was + the measure's only beat, the measure goes, and its beat unit moves to + the next measure, so the measures it governed keep it. A beat unit on + a beat that doesn't start a measure moves to the first beat of the + measure. """ - beats = self.get_components() - beat_index = beats.index(beat) - measure_index, _ = self.timeline.get_measure_index(beat_index) - measure_start = self.timeline.beats_that_start_measures[measure_index] - measure_end = measure_start + self.timeline.beats_in_measure[measure_index] - for index in range(measure_start, min(measure_end, len(beats))): - if index != beat_index: - return beats[index] - if measure_end < len(beats): - return beats[measure_end] - return None + beats = self._components + index = beats.index(beat) + next_beat = beats[index + 1] if index + 1 < len(beats) else None + starts_measure = index == 0 or beat.is_first_in_measure + if starts_measure and next_beat and not next_beat.is_first_in_measure: + self.move_measure_start(beat, next_beat) + return + + beat_unit = self.get_beat_unit_on_beat(beat.id) + if beat_unit is None: + return + if starts_measure: + destination = next_beat + else: + measures = self.timeline.measures + destination = beats[measures.starts[measures.measure_of_beat[index]]] + if destination is None or self.get_beat_unit_on_beat(destination.id): + self.delete_component(beat_unit) + else: + self.set_component_data(beat_unit.id, "beat_id", destination.id) def delete_component(self, component: TC) -> None: if component.KIND == ComponentKind.BEAT_UNIT: super().delete_component(component) self.bump_version() - self.timeline.on_beat_units_changed() return - beat_unit = ( - None if self.is_restoring else self.get_beat_unit_on_beat(component.id) - ) - destination = self._get_beat_unit_destination(component) if beat_unit else None + if not self.is_restoring: + self._on_beat_deleted(component) - component_idx = self.get_components().index(component) super().delete_component(component) self.bump_version() - if self.compute_is_first_in_measure: - self.update_is_first_in_measure_of_subsequent_beats(component_idx - 1) - - if beat_unit: - if destination is None or self.get_beat_unit_on_beat(destination.id): - self.delete_component(beat_unit) - else: - self.set_component_data(beat_unit.id, "beat_id", destination.id) def set_component_data(self, id: int, attr: str, value: Any): value, success = super().set_component_data(id, attr, value) if success: self.bump_version() - if success and self.get_component(id).KIND == ComponentKind.BEAT_UNIT: - self.timeline.on_beat_units_changed() - elif success and self.compute_metric_fraction_dict: - self.timeline.update_metric_fraction_dicts() return value, success def update_component_order(self, component: TC): super().update_component_order(component) - for component in self: - self.update_component_is_first_in_measure(component) - - def update_component_is_first_in_measure(self, component): - component.is_first_in_measure = self.timeline.is_first_in_measure(component) - - def get_beats_in_measure(self, measure_index: int) -> list[Beat] | None: - if self.timeline is None: - raise ValueError("self.timeline is None.") + if not self.is_restoring and not self.is_bulk: + self.timeline.ensure_first_beat_starts_measure() - beats = self.get_components().copy() - measure_start = self.timeline.beats_that_start_measures[measure_index] - measure_end = self.timeline.beats_that_start_measures[measure_index + 1] - return beats[measure_start:measure_end] + def get_beats_in_measure(self, measure_index: int) -> list[Beat]: + measures = self.timeline.measures + start = measures.starts[measure_index] + return self._components[start : start + measures.counts[measure_index]] def distribute_beats(self, measure_index: int) -> None: if self.timeline is None: @@ -288,32 +256,33 @@ def distribute_beats(self, measure_index: int) -> None: ) def scale(self, factor: float) -> None: - # Order, indices, and metric positions are unaffected by uniform - # positive scaling, so the only stale state is the time-keyed - # metric_fraction dicts. Skip per-beat side-effects of calling - # set_component_data and rebuild once. + # Order and measures are unaffected by uniform positive scaling, so + # the per-beat side effects of set_component_data are skipped. for beat in self._components: beat.time *= factor beat.update_hash() # Times were written directly, so nothing else bumps the version. self.bump_version() - self.timeline.update_metric_fraction_dicts() def crop(self, length: float) -> None: - with self.suppressing_is_first_in_measure() as was_computing: - crop_pointlike(self, length) - - if was_computing: + # From the last beat back, so a deleted beat that starts a measure + # never hands it to a beat that is about to go too. + with self.bulk() as was_bulk: + for beat in reversed(self._components.copy()): + if beat.time > length: + self.delete_component(beat) + + if not was_bulk: self.timeline.refresh_measures() def clear(self): - with self.suppressing_is_first_in_measure() as was_computing: + with self.bulk() as was_bulk: for beat_unit in self._beat_units.copy(): self.delete_component(beat_unit) - for component in self._components.copy(): + for component in reversed(self._components.copy()): self.delete_component(component) - if was_computing: + if not was_bulk: self.timeline.refresh_measures() def _get_beats_and_beat_units(self) -> list[TC]: @@ -344,39 +313,23 @@ def _split_beat_units( return beats, beat_units def deserialize_components(self, serialized_components: dict[int, dict[str]]): - # Storing these attributes so we can restore them below. - beats_in_measure = self.timeline.beats_in_measure.copy() - measure_numbers = self.timeline.measure_numbers.copy() - measures_to_force_display = self.timeline.measures_to_force_display.copy() - - with self.suppressing_is_first_in_measure(): + beats, beat_units = self._split_beat_units(serialized_components) + # Files from before measures were marked on their beats keep them in + # lists by measure, which the timeline was given when it was created. + is_legacy = any("is_first_in_measure" not in data for data in beats.values()) + with self.bulk(): # Beat units refer to beats, so they are created once all beats are. - beats, beat_units = self._split_beat_units(serialized_components) - # This call will change the attributes above. super().deserialize_components(beats) + if is_legacy: + self.timeline.mark_measures_from_legacy_lists() super().deserialize_components(beat_units) - # But we restore them here. - self.timeline.set_data("measure_numbers", measure_numbers) - self.timeline.set_data("beats_in_measure", beats_in_measure) - self.timeline.set_data( - "measures_to_force_display", measures_to_force_display - ) - - self.timeline.recalculate_measures() - # Files saved by earlier versions can hold duplicate or out-of-range - # indices. - self.timeline.measures_to_force_display = [ - i - for i in dict.fromkeys(self.timeline.measures_to_force_display) - if 0 <= i < self.timeline.measure_count - ] + self.timeline.refresh_measures() post(Post.BEAT_TIMELINE_COMPONENTS_DESERIALIZED, self.timeline.id) def restore_state(self, prev_state: dict): - self.timeline.clear_cached_metric_positions() beats, beat_units = self._split_beat_units(prev_state) - with self.suppressing_is_first_in_measure(): + with self.bulk(): # Beat units are few, so they are rebuilt from the snapshot rather # than diffed. Removing them first also keeps beat deletions below # from moving them. @@ -401,9 +354,6 @@ def restore_state(self, prev_state: dict): class BeatTimeline(Timeline): SERIALIZABLE = [ "beat_pattern", - "measure_numbers", # order matters, m. ns. need to be restored - "beats_in_measure", # before beats in measure - "measures_to_force_display", "show_time_signatures", "height", "is_visible", @@ -437,6 +387,7 @@ def __init__( ): # Caches keyed by the component manager's version; see _cached. self._caches: dict[str, tuple[int, Any]] = {} + self._metric_fraction_version: int | None = None super().__init__( name=name, height=height, @@ -445,16 +396,18 @@ def __init__( self.validators = self.validators | { "beat_pattern": validate_beat_pattern, - "beats_in_measure": validate_integer_list, - "measure_numbers": validate_integer_list, - "measures_to_force_display": validate_integer_list, "show_time_signatures": validate_bool, } self.beat_pattern = beat_pattern or DEFAULT_BEAT_PATTERN - self._beats_in_measure = beats_in_measure or [] - self.measure_numbers = measure_numbers or [] - self.measures_to_force_display = measures_to_force_display or [] + # Files from before measures were marked on their beats keep them in + # these lists by measure. They are read once, when the file's beats + # are loaded; see mark_measures_from_legacy_lists. + self._legacy_measure_lists = ( + beats_in_measure or [], + measure_numbers or [], + measures_to_force_display or [], + ) # False for files saved before time signatures existed, so their # layout doesn't change; new timelines are created with it on. self.show_time_signatures = show_time_signatures @@ -493,15 +446,30 @@ def default_height(self): def display_measure_number_period(self): return settings.get("beat_timeline", "display_measure_periodicity") + @property + def measures(self) -> Measures: + """The measures, read from the beats that start them.""" + return self._cached("measures", lambda: build_measures(self.components)) + @property def beats_in_measure(self) -> list[int]: - return self._beats_in_measure + return self.measures.counts + + @property + def measure_numbers(self) -> list[int]: + return self.measures.numbers + + @property + def measures_to_force_display(self) -> list[int]: + return self.measures.always_shown + + @property + def beats_that_start_measures(self) -> list[int]: + return self.measures.starts - @beats_in_measure.setter - def beats_in_measure(self, value): - self._beats_in_measure = value - self.recalculate_measures() - self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) + @property + def beats_that_start_measures_set(self) -> set[int]: + return set(self.measures.starts) def should_display_measure_number(self, measure_index): # this is a cheap workaround to deal with pickup measures @@ -516,7 +484,7 @@ def should_display_measure_number(self, measure_index): @property def measure_count(self): - return len(self.beats_in_measure) + return self.measures.count def get_time_by_measure( self, number: int, fraction: float = 0, is_segment_end: bool = False @@ -532,6 +500,8 @@ def get_time_by_measure( if not self.measure_count: raise ValueError("No beats in timeline. Can't get time.") + self._ensure_metric_fraction_dicts() + if not (0 <= fraction <= 1.0): tilia.errors.display(tilia.errors.INVALID_MEASURE_FRACTION, fraction) return [] @@ -620,6 +590,7 @@ def get_time_by_measure( return sorted(times) def get_metric_fraction_by_time(self, time: float) -> float: + self._ensure_metric_fraction_dicts() if mf := self.time_to_metric_fraction.get(time): return mf times = list(self.time_to_metric_fraction.keys()) @@ -636,12 +607,17 @@ def get_metric_fraction_by_time(self, time: float) -> float: ) + metric_fraction[idx - 1] def is_first_in_measure(self, beat): - return self.components.index(beat) in self.beats_that_start_measures_set - - def clear_cached_metric_positions(self): - for beat in self: - beat.clear_cached_metric_position() - self._invalidate_meters() + return self.get_beat_index(beat) in self.beats_that_start_measures_set + + def get_metric_position_of_beat(self, beat: Beat) -> MetricPosition: + measures = self.measures + index = self.get_beat_index(beat) + measure_index = measures.measure_of_beat[index] + return MetricPosition( + measures.numbers[measure_index], + index - measures.starts[measure_index] + 1, + measures.counts[measure_index], + ) @property def beat_units(self) -> list[BeatUnit]: @@ -655,20 +631,10 @@ def beat_units(self) -> list[BeatUnit]: def get_beat_unit_on_beat(self, beat_id: int) -> BeatUnit | None: return self.component_manager.get_beat_unit_on_beat(beat_id) - def _invalidate_meters(self) -> None: - self._measure_meters = None - # Bar lengths or beat units changed: caches built from beats are stale. - self.component_manager.bump_version() - - def on_beat_units_changed(self) -> None: - self._invalidate_meters() - @property def measure_meters(self) -> list[MeasureMeter]: """The meter of every measure, from the beat units in effect.""" - if getattr(self, "_measure_meters", None) is None: - self._measure_meters = self._compute_measure_meters() - return self._measure_meters + return self._cached("measure_meters", self._compute_measure_meters) def get_measure_meter(self, measure_index: int) -> MeasureMeter: return self.measure_meters[measure_index] @@ -773,13 +739,12 @@ def get_time_by_quarter_stamp(self, stamp: float) -> float | None: def _compute_measure_meters(self) -> list[MeasureMeter]: beats = self.components units_by_beat_id = {u.beat_id: u for u in self.component_manager.beat_units} - starts = getattr(self, "beats_that_start_measures", []) + measures = self.measures default_units = parse_units(DEFAULT_UNITS).units meters = [] governing: BeatUnit | None = None - for measure_index, beat_count in enumerate(self.beats_in_measure): - start = starts[measure_index] if measure_index < len(starts) else 0 + for start, beat_count in zip(measures.starts, measures.counts, strict=True): in_measure = [ units_by_beat_id[beat.id] for beat in beats[start : start + beat_count] @@ -876,18 +841,6 @@ def _put_beat_unit( beat_id = self[self.beats_that_start_measures[measure_index]].id self.put_beat_unit_on_beat(beat_id, denominator, units, assumed) - def recalculate_measures(self): - beat_delta = (len(self)) - sum(self.beats_in_measure) - if beat_delta > 0: - self.extend_beats_in_measure(beat_delta) - self.extend_measure_numbers() - elif beat_delta < 0: - self.reduce_beats_in_measure(-beat_delta) - self.reduce_measure_numbers() - - self.clear_cached_metric_positions() - self.update_beats_that_start_measures() - @staticmethod def get_extension_from_beat_pattern( beat_pattern: list[int], @@ -895,133 +848,99 @@ def get_extension_from_beat_pattern( start_index: int = 0, beats_on_starting_measure: int = 0, ) -> list[int]: - if amount == 0: - return [] - - if start_index < 0: - raise ValueError(f"Start index must be a positive value. Got {start_index}") - - beat_pattern_cycle = itertools.cycle(beat_pattern) - - # iterate up to start index - for _ in range(start_index): - next(beat_pattern_cycle) - - if beats_on_starting_measure: - beats = next(beat_pattern_cycle) - if beats_on_starting_measure < beats: - diff = beats - beats_on_starting_measure - extension = [min(diff, amount)] - remaining_beats = amount - diff - else: - # A measure may hold more beats than the beat pattern - # prescribes for it, as "Set amount in measure" sets an - # arbitrary amount. There is nothing left to fill in that - # case, so the extension starts a new measure. - extension = [] - remaining_beats = amount - else: - extension = [] - remaining_beats = amount - - if remaining_beats > 0: - for beats in beat_pattern_cycle: - if remaining_beats > beats: - remaining_beats -= beats - extension.append(beats) - else: - extension.append(remaining_beats) - break - - return extension - - def _get_beats_in_measure_extension(self, amount: int): - if not self.beat_pattern_bars: - raise ValueError("Beat pattern is empty, can't get measure extension.") - - if self.beats_in_measure: - beats_on_starting_measure = self.beats_in_measure[-1] - start_index = ( - self.measure_count % len(self.beat_pattern_bars) - 1 - ) % self.measure_count - - if ( - start_index == -1 - and beats_on_starting_measure == self.beat_pattern_bars[-1] - ): - beats_on_starting_measure = 0 - start_index = 0 - else: - beats_on_starting_measure = 0 - start_index = 0 - - return self.get_extension_from_beat_pattern( - self.beat_pattern_bars, - amount, - start_index=start_index, - beats_on_starting_measure=beats_on_starting_measure, + return get_extension_from_beat_pattern( + beat_pattern, amount, start_index, beats_on_starting_measure ) - def extend_beats_in_measure(self, amount: int) -> None: - extension = self._get_beats_in_measure_extension(amount) - - if not self._beats_in_measure: - self._beats_in_measure += extension - return + def create_component( + self, kind: ComponentKind, *args, id=None, **kwargs + ) -> tuple[TC | None, str | None]: + """ + A beat created without saying whether it starts a measure is placed + by where it falls: after the last beat, it starts a measure once the + last one has as many beats as the beat pattern gives it; before the + first, it starts a measure of its own, numbered one less than the + first, so a pickup is measure 0; anywhere else, it joins the measure + it falls in. No other beat changes measure. + """ + is_placed = kwargs.get("is_first_in_measure") is not None + component, reason = super().create_component(kind, *args, id=id, **kwargs) + manager = self.component_manager + if ( + component is None + or kind != ComponentKind.BEAT + or manager.is_bulk + or manager.is_restoring + ): + return component, reason - bp_index = (self.measure_count % len(self.beat_pattern_bars)) - 1 - is_last_measure_complete = ( - self._beats_in_measure[-1] >= self.beat_pattern_bars[bp_index] + if not is_placed: + self._place_new_beat(component) + self.ensure_first_beat_starts_measure() + self._ensure_first_beat_unit() + post( + Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, + self.id, + max(self.get_beat_index(component) - 1, 0), ) - - if is_last_measure_complete: - self._beats_in_measure += extension - else: - self._beats_in_measure[-1] += extension[0] - self._beats_in_measure += extension[1:] - - def reduce_beats_in_measure(self, amount: int) -> None: - remaining_beats = amount - for beats_in_measure in reversed(self._beats_in_measure): - if beats_in_measure < remaining_beats: - self._beats_in_measure.pop(-1) - remaining_beats -= beats_in_measure - elif beats_in_measure > remaining_beats: - self._beats_in_measure[-1] -= remaining_beats - break - else: - self._beats_in_measure.pop(-1) - break - - def extend_measure_numbers(self): - extra_measure_count = len(self.beats_in_measure) - len(self.measure_numbers) - - for _ in range(extra_measure_count): - if not self.measure_numbers: - self.measure_numbers.append(1) - else: - self.measure_numbers.append(self.measure_numbers[-1] + 1) - - def reduce_measure_numbers(self): - extra_measure_count = len(self.measure_numbers) - len(self.beats_in_measure) - if not extra_measure_count: + return component, reason + + def _place_new_beat(self, beat: Beat) -> None: + index = self.get_beat_index(beat) + if len(self) == 1: + self.set_component_data(beat.id, "is_first_in_measure", True) + elif index == 0: + old_first = self[1] + number = ( + 1 + if old_first.measure_number_override is None + else old_first.measure_number_override + ) + if not old_first.is_first_in_measure: + self.set_component_data(old_first.id, "is_first_in_measure", True) + self.set_component_data(beat.id, "is_first_in_measure", True) + if number >= 1: + self.set_component_data(beat.id, "measure_number_override", number - 1) + elif index == len(self) - 1: + measures = self.measures + last_measure = measures.count - 1 + beats_before = measures.counts[last_measure] - 1 + pattern = self.beat_pattern_bars + if beats_before >= pattern[last_measure % len(pattern)]: + self.set_component_data(beat.id, "is_first_in_measure", True) + + def ensure_first_beat_starts_measure(self) -> None: + if len(self) and not self[0].is_first_in_measure: + self.set_component_data(self[0].id, "is_first_in_measure", True) + + def mark_measures_from_legacy_lists(self) -> None: + """ + Marks the beats that start measures from the lists by measure that + files from before measures were marked on their beats store, fitted + to the beats as TiLiA fitted them on loading. A measure gets a number + of its own where it isn't one more than the measure before (or 1, for + the first). + """ + beats_in_measure, measure_numbers, always_shown = self._legacy_measure_lists + self._legacy_measure_lists = ([], [], []) + if not len(self): return - self.measure_numbers = self.measure_numbers[:-extra_measure_count] - # measures_to_force_display is not sorted, so every index has to be - # checked, not just the ones at the end of the list. - self.measures_to_force_display = [ - i for i in self.measures_to_force_display if i < self.measure_count - ] - def update_beats_that_start_measures(self): - # noinspection PyAttributeOutsideInit - self.beats_that_start_measures = [0] + list( - itertools.accumulate(self.beats_in_measure[:-1]) + counts, numbers = fit_legacy_lists( + len(self), self.beat_pattern_bars, beats_in_measure, measure_numbers ) - self.beats_that_start_measures_set = set(self.beats_that_start_measures) - self._invalidate_meters() - self.update_metric_fraction_dicts() - self._ensure_first_beat_unit() + starts = [0] + list(itertools.accumulate(counts[:-1])) + for measure_index, start in enumerate(starts): + beat = self[start] + expected = numbers[measure_index - 1] + 1 if measure_index else 1 + if not beat.is_first_in_measure: + self.set_component_data(beat.id, "is_first_in_measure", True) + if numbers[measure_index] != expected: + self.set_component_data( + beat.id, "measure_number_override", numbers[measure_index] + ) + if measure_index in always_shown: + self.set_component_data(beat.id, "measure_number_always_shown", True) def _ensure_first_beat_unit(self, copy_next: bool = True) -> None: """ @@ -1033,16 +952,11 @@ def _ensure_first_beat_unit(self, copy_next: bool = True) -> None: piece starts as 6/8); with no other beat units, or `copy_next` False, it gets the default. Either way it is assumed: nobody set a time signature on the measures before the next beat unit. Skipped while - beats are being changed in bulk, which recalculates and calls this - once at the end. + beats are being changed in bulk, which refreshes and calls this once + at the end. """ manager = self.component_manager - if ( - manager.is_restoring - or not manager.compute_is_first_in_measure - or not len(self) - or not self.beats_in_measure - ): + if manager.is_restoring or manager.is_bulk or not len(self): return first_measure = self.components[: self.beats_in_measure[0]] @@ -1084,7 +998,14 @@ def put_beat_unit_on_beat( self.set_component_data(beat_unit.id, "units", units) self.set_component_data(beat_unit.id, "assumed", assumed) + def _ensure_metric_fraction_dicts(self) -> None: + # get_time_by_measure memoizes into the dicts, so they are rebuilt + # only when the beats have changed since they were last built. + if self._metric_fraction_version != self.component_manager.version: + self.update_metric_fraction_dicts() + def update_metric_fraction_dicts(self): + self._metric_fraction_version = self.component_manager.version self.metric_fraction_to_beat_dict = {} self.metric_fraction_to_time = {} self.time_to_metric_fraction = {} @@ -1125,91 +1046,186 @@ def __sort_time_to_metric(self) -> None: } def get_measure_index(self, beat_index: int) -> tuple[int, int]: - prev_n = 0 - for measure_index, n in enumerate(self.beats_that_start_measures): - if beat_index < n: - return measure_index - 1, beat_index - prev_n - elif beat_index == n: - return measure_index, 0 - prev_n = n - - if beat_index > n: - return measure_index, 1 - else: + """The index of the measure a beat is in, and its index there.""" + measures = self.measures + if not 0 <= beat_index < len(measures.measure_of_beat): raise ValueError(f'No beat with index "{beat_index}" at {self}.') + measure_index = measures.measure_of_beat[beat_index] + return measure_index, beat_index - measures.starts[measure_index] def get_beat_index(self, beat: Beat) -> int: - return self.components.index(beat) + indices = self._cached( + "beat_indices", lambda: {b.id: i for i, b in enumerate(self.components)} + ) + return indices[beat.id] - def propagate_measure_number_change(self, start_index: int): - for j in range(len(self.measure_numbers[start_index + 1 :])): - propagate_index = j + start_index + 1 - if propagate_index in self.measures_to_force_display: - break - else: - self.measure_numbers[propagate_index] = ( - self.measure_numbers[propagate_index - 1] + 1 - ) + def _get_first_beat_of_measure(self, measure_index: int) -> Beat: + return self[self.measures.starts[measure_index]] + + def _post_measures_changed(self, measure_index: int) -> None: + post( + Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, + self.id, + self.measures.starts[measure_index], + ) def set_measure_number(self, measure_index: int, number: int) -> None: - self.clear_cached_metric_positions() - self.measure_numbers[measure_index] = number - self.propagate_measure_number_change(measure_index) + """ + Numbers a measure by hand. The measures after it that have no number + of their own count on from it. + """ + beat = self._get_first_beat_of_measure(measure_index) + self.set_component_data(beat.id, "measure_number_override", number) if not number == 0: self.force_display_measure_number(measure_index) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, measure_index) - self.update_metric_fraction_dicts() + self._post_measures_changed(measure_index) - def reset_measure_number(self, measure_index: int) -> None: - self.clear_cached_metric_positions() - if measure_index == 0: - self.measure_numbers[0] = 1 - else: - self.measure_numbers[measure_index] = ( - self.measure_numbers[measure_index - 1] + 1 - ) - self.propagate_measure_number_change(measure_index) - self.update_metric_fraction_dicts() + def set_measure_numbers(self, numbers: list[int], start_measure: int = 0) -> None: + """ + Numbers the measures from `start_measure` on, one per entry. A measure + gets a number of its own, always shown unless it is 0, only where + its number isn't the previous measure's plus one (or 1, for the first + measure); elsewhere it counts on from the measure before. + """ + measures = self.measures + numbers = numbers[: measures.count - start_measure] + with self.component_manager.bulk(): + for offset, number in enumerate(numbers): + measure_index = start_measure + offset + if offset: + previous = numbers[offset - 1] + elif measure_index: + previous = measures.numbers[measure_index - 1] + else: + previous = 0 + beat = self._get_first_beat_of_measure(measure_index) + override = None if number == previous + 1 else number + if beat.measure_number_override != override: + self.set_component_data( + beat.id, "measure_number_override", override + ) + if override not in (None, 0) and not beat.measure_number_always_shown: + self.set_component_data( + beat.id, "measure_number_always_shown", True + ) + self.refresh_measures() - try: - self.unforce_display_measure_number(measure_index) - except ValueError: - pass + def reset_measure_number(self, measure_index: int) -> None: + beat = self._get_first_beat_of_measure(measure_index) + self.set_component_data(beat.id, "measure_number_override", None) + self.unforce_display_measure_number(measure_index) def force_display_measure_number(self, measure_index: int) -> None: - self.clear_cached_metric_positions() - if measure_index not in self.measures_to_force_display: - self.measures_to_force_display.append(measure_index) + beat = self._get_first_beat_of_measure(measure_index) + if not beat.measure_number_always_shown: + self.set_component_data(beat.id, "measure_number_always_shown", True) def unforce_display_measure_number(self, measure_index: int) -> None: - self.clear_cached_metric_positions() - self.measures_to_force_display.remove(measure_index) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, measure_index) + beat = self._get_first_beat_of_measure(measure_index) + if beat.measure_number_always_shown: + self.set_component_data(beat.id, "measure_number_always_shown", False) + self._post_measures_changed(measure_index) def set_beat_amount_in_measure(self, measure_index: int, beat_amount: int) -> None: - self.clear_cached_metric_positions() - new_beats_in_measure = self.beats_in_measure.copy() - new_beats_in_measure[measure_index] = beat_amount - self.set_data("beats_in_measure", new_beats_in_measure) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, measure_index) + """ + Gives a measure `beat_amount` beats. The measures after it keep their + number of beats, so their first beats move. + """ + counts = self.beats_in_measure.copy() + counts[measure_index] = beat_amount + self._remeasure(counts, measure_index) + + def _remeasure(self, counts: list[int], from_measure: int) -> None: + """ + Makes the measures from `from_measure` on hold `counts[from_measure:]` + beats each. Each keeps its number, display and the beat unit on its + first beat, by position. Beats left over at the end make measures + from the beat pattern; measures past the last beat go. + """ + measures = self.measures + if not 0 <= from_measure < measures.count: + return + + beats = self.components + old_first_beats = [beats[start] for start in measures.starts[from_measure:]] + kept_data = [ + ( + beat.measure_number_override, + beat.measure_number_always_shown, + self.get_beat_unit_on_beat(beat.id), + ) + for beat in old_first_beats + ] + + first = measures.starts[from_measure] + remaining = len(beats) - first + new_counts = [] + for count in counts[from_measure:]: + if remaining <= 0: + break + new_counts.append(min(count, remaining)) + remaining -= new_counts[-1] + if remaining: + pattern = self.beat_pattern_bars + new_counts += get_extension_from_beat_pattern( + pattern, + remaining, + start_index=(from_measure + len(new_counts)) % len(pattern), + ) + new_starts = [first] + [ + first + offset for offset in itertools.accumulate(new_counts[:-1]) + ] + + with self.component_manager.bulk(): + # Beat units are recreated on the measures' new first beats, as + # moving them one by one could land one on a beat that still + # holds another. + beat_unit_values = [] + for _, _, beat_unit in kept_data: + if beat_unit is None: + beat_unit_values.append(None) + continue + beat_unit_values.append( + (beat_unit.denominator, beat_unit.units, beat_unit.assumed) + ) + self.component_manager.delete_component(beat_unit) + + new_data = dict(zip(new_starts, kept_data, strict=False)) + new_starts_set = set(new_starts) + for index in range(first, len(beats)): + beat = beats[index] + override, always_shown, _ = new_data.get(index, (None, False, None)) + values = { + "is_first_in_measure": index in new_starts_set, + "measure_number_override": override, + "measure_number_always_shown": always_shown, + } + for attr, value in values.items(): + if getattr(beat, attr) != value: + self.set_component_data(beat.id, attr, value) + + for start, values in zip(new_starts, beat_unit_values, strict=False): + if values is not None: + self.put_beat_unit_on_beat(beats[start].id, *values) + + self.refresh_measures() def distribute_beats(self, measure_index: int) -> None: self.component_manager.distribute_beats(measure_index) def refresh_measures(self) -> None: """ - Rebuild the measure structure and have the UI relabel every beat. - - Run this after any bulk operation that added or removed beats with - per-beat recomputation suppressed. It is safe on an emptied timeline, - and required there: otherwise beats_in_measure and measure_numbers go - on describing beats that no longer exist, and the labels stay on - screen. + Keeps the first beat starting a measure and carrying a beat unit, and + has the UI relabel every beat. Run this after any bulk operation that + added or removed beats. """ - self.recalculate_measures() - self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) + self.ensure_first_beat_starts_measure() + self._ensure_first_beat_unit() post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + def recalculate_measures(self) -> None: + self.refresh_measures() + def get_hand_set_bars(self) -> list[int]: """ Indices of bars whose length differs from the pattern, i.e. bars set @@ -1233,49 +1249,41 @@ def get_bars_overwritten_by(self, pattern_bars: list[int]) -> list[int]: def set_beat_pattern(self, pattern: str) -> bool: """ - Sets the pattern and recomputes every bar from it, discarding bars set - by hand. Measure numbers are kept by bar position. + Sets the pattern and remeasures every beat from it, discarding bars + set by hand. Each measure keeps its number, display and beat unit by + position. """ _, success = self.set_data("beat_pattern", pattern) if not success: return False - self._beats_in_measure = self.get_extension_from_beat_pattern( - self.beat_pattern_bars, len(self) - ) - # Measure numbers must match the new bar count before measures are - # recalculated, which reads a number for every bar. - if len(self.measure_numbers) < self.measure_count: - self.extend_measure_numbers() - elif len(self.measure_numbers) > self.measure_count: - self.reduce_measure_numbers() - - self.clear_cached_metric_positions() - self.update_beats_that_start_measures() - self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + if len(self): + self._remeasure( + get_extension_from_beat_pattern(self.beat_pattern_bars, len(self)), 0 + ) return True def delete_components(self, components: list[TC]) -> None: self._validate_delete_components(components) - self.clear_cached_metric_positions() - - component_manager = self.component_manager - with component_manager.suppressing_is_first_in_measure() as was_computing: - for component in list(reversed(components)): - component_manager.delete_component(component) + manager = self.component_manager + beat_units = [c for c in components if c.KIND == ComponentKind.BEAT_UNIT] + # From the last beat back, so each deletion leaves the measures of + # the beats still to be deleted as they were. + beats = sorted( + (c for c in components if c.KIND == ComponentKind.BEAT), + key=self.get_beat_index, + reverse=True, + ) + for component in beat_units + beats: + manager.delete_component(component) - if not was_computing: - # BeatTLComponentManager.restore_state, reached on every undo and - # redo, suppresses recomputation across a whole delete-then-create - # pass and recomputes once at the end. Recomputing here would make - # every beat it re-creates afterwards sweep the whole timeline. + if manager.is_restoring or manager.is_bulk: return # Deleting the first measure's beat unit resets it to the default, # rather than copying the next one as a structural change would. - if any(c.KIND == ComponentKind.BEAT_UNIT for c in components): + if beat_units: self._ensure_first_beat_unit(copy_next=False) self.refresh_measures() @@ -1287,22 +1295,21 @@ class FillMethod(Enum): @long_operation("Creating beats...") def fill_with_beats(self, method: BeatTimeline.FillMethod, value: int | float): duration = get(Get.MEDIA_DURATION) - # only compute at end - with self.component_manager.suppressing_is_first_in_measure(): - if method == BeatTimeline.FillMethod.BY_AMOUNT: - total = int(value) - for i in range(total): - self.create_component(ComponentKind.BEAT, i * duration / value) - post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, total) - elif method == BeatTimeline.FillMethod.BY_INTERVAL: - total = math.floor(duration / value) - for i in range(total): - self.create_component(ComponentKind.BEAT, i * value) - post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, total) - - self.recalculate_measures() - self.component_manager.update_is_first_in_measure_of_subsequent_beats(0) - post(Post.BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE, self.id, 0) + if method == BeatTimeline.FillMethod.BY_AMOUNT: + times = [i * duration / value for i in range(int(value))] + else: + times = [i * value for i in range(math.floor(duration / value))] + + counts = get_extension_from_beat_pattern(self.beat_pattern_bars, len(times)) + starts = {0, *itertools.accumulate(counts[:-1])} + with self.component_manager.bulk(): + for i, time in enumerate(times): + self.create_component( + ComponentKind.BEAT, time, is_first_in_measure=i in starts + ) + post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, len(times)) + + self.refresh_measures() def add_measure_zero(self, fraction_of_measure_one: float) -> tuple[bool, str]: if self.measure_count < 2: @@ -1324,9 +1331,11 @@ def add_measure_zero(self, fraction_of_measure_one: float) -> tuple[bool, str]: "There is not enough available space before the first measure.", ) - self.create_component(ComponentKind.BEAT, measure_zero_start) - - self.set_measure_number(0, 0) - self.set_beat_amount_in_measure(0, 1) + self.create_component( + ComponentKind.BEAT, + measure_zero_start, + is_first_in_measure=True, + measure_number_override=0, + ) return True, "" diff --git a/tilia/timelines/beat/validators.py b/tilia/timelines/beat/validators.py index 10a4f3022..7d58349eb 100644 --- a/tilia/timelines/beat/validators.py +++ b/tilia/timelines/beat/validators.py @@ -1,10 +1,6 @@ from tilia.timelines.beat.pattern import parse -def validate_integer_list(value): - return all([isinstance(x, int) for x in value]) - - def validate_beat_pattern(value: str | list[int]) -> bool: """ Accepts pattern text, or a list of bar lengths as stored by files and diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 390c7360a..5c502f4c4 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -57,10 +57,6 @@ class BeatTimelineUI(TimelineUI): ACCEPTS_HORIZONTAL_ARROWS = True UPDATE_TRIGGERS = TimelineUI.UPDATE_TRIGGERS + [ "beat_pattern", - "measure_numbers", - "beats_that_start_measures", - "measures_to_force_display", - "beats_in_measure", "show_time_signatures", ] timeline_class = BeatTimeline @@ -501,9 +497,7 @@ def on_add(self, time: float | None = None): if time is None: time = get(Get.SELECTED_TIME) - self.timeline_ui: BeatTimelineUI component, _ = self.timeline.create_component(ComponentKind.BEAT, time) - self.timeline.recalculate_measures() return False if component is None else True @staticmethod @@ -565,6 +559,9 @@ def _deselect_all_but_first(self): self.element_manager.deselect_element(element) def should_display_measure_number(self, beat_ui): + if self.timeline.component_manager.is_bulk: + # Beats are relabelled once the bulk change is done. + return False beat = self.timeline.get_component(beat_ui.id) beat_index = self.timeline.components.index(beat) measure_index, _ = self.timeline.get_measure_index(beat_index) @@ -646,28 +643,3 @@ def create_pasted_beats( def update_beat_pattern(self): pass # not implemented - - def update_measure_numbers(self): - for beat_ui in self: - try: - beat_ui.update_label() - except IndexError: - # State is being restored and - # beats in measure has not been - # updated yet. This is a dangerous - # workaround, as it might conceal - # other exceptions. Let's fix this ASAP. - continue - - def update_measures_to_force_display(self): - for beat_ui in self: - beat_ui.update_label() - - def update_beats_in_measure(self): - for beat_ui in self: - beat_ui.update_is_first_in_measure() - self.update_time_signatures() - - def beats_that_start_measures(self): - for beat_ui in self: - beat_ui.update_is_first_in_measure() From 9fa44a7fe6eb130dc56851882090b2ecfa5f0a36 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:33:10 +0100 Subject: [PATCH 34/56] feat: write time signatures and their taps in the beat pattern A beat pattern can now give each bar its time signature rather than only its number of beats, so a timeline's meter is set in one step, when it is created: - `4/4`, `6/8` and `2/2` are tapped once per unit of the denominator, and an added numerator once per part: `2+3/8` is two taps. - Taps in brackets say what each beat is worth, in units of the denominator, and must add up to the numerator: `6/8(3 3)` is 6/8 tapped in two, `4/4(3/2 3/2 1)` is 4/4 tapped as 3+3+2 eighths. - A number alone, as in every pattern so far, is that many beats with an assumed time signature of quarter notes. - Counts and groups work as before: `4[4/4] 3/4 3[4/4]`. Each bar becomes a beat count and a beat unit, so nothing new is stored on the beats. When a beat starts a measure, the beat unit the pattern plans for it is written on it wherever the time signature changes, including when tapping goes past the end of the pattern and starts it again. Filling the timeline and setting the pattern do the same for every measure; a pattern of beat counts only leaves the beat units as they are. `format_planned_bars` writes bars back as pattern text, which reads back as the same bars. --- tests/timelines/beat/test_pattern.py | 150 +++++++++- .../timelines/beat/test_beat_timeline_ui.py | 108 +++++++ tests/ui/windows/test_beat_pattern_dialog.py | 2 +- tilia/timelines/beat/pattern.py | 281 +++++++++++++++--- tilia/timelines/beat/timeline.py | 73 ++++- tilia/ui/windows/beat_pattern.py | 2 +- 6 files changed, 570 insertions(+), 46 deletions(-) diff --git a/tests/timelines/beat/test_pattern.py b/tests/timelines/beat/test_pattern.py index 1f566cf54..50f6d99b9 100644 --- a/tests/timelines/beat/test_pattern.py +++ b/tests/timelines/beat/test_pattern.py @@ -1,11 +1,16 @@ +from fractions import Fraction as F + import pytest from tilia.timelines.beat.pattern import ( MAX_BARS, + MAX_BEATS_PER_BAR, MAX_DEPTH, MAX_DIGITS, BarRun, ParseStatus, + PlannedBar, + format_planned_bars, group_runs, parse, ) @@ -70,8 +75,8 @@ class TestInvalid: ("4 a", 2, "Unexpected 'a'."), ("4, 4", 1, "Unexpected ','."), ("-4", 0, "Unexpected '-'."), - ("0", 0, "Bar lengths must be at least 1."), - ("4 0", 2, "Bar lengths must be at least 1."), + ("0", 0, "Bars must have at least 1 beat."), + ("4 0", 2, "Bars must have at least 1 beat."), ("0[4]", 0, "Repeat count must be at least 1."), ("2[]", 1, "Empty group '[]'."), ("2[ ]", 1, "Empty group '[]'."), @@ -144,3 +149,144 @@ def test_does_not_merge_non_consecutive_bars(self): BarRun(1, 1, 3), BarRun(2, 1, 4), ] + + +def bar_values(text): + return [ + (bar.beat_count, bar.denominator, bar.units, bar.assumed) + for bar in parse(text).planned_bars + ] + + +class TestTimeSignatures: + @pytest.mark.parametrize( + "text, bars", + [ + # A number of beats alone assumes quarter notes. + ("3", [(3, 4, (F(1),), True)]), + ("4/4", [(4, 4, (F(1),), False)]), + ("6/8", [(6, 8, (F(1),), False)]), + ("2/2", [(2, 2, (F(1),), False)]), + # An added numerator is tapped once per part. + ("2+3/8", [(2, 8, (F(2), F(3)), False)]), + ("3+3+2/8", [(3, 8, (F(3), F(3), F(2)), False)]), + # Taps say what each beat is worth. + ("6/8(3 3)", [(2, 8, (F(3),), False)]), + ("3/4(3)", [(1, 4, (F(3),), False)]), + ("4/4(3/2 3/2 1)", [(3, 4, (F(3, 2), F(3, 2), F(1)), False)]), + ("4/4(1 2 1)", [(3, 4, (F(1), F(2), F(1)), False)]), + ("4/4(2 2)", [(2, 4, (F(2),), False)]), + ("4/4(1+1+1+1)", [(4, 4, (F(1),), False)]), + ("8/8(1 2 1 2 2)", [(5, 8, (F(1), F(2), F(1), F(2), F(2)), False)]), + # A fractional numerator, with its taps. + ( + "11/2/4(3/2 3/2 1 3/2)", + [(4, 4, (F(3, 2), F(3, 2), F(1), F(3, 2)), False)], + ), + # Spaces around the operators. + ("4 / 4", [(4, 4, (F(1),), False)]), + ("2 + 3 / 8 (2 3)", [(2, 8, (F(2), F(3)), False)]), + ], + ) + def test_bars(self, text, bars): + result = parse(text) + assert result.is_complete, result.error + assert bar_values(text) == bars + + def test_groups_of_time_signatures(self): + assert parse("4[4/4] 3/4 3[4/4]").bars == [4, 4, 4, 4, 3, 4, 4, 4] + assert [bar.numerator for bar in parse("2[4/4 3/4]").planned_bars] == [ + 4, + 3, + 4, + 3, + ] + + def test_values_and_numerator(self): + (bar,) = parse("4/4(3/2 3/2 1)").planned_bars + assert bar.values == [F(3, 2), F(3, 2), F(1)] + assert bar.numerator == 4 + assert bar.units_text == "3/2+3/2+1" + + @pytest.mark.parametrize( + "text, position", + [ + ("4/", 2), + ("4/ ", 3), + ("2+", 2), + ("2+3", 3), + ("4/4(", 3), + ("4/4(1 1", 3), + ("2[4/", 4), + ], + ) + def test_incomplete(self, text, position): + result = parse(text) + assert result.status == ParseStatus.INCOMPLETE + assert result.position == position + + @pytest.mark.parametrize( + "text, message", + [ + ("4/4(1 1 1)", "The taps add up to 3, not 4."), + ("6/8(3 2)", "The taps add up to 5, not 6."), + ("4/0", "Denominators go from 1 to 128."), + ("4/129", "Denominators go from 1 to 128."), + ("0/4", "Numerators must be greater than 0."), + ("3/2/4", "Give the taps of a fractional numerator."), + ("4/4()", "Empty taps '()'."), + ("4/4(1 0 3)", "Units must be greater than 0."), + ("4/4(a)", "'a' is not a whole number or fraction such as 1/2."), + ("4/a", "Unexpected 'a'."), + ( + f"{MAX_BEATS_PER_BAR + 1}/4", + f"Bars can have at most {MAX_BEATS_PER_BAR} beats.", + ), + ( + f"{MAX_BEATS_PER_BAR + 1}", + f"Bars can have at most {MAX_BEATS_PER_BAR} beats.", + ), + ], + ) + def test_invalid(self, text, message): + result = parse(text) + assert result.status == ParseStatus.INVALID + assert result.error == message + + @pytest.mark.parametrize( + "text", + [ + "4", + "4 4 3", + "4/4", + "4[4/4] 3/4 3[4/4]", + "6/8(3 3)", + "3/4(3)", + "2+3/8", + "4/4(3/2 3/2 1)", + "11/2/4(3/2 3/2 1 3/2)", + "2[4/4 3[3/4]]", + "12/8(3 3 3 3) 4 2/2", + ], + ) + def test_formatting_reads_back_as_the_same_bars(self, text): + bars = parse(text).planned_bars + assert parse(format_planned_bars(bars)).planned_bars == bars + + @pytest.mark.parametrize( + "text, formatted", + [ + ("4 4 4 3", "3[4] 3"), + ("4/4 4/4 3/4", "2[4/4] 3/4"), + ("8/8(1 1 1 1 1 1 1 1)", "8/8"), + ("6/8(3 3)", "6/8(3 3)"), + ("8/8(3 3 2)", "3+3+2/8"), + ], + ) + def test_formatting(self, text, formatted): + assert format_planned_bars(parse(text).planned_bars) == formatted + + def test_planned_bar_from_values_finds_the_repeating_units(self): + bar = PlannedBar.from_values([F(1), F(2)] * 3, 8) + assert bar.units == (F(1), F(2)) + assert bar.beat_count == 6 diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 1efff5503..996f289a3 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -29,6 +29,7 @@ from tilia.requests import Get, Post, get, post from tilia.settings import settings from tilia.timelines.beat.timeline import BeatTimeline +from tilia.timelines.beat.units import format_units from tilia.timelines.component_kinds import ComponentKind from tilia.ui import commands from tilia.ui.commands import get_qaction @@ -495,6 +496,113 @@ def test_set_pattern_from_context_menu(self, tluis): assert tluis[0].timeline.beats_in_measure == [3, 3] +def time_signatures(timeline): + return [ + (format_units([meter.numerator]), meter.denominator) + for meter in timeline.measure_meters + ] + + +class TestPatternWithTimeSignatures: + def test_tapping_follows_the_time_signatures(self, tluis): + add_beat_timeline_with_pattern("2[4/4] 3/4 2[4/4(3/2 3/2 1)]") + add_beats(range(4 + 4 + 3 + 3 + 3)) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [4, 4, 3, 3, 3] + assert time_signatures(timeline) == [ + ("4", 4), + ("4", 4), + ("3", 4), + ("4", 4), + ("4", 4), + ] + values = [m.beat_values for m in timeline.measure_meters] + assert values[3] == (F(3, 2), F(3, 2), F(1)) + + def test_beat_units_go_where_the_time_signature_changes(self, tluis): + add_beat_timeline_with_pattern("2[4/4] 6/8(3 3)") + add_beats(range(10)) + + timeline = tluis[0].timeline + units = [ + ( + timeline.get_beat_index(timeline.get_component(u.beat_id)), + u.denominator, + u.units, + u.assumed, + ) + for u in timeline.beat_units + ] + assert units == [(0, 4, "1", False), (8, 8, "3", False)] + + def test_tapping_past_the_pattern_starts_it_again(self, tluis): + add_beat_timeline_with_pattern("4/4 3/4") + add_beats(range(4 + 3 + 4 + 3)) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [4, 3, 4, 3] + assert time_signatures(timeline) == [("4", 4), ("3", 4), ("4", 4), ("3", 4)] + + def test_numbers_of_beats_alone_are_assumed(self, tluis): + add_beat_timeline_with_pattern("3") + add_beats(range(6)) + + timeline = tluis[0].timeline + assert time_signatures(timeline) == [("3", 4), ("3", 4)] + assert all(m.is_assumed for m in timeline.measure_meters) + + def test_written_time_signatures_are_not_assumed(self, tluis): + add_beat_timeline_with_pattern("3/4") + add_beats(range(6)) + + assert not any(m.is_assumed for m in tluis[0].timeline.measure_meters) + + def test_fill_with_beats_follows_the_time_signatures(self, tluis, tilia_state): + tilia_state.duration = 100 + add_beat_timeline_with_pattern("6/8(3 3) 2/4") + + with patch_fill_beat_timeline_dialog( + tluis[0].timeline, BeatTimeline.FillMethod.BY_AMOUNT, 8 + ): + commands.execute("timeline.beat.fill") + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [2, 2, 2, 2] + assert time_signatures(timeline) == [("6", 8), ("2", 4), ("6", 8), ("2", 4)] + + def test_setting_the_pattern_remeasures_with_its_time_signatures(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(12)) + + with undoable(): + commands.execute( + "timeline.beat.set_pattern", tluis[0], pattern="6/8(3 3) 3/4(1 1 1)" + ) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [2, 3, 2, 3, 2] + assert time_signatures(timeline) == [ + ("6", 8), + ("3", 4), + ("6", 8), + ("3", 4), + ("6", 8), + ] + + def test_time_signatures_survive_save_and_reopen(self, tluis, tmp_path): + add_beat_timeline_with_pattern("2[4/4] 3/4") + add_beats(range(11)) + + save_and_reopen(tmp_path) + + timeline = tluis[0].timeline + assert timeline.beat_pattern == "2[4/4] 3/4" + assert time_signatures(timeline) == [("4", 4), ("4", 4), ("3", 4)] + add_beats(range(11, 15)) + assert time_signatures(timeline)[-1] == ("4", 4) + + class TestCreateDeleteBeat: def test_create_single(self, beat_tlui): beat_tlui.create_beat(0) diff --git a/tests/ui/windows/test_beat_pattern_dialog.py b/tests/ui/windows/test_beat_pattern_dialog.py index 3747c8a8b..71355bf04 100644 --- a/tests/ui/windows/test_beat_pattern_dialog.py +++ b/tests/ui/windows/test_beat_pattern_dialog.py @@ -88,7 +88,7 @@ def test_incomplete_shows_error_with_position(self): assert preview.rows == [] def test_empty(self): - assert get_pattern_preview(parse("")).error == "Enter at least one bar length." + assert get_pattern_preview(parse("")).error == "Enter at least one bar." class TestDialog: diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py index 236c76e56..83c6d7d33 100644 --- a/tilia/timelines/beat/pattern.py +++ b/tilia/timelines/beat/pattern.py @@ -1,31 +1,62 @@ """ Parsing for the beat pattern syntax. -A pattern is a whitespace-separated list of items. An item is either a bar -length or a repeated group of items:: - - items := item (whitespace item)* - item := INT | INT? "[" items "]" - -So ``4 4 3`` is three bars, ``10[4] 3 15[4]`` is ten bars of 4, one of 3 and -fifteen of 4, and ``2[4 3[3]]`` expands to ``4 3 3 3 4 3 3 3``. A group with no -count (``[4 3]``) is played once. +A pattern is a whitespace-separated list of items. An item is a bar or a +repeated group of items:: + + items := item (whitespace item)* + item := bar | INT? "[" items "]" + bar := INT | numerator "/" INT taps? + numerator := part ("+" part)* + part := INT ("/" INT)? + taps := "(" value (whitespace value)* ")" + value := INT ("/" INT)? + +A bar is a time signature, such as ``4/4``, ``6/8`` or ``2+3/8``, or just a +number of beats, such as ``4``, whose time signature TiLiA assumes (four +quarter notes). A time signature is tapped once per unit of its denominator +(``6/8`` is six taps of an eighth note), or once per part of an added +numerator (``2+3/8`` is two taps, of a quarter and of a dotted quarter). +``taps`` says what each tap is worth instead, in units of the denominator: +``6/8(3 3)`` is 6/8 tapped in two, ``4/4(3/2 3/2 1)`` is 4/4 tapped as +3+3+2 eighths. The taps must add up to the numerator. A numerator can hold a +fraction, written before the denominator, for bars like ``11/2/4(3/2 3/2 1 +3/2)``. + +So ``4/4 4/4 3/4`` is three bars, ``10[4/4] 3/4 15[4/4]`` is ten bars of 4/4, +one of 3/4 and fifteen of 4/4, and ``2[4/4 3[3/4]]`` expands to ``4/4 3/4 3/4 +3/4 4/4 3/4 3/4 3/4``. A group with no count (``[4/4 3/4]``) is played once. +Spaces are allowed around ``/`` and ``+``, and before ``(``. """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum, auto +from fractions import Fraction + +from tilia.timelines.beat.units import ( + MAX_DENOMINATOR, + fit_units, + format_units, + parse_units, +) # Guards against inputs like "999999[999999[4]]" expanding into a list that # would exhaust memory. Far above the bar count of any real piece. MAX_BARS = 100_000 +# Far above the beats tapped in any real bar. Each tap's value is kept, so a +# bar's beats are capped like its bars. +MAX_BEATS_PER_BAR = 1_000 # Groups are parsed recursively, so nesting is capped well below Python's # recursion limit. Far deeper than any pattern worth writing. MAX_DEPTH = 32 # Significant digits in a number. Any more and the number is far above # MAX_BARS, and int() refuses strings past a few thousand digits. MAX_DIGITS = 9 +# The time signature TiLiA assumes for a bar given only its number of beats: +# that many quarter notes. +ASSUMED_DENOMINATOR = 4 # Only ASCII digits: str.isdigit() also accepts characters such as "²" that # int() rejects. _DIGITS = frozenset("0123456789") @@ -38,10 +69,47 @@ class ParseStatus(Enum): INVALID = auto() +@dataclass(frozen=True) +class PlannedBar: + """ + A bar of a beat pattern: how many beats are tapped in it, and the beat + unit that gives each its value (see `tilia.timelines.beat.units`). + `units` repeats over the taps, and is as short as that allows. + `assumed` is True for a bar given only its number of beats. + """ + + beat_count: int + denominator: int + units: tuple[Fraction, ...] + assumed: bool = False + + @classmethod + def from_values( + cls, values: list[Fraction], denominator: int, assumed: bool = False + ) -> PlannedBar: + return cls(len(values), denominator, _shortest_period(values), assumed) + + @property + def values(self) -> list[Fraction]: + """What each tap is worth, in units of the denominator.""" + return fit_units(list(self.units), self.beat_count) + + @property + def numerator(self) -> Fraction: + if len(self.units) == 1: + return self.units[0] * self.beat_count + return sum(self.values, Fraction(0)) + + @property + def units_text(self) -> str: + """The units as a beat unit stores them, e.g. "3/2+3/2+1".""" + return format_units(self.units) + + @dataclass(frozen=True) class ParseResult: status: ParseStatus - bars: list[int] = field(default_factory=list) + planned_bars: list[PlannedBar] = field(default_factory=list) error: str = "" position: int | None = None @@ -49,6 +117,11 @@ class ParseResult: def is_complete(self) -> bool: return self.status == ParseStatus.COMPLETE + @property + def bars(self) -> list[int]: + """The number of beats in each bar.""" + return [bar.beat_count for bar in self.planned_bars] + @dataclass(frozen=True) class BarRun: @@ -59,6 +132,16 @@ class BarRun: beats: int +def _shortest_period(values: list[Fraction]) -> tuple[Fraction, ...]: + """The shortest start of `values` that repeats into all of them.""" + for length in range(1, len(values) + 1): + if len(values) % length == 0 and values == values[:length] * ( + len(values) // length + ): + return tuple(values[:length]) + return tuple(values) + + class _Incomplete(Exception): def __init__(self, message: str, position: int) -> None: super().__init__(message) @@ -85,6 +168,9 @@ def at_end(self) -> bool: def peek(self) -> str: return self.text[self.pos] + def at(self, character: str) -> bool: + return not self.at_end() and self.peek() == character + def at_digit(self) -> bool: return not self.at_end() and self.peek() in _DIGITS @@ -101,13 +187,22 @@ def parse_int(self) -> int: raise _Invalid(f"Numbers can have at most {MAX_DIGITS} digits.", start) return int(digits or "0") - def parse_items(self, open_position: int | None = None) -> list[int]: + def expect_int(self, missing: str) -> int: + """Parses a number after skipping whitespace, which must be there.""" + self.skip_whitespace() + if self.at_end(): + raise _Incomplete(missing, self.pos) + if not self.at_digit(): + raise _Invalid(f"Unexpected '{self.peek()}'.", self.pos) + return self.parse_int() + + def parse_items(self, open_position: int | None = None) -> list[PlannedBar]: """ Parses items up to the end of the text, or up to the "]" closing the group opened at `open_position`. Leaves that "]" unconsumed. """ in_group = open_position is not None - bars: list[int] = [] + bars: list[PlannedBar] = [] while True: self.skip_whitespace() if self.at_end(): @@ -128,42 +223,124 @@ def parse_items(self, open_position: int | None = None) -> list[int]: if not bars: if in_group: raise _Invalid("Empty group '[]'.", open_position) - raise _Incomplete("Enter at least one bar length.", 0) + raise _Incomplete("Enter at least one bar.", 0) return bars - def parse_item(self) -> list[int]: + def parse_item(self) -> list[PlannedBar]: start = self.pos count = self.parse_int() if self.at_digit() else None self.skip_whitespace() - if not self.at_end() and self.peek() == "[": - open_position = self.pos - if self.depth >= MAX_DEPTH: - raise _Invalid( - f"Groups can be nested at most {MAX_DEPTH} deep.", open_position - ) - self.pos += 1 - self.depth += 1 - group = self.parse_items(open_position) - self.depth -= 1 - self.pos += 1 # the closing "]" - repeats = 1 if count is None else count - if repeats < 1: - raise _Invalid("Repeat count must be at least 1.", start) - if len(group) * repeats > MAX_BARS: - raise _Invalid(f"Pattern expands to more than {MAX_BARS} bars.", start) - return group * repeats + if self.at("["): + return self.parse_group(start, count) if count is None: raise _Invalid(f"Unexpected '{self.peek()}'.", self.pos) + if self.at("/") or self.at("+"): + return [self.parse_time_signature(start, count)] if count < 1: - raise _Invalid("Bar lengths must be at least 1.", start) - return [count] + raise _Invalid("Bars must have at least 1 beat.", start) + if count > MAX_BEATS_PER_BAR: + raise _Invalid(f"Bars can have at most {MAX_BEATS_PER_BAR} beats.", start) + return [PlannedBar(count, ASSUMED_DENOMINATOR, (Fraction(1),), assumed=True)] + + def parse_group(self, start: int, count: int | None) -> list[PlannedBar]: + open_position = self.pos + if self.depth >= MAX_DEPTH: + raise _Invalid( + f"Groups can be nested at most {MAX_DEPTH} deep.", open_position + ) + self.pos += 1 + self.depth += 1 + group = self.parse_items(open_position) + self.depth -= 1 + self.pos += 1 # the closing "]" + repeats = 1 if count is None else count + if repeats < 1: + raise _Invalid("Repeat count must be at least 1.", start) + if len(group) * repeats > MAX_BARS: + raise _Invalid(f"Pattern expands to more than {MAX_BARS} bars.", start) + return group * repeats + + def parse_time_signature(self, start: int, first: int) -> PlannedBar: + """ + Parses a time signature whose numerator starts with `first`, already + read, up to its denominator and taps. + """ + parts = [Fraction(first)] + while True: + if self.at("+"): + self.pos += 1 + parts.append(Fraction(self.expect_int("Enter a number after '+'."))) + self.skip_whitespace() + continue + if not self.at("/"): + raise _Incomplete("Enter a denominator after '/'.", self.pos) + self.pos += 1 + number = self.expect_int("Enter a denominator after '/'.") + # A second "/" makes the number a part's denominator: the last + # number after a "/" is the time signature's. + after_number = self.pos + self.skip_whitespace() + if self.at("/"): + if number == 0: + raise _Invalid("Division by 0.", after_number) + parts[-1] /= number + continue + self.pos = after_number + denominator = number + break + + if any(part <= 0 for part in parts): + raise _Invalid("Numerators must be greater than 0.", start) + if not 1 <= denominator <= MAX_DENOMINATOR: + raise _Invalid(f"Denominators go from 1 to {MAX_DENOMINATOR}.", start) + + taps_start = self.pos + self.skip_whitespace() + if self.at("("): + values = self.parse_taps() + numerator = sum(parts, Fraction(0)) + if sum(values, Fraction(0)) != numerator: + raise _Invalid( + f"The taps add up to {format_units([sum(values)])}, not " + f"{format_units([numerator])}.", + taps_start, + ) + else: + self.pos = taps_start + if len(parts) > 1: + values = parts + elif parts[0].denominator == 1: + values = [Fraction(1)] * int(parts[0]) + else: + raise _Invalid("Give the taps of a fractional numerator.", start) + + if len(values) > MAX_BEATS_PER_BAR: + raise _Invalid(f"Bars can have at most {MAX_BEATS_PER_BAR} beats.", start) + return PlannedBar.from_values(values, denominator) + + def parse_taps(self) -> list[Fraction]: + open_position = self.pos + self.pos += 1 + close = self.text.find(")", self.pos) + if close == -1: + if "(" in self.text[self.pos :] or "[" in self.text[self.pos :]: + raise _Invalid("Unclosed '('.", open_position) + raise _Incomplete("Unclosed '('.", open_position) + inside = self.text[self.pos : close] + if not inside.strip(): + raise _Invalid("Empty taps '()'.", open_position) + result = parse_units("+".join(inside.replace("+", " ").split())) + if not result.is_valid: + raise _Invalid(result.error, self.pos) + self.pos = close + 1 + return result.units def parse(text: str) -> ParseResult: - """Parses a beat pattern into the list of bar lengths it expands to.""" + """Parses a beat pattern into the bars it expands to.""" try: bars = _Parser(text).parse_items() except _Incomplete as e: @@ -195,3 +372,39 @@ def format_bars(bars: list[int]) -> str: f"{run.count}[{run.beats}]" if run.count > 1 else str(run.beats) for run in group_runs(bars) ) + + +def format_planned_bar(bar: PlannedBar) -> str: + """ + Writes a bar as the pattern syntax would: its number of beats if it is + assumed, else its time signature, with its taps where they aren't the + time signature's own. + """ + if bar.assumed: + return str(bar.beat_count) + values = bar.values + if all(value == 1 for value in values): + return f"{len(values)}/{bar.denominator}" + if len(set(values)) > 1 and all(value.denominator == 1 for value in values): + return f"{format_units(values)}/{bar.denominator}" + taps = " ".join(str(value) for value in values) + return f"{format_units([bar.numerator])}/{bar.denominator}({taps})" + + +def format_planned_bars(bars: list[PlannedBar]) -> str: + """ + Writes bars as a pattern, collapsing runs of equal bars: three bars of + 4/4 and one of 3/4 become ``3[4/4] 3/4``. Nested groups are not + recovered. + """ + items = [] + index = 0 + while index < len(bars): + end = index + while end + 1 < len(bars) and bars[end + 1] == bars[index]: + end += 1 + text = format_planned_bar(bars[index]) + count = end - index + 1 + items.append(f"{count}[{text}]" if count > 1 else text) + index = end + 1 + return " ".join(items) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index d4e3ced55..5668a3061 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -31,7 +31,7 @@ fit_legacy_lists, get_extension_from_beat_pattern, ) -from tilia.timelines.beat.pattern import parse +from tilia.timelines.beat.pattern import PlannedBar, parse from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, DEFAULT_UNITS, @@ -414,7 +414,7 @@ def __init__( @property def beat_pattern(self) -> str: - """The pattern as the user wrote it, e.g. "10[4] 3".""" + """The pattern as the user wrote it, e.g. "10[4/4] 3/4".""" return self._beat_pattern @beat_pattern.setter @@ -431,12 +431,29 @@ def beat_pattern(self, value: str | list[int]) -> None: text = DEFAULT_BEAT_PATTERN result = parse(text) self._beat_pattern = text - self._beat_pattern_bars = result.bars + self._planned_bars = result.planned_bars @property def beat_pattern_bars(self) -> list[int]: """The bar lengths the pattern expands to.""" - return self._beat_pattern_bars + return [bar.beat_count for bar in self._planned_bars] + + @property + def planned_bars(self) -> list[PlannedBar]: + """The bars the pattern expands to.""" + return self._planned_bars + + @property + def plans_time_signatures(self) -> bool: + """ + Whether the pattern gives time signatures, rather than only the number + of beats in each bar, as patterns from before time signatures did. + """ + return any(not bar.assumed for bar in self._planned_bars) + + def get_planned_bar(self, measure_index: int) -> PlannedBar: + """The bar the pattern plans for a measure, repeating from its start.""" + return self._planned_bars[measure_index % len(self._planned_bars)] @property def default_height(self): @@ -889,6 +906,7 @@ def _place_new_beat(self, beat: Beat) -> None: index = self.get_beat_index(beat) if len(self) == 1: self.set_component_data(beat.id, "is_first_in_measure", True) + self.lay_planned_beat_units(0) elif index == 0: old_first = self[1] number = ( @@ -905,9 +923,44 @@ def _place_new_beat(self, beat: Beat) -> None: measures = self.measures last_measure = measures.count - 1 beats_before = measures.counts[last_measure] - 1 - pattern = self.beat_pattern_bars - if beats_before >= pattern[last_measure % len(pattern)]: + if beats_before >= self.get_planned_bar(last_measure).beat_count: self.set_component_data(beat.id, "is_first_in_measure", True) + self.lay_planned_beat_units(last_measure + 1) + + def lay_planned_beat_units(self, from_measure: int) -> None: + """ + Gives the measures from `from_measure` on the beat units the pattern + plans for them, replacing theirs: one on the first beat of each + measure whose planned time signature differs from the one before. + Does nothing when the pattern gives no time signatures. + """ + measures = self.measures + if not self.plans_time_signatures or from_measure >= measures.count: + return + + previous = None + if from_measure: + meter = self.get_measure_meter(from_measure - 1) + previous = (meter.denominator, meter.units, meter.is_assumed) + + beats = self.components + with self.component_manager.bulk(): + for beat_unit in self.beat_units: + beat_index = self.get_beat_index(self.get_component(beat_unit.beat_id)) + if measures.measure_of_beat[beat_index] >= from_measure: + self.component_manager.delete_component(beat_unit) + + for measure_index in range(from_measure, measures.count): + bar = self.get_planned_bar(measure_index) + meter = (bar.denominator, bar.units, bar.assumed) + if meter != previous: + self.put_beat_unit_on_beat( + beats[measures.starts[measure_index]].id, + bar.denominator, + bar.units_text, + assumed=bar.assumed, + ) + previous = meter def ensure_first_beat_starts_measure(self) -> None: if len(self) and not self[0].is_first_in_measure: @@ -1250,8 +1303,9 @@ def get_bars_overwritten_by(self, pattern_bars: list[int]) -> list[int]: def set_beat_pattern(self, pattern: str) -> bool: """ Sets the pattern and remeasures every beat from it, discarding bars - set by hand. Each measure keeps its number, display and beat unit by - position. + set by hand. Each measure keeps its number and display by position. + A pattern with time signatures gives every measure its beat unit; one + with only numbers of beats leaves each measure the beat unit it had. """ _, success = self.set_data("beat_pattern", pattern) if not success: @@ -1261,6 +1315,8 @@ def set_beat_pattern(self, pattern: str) -> bool: self._remeasure( get_extension_from_beat_pattern(self.beat_pattern_bars, len(self)), 0 ) + self.lay_planned_beat_units(0) + self.refresh_measures() return True def delete_components(self, components: list[TC]) -> None: @@ -1308,6 +1364,7 @@ def fill_with_beats(self, method: BeatTimeline.FillMethod, value: int | float): ComponentKind.BEAT, time, is_first_in_measure=i in starts ) post(Post.LONG_OPERATION, LongOperation.PROGRESS, i + 1, len(times)) + self.lay_planned_beat_units(0) self.refresh_measures() diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index ab374a3e9..42f8a8518 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -18,7 +18,7 @@ from tilia.timelines.beat.pattern import BarRun, ParseResult, group_runs, parse from tilia.ui.windows.pattern_preview import PatternPreview, PreviewContent -_PATTERN_CHARACTERS = set("0123456789[]") +_PATTERN_CHARACTERS = set("0123456789[]/+()") class BeatPatternValidator(QValidator): From 8cbfd8f39afe7523a633eb417640ca18105fd264 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:47:05 +0100 Subject: [PATCH 35/56] feat: edit bars and time signatures in one dialog The beat pattern dialog becomes "Bars and time signatures". Its line takes the pattern of time signatures, and its table lists the bars that pattern makes over the tapped beats, one row per run of alike bars, with three columns that can be edited: - Bars: their numbers. "9" counts on from 9; "1-8, 1-8" numbers a repeat. Only bars with beats keep a number, so rows of bars not tapped yet are grey and their numbers can't be edited. - Time signature: a bar of the pattern syntax, such as 3/4 or 6/8(3 3). - Taps: what each tap is worth, such as "3/2 3/2 1". The time signature's numerator follows the taps. Editing a row rewrites the pattern, and editing the pattern rewrites the rows. Opened on a timeline with beats, the dialog starts from the measures as they are, followed by the bars the pattern plans after them, so a measure with another number of beats than planned shows as a row of its own. Rows that pressing OK would change are highlighted, and OK remeasures every beat from the rows and numbers the measures, so it no longer asks before overwriting bars set by hand. "Set beat pattern" with a pattern given, as the CLI does, still asks. Remeasuring and laying out planned beat units leave the beat units that don't change where they are, rather than recreating them. --- tests/ui/timelines/beat/interact.py | 43 +- .../timelines/beat/test_beat_timeline_ui.py | 143 +++++- tests/ui/windows/test_beat_pattern_dialog.py | 303 ++++++++++--- tilia/requests/get.py | 1 + tilia/timelines/beat/timeline.py | 89 +++- tilia/ui/dialog_manager.py | 2 + tilia/ui/strings.py | 24 +- tilia/ui/timelines/beat/dialogs.py | 25 +- tilia/ui/timelines/beat/timeline.py | 57 ++- tilia/ui/windows/beat_pattern.py | 414 ++++++++++++++---- 10 files changed, 897 insertions(+), 204 deletions(-) diff --git a/tests/ui/timelines/beat/interact.py b/tests/ui/timelines/beat/interact.py index a60eb95c6..856e136e5 100644 --- a/tests/ui/timelines/beat/interact.py +++ b/tests/ui/timelines/beat/interact.py @@ -3,7 +3,14 @@ from PySide6.QtCore import QEvent from PySide6.QtTest import QTest -from PySide6.QtWidgets import QDialog, QGraphicsSceneHoverEvent, QMessageBox +from PySide6.QtWidgets import ( + QAbstractItemDelegate, + QApplication, + QDialog, + QGraphicsSceneHoverEvent, + QLineEdit, + QMessageBox, +) from tests.ui.timelines.interact import click_timeline_ui_view from tilia.timelines.beat.timeline import BeatTimeline @@ -46,6 +53,40 @@ def exec_(dialog): return patch.object(BeatPatternDialog, "exec", new=exec_) +def patch_bars_dialog(on_exec): + """ + Makes the bars and time signatures dialog run `on_exec(dialog)`, which + edits it as a user would, then press OK. + """ + + def exec_(dialog): + on_exec(dialog) + dialog.ok_button.click() + return dialog.result() + + return patch.object(BeatPatternDialog, "exec", new=exec_) + + +def edit_bars_dialog_cell(dialog, row: int, column: int, text: str) -> None: + """ + Edits a cell of the bars and time signatures dialog's table as a user + would: opens its editor, types `text` over what is there and commits it. + The editor's Enter key doesn't reach the table in offscreen tests, so the + commit is made as the table makes it on Enter. + """ + table = dialog.table + table.setCurrentCell(row, column) + table.editItem(table.item(row, column)) + editor = table.findChild(QLineEdit) + editor.selectAll() + QTest.keyClicks(editor, text) + table.commitData(editor) + table.closeEditor(editor, QAbstractItemDelegate.EndEditHint.NoHint) + # The closed editor is deleted later; deleting it now keeps the next + # edit from finding it. + QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + def click_time_signature_label( timeline_ui, label, button="left", modifier=None, double=False ): diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 996f289a3..849601c21 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -11,7 +11,9 @@ from tests.ui.timelines.beat.interact import ( click_beat_ui, click_time_signature_label, + edit_bars_dialog_cell, hover_time_signature_label, + patch_bars_dialog, patch_beat_pattern_dialog, patch_beat_unit_dialog, patch_beat_unit_scope_prompt, @@ -47,6 +49,11 @@ LabelState, ) from tilia.ui.windows import WindowKind +from tilia.ui.windows.beat_pattern import ( + BARS_COLUMN, + TAPS_COLUMN, + TIME_SIGNATURE_COLUMN, +) def save_as_file_with_measure_lists( @@ -394,12 +401,15 @@ def test_dialog_starts_with_current_pattern(self, tluis): seen = {} def record(dialog): - seen["preview"] = dialog.preview.footer.text() + seen["text"] = dialog.text + seen["footer"] = dialog.footer.text() - with patch_beat_pattern_dialog("4", on_exec=record): + with patch_bars_dialog(record): commands.execute("timeline.beat.set_pattern", tluis[0]) - assert seen["preview"].endswith("5 beats tapped.") + assert seen["text"] == "2[3] 4" + assert seen["footer"].endswith("5 beats tapped, in 2 bars.") + assert tluis[0].timeline.beats_in_measure == [3, 2] def test_cancel_dialog_changes_nothing(self, tluis): add_beat_timeline_with_pattern("4") @@ -457,22 +467,23 @@ def test_hand_set_bar_matching_new_pattern_is_not_asked_about(self, tluis): assert timeline_ui.timeline.beat_pattern == "4 3" assert timeline_ui.timeline.beats_in_measure == [4, 3, 4, 1] - def test_dialog_preview_lists_bars_to_overwrite(self, tluis): + def test_dialog_highlights_bars_that_change(self, tluis): add_beat_timeline_with_pattern("4") add_beats(range(12)) self._set_second_bar_to_three_beats(tluis[0]) seen = {} def record(dialog): - seen["preview"] = dialog.preview.footer.text() + seen["rows"] = dialog.get_table_rows() + seen["highlighted"] = dialog.get_highlighted_rows() with patch_beat_pattern_dialog("4", on_exec=record): - with patch_yes_or_no_dialog(False): - commands.execute("timeline.beat.set_pattern", tluis[0]) + commands.execute("timeline.beat.set_pattern", tluis[0]) - assert seen["preview"].endswith( - "Bar 2 was set by hand and will be overwritten." - ) + assert seen["rows"][0][0] == "1–3" + assert seen["highlighted"] == {0} + # What the dialog showed is applied, with no question asked. + assert tluis[0].timeline.beats_in_measure == [4, 4, 4] def test_context_menu_has_set_pattern(self, tluis): add_beat_timeline_with_pattern("4") @@ -503,6 +514,118 @@ def time_signatures(timeline): ] +class TestEditBarsDialog: + @staticmethod + def edit_bars(timeline_ui, edit): + with patch_bars_dialog(edit): + commands.execute("timeline.beat.set_pattern", timeline_ui) + + def test_a_bar_with_another_number_of_beats_has_a_row_of_its_own(self, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(12)) + tluis[0].select_element(tluis[0][5]) + commands.execute("timeline.component.delete") + seen = {} + + def record(dialog): + seen["rows"] = dialog.get_table_rows() + seen["text"] = dialog.text + + self.edit_bars(tluis[0], record) + + assert seen["rows"] == [ + ("1", "4/4", "1 1 1 1"), + ("2", "3/4", "1 1 1"), + ("3", "4/4", "1 1 1 1"), + ] + assert seen["text"] == "4/4 3/4 4/4" + # Pressing OK on what is shown changes nothing. + assert tluis[0].timeline.beats_in_measure == [4, 3, 4] + + def test_correcting_a_bar_moves_the_later_bars(self, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(12)) + # A tap too many in the first bar. + add_beats([0.5]) + assert tluis[0].timeline.beats_in_measure == [5, 4, 4] + + with undoable(): + self.edit_bars( + tluis[0], + lambda dialog: edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "1 1 1 1"), + ) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [4, 4, 4, 1] + assert timeline.beat_pattern == "3[4/4]" + + def test_changing_a_time_signature(self, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(12)) + + def edit(dialog): + edit_bars_dialog_cell(dialog, 0, BARS_COLUMN, "1") + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "6/8(3 3)") + + with undoable(): + self.edit_bars(tluis[0], edit) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [2, 2, 2, 2, 2, 2] + assert time_signatures(timeline) == [("6", 8)] * 6 + + def test_numbering_a_repeat(self, tluis, tmp_path): + add_beat_timeline_with_pattern("4/4") + add_beats(range(24)) + + with undoable(): + self.edit_bars( + tluis[0], + lambda dialog: edit_bars_dialog_cell( + dialog, 0, BARS_COLUMN, "1-3, 1-3" + ), + ) + + assert tluis[0].timeline.measure_numbers == [1, 2, 3, 1, 2, 3] + save_and_reopen(tmp_path) + assert tluis[0].timeline.measure_numbers == [1, 2, 3, 1, 2, 3] + + def test_rows_reflect_the_numbers(self, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(16)) + tluis[0].timeline.set_measure_numbers([1, 2, 1, 2]) + seen = {} + + self.edit_bars( + tluis[0], lambda dialog: seen.update(rows=dialog.get_table_rows()) + ) + + assert [row[0] for row in seen["rows"]] == ["1–2", "1–2"] + assert tluis[0].timeline.measure_numbers == [1, 2, 1, 2] + + def test_bars_not_tapped_yet_are_listed(self, tluis): + add_beat_timeline_with_pattern("2[4/4] 3/4 2/4") + add_beats(range(5)) + seen = {} + + def record(dialog): + seen["tapped"] = [row.is_tapped for row in dialog.rows] + seen["text"] = dialog.text + + self.edit_bars(tluis[0], record) + + assert seen["tapped"] == [True, False, False] + assert seen["text"] == "2[4/4] 3/4 2/4" + + def test_assumed_time_signatures_stay_assumed(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(8)) + + self.edit_bars(tluis[0], lambda dialog: None) + + assert all(m.is_assumed for m in tluis[0].timeline.measure_meters) + + class TestPatternWithTimeSignatures: def test_tapping_follows_the_time_signatures(self, tluis): add_beat_timeline_with_pattern("2[4/4] 3/4 2[4/4(3/2 3/2 1)]") diff --git a/tests/ui/windows/test_beat_pattern_dialog.py b/tests/ui/windows/test_beat_pattern_dialog.py index 71355bf04..af6954278 100644 --- a/tests/ui/windows/test_beat_pattern_dialog.py +++ b/tests/ui/windows/test_beat_pattern_dialog.py @@ -1,17 +1,29 @@ +from fractions import Fraction as F + import pytest from PySide6.QtCore import Qt from PySide6.QtGui import QValidator from PySide6.QtTest import QTest -from tests.ui.timelines.beat.interact import patch_beat_pattern_dialog -from tilia.timelines.beat.pattern import parse +from tests.ui.timelines.beat.interact import ( + edit_bars_dialog_cell, + patch_beat_pattern_dialog, +) +from tilia.timelines.beat.pattern import PlannedBar, parse from tilia.ui import commands from tilia.ui.windows.beat_pattern import ( + BARS_COLUMN, + MAX_TABLE_HEIGHT, + TAPS_COLUMN, + TIME_SIGNATURE_COLUMN, + UNTAPPED_COLOR, BeatPatternDialog, BeatPatternValidator, - get_pattern_preview, + get_numbers, + get_overrides, + lay_out, + parse_bar_numbers, ) -from tilia.ui.windows.pattern_preview import MAX_TABLE_HEIGHT def add_beat_timeline(): @@ -20,14 +32,23 @@ def add_beat_timeline(): commands.execute("timelines.add.beat", name="") +def bars(text): + return parse(text).planned_bars + + class TestValidator: @pytest.mark.parametrize( "text, state", [ ("4 4 3", QValidator.State.Acceptable), ("10[4] 3", QValidator.State.Acceptable), + ("4[4/4] 3/4", QValidator.State.Acceptable), + ("6/8(3 3)", QValidator.State.Acceptable), + ("2+3/8", QValidator.State.Acceptable), ("", QValidator.State.Intermediate), ("10[", QValidator.State.Intermediate), + ("4/", QValidator.State.Intermediate), + ("4/4(1 1", QValidator.State.Intermediate), ("4]", QValidator.State.Intermediate), ("0", QValidator.State.Intermediate), ("4a", QValidator.State.Invalid), @@ -39,92 +60,241 @@ def test_state(self, text, state): assert BeatPatternValidator().validate(text, len(text))[0] == state -class TestPatternPreview: - def test_rows_and_footer(self): - preview = get_pattern_preview(parse("10[4] 3 15[4]")) +class TestLayOut: + def test_without_beats(self): + assert lay_out(bars("4/4 3/4"), 0) == (bars("4/4 3/4"), 0) - assert preview.headers == ["Bars", "Beats per bar"] - assert preview.rows == [("1\u201310", "4"), ("11", "3"), ("12\u201326", "4")] - assert ( - preview.footer == "26 bars, 103 beats, then the pattern repeats from bar 1." - ) - assert preview.error == "" + def test_beats_fill_part_of_the_pattern(self): + assert lay_out(bars("4/4 3/4 2/4"), 5) == (bars("4/4 3/4 2/4"), 2) + + def test_beats_go_past_the_pattern(self): + assert lay_out(bars("4/4 3/4"), 12) == (bars("4/4 3/4 4/4 3/4"), 4) + + +class TestBarNumbers: + def test_numbers_count_on_from_the_bar_before(self): + assert get_numbers(5, {2: 1}) == [1, 2, 1, 2, 3] + + def test_overrides_are_where_the_count_breaks(self): + assert get_overrides([0, 1, 2, 1, 2, 9]) == {0: 0, 3: 1, 5: 9} + + @pytest.mark.parametrize( + "text, count, numbers", + [ + ("5", 3, [5, 6, 7]), + ("5-7", 3, [5, 6, 7]), + ("5–7", 3, [5, 6, 7]), + ("1-4, 1-4", 8, [1, 2, 3, 4, 1, 2, 3, 4]), + ("1-3, 3, 4", 5, [1, 2, 3, 3, 4]), + (" 2 ", 1, [2]), + ], + ) + def test_valid(self, text, count, numbers): + assert parse_bar_numbers(text, count) == (numbers, "") + + @pytest.mark.parametrize( + "text, count, error", + [ + ("1-4", 3, "'1-4' numbers 4 bars, not 3 bars."), + ("1-2, 1", 4, "'1-2, 1' numbers 3 bars, not 4 bars."), + ("a", 2, "'a' is not a bar number or a range such as 1-4."), + ("1-2-3", 3, "'1-2-3' is not a bar number or a range such as 1-4."), + ("4-1", 4, "'4-1' counts down."), + ("", 2, "'' is not a bar number or a range such as 1-4."), + ], + ) + def test_invalid(self, text, count, error): + assert parse_bar_numbers(text, count) == (None, error) + + +class TestTable: + def test_rows_group_alike_bars(self, qtui): + dialog = BeatPatternDialog("4[4/4] 3/4 3[4/4(3/2 3/2 1)]") + + assert dialog.get_table_rows() == [ + ("1–4", "4/4", "1 1 1 1"), + ("5", "3/4", "1 1 1"), + ("6–8", "4/4(3/2 3/2 1)", "3/2 3/2 1"), + ] + + def test_rows_of_bars_given_only_their_beats(self, qtui): + dialog = BeatPatternDialog("2[3] 4") - def test_singular(self): - preview = get_pattern_preview(parse("1"), beat_count=1) + assert dialog.get_table_rows() == [ + ("1–2", "3", "1 1 1"), + ("3", "4", "1 1 1 1"), + ] - assert preview.footer == ( - "1 bar, 1 beat, then the pattern repeats from bar 1.\n1 beat tapped." + def test_rows_cover_the_tapped_beats(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=10) + + assert dialog.get_table_rows() == [("1–3", "4/4", "1 1 1 1")] + assert dialog.footer.text() == ( + "1 bar, then the list starts again from bar 1.\n" + "10 beats tapped, in 3 bars." ) - def test_every_run_is_listed(self): - preview = get_pattern_preview(parse("50[1 2]")) + def test_untapped_bars_are_grey_and_keep_no_number(self, qtui): + dialog = BeatPatternDialog("4[4/4] 3/4", beat_count=8) + + assert [row.is_tapped for row in dialog.rows] == [True, False, False] + untapped = dialog.table.item(1, BARS_COLUMN) + assert untapped.foreground().color() == UNTAPPED_COLOR + assert not untapped.flags() & Qt.ItemFlag.ItemIsEditable + assert dialog.table.item(1, TAPS_COLUMN).flags() & Qt.ItemFlag.ItemIsEditable - assert len(preview.rows) == 100 + def test_numbers_split_rows(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=16, numbers=[1, 2, 1, 2]) - def test_beat_count(self): - footer = get_pattern_preview(parse("4"), beat_count=12).footer + assert [row[0] for row in dialog.get_table_rows()] == [ + "1–2", + "1–2", + ] + + def test_table_scrolls_instead_of_growing(self, qtui): + dialog = BeatPatternDialog("50[1 2]") - assert footer.endswith("12 beats tapped.") + assert dialog.table.rowCount() == 100 + assert dialog.table.maximumHeight() == MAX_TABLE_HEIGHT - def test_overwritten_bars(self): - assert get_pattern_preview( - parse("4"), overwritten_bars=[10, 13] - ).footer.endswith("Bars 11, 14 were set by hand and will be overwritten.") - assert get_pattern_preview(parse("4"), overwritten_bars=[2]).footer.endswith( - "Bar 3 was set by hand and will be overwritten." + def test_bars_that_change_are_highlighted(self, qtui): + current = bars("4/4 3/4 4/4") + dialog = BeatPatternDialog( + "4/4 3/4 4/4", beat_count=11, current_bars=current, numbers=[1, 2, 3] ) + assert dialog.get_highlighted_rows() == set() + + dialog.line_edit.setText("4/4") + + assert dialog.get_table_rows()[0][0] == "1–3" + assert dialog.get_highlighted_rows() == {0} + + +class TestEditingCells: + def test_taps_change_the_time_signature(self, qtui): + dialog = BeatPatternDialog("4[4/4] 3/4") + + edit_bars_dialog_cell(dialog, 1, TAPS_COLUMN, "3") + + assert dialog.get_table_rows()[1] == ("5", "3/4(3)", "3") + assert dialog.text == "4[4/4] 3/4(3)" + + def test_taps_that_add_up_to_another_numerator(self, qtui): + dialog = BeatPatternDialog("4/4") - def test_overwritten_bars_highlight_their_rows(self): - # Bar 22 is the 11th bar of the second pass through the pattern. - preview = get_pattern_preview(parse("10[4] 3"), overwritten_bars=[21]) + edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "1 1 1") - assert preview.highlighted_rows == {1} + assert dialog.text == "3/4" - def test_incomplete_shows_error_with_position(self): - preview = get_pattern_preview(parse("10[")) + def test_fractional_taps(self, qtui): + dialog = BeatPatternDialog("2[4/4]") - assert preview.error == "Unclosed '['. (at character 3)" - assert preview.rows == [] + edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "3/2 3/2 1") - def test_empty(self): - assert get_pattern_preview(parse("")).error == "Enter at least one bar." + assert dialog.text == "2[4/4(3/2 3/2 1)]" + assert dialog.rows[0].bar.values == [F(3, 2), F(3, 2), F(1)] + + def test_time_signature(self, qtui): + dialog = BeatPatternDialog("4[4/4] 3/4") + + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "6/8(3 3)") + + assert dialog.text == "4[6/8(3 3)] 3/4" + + def test_editing_a_row_of_a_repeated_pattern(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=12) + + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "3/4") + + assert dialog.text == "3[3/4]" + assert dialog.get_table_rows() == [("1–4", "3/4", "1 1 1")] + + def test_invalid_cell_is_reported_and_reverted(self, qtui): + dialog = BeatPatternDialog("4/4") + + edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "1 0") + + assert dialog.error.text() == "Units must be greater than 0." + assert dialog.get_table_rows() == [("1", "4/4", "1 1 1 1")] + assert dialog.text == "4/4" + + def test_more_than_one_time_signature_is_refused(self, qtui): + dialog = BeatPatternDialog("4/4") + + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "4/4 3/4") + + assert dialog.error.text() == "Enter one time signature, such as 3/4." + + def test_bar_numbers_make_a_repeat(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=40) + + edit_bars_dialog_cell(dialog, 0, BARS_COLUMN, "1-4, 1-6") + + assert [row[0] for row in dialog.get_table_rows()] == [ + "1–4", + "1–6", + ] + assert dialog.get_result().numbers == [1, 2, 3, 4, 1, 2, 3, 4, 5, 6] + + def test_bars_after_count_on(self, qtui): + dialog = BeatPatternDialog("4/4 3/4", beat_count=21) + + edit_bars_dialog_cell(dialog, 1, BARS_COLUMN, "10") + + assert dialog.get_result().numbers == [1, 10, 11, 12, 13, 14] + + def test_numbers_set_further_on_are_kept(self, qtui): + dialog = BeatPatternDialog("4/4 3/4", beat_count=21, numbers=[1, 2, 3, 1, 2, 3]) + + edit_bars_dialog_cell(dialog, 1, BARS_COLUMN, "5") + + assert dialog.get_result().numbers == [1, 5, 6, 1, 2, 3] + + def test_editing_the_pattern_keeps_the_numbers(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=16, numbers=[1, 2, 1, 2]) + + dialog.line_edit.setText("2/4") + + assert dialog.get_result().numbers == [1, 2, 1, 2, 3, 4, 5, 6] class TestDialog: def test_complete_pattern_enables_ok(self, qtui): dialog = BeatPatternDialog() - QTest.keyClicks(dialog.line_edit, "2[3] 4") + QTest.keyClicks(dialog.line_edit, "2[3/4] 4/4") assert dialog.ok_button.isEnabled() - assert dialog.preview.rows() == [("1\u20132", "3"), ("3", "4")] - assert dialog.text == "2[3] 4" + assert dialog.get_table_rows() == [ + ("1–2", "3/4", "1 1 1"), + ("3", "4/4", "1 1 1 1"), + ] + assert dialog.text == "2[3/4] 4/4" def test_incomplete_pattern_disables_ok(self, qtui): dialog = BeatPatternDialog() # Typed brackets are paired, so set the text to leave one unclosed. dialog.line_edit.setText("2[3") assert not dialog.ok_button.isEnabled() - assert dialog.preview.error.text().startswith("Unclosed '['.") - assert dialog.preview.table.isHidden() - assert dialog.preview.footer.isHidden() + assert dialog.error.text().startswith("Unclosed '['.") + assert dialog.table.isHidden() + assert dialog.footer.isHidden() + + def test_error_names_the_character(self, qtui): + dialog = BeatPatternDialog("4/4(1 1 1)") + + assert dialog.error.text() == "The taps add up to 3, not 4. (at character 4)" def test_table_comes_back_when_the_pattern_is_complete(self, qtui): dialog = BeatPatternDialog() dialog.line_edit.setText("2[3") dialog.line_edit.setText("2[3]") - assert not dialog.preview.table.isHidden() - assert dialog.preview.error.isHidden() - - def test_table_scrolls_instead_of_growing(self, qtui): - dialog = BeatPatternDialog("50[1 2]") - - assert dialog.preview.table.rowCount() == 100 - assert dialog.preview.table.maximumHeight() == MAX_TABLE_HEIGHT + assert not dialog.table.isHidden() + assert dialog.error.isHidden() def test_empty_dialog_disables_ok(self, qtui): dialog = BeatPatternDialog() assert not dialog.ok_button.isEnabled() + assert dialog.error.text() == "Enter at least one bar." def test_characters_outside_the_syntax_are_blocked(self, qtui): dialog = BeatPatternDialog() @@ -135,27 +305,24 @@ def test_initial_text(self, qtui): dialog = BeatPatternDialog("3 4", beat_count=7) assert dialog.text == "3 4" assert dialog.ok_button.isEnabled() - assert dialog.preview.footer.text().endswith("7 beats tapped.") + assert dialog.footer.text().endswith("7 beats tapped, in 2 bars.") - def test_overwritten_bars_follow_the_text(self, qtui): - dialog = BeatPatternDialog( - get_overwritten_bars=lambda bars: [0] if bars[0] != 4 else [] - ) - QTest.keyClicks(dialog.line_edit, "3") - assert "Bar 1 was set by hand" in dialog.preview.footer.text() - assert dialog.preview.highlighted_rows() == {0} - dialog.line_edit.setText("4") - assert "set by hand" not in dialog.preview.footer.text() - assert dialog.preview.highlighted_rows() == set() + def test_result(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=8, numbers=[0, 1]) + + result = dialog.get_result() + + assert result.pattern == "4/4" + assert result.numbers == [0, 1] class TestCreateTimeline: def test_typed_pattern_reaches_the_timeline(self, tluis): - with patch_beat_pattern_dialog("2[3] 4"): + with patch_beat_pattern_dialog("2[3/4] 4/4"): add_beat_timeline() assert len(tluis) == 1 - assert tluis[0].timeline.beat_pattern == "2[3] 4" + assert tluis[0].timeline.beat_pattern == "2[3/4] 4/4" def test_cancel_creates_no_timeline(self, tluis): with patch_beat_pattern_dialog(None): @@ -213,3 +380,7 @@ def test_closing_bracket_without_one_after_the_cursor(self, qtui): QTest.keyClicks(dialog.line_edit, "]") assert dialog.line_edit.text() == "2[3]" + + +def test_planned_bars_compare_by_value(): + assert PlannedBar.from_values([F(1)] * 4, 4) == bars("4/4")[0] diff --git a/tilia/requests/get.py b/tilia/requests/get.py index cf9c35826..14918fda8 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -12,6 +12,7 @@ class Get(Enum): FIRST_TIMELINE_UI_IN_SELECT_ORDER = auto() FILE_PATH = auto() FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA = auto() + FROM_USER_BARS = auto() FROM_USER_BEAT_PATTERN = auto() FROM_USER_BEAT_UNIT = auto() FROM_USER_BEAT_UNIT_SCOPE = auto() diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 5668a3061..6928f9569 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -31,7 +31,7 @@ fit_legacy_lists, get_extension_from_beat_pattern, ) -from tilia.timelines.beat.pattern import PlannedBar, parse +from tilia.timelines.beat.pattern import PlannedBar, format_planned_bars, parse from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, DEFAULT_UNITS, @@ -455,6 +455,43 @@ def get_planned_bar(self, measure_index: int) -> PlannedBar: """The bar the pattern plans for a measure, repeating from its start.""" return self._planned_bars[measure_index % len(self._planned_bars)] + def get_measures_as_planned_bars(self) -> list[PlannedBar]: + """ + Each measure as a bar of a pattern: its beats, with the values its + beat unit gives them. The last measure, while it has only the first + of the beats the pattern plans for it, is the planned bar. + """ + bars = [] + for measure_index, meter in enumerate(self.measure_meters): + values = list(meter.beat_values) + bar = PlannedBar.from_values(values, meter.denominator, meter.is_assumed) + planned = self.get_planned_bar(measure_index) + if ( + measure_index == self.measure_count - 1 + and len(values) < planned.beat_count + and planned.denominator == meter.denominator + and planned.values[: len(values)] == values + ): + bar = planned + bars.append(bar) + return bars + + def get_bars_to_edit(self) -> tuple[str, list[PlannedBar]]: + """ + The pattern and the bars to edit it from: the measures as they are, + then the bars the pattern plans after them, up to its end. The + pattern is the timeline's when the measures follow it, or else one + written from the bars. + """ + measures = self.get_measures_as_planned_bars() + follow_pattern = all( + bar == self.get_planned_bar(index) for index, bar in enumerate(measures) + ) + bars = measures + self._planned_bars[len(measures) :] + if follow_pattern: + return self.beat_pattern, bars + return format_planned_bars(bars), bars + @property def default_height(self): return settings.get("beat_timeline", "default_height") @@ -944,23 +981,34 @@ def lay_planned_beat_units(self, from_measure: int) -> None: previous = (meter.denominator, meter.units, meter.is_assumed) beats = self.components + planned: dict[int | str, tuple[int, str, bool]] = {} + for measure_index in range(from_measure, measures.count): + bar = self.get_planned_bar(measure_index) + meter = (bar.denominator, bar.units, bar.assumed) + if meter != previous: + beat_id = beats[measures.starts[measure_index]].id + planned[beat_id] = (bar.denominator, bar.units_text, bar.assumed) + previous = meter + + # Beat units already as planned are left in place, so the change + # touches only what it changes. with self.component_manager.bulk(): for beat_unit in self.beat_units: beat_index = self.get_beat_index(self.get_component(beat_unit.beat_id)) - if measures.measure_of_beat[beat_index] >= from_measure: + if ( + measures.measure_of_beat[beat_index] >= from_measure + and beat_unit.beat_id not in planned + ): self.component_manager.delete_component(beat_unit) - for measure_index in range(from_measure, measures.count): - bar = self.get_planned_bar(measure_index) - meter = (bar.denominator, bar.units, bar.assumed) - if meter != previous: - self.put_beat_unit_on_beat( - beats[measures.starts[measure_index]].id, - bar.denominator, - bar.units_text, - assumed=bar.assumed, - ) - previous = meter + for beat_id, (denominator, units, assumed) in planned.items(): + beat_unit = self.get_beat_unit_on_beat(beat_id) + if beat_unit is None or ( + beat_unit.denominator, + beat_unit.units, + beat_unit.assumed, + ) != (denominator, units, assumed): + self.put_beat_unit_on_beat(beat_id, denominator, units, assumed) def ensure_first_beat_starts_measure(self) -> None: if len(self) and not self[0].is_first_in_measure: @@ -1230,14 +1278,19 @@ def _remeasure(self, counts: list[int], from_measure: int) -> None: ] with self.component_manager.bulk(): - # Beat units are recreated on the measures' new first beats, as - # moving them one by one could land one on a beat that still - # holds another. - beat_unit_values = [] - for _, _, beat_unit in kept_data: + # Beat units that move are recreated on their measures' new first + # beats, as moving them one by one could land one on a beat that + # still holds another. Those that stay are left in place. + beat_unit_values: list[tuple[int, str, bool] | None] = [] + for index, (_, _, beat_unit) in enumerate(kept_data): + new_start = new_starts[index] if index < len(new_starts) else None + old_first = old_first_beats[index] if beat_unit is None: beat_unit_values.append(None) continue + if new_start is not None and beats[new_start] is old_first: + beat_unit_values.append(None) + continue beat_unit_values.append( (beat_unit.denominator, beat_unit.units, beat_unit.assumed) ) diff --git a/tilia/ui/dialog_manager.py b/tilia/ui/dialog_manager.py index f586401dd..af7ab3652 100644 --- a/tilia/ui/dialog_manager.py +++ b/tilia/ui/dialog_manager.py @@ -24,6 +24,7 @@ from tilia.ui.dialogs.mode_params import ask_for_mode_params from tilia.ui.timelines.beat.dialogs import ( ask_beat_timeline_fill_method, + ask_for_bars, ask_for_beat_pattern, ask_for_beat_unit, ) @@ -44,6 +45,7 @@ def __init__(self): (Get.FROM_USER_INT, ask_for_int), (Get.FROM_USER_YES_OR_NO, ask_yes_or_no), (Get.FROM_USER_COLOR, ask_for_color), + (Get.FROM_USER_BARS, ask_for_bars), (Get.FROM_USER_BEAT_PATTERN, ask_for_beat_pattern), (Get.FROM_USER_BEAT_UNIT, ask_for_beat_unit), (Get.FROM_USER_BEAT_UNIT_SCOPE, ask_for_beat_unit_scope), diff --git a/tilia/ui/strings.py b/tilia/ui/strings.py index 0130c5c4f..43157076d 100644 --- a/tilia/ui/strings.py +++ b/tilia/ui/strings.py @@ -1,13 +1,25 @@ VALIDATE_BOUNDED_INTEGER = "{0} must be a value between {1} and {2}." -BEAT_PATTERN_DIALOG_TITLE = "Beat pattern" +BEAT_PATTERN_DIALOG_TITLE = "Bars and time signatures" BEAT_PATTERN_DIALOG_PROMPT = ( - "Beats per bar, separated by spaces. The pattern repeats after its last bar." + "Time signatures of the bars, separated by spaces. Tapping past the last" + " bar starts the list again. Double-click a cell below to change its bars." ) BEAT_PATTERN_DIALOG_TOOLTIP = """Examples: - - '4' = 4 beats in every bar; - - '4 3 3' = a cycle of 4, then 3, then 3 beats per bar; - - '10[4] 3 15[4]' = 10 bars of 4, a bar of 3, then 15 bars of 4; - - '2[4 3[3]]' = 4 3 3 3 4 3 3 3.""" + - '4/4' = 4/4 in every bar, tapped on each quarter note; + - '4[4/4] 3/4 3[4/4]' = 4 bars of 4/4, a bar of 3/4, then 3 bars of 4/4; + - '2[4/4 3[3/4]]' = 4/4 3/4 3/4 3/4 4/4 3/4 3/4 3/4; + - '6/8(3 3)' = 6/8 tapped in two, three eighth notes per tap; + - '4/4(3/2 3/2 1)' = 4/4 tapped as 3+3+2 eighth notes; + - '2+3/8' = 5/8 tapped as 2+3 eighth notes; + - '4' = 4 taps in every bar, of an assumed quarter note.""" +BEAT_PATTERN_TABLE_HEADERS = ["Bars", "Time signature", "Taps"] +BEAT_PATTERN_TABLE_TOOLTIP = """Double-click a cell to change it: + - Bars: their numbers, e.g. '9', or '1-8, 1-8' for a repeat; + - Time signature: e.g. '3/4'; + - Taps: what each tap is worth, in units of the denominator, e.g. '3/2 3/2 1'. +Highlighted bars change when you press OK. Grey bars aren't tapped yet.""" +BEAT_PATTERN_SUMMARY = "{} bar{}, then the list starts again from bar 1." +BEAT_PATTERN_TAPPED = "{} beat{} tapped, in {} bar{}." BEAT_PATTERN_OVERWRITE_TITLE = "Overwrite bars set by hand" BEAT_PATTERN_OVERWRITE_PROMPT = ( "This pattern will overwrite bars set by hand (bars {})." diff --git a/tilia/ui/timelines/beat/dialogs.py b/tilia/ui/timelines/beat/dialogs.py index 9c5f08c46..be1cbabbe 100644 --- a/tilia/ui/timelines/beat/dialogs.py +++ b/tilia/ui/timelines/beat/dialogs.py @@ -1,16 +1,23 @@ -from collections.abc import Callable - -from tilia.ui.windows.beat_pattern import BeatPatternDialog +from tilia.timelines.beat.pattern import PlannedBar +from tilia.ui.windows.beat_pattern import BarsEdit, BeatPatternDialog from tilia.ui.windows.beat_unit import BeatUnitDialog, BeatUnitInput from tilia.ui.windows.fill_beat_timeline import BeatTimeline, FillBeatTimeline -def ask_for_beat_pattern( - initial_text: str = "", - beat_count: int | None = None, - get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, -) -> tuple[bool, str]: - return BeatPatternDialog.ask(initial_text, beat_count, get_overwritten_bars) +def ask_for_beat_pattern(initial_text: str = "") -> tuple[bool, str]: + """Asks for the bars of a timeline that has no beats yet.""" + accepted, result = BeatPatternDialog.ask(initial_text) + return accepted, result.pattern if result else "" + + +def ask_for_bars( + initial_text: str, + beat_count: int, + current_bars: list[PlannedBar], + numbers: list[int], +) -> tuple[bool, BarsEdit | None]: + """Asks for changes to the bars of a timeline that has beats.""" + return BeatPatternDialog.ask(initial_text, beat_count, current_bars, numbers) def ask_for_beat_unit( diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 5c502f4c4..48eb0dbbb 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -126,7 +126,9 @@ def register_commands(cls, collection: TimelineUIs): ) commands.register( - "timeline.beat.set_pattern", cls.on_set_pattern, text="Set beat pattern" + "timeline.beat.set_pattern", + cls.on_set_pattern, + text="Edit bars and time signatures", ) cls.register_timeline_command( @@ -463,35 +465,50 @@ def on_set_amount_in_measure( @staticmethod @command_callback def on_set_pattern( - timeline_ui: "BeatTimelineUI", pattern: str | None = None + timeline_ui: "BeatTimelineUI", + pattern: str | None = None, + numbers: list[int] | None = None, ) -> bool: + """ + Remeasures the beats from a pattern of time signatures and numbers + the measures. Without a pattern, asks for both in a dialog that shows + the bars as they are and what changes; with one, asks before + overwriting bars set by hand. + """ timeline = timeline_ui.timeline if pattern is None: - accepted, pattern = get( - Get.FROM_USER_BEAT_PATTERN, - timeline.beat_pattern, + text, _ = timeline.get_bars_to_edit() + accepted, edit = get( + Get.FROM_USER_BARS, + text, len(timeline), - timeline.get_bars_overwritten_by, + timeline.get_measures_as_planned_bars(), + timeline.measure_numbers, ) if not accepted: return False + pattern, numbers = edit.pattern, edit.numbers + else: + result = parse(pattern) + if not result.is_complete: + tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) + return False - result = parse(pattern) - if not result.is_complete: - tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) - return False + overwritten = timeline.get_bars_overwritten_by(result.bars) + if overwritten and not get( + Get.FROM_USER_YES_OR_NO, + BEAT_PATTERN_OVERWRITE_TITLE, + BEAT_PATTERN_OVERWRITE_PROMPT.format( + ", ".join(str(i + 1) for i in overwritten) + ), + ): + return False - overwritten = timeline.get_bars_overwritten_by(result.bars) - if overwritten and not get( - Get.FROM_USER_YES_OR_NO, - BEAT_PATTERN_OVERWRITE_TITLE, - BEAT_PATTERN_OVERWRITE_PROMPT.format( - ", ".join(str(i + 1) for i in overwritten) - ), - ): + if not timeline.set_beat_pattern(pattern): return False - - return timeline.set_beat_pattern(pattern) + if numbers is not None: + timeline.set_measure_numbers(numbers) + return True def on_add(self, time: float | None = None): if time is None: diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index 42f8a8518..84bb4fb74 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -1,24 +1,46 @@ +""" +The dialog where a beat timeline's bars are written: a pattern of time +signatures on one line (see `tilia.timelines.beat.pattern`), and a table of +the bars it makes over the tapped beats, where each row's bar numbers, time +signature and taps can be edited. Editing a row rewrites the pattern, and +editing the pattern rewrites the rows. +""" + from __future__ import annotations -from collections.abc import Callable +from dataclasses import dataclass from PySide6.QtCore import Qt -from PySide6.QtGui import QKeyEvent, QValidator +from PySide6.QtGui import QBrush, QColor, QKeyEvent, QValidator from PySide6.QtWidgets import ( + QAbstractItemView, QDialog, QDialogButtonBox, + QHeaderView, QLabel, QLineEdit, QPushButton, + QTableWidget, + QTableWidgetItem, QVBoxLayout, ) import tilia.ui.strings from tilia.requests import Get, get -from tilia.timelines.beat.pattern import BarRun, ParseResult, group_runs, parse -from tilia.ui.windows.pattern_preview import PatternPreview, PreviewContent +from tilia.timelines.beat.pattern import ( + PlannedBar, + format_planned_bar, + format_planned_bars, + parse, +) +from tilia.timelines.beat.units import parse_units _PATTERN_CHARACTERS = set("0123456789[]/+()") +# The table scrolls past this height rather than growing the dialog. +MAX_TABLE_HEIGHT = 240 +CHANGED_COLOR = QColor("#ffe2b3") +UNTAPPED_COLOR = QColor("gray") +BARS_COLUMN, TIME_SIGNATURE_COLUMN, TAPS_COLUMN = range(3) class BeatPatternValidator(QValidator): @@ -78,93 +100,200 @@ def keyPressEvent(self, event: QKeyEvent) -> None: super().keyPressEvent(event) -def _describe_bars(run: BarRun) -> str: - if run.count == 1: - return str(run.start + 1) - return f"{run.start + 1}–{run.start + run.count}" +@dataclass(frozen=True) +class BarsEdit: + """What the dialog was left with: the pattern, and the number of each + bar the tapped beats fill.""" + + pattern: str + numbers: list[int] -def _describe_bar_numbers(bars: list[int]) -> str: - numbers = ", ".join(str(bar + 1) for bar in bars) - return ("Bar " if len(bars) == 1 else "Bars ") + numbers +@dataclass(frozen=True) +class BarRow: + """Consecutive bars that are alike. `start` is a 0-based bar index.""" + start: int + bar: PlannedBar + numbers: tuple[int, ...] + is_tapped: bool + + @property + def count(self) -> int: + return len(self.numbers) -def get_pattern_preview( - result: ParseResult, - beat_count: int | None = None, - overwritten_bars: list[int] | None = None, -) -> PreviewContent: + +def lay_out(planned: list[PlannedBar], beat_count: int) -> tuple[list[PlannedBar], int]: + """ + The bars `planned` makes over `beat_count` beats, repeating it from its + start as often as the beats need, then the rest of it; and how many of + them the beats reach. """ - The bars a pattern expands to, grouped where consecutive bars are equal. - `overwritten_bars` are 0-based indices of the timeline's bars set by hand - that applying the pattern would change; their rows are highlighted. + bars: list[PlannedBar] = [] + beats = 0 + while beats < beat_count: + bar = planned[len(bars) % len(planned)] + bars.append(bar) + beats += bar.beat_count + tapped = len(bars) + return bars + planned[tapped:], tapped + + +def get_numbers(count: int, overrides: dict[int, int]) -> list[int]: + """Bar numbers, counting on from the bar before where none is set.""" + numbers: list[int] = [] + for index in range(count): + default = numbers[-1] + 1 if numbers else 1 + numbers.append(overrides.get(index, default)) + return numbers + + +def get_overrides(numbers: list[int]) -> dict[int, int]: + """The numbers that don't count on from the bar before (or from 1).""" + return { + index: number + for index, number in enumerate(numbers) + if number != (numbers[index - 1] + 1 if index else 1) + } + + +def get_rows(bars: list[PlannedBar], tapped: int, numbers: list[int]) -> list[BarRow]: + """Groups consecutive bars that are alike and numbered one after another.""" + rows: list[BarRow] = [] + for index, (bar, number) in enumerate(zip(bars, numbers, strict=True)): + is_tapped = index < tapped + if rows: + last = rows[-1] + if ( + last.bar == bar + and last.is_tapped == is_tapped + and last.numbers[-1] + 1 == number + ): + rows[-1] = BarRow(last.start, bar, last.numbers + (number,), is_tapped) + continue + rows.append(BarRow(index, bar, (number,), is_tapped)) + return rows + + +def format_bar_numbers(numbers: tuple[int, ...] | list[int]) -> str: + """Writes consecutive bar numbers as a range: "1–4", or "5" alone.""" + if len(numbers) == 1: + return str(numbers[0]) + return f"{numbers[0]}–{numbers[-1]}" + + +def parse_bar_numbers(text: str, count: int) -> tuple[list[int] | None, str]: """ + Reads the numbers of `count` bars: a first number to count on from + ("9"), or ranges and numbers separated by commas that name `count` bars + ("1-8, 1-8"), where a number that doesn't follow the one before is a + repeat or a jump. + """ + numbers: list[int] = [] + for item in text.replace("–", "-").split(","): + item = item.strip() + bounds = [bound.strip() for bound in item.split("-")] + if not 1 <= len(bounds) <= 2 or not all( + bound.isascii() and bound.isdigit() for bound in bounds + ): + return None, f"'{item}' is not a bar number or a range such as 1-4." + first, last = int(bounds[0]), int(bounds[-1]) + if last < first: + return None, f"'{item}' counts down." + numbers += range(first, last + 1) + if len(numbers) > count: + break + + if len(numbers) == 1: + return list(range(numbers[0], numbers[0] + count)), "" + if len(numbers) != count: + bars = "bar" if count == 1 else "bars" + return None, f"'{text}' numbers {len(numbers)} bars, not {count} {bars}." + return numbers, "" + + +def parse_time_signature(text: str) -> tuple[PlannedBar | None, str]: + """Reads one bar of the pattern syntax, such as "6/8" or "6/8(3 3)".""" + result = parse(text) if not result.is_complete: - if result.position is None or result.error.startswith("Enter"): - return PreviewContent(error=result.error) - return PreviewContent( - error=f"{result.error} (at character {result.position + 1})" - ) + return None, result.error + if len(result.planned_bars) != 1: + return None, "Enter one time signature, such as 3/4." + return result.planned_bars[0], "" - runs = group_runs(result.bars) - pattern_length = len(result.bars) - overwritten_in_pattern = {bar % pattern_length for bar in overwritten_bars or []} - highlighted = frozenset( - index - for index, run in enumerate(runs) - if any( - run.start <= bar < run.start + run.count for bar in overwritten_in_pattern - ) - ) - - total_beats = sum(result.bars) - lines = [ - f"{pattern_length} bar{'' if pattern_length == 1 else 's'}, " - f"{total_beats} beat{'' if total_beats == 1 else 's'}, " - "then the pattern repeats from bar 1." - ] - if beat_count is not None: - lines.append(f"{beat_count} beat{'' if beat_count == 1 else 's'} tapped.") - if overwritten_bars: - lines.append( - f"{_describe_bar_numbers(overwritten_bars)} " - f"{'was' if len(overwritten_bars) == 1 else 'were'} " - "set by hand and will be overwritten." - ) - return PreviewContent( - headers=["Bars", "Beats per bar"], - rows=[(_describe_bars(run), str(run.beats)) for run in runs], - footer="\n".join(lines), - highlighted_rows=highlighted, - ) +def parse_taps(text: str, denominator: int) -> tuple[PlannedBar | None, str]: + """Reads what each tap of a bar is worth, such as "3/2 3/2 1".""" + result = parse_units("+".join(text.replace("+", " ").split())) + if not result.is_valid: + return None, result.error + return PlannedBar.from_values(result.units, denominator), "" + + +def format_taps(bar: PlannedBar) -> str: + return " ".join(str(value) for value in bar.values) class BeatPatternDialog(QDialog): def __init__( self, initial_text: str = "", - beat_count: int | None = None, - get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, + beat_count: int = 0, + current_bars: list[PlannedBar] | None = None, + numbers: list[int] | None = None, ) -> None: + """ + `current_bars` are the bars the timeline has, and `numbers` their + numbers; rows of bars that would change are highlighted. + """ super().__init__( get(Get.MAIN_WINDOW), Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, ) self.setWindowTitle(tilia.ui.strings.BEAT_PATTERN_DIALOG_TITLE) self._beat_count = beat_count - self._get_overwritten_bars = get_overwritten_bars + self._current_bars = current_bars or [] + self._overrides = get_overrides(numbers or []) + self._planned: list[PlannedBar] = [] + self._rows: list[BarRow] = [] + self._is_updating_table = False self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(5, 5, 5, 5) prompt = QLabel(tilia.ui.strings.BEAT_PATTERN_DIALOG_PROMPT) + prompt.setWordWrap(True) self.line_edit = PatternLineEdit(initial_text) self.line_edit.setValidator(BeatPatternValidator(self.line_edit)) self.line_edit.setToolTip(tilia.ui.strings.BEAT_PATTERN_DIALOG_TOOLTIP) - self.preview = PatternPreview(self) + self.table = QTableWidget(self) + self.table.setColumnCount(3) + self.table.setHorizontalHeaderLabels( + tilia.ui.strings.BEAT_PATTERN_TABLE_HEADERS + ) + self.table.verticalHeader().setVisible(False) + self.table.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeMode.Stretch + ) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + | QAbstractItemView.EditTrigger.EditKeyPressed + | QAbstractItemView.EditTrigger.AnyKeyPressed + ) + self.table.setMaximumHeight(MAX_TABLE_HEIGHT) + self.table.setToolTip(tilia.ui.strings.BEAT_PATTERN_TABLE_TOOLTIP) + self.table.itemChanged.connect(self._on_item_changed) + + self.footer = QLabel(self) + self.footer.setTextFormat(Qt.TextFormat.PlainText) + self.footer.setWordWrap(True) + + self.error = QLabel(self) + self.error.setTextFormat(Qt.TextFormat.PlainText) + self.error.setWordWrap(True) self.button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, @@ -175,7 +304,9 @@ def __init__( self.layout().addWidget(prompt) self.layout().addWidget(self.line_edit) - self.layout().addWidget(self.preview) + self.layout().addWidget(self.table) + self.layout().addWidget(self.footer) + self.layout().addWidget(self.error) self.layout().addWidget(self.button_box) self.line_edit.textChanged.connect(self._on_text_changed) @@ -189,26 +320,161 @@ def ok_button(self) -> QPushButton: def text(self) -> str: return self.line_edit.text().strip() + @property + def rows(self) -> list[BarRow]: + return list(self._rows) + + def get_table_rows(self) -> list[tuple[str, str, str]]: + return [ + tuple( + self.table.item(row, column).text() + for column in range(self.table.columnCount()) + ) + for row in range(self.table.rowCount()) + ] + + def get_highlighted_rows(self) -> set[int]: + return { + row + for row in range(self.table.rowCount()) + if self.table.item(row, 0).background().color() == CHANGED_COLOR + } + + def get_result(self) -> BarsEdit: + _, tapped = lay_out(self._planned, self._beat_count) + return BarsEdit(self.text, get_numbers(tapped, self._overrides)) + + def _show_error(self, message: str) -> None: + self.error.setText(message) + self.error.setVisible(bool(message)) + def _on_text_changed(self, text: str) -> None: result = parse(text) - overwritten = ( - self._get_overwritten_bars(result.bars) - if result.is_complete and self._get_overwritten_bars - else [] - ) - self.preview.set_content( - get_pattern_preview(result, self._beat_count, overwritten) - ) self.ok_button.setEnabled(result.is_complete) + self.table.setVisible(result.is_complete) + self.footer.setVisible(result.is_complete) + if not result.is_complete: + if result.position is None or result.error.startswith("Enter"): + self._show_error(result.error) + else: + self._show_error(f"{result.error} (at character {result.position + 1})") + return + + self._show_error("") + self._planned = result.planned_bars + self._update_table() + + def _update_table(self) -> None: + bars, tapped = lay_out(self._planned, self._beat_count) + numbers = get_numbers(len(bars), self._overrides) + self._rows = get_rows(bars, tapped, numbers) + + self._is_updating_table = True + self.table.setRowCount(len(self._rows)) + for row_index, row in enumerate(self._rows): + texts = [ + format_bar_numbers(row.numbers), + format_planned_bar(row.bar), + format_taps(row.bar), + ] + is_changed = self._is_changed(row) + for column, text in enumerate(texts): + item = QTableWidgetItem(text) + flags = item.flags() + if column == BARS_COLUMN and not row.is_tapped: + # Only measures with beats keep a number. + flags &= ~Qt.ItemFlag.ItemIsEditable + item.setFlags(flags) + if not row.is_tapped: + item.setForeground(QBrush(UNTAPPED_COLOR)) + if is_changed: + item.setBackground(CHANGED_COLOR) + self.table.setItem(row_index, column, item) + self._is_updating_table = False + + self.footer.setText(self._get_footer(bars, tapped)) + + def _is_changed(self, row: BarRow) -> bool: + """Whether the row's bars differ from the timeline's.""" + if not self._current_bars: + return False + for index in range(row.start, row.start + row.count): + if index >= len(self._current_bars): + return row.is_tapped + if self._current_bars[index] != row.bar: + return True + return False + + def _get_footer(self, bars: list[PlannedBar], tapped: int) -> str: + length = len(self._planned) + lines = [ + tilia.ui.strings.BEAT_PATTERN_SUMMARY.format( + length, "" if length == 1 else "s" + ) + ] + if self._beat_count: + lines.append( + tilia.ui.strings.BEAT_PATTERN_TAPPED.format( + self._beat_count, + "" if self._beat_count == 1 else "s", + tapped, + "" if tapped == 1 else "s", + ) + ) + return "\n".join(lines) + + def _on_item_changed(self, item: QTableWidgetItem) -> None: + if self._is_updating_table: + return + row = self._rows[item.row()] + if item.column() == BARS_COLUMN: + self._edit_numbers(row, item.text()) + return + + if item.column() == TIME_SIGNATURE_COLUMN: + bar, error = parse_time_signature(item.text()) + else: + bar, error = parse_taps(item.text(), row.bar.denominator) + if bar is None: + self._show_error(error) + self._update_table() + return + + self._show_error("") + bars, _ = lay_out(self._planned, self._beat_count) + for index in range(row.start, row.start + row.count): + bars[index] = bar + # Setting the text parses it back into the same bars and redraws the + # table. + self.line_edit.setText(format_planned_bars(bars)) + + def _edit_numbers(self, row: BarRow, text: str) -> None: + numbers, error = parse_bar_numbers(text, row.count) + if numbers is None: + self._show_error(error) + self._update_table() + return + + self._show_error("") + bars, tapped = lay_out(self._planned, self._beat_count) + all_numbers = get_numbers(tapped, self._overrides) + all_numbers[row.start : row.start + row.count] = numbers + # The bars after the row count on from it, unless numbered by hand. + for index in range(row.start + row.count, tapped): + if index not in self._overrides: + all_numbers[index] = all_numbers[index - 1] + 1 + self._overrides = get_overrides(all_numbers) + self._update_table() @classmethod def ask( cls, initial_text: str = "", - beat_count: int | None = None, - get_overwritten_bars: Callable[[list[int]], list[int]] | None = None, - ) -> tuple[bool, str]: - dialog = cls(initial_text, beat_count, get_overwritten_bars) + beat_count: int = 0, + current_bars: list[PlannedBar] | None = None, + numbers: list[int] | None = None, + ) -> tuple[bool, BarsEdit | None]: + dialog = cls(initial_text, beat_count, current_bars, numbers) if dialog.exec() == QDialog.DialogCode.Accepted: - return True, dialog.text - return False, "" + return True, dialog.get_result() + return False, None From 68382c62e535fd240ad38d1f8e376516bc9f7bd8 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:49:10 +0100 Subject: [PATCH 36/56] feat: edit time signatures, not beat units Beat units stay how TiLiA stores what each tap is worth, but the app no longer asks for them. Everywhere a user changes the meter, it now takes a time signature: - "Set beat unit" and its dialog are gone. "Edit bars and time signatures", in the beat and time signature context menus and on double-clicking a label, opens the bars dialog at that measure, which gets a row of its own so it can be changed apart from the alike measures around it. - The time signature inspector shows the measure's time signature and its taps, and edits the time signature. The measure keeps its taps: 6/8 over two taps is two dotted quarters, 3/4 over four is four dotted eighths. Taps given in brackets that change their number are refused, as changing taps is the dialog's job. Asking whether the change applies to this measure only or until the next change is as before. - In the dialog, a tapped row's time signature keeps its taps the same way; a row not tapped yet takes the time signature's own. A time signature TiLiA assumed reads "4/4 (assumed)", and writing one over it confirms it. - Tooltips and labels speak of time signatures and taps. The CLI's beat unit commands and CSV import's denominator and units columns are unchanged. --- tests/test_export.py | 3 +- tests/timelines/beat/test_pattern.py | 35 +++ tests/ui/timelines/beat/interact.py | 31 -- .../timelines/beat/test_beat_timeline_ui.py | 290 +++++++++--------- tests/ui/windows/test_beat_pattern_dialog.py | 42 ++- tests/ui/windows/test_beat_unit_dialog.py | 115 ------- tilia/errors.py | 1 + tilia/requests/get.py | 1 - tilia/timelines/beat/pattern.py | 45 +++ tilia/ui/dialog_manager.py | 2 - tilia/ui/strings.py | 25 +- tilia/ui/timelines/beat/beat_unit.py | 111 ++++--- tilia/ui/timelines/beat/context_menu.py | 6 +- tilia/ui/timelines/beat/dialogs.py | 20 +- tilia/ui/timelines/beat/timeline.py | 110 +++---- tilia/ui/windows/beat_pattern.py | 73 +++-- tilia/ui/windows/beat_unit.py | 195 +----------- tilia/ui/windows/pattern_preview.py | 99 ------ 18 files changed, 450 insertions(+), 754 deletions(-) delete mode 100644 tests/ui/windows/test_beat_unit_dialog.py delete mode 100644 tilia/ui/windows/pattern_preview.py diff --git a/tests/test_export.py b/tests/test_export.py index 6848d6c0b..dd84e694d 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -187,8 +187,7 @@ def test_beat_units_are_exported(self, tilia, beat_tlui, tmp_path): for time in range(4): commands.execute("media.seek", time) commands.execute("timeline.beat.add") - beat_tlui.select_element(beat_tlui[2]) - commands.execute("timeline.beat.set_beat_unit", denominator=8, units="2+1") + commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="6/8(2 1 2 1)") commands.execute("file.export.json", path=tmp_path / "test.json") with open(tmp_path / "test.json", encoding="utf-8") as f: diff --git a/tests/timelines/beat/test_pattern.py b/tests/timelines/beat/test_pattern.py index 50f6d99b9..d2ee5e127 100644 --- a/tests/timelines/beat/test_pattern.py +++ b/tests/timelines/beat/test_pattern.py @@ -13,6 +13,7 @@ format_planned_bars, group_runs, parse, + parse_time_signature_over_taps, ) @@ -290,3 +291,37 @@ def test_planned_bar_from_values_finds_the_repeating_units(self): bar = PlannedBar.from_values([F(1), F(2)] * 3, 8) assert bar.units == (F(1), F(2)) assert bar.beat_count == 6 + + +class TestTimeSignatureOverTaps: + @pytest.mark.parametrize( + "text, beat_count, values, denominator", + [ + # As many taps as the time signature's own. + ("4/4", 4, [F(1)] * 4, 4), + # Each tap gets an equal share of the numerator. + ("6/8", 2, [F(3), F(3)], 8), + ("3/4", 4, [F(3, 4)] * 4, 4), + ("2/2", 4, [F(1, 2)] * 4, 2), + # An added numerator with a part per tap. + ("2+3/8", 2, [F(2), F(3)], 8), + ("2+3/8", 5, [F(1)] * 5, 8), + # Taps given in brackets are kept, however many. + ("6/8(3 3)", 6, [F(3), F(3)], 8), + ], + ) + def test_values(self, text, beat_count, values, denominator): + bar, error = parse_time_signature_over_taps(text, beat_count) + assert error == "" + assert (bar.values, bar.denominator) == (values, denominator) + + def test_a_share_too_fine_is_refused(self): + bar, error = parse_time_signature_over_taps("1/4", 129) + assert bar is None + assert error == "1/4 can't be shared between 129 taps." + + def test_more_than_one_bar_is_refused(self): + assert parse_time_signature_over_taps("4/4 3/4", 4) == ( + None, + "Enter one time signature, such as 3/4.", + ) diff --git a/tests/ui/timelines/beat/interact.py b/tests/ui/timelines/beat/interact.py index 856e136e5..dfaaea2c1 100644 --- a/tests/ui/timelines/beat/interact.py +++ b/tests/ui/timelines/beat/interact.py @@ -17,7 +17,6 @@ from tilia.ui import strings from tilia.ui.coords import time_x_converter from tilia.ui.windows.beat_pattern import BeatPatternDialog -from tilia.ui.windows.beat_unit import BeatUnitDialog from tilia.ui.windows.fill_beat_timeline import FillBeatTimeline @@ -106,36 +105,6 @@ def hover_time_signature_label(timeline_ui, label, entered=True): timeline_ui.scene.sendEvent(label, QGraphicsSceneHoverEvent(event_type)) -def patch_beat_unit_dialog( - denominator: int | None = None, - units: str | None = None, - only_this_measure: bool = False, - cancel: bool = False, - on_exec=None, -): - """ - Makes the beat unit dialog take the given values (keeping any left as - None) and press OK, or press Cancel. `on_exec(dialog)` runs first. - """ - - def exec_(dialog): - if on_exec: - on_exec(dialog) - if cancel: - return QDialog.DialogCode.Rejected - if denominator is not None: - dialog.denominator_edit.setValue(denominator) - if units is not None: - dialog.units_edit.clear() - QTest.keyClicks(dialog.units_edit, units) - if only_this_measure: - dialog.measure_option.click() - dialog.ok_button.click() - return dialog.result() - - return patch.object(BeatUnitDialog, "exec", new=exec_) - - def patch_fill_beat_timeline_dialog(timeline, method, value: float): """ Makes the "Fill with beats" dialog choose `timeline`, fill `method` and diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 849601c21..3599aa0da 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -15,7 +15,6 @@ hover_time_signature_label, patch_bars_dialog, patch_beat_pattern_dialog, - patch_beat_unit_dialog, patch_beat_unit_scope_prompt, patch_fill_beat_timeline_dialog, ) @@ -36,7 +35,7 @@ from tilia.ui import commands from tilia.ui.commands import get_qaction from tilia.ui.coords import time_x_converter -from tilia.ui.strings import BEAT_UNIT_ASSUMED_TOOLTIP +from tilia.ui.strings import BEAT_UNIT_AMBIGUOUS_TOOLTIP, BEAT_UNIT_ASSUMED_TOOLTIP from tilia.ui.timelines.beat.context_menu import ( BeatContextMenu, BeatTimelineUIContextMenu, @@ -1613,16 +1612,17 @@ def test_drag_beyond_timeline_start_is_clamped(self, beat_tlui, tilia_state): assert beat_tlui[0].get_data("time") == 0 -def set_beat_unit(timeline_ui, measure_index, denominator, units, **kwargs): - """Selects the measure's first beat and sets its beat unit, as a user would.""" - timeline = timeline_ui.timeline - first_beat = timeline[timeline.beats_that_start_measures[measure_index]] - timeline_ui.deselect_all_elements() - timeline_ui.select_element(timeline_ui.get_element(first_beat.id)) - commands.execute( - "timeline.beat.set_beat_unit", denominator=denominator, units=units, **kwargs +def set_beat_unit( + timeline_ui, measure_index, denominator, units, only_this_measure=False +): + """ + Gives a measure a beat unit, as editing its time signature in the + inspector does, and records the state, so it can be undone. + """ + timeline_ui.timeline.set_beat_unit( + measure_index, denominator, units, only_this_measure ) - timeline_ui.deselect_all_elements() + post(Post.APP_STATE_RECORD, "set beat unit") def newest_beat_unit_ui(timeline_ui): @@ -1780,7 +1780,9 @@ def test_ambiguous_fit_is_amber(self, tluis): LabelState.NORMAL, LabelState.AMBIGUOUS, ] - assert labels[1].toolTip() == "3 taps under 2+3: treated as 2+3+2." + assert labels[1].toolTip() == BEAT_UNIT_AMBIGUOUS_TOOLTIP.format( + 3, "2+3", "2+3+2" + ) def test_clicking_a_label_selects_its_beat_unit(self, tluis): add_beat_timeline_with_pattern("2") @@ -1981,134 +1983,98 @@ def test_beat_units_survive_save_and_reopen(self, tluis, tmp_path): assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("3", "8")] -class TestSetBeatUnitCommand: - def test_from_selected_beat_through_dialog(self, tluis): - add_beat_timeline_with_pattern("2") - add_beats(range(6)) - timeline_ui = tluis[0] - # A beat in the middle of measure 2 sets that measure's beat unit. - timeline_ui.select_element(timeline_ui[3]) - - with patch_beat_unit_dialog(denominator=8, units="3"): - with undoable(): - commands.execute("timeline.beat.set_beat_unit") +class TestEditBarsCommand: + @staticmethod + def record_dialog(seen): + def record(dialog): + seen["rows"] = dialog.get_table_rows() + seen["current_row"] = dialog.current_row - beat_unit = timeline_ui.timeline.beat_units[-1] - assert beat_unit.beat_id == timeline_ui.timeline[2].id - assert label_texts(all_labels(timeline_ui)) == [("2", "4"), ("6", "8")] + return record - def test_dialog_starts_with_the_measure_values(self, tluis): - add_beat_timeline_with_pattern("2") + def test_opens_the_dialog_at_the_selected_beats_measure(self, tluis): + add_beat_timeline_with_pattern("2/4") add_beats(range(6)) set_beat_unit(tluis[0], 1, 8, "3") - tluis[0].select_element(tluis[0][4]) + # A beat in the middle of measure 3. + tluis[0].select_element(tluis[0][5]) seen = {} - def record(dialog): - seen["values"] = ( - dialog.denominator_edit.value(), - dialog.units_edit.text(), - ) - seen["preview"] = dialog.preview.footer.text() - - with patch_beat_unit_dialog(cancel=True, on_exec=record): - commands.execute("timeline.beat.set_beat_unit") + with patch_bars_dialog(self.record_dialog(seen)): + commands.execute("timeline.beat.edit_bars") - assert seen["values"] == (8, "3") - assert seen["preview"].endswith("becomes 6/8.") + # The measure has a row of its own, apart from the alike one before. + assert seen["rows"] == [ + ("1", "2/4", "1 1"), + ("2", "6/8", "3 3"), + ("3", "6/8", "3 3"), + ] + assert seen["current_row"] == 2 def test_cancel_changes_nothing(self, tluis): add_beat_timeline_with_pattern("2") add_beats(range(4)) tluis[0].select_element(tluis[0][0]) - with patch_beat_unit_dialog(cancel=True): - commands.execute("timeline.beat.set_beat_unit") + with patch_beat_pattern_dialog(None): + commands.execute("timeline.beat.edit_bars") assert_only_the_default_beat_unit(tluis[0].timeline) - def test_this_measure_only_through_dialog(self, tluis): - add_beat_timeline_with_pattern("2") + def test_editing_a_time_signature(self, tluis): + add_beat_timeline_with_pattern("2/4") add_beats(range(6)) tluis[0].select_element(tluis[0][2]) - with patch_beat_unit_dialog(denominator=8, units="3", only_this_measure=True): - with undoable(): - commands.execute("timeline.beat.set_beat_unit") - - assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("6", "8"), ("2", "4")] - - @pytest.mark.parametrize( - "beat_index, next_has_own_unit, show_scope", - [(0, False, True), (4, False, False), (0, True, False)], - ) - def test_scope_is_offered_only_when_it_matters( - self, tluis, beat_index, next_has_own_unit, show_scope - ): - add_beat_timeline_with_pattern("2") - add_beats(range(6)) - if next_has_own_unit: - set_beat_unit(tluis[0], 1, 4, "1") - tluis[0].select_element(tluis[0][beat_index]) - seen = {} - - def record(dialog): - seen["shown"] = not dialog.measure_option.isHidden() - - with patch_beat_unit_dialog(cancel=True, on_exec=record): - commands.execute("timeline.beat.set_beat_unit") - - assert seen["shown"] == show_scope - - def test_double_clicking_a_label_edits_in_place(self, tluis): - add_beat_timeline_with_pattern("2") - add_beats(range(6)) - set_beat_unit(tluis[0], 1, 8, "3") - beat_unit_ui = newest_beat_unit_ui(tluis[0]) + def edit(dialog): + edit_bars_dialog_cell(dialog, 0, BARS_COLUMN, "1, 2, 3") + edit_bars_dialog_cell(dialog, 1, TIME_SIGNATURE_COLUMN, "6/8") - with patch_beat_unit_dialog(units="1"): - click_time_signature_label(tluis[0], beat_unit_ui.labels[0], double=True) + with undoable(): + with patch_bars_dialog(edit): + commands.execute("timeline.beat.edit_bars") - assert len(tluis[0].timeline.beat_units) == 2 - assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "8")] + assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("6", "8"), ("2", "4")] + assert tluis[0].timeline.beats_in_measure == [2, 2, 2] - def test_editing_an_inherited_label_starts_a_new_beat_unit(self, tluis): + def test_double_clicking_a_label_opens_the_dialog_at_its_measure(self, tluis): add_beat_timeline_with_pattern("2 3") add_beats(range(10)) set_beat_unit(tluis[0], 0, 8, "1") beat_unit_ui = newest_beat_unit_ui(tluis[0]) + seen = {} - with patch_beat_unit_dialog(denominator=4): - click_time_signature_label(tluis[0], beat_unit_ui.get_label(1), double=True) + with patch_bars_dialog(self.record_dialog(seen)): + click_time_signature_label(tluis[0], beat_unit_ui.get_label(2), double=True) - assert label_texts(all_labels(tluis[0])) == [ - ("2", "8"), - ("3", "4"), - ("2", "4"), - ("3", "4"), - ] - assert len(tluis[0].timeline.beat_units) == 2 + assert seen["current_row"] == 2 - def test_beat_context_menu_has_set_beat_unit(self, tluis): + def test_beat_context_menu_has_edit_bars(self, tluis): add_beat_timeline_with_pattern("4") add_beats(range(4)) menu = BeatContextMenu(tluis[0][0]) - assert "timeline.beat.set_beat_unit" in get_command_names(menu) + assert "timeline.beat.edit_bars" in get_command_names(menu) + assert "timeline.beat.set_beat_unit" not in get_command_names(menu) - def test_set_beat_unit_from_beat_context_menu(self, tluis): + def test_edit_bars_from_beat_context_menu(self, tluis): add_beat_timeline_with_pattern("4") - add_beats(range(4)) + add_beats(range(8)) tluis[0].select_element(tluis[0][0]) action = get_command_action( - BeatContextMenu(tluis[0][0]), "timeline.beat.set_beat_unit" + BeatContextMenu(tluis[0][0]), "timeline.beat.edit_bars" ) - with patch_beat_unit_dialog(denominator=8): + with patch_bars_dialog( + lambda dialog: edit_bars_dialog_cell( + dialog, 0, TIME_SIGNATURE_COLUMN, "8/8" + ) + ): action.trigger() - assert label_texts(all_labels(tluis[0])) == [("4", "8")] + # Only the measure the dialog was opened at changes. + assert label_texts(all_labels(tluis[0])) == [("8", "8"), ("4", "4")] def test_label_context_menu_items(self, tluis): add_beat_timeline_with_pattern("4") @@ -2118,7 +2084,7 @@ def test_label_context_menu_items(self, tluis): names = get_command_names(BeatUnitContextMenu(beat_unit_ui)) - assert names == ["timeline.beat.set_beat_unit", "timeline.component.delete"] + assert names == ["timeline.beat.edit_bars", "timeline.component.delete"] def test_delete_from_label_context_menu(self, tluis): add_beat_timeline_with_pattern("4") @@ -2134,7 +2100,7 @@ def test_delete_from_label_context_menu(self, tluis): assert_only_the_default_beat_unit(tluis[0].timeline) - def test_edit_from_label_context_menu(self, tluis): + def test_edit_bars_from_label_context_menu(self, tluis): add_beat_timeline_with_pattern("2 3") add_beats(range(10)) set_beat_unit(tluis[0], 0, 8, "1") @@ -2144,18 +2110,14 @@ def test_edit_from_label_context_menu(self, tluis): tluis[0], beat_unit_ui.get_label(1), button="right" ) action = get_command_action( - BeatUnitContextMenu(beat_unit_ui), "timeline.beat.set_beat_unit" + BeatUnitContextMenu(beat_unit_ui), "timeline.beat.edit_bars" ) + seen = {} - with patch_beat_unit_dialog(denominator=4, only_this_measure=True): + with patch_bars_dialog(self.record_dialog(seen)): action.trigger() - assert label_texts(all_labels(tluis[0])) == [ - ("2", "8"), - ("3", "4"), - ("2", "8"), - ("3", "8"), - ] + assert seen["current_row"] == 1 def test_deleting_the_beat_moves_its_beat_unit(self, tluis): add_beat_timeline_with_pattern("4") @@ -2184,7 +2146,7 @@ def test_undo_restores_beat_unit_on_its_beat(self, tluis): assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("6", "8")] -class TestBeatUnitInspector: +class TestTimeSignatureInspector: @pytest.fixture(autouse=True) def close_inspector(self): yield @@ -2215,11 +2177,12 @@ def test_shows_values(self, qtui, tluis): ) widgets = inspector.field_name_to_widgets - assert widgets["Denominator"][1].value() == 8 - assert widgets["Units per tap"][1].text() == "3" - assert widgets["Starts at measure"][1].text() == "2" + assert widgets["Time signature"][1].text() == "6/8" + assert widgets["Taps"][1].text() == "3 3" + assert widgets["Measure"][1].text() == "2" + assert "Denominator" not in widgets - def test_edit_units_until_next_change(self, qtui, tluis): + def test_edit_until_next_change(self, qtui, tluis): add_beat_timeline_with_pattern("2") add_beats(range(8)) set_beat_unit(tluis[0], 1, 8, "3") @@ -2230,12 +2193,46 @@ def test_edit_units_until_next_change(self, qtui, tluis): with patch_beat_unit_scope_prompt("onward"): with undoable(): - self.type_into(inspector.field_name_to_widgets["Units per tap"][1], "1") + self.type_into( + inspector.field_name_to_widgets["Time signature"][1], "2/8" + ) assert len(tluis[0].timeline.beat_units) == 2 assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "8")] - def test_edit_denominator_this_measure_only(self, qtui, tluis): + def test_the_measure_keeps_its_taps(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward"): + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "6/8") + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [2, 2] + assert label_texts(all_labels(tluis[0])) == [("6", "8")] + assert [m.beat_values for m in timeline.measure_meters] == [(3, 3), (3, 3)] + + def test_other_taps_are_refused(self, qtui, tluis, tilia_errors): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = self.open_inspector_for_label( + tluis[0], beat_unit_ui.labels[0], qtui + ) + + self.type_into( + inspector.field_name_to_widgets["Time signature"][1], "6/8(1 1 1 1 1 1)" + ) + + tilia_errors.assert_error() + tilia_errors.assert_in_error_message("This measure has 2 taps.") + assert_only_the_default_beat_unit(tluis[0].timeline) + + def test_edit_this_measure_only(self, qtui, tluis): add_beat_timeline_with_pattern("2") add_beats(range(8)) set_beat_unit(tluis[0], 1, 8, "1") @@ -2245,10 +2242,7 @@ def test_edit_denominator_this_measure_only(self, qtui, tluis): ) with patch_beat_unit_scope_prompt("measure"): - spin_box = inspector.field_name_to_widgets["Denominator"][1] - spin_box.setValue(2) - spin_box.editingFinished.emit() - QApplication.processEvents() + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "2/2") assert label_texts(all_labels(tluis[0])) == [("2", "4"), ("2", "2"), ("2", "8")] @@ -2261,18 +2255,18 @@ def test_cancelling_the_scope_question_changes_nothing(self, qtui, tluis): tluis[0], beat_unit_ui.labels[0], qtui ) - line_edit = inspector.field_name_to_widgets["Units per tap"][1] + line_edit = inspector.field_name_to_widgets["Time signature"][1] # Ended by clicking away rather than Enter, which closes the # inspector, so the field can be seen going back. with patch_beat_unit_scope_prompt(None): line_edit.selectAll() - QTest.keyClicks(line_edit, "1") + QTest.keyClicks(line_edit, "2/8") line_edit.editingFinished.emit() QApplication.processEvents() assert tluis[0].timeline.beat_units[-1].units == "3" - assert line_edit.text() == "3" + assert line_edit.text() == "6/8" def test_no_question_when_the_beat_unit_covers_one_measure(self, qtui, tluis): add_beat_timeline_with_pattern("2") @@ -2284,7 +2278,7 @@ def test_no_question_when_the_beat_unit_covers_one_measure(self, qtui, tluis): ) with patch_beat_unit_scope_prompt("onward") as prompts: - self.type_into(inspector.field_name_to_widgets["Units per tap"][1], "1") + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "2/8") assert prompts == [] assert tluis[0].timeline.beat_units[-1].units == "1" @@ -2299,10 +2293,7 @@ def test_edit_from_an_inherited_label_starts_there(self, qtui, tluis): ) with patch_beat_unit_scope_prompt("onward"): - spin_box = inspector.field_name_to_widgets["Denominator"][1] - spin_box.setValue(4) - spin_box.editingFinished.emit() - QApplication.processEvents() + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "2/4") assert label_texts(all_labels(tluis[0])) == [ ("2", "8"), @@ -2311,7 +2302,7 @@ def test_edit_from_an_inherited_label_starts_there(self, qtui, tluis): ("3", "4"), ] - def test_half_typed_units_are_not_applied(self, qtui, tluis): + def test_half_typed_time_signature_is_not_applied(self, qtui, tluis): add_beat_timeline_with_pattern("2") add_beats(range(4)) set_beat_unit(tluis[0], 1, 8, "3") @@ -2320,7 +2311,7 @@ def test_half_typed_units_are_not_applied(self, qtui, tluis): tluis[0], beat_unit_ui.labels[0], qtui ) - self.type_into(inspector.field_name_to_widgets["Units per tap"][1], "2+") + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "2/") assert tluis[0].timeline.beat_units[-1].units == "3" @@ -2334,10 +2325,10 @@ def test_edit_reported_twice_asks_once(self, qtui, tluis): inspector = self.open_inspector_for_label( tluis[0], beat_unit_ui.labels[0], qtui ) - line_edit = inspector.field_name_to_widgets["Units per tap"][1] + line_edit = inspector.field_name_to_widgets["Time signature"][1] with patch_beat_unit_scope_prompt("onward") as prompts: line_edit.selectAll() - QTest.keyClicks(line_edit, "1") + QTest.keyClicks(line_edit, "2/8") line_edit.editingFinished.emit() line_edit.editingFinished.emit() QApplication.processEvents() @@ -2353,11 +2344,11 @@ def test_clicking_another_element_keeps_its_inspector(self, qtui, tluis): inspector = self.open_inspector_for_label( tluis[0], beat_unit_ui.labels[0], qtui ) - line_edit = inspector.field_name_to_widgets["Units per tap"][1] + line_edit = inspector.field_name_to_widgets["Time signature"][1] with patch_beat_unit_scope_prompt("onward"): line_edit.selectAll() - QTest.keyClicks(line_edit, "1") + QTest.keyClicks(line_edit, "2/8") # Clicking a beat ends the edit, then selects the beat. line_edit.editingFinished.emit() click_beat_ui(tluis[0][5]) @@ -2450,14 +2441,17 @@ def test_beat_unit_set_by_the_user_is_not_assumed(self, tluis): assert second.body.opacity() == 1 assert second.toolTip() == "" - def test_confirming_unchanged_values_in_the_dialog(self, tluis): + def test_confirming_an_assumed_time_signature_in_the_dialog(self, tluis): add_beat_timeline_with_pattern("4") add_beats(range(4)) - tluis[0].select_element(tluis[0][0]) - with patch_beat_unit_dialog(): + with patch_bars_dialog( + lambda dialog: edit_bars_dialog_cell( + dialog, 0, TIME_SIGNATURE_COLUMN, "4/4" + ) + ): with undoable(): - commands.execute("timeline.beat.set_beat_unit") + commands.execute("timeline.beat.set_pattern", tluis[0]) beat_unit = tluis[0].timeline.beat_units[0] assert (beat_unit.denominator, beat_unit.units) == (4, "1") @@ -2626,7 +2620,7 @@ def test_menu_offers_removing_the_others(self, tluis): names = get_command_names(BeatUnitContextMenu(beat_unit_ui)) assert names == [ - "timeline.beat.set_beat_unit", + "timeline.beat.edit_bars", "timeline.beat.remove_other_beat_units", "timeline.component.delete", ] @@ -2950,20 +2944,22 @@ def test_reopening_twice_changes_nothing(self, tluis, tmp_path, tilia): assert tluis[0].timeline.get_state()["components"] == components assert not tilia.is_file_modified() - def test_undo_and_redo(self, tluis): + def test_undo_and_redo(self, qtui, tluis): add_beat_timeline_with_pattern("2 3") add_beats(range(6)) - tluis[0].select_element(tluis[0][2]) + beat_unit_ui = newest_beat_unit_ui(tluis[0]) + inspector = TestTimeSignatureInspector.open_inspector_for_label( + tluis[0], beat_unit_ui.get_label(1), qtui + ) - with undoable(): - commands.execute( - "timeline.beat.set_beat_unit", - denominator=8, - units="1", - only_this_measure=True, - ) + with patch_beat_unit_scope_prompt("measure"): + with undoable(): + TestTimeSignatureInspector.type_into( + inspector.field_name_to_widgets["Time signature"][1], "3/8" + ) assert self.snapshot(tluis[0]) == self.EXPECTED + post(Post.WINDOW_CLOSE, WindowKind.INSPECT) def test_tapping_on_after_reopening(self, tluis, tmp_path): self.setup_timeline(tluis) diff --git a/tests/ui/windows/test_beat_pattern_dialog.py b/tests/ui/windows/test_beat_pattern_dialog.py index af6954278..7b8b2ef5b 100644 --- a/tests/ui/windows/test_beat_pattern_dialog.py +++ b/tests/ui/windows/test_beat_pattern_dialog.py @@ -114,15 +114,15 @@ def test_rows_group_alike_bars(self, qtui): assert dialog.get_table_rows() == [ ("1–4", "4/4", "1 1 1 1"), ("5", "3/4", "1 1 1"), - ("6–8", "4/4(3/2 3/2 1)", "3/2 3/2 1"), + ("6–8", "4/4", "3/2 3/2 1"), ] def test_rows_of_bars_given_only_their_beats(self, qtui): dialog = BeatPatternDialog("2[3] 4") assert dialog.get_table_rows() == [ - ("1–2", "3", "1 1 1"), - ("3", "4", "1 1 1 1"), + ("1–2", "3/4 (assumed)", "1 1 1"), + ("3", "4/4 (assumed)", "1 1 1 1"), ] def test_rows_cover_the_tapped_beats(self, qtui): @@ -176,7 +176,7 @@ def test_taps_change_the_time_signature(self, qtui): edit_bars_dialog_cell(dialog, 1, TAPS_COLUMN, "3") - assert dialog.get_table_rows()[1] == ("5", "3/4(3)", "3") + assert dialog.get_table_rows()[1] == ("5", "3/4", "3") assert dialog.text == "4[4/4] 3/4(3)" def test_taps_that_add_up_to_another_numerator(self, qtui): @@ -204,11 +204,43 @@ def test_time_signature(self, qtui): def test_editing_a_row_of_a_repeated_pattern(self, qtui): dialog = BeatPatternDialog("4/4", beat_count=12) - edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "3/4") + edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "1 1 1") + # The three bars edited become the pattern, which the twelve beats + # go through again for a fourth bar. assert dialog.text == "3[3/4]" assert dialog.get_table_rows() == [("1–4", "3/4", "1 1 1")] + def test_a_tapped_bar_keeps_its_taps(self, qtui): + dialog = BeatPatternDialog("2/4", beat_count=4) + + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "6/8") + + assert dialog.get_table_rows() == [("1–2", "6/8", "3 3")] + assert dialog.text == "2[6/8(3 3)]" + + def test_a_tapped_bar_takes_the_taps_given(self, qtui): + dialog = BeatPatternDialog("2/4", beat_count=4) + + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "6/8(1 1 1 1 1 1)") + + assert dialog.text == "2[6/8]" + + def test_a_bar_not_tapped_yet_takes_the_time_signatures_taps(self, qtui): + dialog = BeatPatternDialog("2/4 2/4", beat_count=2) + + edit_bars_dialog_cell(dialog, 1, TIME_SIGNATURE_COLUMN, "6/8") + + assert dialog.text == "2/4 6/8" + + def test_confirming_an_assumed_time_signature(self, qtui): + dialog = BeatPatternDialog("4", beat_count=8) + + edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "4/4") + + assert dialog.text == "2[4/4]" + assert not dialog.rows[0].bar.assumed + def test_invalid_cell_is_reported_and_reverted(self, qtui): dialog = BeatPatternDialog("4/4") diff --git a/tests/ui/windows/test_beat_unit_dialog.py b/tests/ui/windows/test_beat_unit_dialog.py deleted file mode 100644 index b7e09b9e3..000000000 --- a/tests/ui/windows/test_beat_unit_dialog.py +++ /dev/null @@ -1,115 +0,0 @@ -import pytest -from PySide6.QtGui import QValidator -from PySide6.QtTest import QTest - -from tilia.ui.windows.beat_unit import ( - BeatUnitDialog, - BeatUnitInput, - UnitsValidator, - get_units_preview, -) - - -class TestValidator: - @pytest.mark.parametrize( - "text, state", - [ - ("1", QValidator.State.Acceptable), - ("2+3", QValidator.State.Acceptable), - ("1/3", QValidator.State.Acceptable), - ("", QValidator.State.Intermediate), - ("2+", QValidator.State.Intermediate), - ("1/", QValidator.State.Intermediate), - ("0", QValidator.State.Intermediate), - ("1.5", QValidator.State.Invalid), - ("-1", QValidator.State.Invalid), - ("a", QValidator.State.Invalid), - ], - ) - def test_state(self, text, state): - assert UnitsValidator().validate(text, len(text))[0] == state - - -class TestUnitsPreview: - def test_one_unit(self): - preview = get_units_preview(8, "3", None) - - assert preview.headers == ["Tap", "Units (1/8)", "Length"] - assert preview.rows == [("1", "3", "1 1/2 quarters")] - assert preview.footer == "Every tap is worth the same." - - def test_several_units_repeat(self): - preview = get_units_preview(4, "2+3", 2) - - assert preview.rows == [("1", "2", "2 quarters"), ("2", "3", "3 quarters")] - assert preview.footer == ( - "Repeats every 2 taps, from tap 1 in each measure.\n" - "This measure (2 taps) becomes 5/4." - ) - - def test_fractional_units(self): - preview = get_units_preview(4, "1/3", None) - - assert preview.rows == [("1", "1/3", "1/3 quarter")] - - def test_whole_and_fraction(self): - assert get_units_preview(4, "1/3", 13).footer.endswith( - "This measure (13 taps) becomes 4 1/3 over 4." - ) - - def test_ambiguous(self): - assert get_units_preview(4, "2+3", 3).footer.endswith( - "3 taps under 2+3: treated as 2+3+2." - ) - - def test_error(self): - preview = get_units_preview(4, "2+", 3) - - assert preview.error == "Missing unit around '+'." - assert preview.rows == [] - - -class TestDialog: - def test_initial_values(self, qtui): - dialog = BeatUnitDialog(8, "3", beat_count=2) - - assert dialog.denominator_edit.value() == 8 - assert dialog.units_edit.text() == "3" - assert dialog.ok_button.isEnabled() - assert dialog.preview.footer.text().endswith("becomes 6/8.") - - def test_preview_follows_input(self, qtui): - dialog = BeatUnitDialog(4, "1", beat_count=2) - - dialog.units_edit.clear() - QTest.keyClicks(dialog.units_edit, "2+3") - - assert dialog.preview.footer.text().endswith("becomes 5/4.") - - def test_incomplete_units_disable_ok(self, qtui): - dialog = BeatUnitDialog(4, "1") - - QTest.keyClicks(dialog.units_edit, "+") - - assert not dialog.ok_button.isEnabled() - - def test_characters_outside_the_syntax_are_blocked(self, qtui): - dialog = BeatUnitDialog(4, "") - - QTest.keyClicks(dialog.units_edit, "1.5a") - - assert dialog.units_edit.text() == "15" - - def test_scope_defaults_to_onward(self, qtui): - dialog = BeatUnitDialog(4, " 2 + 3 ") - - assert dialog.result_input == BeatUnitInput(4, "2+3", False) - - dialog.measure_option.click() - assert dialog.result_input.only_this_measure - - def test_scope_can_be_hidden(self, qtui): - dialog = BeatUnitDialog(4, "1", show_scope=False) - - assert dialog.onward_option.isHidden() - assert dialog.measure_option.isHidden() diff --git a/tilia/errors.py b/tilia/errors.py index ea3232ed6..f212c39f9 100644 --- a/tilia/errors.py +++ b/tilia/errors.py @@ -111,6 +111,7 @@ class Error(NamedTuple): ) SCORE_SVG_CREATE_ERROR = Error("Error creating SVG", "{}") INVALID_BEAT_PATTERN = Error("Set beat pattern", "Invalid beat pattern: {}") +INVALID_TIME_SIGNATURE = Error("Time signature", "Invalid time signature: {}") INVALID_MEASURE_FRACTION = Error( "Invalid measure fraction", "Measure fraction '{}' is not valid. Using closest valid value.", diff --git a/tilia/requests/get.py b/tilia/requests/get.py index 14918fda8..56c23e4f1 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -14,7 +14,6 @@ class Get(Enum): FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA = auto() FROM_USER_BARS = auto() FROM_USER_BEAT_PATTERN = auto() - FROM_USER_BEAT_UNIT = auto() FROM_USER_BEAT_UNIT_SCOPE = auto() FROM_USER_BEAT_TIMELINE_FILL_METHOD = auto() FROM_USER_COLOR = auto() diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py index 83c6d7d33..4c74aa83a 100644 --- a/tilia/timelines/beat/pattern.py +++ b/tilia/timelines/beat/pattern.py @@ -351,6 +351,41 @@ def parse(text: str) -> ParseResult: return ParseResult(ParseStatus.COMPLETE, bars) +def parse_time_signature(text: str) -> tuple[PlannedBar | None, str]: + """Reads one bar of the pattern syntax, such as "6/8" or "6/8(3 3)".""" + result = parse(text) + if not result.is_complete: + return None, result.error + if len(result.planned_bars) != 1: + return None, "Enter one time signature, such as 3/4." + return result.planned_bars[0], "" + + +def parse_time_signature_over_taps( + text: str, beat_count: int +) -> tuple[PlannedBar | None, str]: + """ + Reads the time signature of a bar that already has `beat_count` taps. + The bar keeps its taps unless the text gives others, in brackets: each + tap is then worth the same, the numerator divided by the taps, or, for + an added numerator with as many parts as taps, a part each. 6/8 over two + taps is 3 and 3; 3/4 over four is 3/4 each. + """ + bar, error = parse_time_signature(text) + if bar is None: + return None, error + if bar.assumed or bar.beat_count == beat_count or "(" in text: + return bar, "" + + value = bar.numerator / beat_count + if value.denominator > MAX_DENOMINATOR: + return None, ( + f"{format_units([bar.numerator])}/{bar.denominator} can't be shared " + f"between {beat_count} taps." + ) + return PlannedBar.from_values([value] * beat_count, bar.denominator), "" + + def group_runs(bars: list[int]) -> list[BarRun]: """Groups consecutive bars of equal length, e.g. for previewing a pattern.""" runs: list[BarRun] = [] @@ -391,6 +426,16 @@ def format_planned_bar(bar: PlannedBar) -> str: return f"{format_units([bar.numerator])}/{bar.denominator}({taps})" +def format_time_signature(bar: PlannedBar) -> str: + """A bar's time signature alone, such as "6/8" or "11/2/4".""" + return f"{format_units([bar.numerator])}/{bar.denominator}" + + +def format_taps(bar: PlannedBar) -> str: + """What each tap of a bar is worth, such as "3/2 3/2 1".""" + return " ".join(str(value) for value in bar.values) + + def format_planned_bars(bars: list[PlannedBar]) -> str: """ Writes bars as a pattern, collapsing runs of equal bars: three bars of diff --git a/tilia/ui/dialog_manager.py b/tilia/ui/dialog_manager.py index af7ab3652..a66d22dc3 100644 --- a/tilia/ui/dialog_manager.py +++ b/tilia/ui/dialog_manager.py @@ -26,7 +26,6 @@ ask_beat_timeline_fill_method, ask_for_bars, ask_for_beat_pattern, - ask_for_beat_unit, ) from tilia.ui.windows.beat_unit import ask_for_beat_unit_scope @@ -47,7 +46,6 @@ def __init__(self): (Get.FROM_USER_COLOR, ask_for_color), (Get.FROM_USER_BARS, ask_for_bars), (Get.FROM_USER_BEAT_PATTERN, ask_for_beat_pattern), - (Get.FROM_USER_BEAT_UNIT, ask_for_beat_unit), (Get.FROM_USER_BEAT_UNIT_SCOPE, ask_for_beat_unit_scope), (Get.FROM_USER_MEDIA_PATH, ask_for_media_file), (Get.FROM_USER_MODE_PARAMS, ask_for_mode_params), diff --git a/tilia/ui/strings.py b/tilia/ui/strings.py index 43157076d..30c675081 100644 --- a/tilia/ui/strings.py +++ b/tilia/ui/strings.py @@ -15,9 +15,11 @@ BEAT_PATTERN_TABLE_HEADERS = ["Bars", "Time signature", "Taps"] BEAT_PATTERN_TABLE_TOOLTIP = """Double-click a cell to change it: - Bars: their numbers, e.g. '9', or '1-8, 1-8' for a repeat; - - Time signature: e.g. '3/4'; + - Time signature: e.g. '6/8'. Tapped bars keep their taps, so 6/8 over + two taps is two dotted quarters; - Taps: what each tap is worth, in units of the denominator, e.g. '3/2 3/2 1'. Highlighted bars change when you press OK. Grey bars aren't tapped yet.""" +BEAT_PATTERN_ASSUMED_TIME_SIGNATURE = "{} (assumed)" BEAT_PATTERN_SUMMARY = "{} bar{}, then the list starts again from bar 1." BEAT_PATTERN_TAPPED = "{} beat{} tapped, in {} bar{}." BEAT_PATTERN_OVERWRITE_TITLE = "Overwrite bars set by hand" @@ -25,24 +27,21 @@ "This pattern will overwrite bars set by hand (bars {})." "\nDo you want to continue?" ) -BEAT_UNIT_DIALOG_TITLE = "Beat unit" -BEAT_UNIT_DENOMINATOR_LABEL = "Denominator:" -BEAT_UNIT_UNITS_LABEL = "Units per tap:" -BEAT_UNIT_UNITS_TOOLTIP = """How many denominator units each tapped beat is worth. -Separate taps in a measure with '+'. Examples: - - '1' = one unit per tap (4/4 tapped in 4); - - '3' = three units per tap (6/8 tapped in 2); - - '1/2' = half a unit per tap (2/2 tapped in 4); - - '2+3' = two units, then three (5/4 tapped as 2+3).""" +TIME_SIGNATURE_SCOPE_TITLE = "Time signature" +TIME_SIGNATURE_TAPS_ERROR = ( + "This measure has {} taps. To change them, use Edit bars and time signatures." +) BEAT_UNIT_SCOPE_ONWARD = "From this measure until the next change" BEAT_UNIT_SCOPE_MEASURE = "This measure only" BEAT_UNIT_SCOPE_PROMPT = "Where should this change apply?" BEAT_UNIT_CONFLICT_TOOLTIP = ( - "This measure has more than one beat unit. The first one applies." + "This measure has more than one time signature. The first one applies." +) +BEAT_UNIT_AMBIGUOUS_TOOLTIP = ( + "{} taps, where the time signature has a pattern of taps worth {}: read as {}." ) -BEAT_UNIT_AMBIGUOUS_TOOLTIP = "{} taps under {}: treated as {}." BEAT_UNIT_ASSUMED_TOOLTIP = ( - "Assumed from the tapped beats. Set the beat unit to confirm." + "Assumed from the tapped beats." " Edit bars and time signatures to set it." ) PROMPT_CREATE_LEVEL_BELOW_TITLE = "Create level below" PROMPT_CREATE_LEVEL_BELOW_MESSAGE = ( diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py index 88d92d46a..2104c0270 100644 --- a/tilia/ui/timelines/beat/beat_unit.py +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -6,8 +6,15 @@ from PySide6.QtGui import QGuiApplication, QPen from PySide6.QtWidgets import QGraphicsItem, QGraphicsLineItem, QGraphicsScene +import tilia.errors +import tilia.ui.strings from tilia.requests import Get, Post, get, post -from tilia.timelines.beat.units import MAX_DENOMINATOR, format_units, parse_units +from tilia.timelines.beat.pattern import ( + PlannedBar, + format_taps, + format_time_signature, + parse_time_signature_over_taps, +) from tilia.ui import commands from tilia.ui.coords import time_x_converter from tilia.ui.timelines.base.element import TimelineUIElement @@ -18,7 +25,7 @@ TimeSignatureLabel, TimeSignatureLabelSpec, ) -from tilia.ui.windows.beat_unit import UnitsValidator +from tilia.ui.windows.beat_pattern import BeatPatternValidator from tilia.ui.windows.inspect import InspectRowKind if TYPE_CHECKING: @@ -42,8 +49,10 @@ def attempt() -> None: class BeatUnitUI(TimelineUIElement): """ - A beat unit has no mark of its own: it is shown, and selected, through - the time signature labels of the measures it governs. + A beat unit, which gives the taps of the measures it governs their + values, has no mark of its own: it is shown, and selected, through the + time signature labels of those measures. The inspector edits the time + signature of the measure whose label was clicked. """ UPDATE_TRIGGERS = ["denominator", "units", "beat_id", "assumed"] @@ -51,22 +60,15 @@ class BeatUnitUI(TimelineUIElement): INSPECTOR_FIELDS = [ ( - "Denominator", - InspectRowKind.SPIN_BOX_ON_FINISH, - lambda: {"min": 1, "max": MAX_DENOMINATOR}, - ), - ( - "Units per tap", + "Time signature", InspectRowKind.SINGLE_LINE_EDIT_ON_FINISH, - lambda: {"validator": UnitsValidator}, + lambda: {"validator": BeatPatternValidator}, ), - ("Starts at measure", InspectRowKind.LABEL, None), + ("Taps", InspectRowKind.LABEL, None), + ("Measure", InspectRowKind.LABEL, None), ("Assumed", InspectRowKind.LABEL, None), ] - FIELD_NAMES_TO_ATTRIBUTES = { - "Denominator": "denominator", - "Units per tap": "units", - } + FIELD_NAMES_TO_ATTRIBUTES: dict[str, str] = {} def __init__( self, @@ -186,7 +188,7 @@ def on_left_click(self, item: QGraphicsItem) -> None: def on_double_left_click(self, item: QGraphicsItem) -> None: self._set_active_label(item) - commands.execute("timeline.beat.set_beat_unit") + commands.execute("timeline.beat.edit_bars") def on_right_click(self, x: int, y: int, item: QGraphicsItem) -> None: self._set_active_label(item) @@ -198,44 +200,66 @@ def start_measure_index(self) -> int: beat = timeline.get_component(self.get_data("beat_id")) return timeline.get_measure_index(timeline.get_beat_index(beat))[0] + @property + def inspected_measure_index(self) -> int: + """The measure whose label was clicked, or where the beat unit starts.""" + if self.active_measure_index is not None: + return self.active_measure_index + return self.start_measure_index + + def _get_inspected_bar(self) -> PlannedBar: + meter = self.timeline_ui.timeline.get_measure_meter( + self.inspected_measure_index + ) + return PlannedBar.from_values(list(meter.beat_values), meter.denominator) + def get_inspector_dict(self) -> dict: timeline = self.timeline_ui.timeline + bar = self._get_inspected_bar() return { - "Denominator": self.get_data("denominator"), - "Units per tap": self.get_data("units"), - "Starts at measure": str( - timeline.measure_numbers[self.start_measure_index] - ), + "Time signature": format_time_signature(bar), + "Taps": format_taps(bar), + "Measure": str(timeline.measure_numbers[self.inspected_measure_index]), "Assumed": "Yes" if self.get_data("assumed") else "No", } - def on_inspector_edit(self, field_name: str, value: int | str) -> None: + def on_inspector_edit(self, field_name: str, value: str) -> None: """ - Applies an inspector edit to the measure whose label was clicked, - asking first whether it applies there only or until the next change. + Sets the time signature of the measure whose label was clicked, over + its taps, asking first whether it applies there only or until the + next change. """ - attr = self.FIELD_NAMES_TO_ATTRIBUTES[field_name] - if attr == "units": - value = format_units(parse_units(value).units) - if value == self.get_data(attr) or self._pending_edit == (attr, value): + measure_index = self.inspected_measure_index + timeline = self.timeline_ui.timeline + if field_name != "Time signature" or measure_index >= timeline.measure_count: return - measure_index = ( - self.active_measure_index - if self.active_measure_index is not None - else self.start_measure_index - ) - self._pending_edit = (attr, value) + beat_count = timeline.beats_in_measure[measure_index] + bar, error = parse_time_signature_over_taps(value, beat_count) + if bar is not None and bar.beat_count != beat_count: + error = tilia.ui.strings.TIME_SIGNATURE_TAPS_ERROR.format(beat_count) + if bar is None or error: + tilia.errors.display(tilia.errors.INVALID_TIME_SIGNATURE, error) + self._refresh_inspector() + return + + edit = (bar.denominator, bar.units_text) + meter = timeline.get_measure_meter(measure_index) + current = PlannedBar.from_values(list(meter.beat_values), meter.denominator) + if bar == current or self._pending_edit == edit: + return + + self._pending_edit = edit # Editing often finishes because the user clicked elsewhere. The # scope question is modal, and opening it inside that mouse press # keeps the view from ever seeing the release (its selection box # stays up). Ask once the mouse is released instead. _call_once_mouse_is_released( - lambda: self._apply_inspector_edit(attr, value, measure_index) + lambda: self._apply_inspector_edit(*edit, measure_index) ) def _apply_inspector_edit( - self, attr: str, value: int | str, measure_index: int + self, denominator: int, units: str, measure_index: int ) -> None: try: if self.id not in self.timeline_ui.id_to_element: @@ -243,10 +267,6 @@ def _apply_inspector_edit( timeline = self.timeline_ui.timeline if measure_index >= timeline.measure_count: return - values = { - "denominator": self.get_data("denominator"), - "units": self.get_data("units"), - } | {attr: value} only_this_measure = False next_index = measure_index + 1 @@ -259,13 +279,8 @@ def _apply_inspector_edit( self._refresh_inspector() return - timeline.set_beat_unit( - measure_index, - values["denominator"], - values["units"], - only_this_measure, - ) - post(Post.APP_STATE_RECORD, "beat unit edit via inspect") + timeline.set_beat_unit(measure_index, denominator, units, only_this_measure) + post(Post.APP_STATE_RECORD, "time signature edit via inspect") self._refresh_inspector() finally: self._pending_edit = None diff --git a/tilia/ui/timelines/beat/context_menu.py b/tilia/ui/timelines/beat/context_menu.py index 8870a2836..e179c3f92 100644 --- a/tilia/ui/timelines/beat/context_menu.py +++ b/tilia/ui/timelines/beat/context_menu.py @@ -22,7 +22,7 @@ class BeatContextMenu(TimelineUIElementContextMenu): (MenuItemKind.COMMAND, "timeline.beat.reset_measure_number"), (MenuItemKind.COMMAND, "timeline.beat.distribute"), (MenuItemKind.COMMAND, "timeline.beat.set_amount_in_measure"), - (MenuItemKind.COMMAND, "timeline.beat.set_beat_unit"), + (MenuItemKind.COMMAND, "timeline.beat.edit_bars"), (MenuItemKind.SEPARATOR, None), (MenuItemKind.COMMAND, "timeline.component.copy"), (MenuItemKind.COMMAND, "timeline.component.cut"), @@ -33,9 +33,9 @@ class BeatContextMenu(TimelineUIElementContextMenu): class BeatUnitContextMenu(TimelineUIElementContextMenu): - name = "Beat unit" + name = "Time signature" items = [ - (MenuItemKind.COMMAND, "timeline.beat.set_beat_unit"), + (MenuItemKind.COMMAND, "timeline.beat.edit_bars"), (MenuItemKind.SEPARATOR, None), (MenuItemKind.COMMAND, "timeline.component.delete"), ] diff --git a/tilia/ui/timelines/beat/dialogs.py b/tilia/ui/timelines/beat/dialogs.py index be1cbabbe..6ff0b1f69 100644 --- a/tilia/ui/timelines/beat/dialogs.py +++ b/tilia/ui/timelines/beat/dialogs.py @@ -1,6 +1,5 @@ from tilia.timelines.beat.pattern import PlannedBar from tilia.ui.windows.beat_pattern import BarsEdit, BeatPatternDialog -from tilia.ui.windows.beat_unit import BeatUnitDialog, BeatUnitInput from tilia.ui.windows.fill_beat_timeline import BeatTimeline, FillBeatTimeline @@ -15,18 +14,15 @@ def ask_for_bars( beat_count: int, current_bars: list[PlannedBar], numbers: list[int], + focus_bar: int | None = None, ) -> tuple[bool, BarsEdit | None]: - """Asks for changes to the bars of a timeline that has beats.""" - return BeatPatternDialog.ask(initial_text, beat_count, current_bars, numbers) - - -def ask_for_beat_unit( - denominator: int, - units: str, - beat_count: int | None = None, - show_scope: bool = True, -) -> tuple[bool, BeatUnitInput | None]: - return BeatUnitDialog.ask(denominator, units, beat_count, show_scope) + """ + Asks for changes to the bars of a timeline that has beats, showing the + row of `focus_bar` first. + """ + return BeatPatternDialog.ask( + initial_text, beat_count, current_bars, numbers, focus_bar + ) def ask_beat_timeline_fill_method() -> tuple[ diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 48eb0dbbb..7a1db9291 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -133,10 +133,10 @@ def register_commands(cls, collection: TimelineUIs): cls.register_timeline_command( collection, - "set_beat_unit", - cls.on_set_beat_unit, + "edit_bars", + cls.on_edit_bars, TimelineSelector.SELECTED, - text="Set beat unit", + text="Edit bars and time signatures", ) cls.register_timeline_command( @@ -144,7 +144,7 @@ def register_commands(cls, collection: TimelineUIs): "remove_other_beat_units", cls.on_remove_other_beat_units, TimelineSelector.SELECTED, - text="Remove other beat units in this measure", + text="Keep only the first time signature in this measure", ) commands.register( @@ -338,8 +338,8 @@ def on_toggle_time_signatures(timeline_ui: BeatTimelineUI) -> bool: def _get_beat_unit_target_measure(self) -> int | None: """ - The measure a beat unit command applies to: the one whose label was - clicked, or else the measure of the first selected beat. + The measure a time signature command applies to: the one whose label + was clicked, or else the measure of the first selected beat. """ for element in self.selected_elements: if ( @@ -352,44 +352,34 @@ def _get_beat_unit_target_measure(self) -> int | None: return self._get_measure_indices(beat_uis)[0] return None - def on_set_beat_unit( - self, - measure_index: int | None = None, - denominator: int | None = None, - units: str | None = None, - only_this_measure: bool | None = None, - ) -> bool: - timeline = self.timeline + def on_edit_bars(self, measure_index: int | None = None) -> bool: + """ + Opens the bars and time signatures dialog at the measure of the + clicked label or selected beat. + """ if measure_index is None: measure_index = self._get_beat_unit_target_measure() - if measure_index is None or not 0 <= measure_index < timeline.measure_count: - return False + return self.edit_bars(measure_index) - if denominator is None or units is None: - meter = timeline.get_measure_meter(measure_index) - next_index = measure_index + 1 - # Both scopes do the same when the next measure starts its own - # beat unit, or there is none. - show_scope = ( - next_index < timeline.measure_count - and not timeline.get_measure_meter(next_index).starts_here - ) - accepted, result = get( - Get.FROM_USER_BEAT_UNIT, - meter.denominator, - format_units(meter.units), - timeline.beats_in_measure[measure_index], - show_scope, - ) - if not accepted: - return False - denominator, units = result.denominator, result.units - if only_this_measure is None: - only_this_measure = result.only_this_measure - - return timeline.set_beat_unit( - measure_index, denominator, units, bool(only_this_measure) + def edit_bars(self, measure_index: int | None = None) -> bool: + """ + Asks for the bars in a dialog that shows them as they are and what + changes, then remeasures the beats from it and numbers the measures. + """ + timeline = self.timeline + text, _ = timeline.get_bars_to_edit() + accepted, edit = get( + Get.FROM_USER_BARS, + text, + len(timeline), + timeline.get_measures_as_planned_bars(), + timeline.measure_numbers, + measure_index, ) + if not accepted or not timeline.set_beat_pattern(edit.pattern): + return False + timeline.set_measure_numbers(edit.numbers) + return True def on_remove_other_beat_units(self, measure_index: int | None = None) -> bool: """ @@ -477,32 +467,22 @@ def on_set_pattern( """ timeline = timeline_ui.timeline if pattern is None: - text, _ = timeline.get_bars_to_edit() - accepted, edit = get( - Get.FROM_USER_BARS, - text, - len(timeline), - timeline.get_measures_as_planned_bars(), - timeline.measure_numbers, - ) - if not accepted: - return False - pattern, numbers = edit.pattern, edit.numbers - else: - result = parse(pattern) - if not result.is_complete: - tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) - return False + return timeline_ui.edit_bars() - overwritten = timeline.get_bars_overwritten_by(result.bars) - if overwritten and not get( - Get.FROM_USER_YES_OR_NO, - BEAT_PATTERN_OVERWRITE_TITLE, - BEAT_PATTERN_OVERWRITE_PROMPT.format( - ", ".join(str(i + 1) for i in overwritten) - ), - ): - return False + result = parse(pattern) + if not result.is_complete: + tilia.errors.display(tilia.errors.INVALID_BEAT_PATTERN, result.error) + return False + + overwritten = timeline.get_bars_overwritten_by(result.bars) + if overwritten and not get( + Get.FROM_USER_YES_OR_NO, + BEAT_PATTERN_OVERWRITE_TITLE, + BEAT_PATTERN_OVERWRITE_PROMPT.format( + ", ".join(str(i + 1) for i in overwritten) + ), + ): + return False if not timeline.set_beat_pattern(pattern): return False diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index 84bb4fb74..3e6214805 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -29,9 +29,12 @@ from tilia.requests import Get, get from tilia.timelines.beat.pattern import ( PlannedBar, - format_planned_bar, format_planned_bars, + format_taps, + format_time_signature, parse, + parse_time_signature, + parse_time_signature_over_taps, ) from tilia.timelines.beat.units import parse_units @@ -157,12 +160,20 @@ def get_overrides(numbers: list[int]) -> dict[int, int]: } -def get_rows(bars: list[PlannedBar], tapped: int, numbers: list[int]) -> list[BarRow]: - """Groups consecutive bars that are alike and numbered one after another.""" +def get_rows( + bars: list[PlannedBar], + tapped: int, + numbers: list[int], + breaks: frozenset[int] = frozenset(), +) -> list[BarRow]: + """ + Groups consecutive bars that are alike and numbered one after another. + A bar whose index is in `breaks` starts a row of its own. + """ rows: list[BarRow] = [] for index, (bar, number) in enumerate(zip(bars, numbers, strict=True)): is_tapped = index < tapped - if rows: + if rows and index not in breaks: last = rows[-1] if ( last.bar == bar @@ -212,16 +223,6 @@ def parse_bar_numbers(text: str, count: int) -> tuple[list[int] | None, str]: return numbers, "" -def parse_time_signature(text: str) -> tuple[PlannedBar | None, str]: - """Reads one bar of the pattern syntax, such as "6/8" or "6/8(3 3)".""" - result = parse(text) - if not result.is_complete: - return None, result.error - if len(result.planned_bars) != 1: - return None, "Enter one time signature, such as 3/4." - return result.planned_bars[0], "" - - def parse_taps(text: str, denominator: int) -> tuple[PlannedBar | None, str]: """Reads what each tap of a bar is worth, such as "3/2 3/2 1".""" result = parse_units("+".join(text.replace("+", " ").split())) @@ -230,8 +231,11 @@ def parse_taps(text: str, denominator: int) -> tuple[PlannedBar | None, str]: return PlannedBar.from_values(result.units, denominator), "" -def format_taps(bar: PlannedBar) -> str: - return " ".join(str(value) for value in bar.values) +def format_time_signature_cell(bar: PlannedBar) -> str: + text = format_time_signature(bar) + if bar.assumed: + return tilia.ui.strings.BEAT_PATTERN_ASSUMED_TIME_SIGNATURE.format(text) + return text class BeatPatternDialog(QDialog): @@ -241,10 +245,12 @@ def __init__( beat_count: int = 0, current_bars: list[PlannedBar] | None = None, numbers: list[int] | None = None, + focus_bar: int | None = None, ) -> None: """ `current_bars` are the bars the timeline has, and `numbers` their - numbers; rows of bars that would change are highlighted. + numbers; rows of bars that would change are highlighted. The table + starts scrolled to the row of `focus_bar`. """ super().__init__( get(Get.MAIN_WINDOW), @@ -256,6 +262,11 @@ def __init__( self._overrides = get_overrides(numbers or []) self._planned: list[PlannedBar] = [] self._rows: list[BarRow] = [] + # The bar the dialog was opened at has a row of its own, so it can + # be edited apart from the alike bars around it. + self._row_breaks = ( + frozenset() if focus_bar is None else frozenset({focus_bar, focus_bar + 1}) + ) self._is_updating_table = False self.setLayout(QVBoxLayout()) @@ -311,6 +322,8 @@ def __init__( self.line_edit.textChanged.connect(self._on_text_changed) self._on_text_changed(initial_text) + if focus_bar is not None: + self.focus_bar(focus_bar) @property def ok_button(self) -> QPushButton: @@ -340,6 +353,20 @@ def get_highlighted_rows(self) -> set[int]: if self.table.item(row, 0).background().color() == CHANGED_COLOR } + def focus_bar(self, bar_index: int) -> None: + """Scrolls to the row of a bar and makes it the current one.""" + for row_index, row in enumerate(self._rows): + if row.start <= bar_index < row.start + row.count: + self.table.setCurrentCell(row_index, TIME_SIGNATURE_COLUMN) + self.table.scrollToItem( + self.table.item(row_index, TIME_SIGNATURE_COLUMN) + ) + return + + @property + def current_row(self) -> int: + return self.table.currentRow() + def get_result(self) -> BarsEdit: _, tapped = lay_out(self._planned, self._beat_count) return BarsEdit(self.text, get_numbers(tapped, self._overrides)) @@ -367,14 +394,14 @@ def _on_text_changed(self, text: str) -> None: def _update_table(self) -> None: bars, tapped = lay_out(self._planned, self._beat_count) numbers = get_numbers(len(bars), self._overrides) - self._rows = get_rows(bars, tapped, numbers) + self._rows = get_rows(bars, tapped, numbers, self._row_breaks) self._is_updating_table = True self.table.setRowCount(len(self._rows)) for row_index, row in enumerate(self._rows): texts = [ format_bar_numbers(row.numbers), - format_planned_bar(row.bar), + format_time_signature_cell(row.bar), format_taps(row.bar), ] is_changed = self._is_changed(row) @@ -431,7 +458,10 @@ def _on_item_changed(self, item: QTableWidgetItem) -> None: self._edit_numbers(row, item.text()) return - if item.column() == TIME_SIGNATURE_COLUMN: + if item.column() == TIME_SIGNATURE_COLUMN and row.is_tapped: + # A tapped bar keeps its taps, unless the text gives others. + bar, error = parse_time_signature_over_taps(item.text(), row.bar.beat_count) + elif item.column() == TIME_SIGNATURE_COLUMN: bar, error = parse_time_signature(item.text()) else: bar, error = parse_taps(item.text(), row.bar.denominator) @@ -473,8 +503,9 @@ def ask( beat_count: int = 0, current_bars: list[PlannedBar] | None = None, numbers: list[int] | None = None, + focus_bar: int | None = None, ) -> tuple[bool, BarsEdit | None]: - dialog = cls(initial_text, beat_count, current_bars, numbers) + dialog = cls(initial_text, beat_count, current_bars, numbers, focus_bar) if dialog.exec() == QDialog.DialogCode.Accepted: return True, dialog.get_result() return False, None diff --git a/tilia/ui/windows/beat_unit.py b/tilia/ui/windows/beat_unit.py index 8c40ed091..33fd265d1 100644 --- a/tilia/ui/windows/beat_unit.py +++ b/tilia/ui/windows/beat_unit.py @@ -1,204 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass -from fractions import Fraction - -from PySide6.QtCore import Qt -from PySide6.QtGui import QValidator -from PySide6.QtWidgets import ( - QButtonGroup, - QDialog, - QDialogButtonBox, - QFormLayout, - QLineEdit, - QMessageBox, - QPushButton, - QRadioButton, - QSpinBox, - QVBoxLayout, -) +from PySide6.QtWidgets import QMessageBox import tilia.ui.strings from tilia.requests import Get, get -from tilia.timelines.beat.units import ( - MAX_DENOMINATOR, - fit_units, - format_units, - is_fit_ambiguous, - parse_units, -) -from tilia.ui.format import format_numerator -from tilia.ui.windows.pattern_preview import PatternPreview, PreviewContent - -_UNITS_CHARACTERS = set("0123456789/+") - - -@dataclass(frozen=True) -class BeatUnitInput: - denominator: int - units: str - only_this_measure: bool - - -class UnitsValidator(QValidator): - """Blocks characters that can never be part of units; see parse_units.""" - - def validate(self, text: str, pos: int) -> tuple[QValidator.State, str, int]: - if any(c not in _UNITS_CHARACTERS and not c.isspace() for c in text): - return QValidator.State.Invalid, text, pos - if parse_units(text).is_valid: - return QValidator.State.Acceptable, text, pos - return QValidator.State.Intermediate, text, pos - - -def get_units_preview( - denominator: int, units_text: str, beat_count: int | None -) -> PreviewContent: - """ - What each tap in the units pattern is worth, with a footer on how the - pattern repeats and the time signature the measure would get. - """ - result = parse_units(units_text) - if not result.is_valid: - return PreviewContent(error=result.error) - - rows = [ - ( - str(index + 1), - format_numerator(unit), - _describe_quarters(unit * 4 / denominator), - ) - for index, unit in enumerate(result.units) - ] - if len(result.units) == 1: - lines = ["Every tap is worth the same."] - else: - lines = [f"Repeats every {len(result.units)} taps, from tap 1 in each measure."] - if beat_count is not None: - values = fit_units(result.units, beat_count) - numerator = format_numerator(sum(values, Fraction(0))) - # "4 1/3/4" would be unreadable. - separator = "/" if numerator.isdigit() else " over " - lines.append( - f"This measure ({beat_count} tap{'' if beat_count == 1 else 's'}) " - f"becomes {numerator}{separator}{denominator}." - ) - if is_fit_ambiguous(result.units, beat_count): - lines.append( - tilia.ui.strings.BEAT_UNIT_AMBIGUOUS_TOOLTIP.format( - beat_count, format_units(result.units), format_units(values) - ) - ) - return PreviewContent( - headers=["Tap", f"Units (1/{denominator})", "Length"], - rows=rows, - footer="\n".join(lines), - ) - - -def _describe_quarters(quarters: Fraction) -> str: - return f"{format_numerator(quarters)} quarter" + ("s" if quarters > 1 else "") - - -class BeatUnitDialog(QDialog): - def __init__( - self, - denominator: int, - units: str, - beat_count: int | None = None, - show_scope: bool = True, - ) -> None: - super().__init__( - get(Get.MAIN_WINDOW), - Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint, - ) - self.setWindowTitle(tilia.ui.strings.BEAT_UNIT_DIALOG_TITLE) - self._beat_count = beat_count - - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(5, 5, 5, 5) - - form = QFormLayout() - self.denominator_edit = QSpinBox() - self.denominator_edit.setRange(1, MAX_DENOMINATOR) - self.denominator_edit.setValue(denominator) - self.units_edit = QLineEdit(units) - self.units_edit.setValidator(UnitsValidator(self.units_edit)) - self.units_edit.setToolTip(tilia.ui.strings.BEAT_UNIT_UNITS_TOOLTIP) - form.addRow(tilia.ui.strings.BEAT_UNIT_DENOMINATOR_LABEL, self.denominator_edit) - form.addRow(tilia.ui.strings.BEAT_UNIT_UNITS_LABEL, self.units_edit) - - self.preview = PatternPreview(self) - - self.onward_option = QRadioButton(tilia.ui.strings.BEAT_UNIT_SCOPE_ONWARD) - self.measure_option = QRadioButton(tilia.ui.strings.BEAT_UNIT_SCOPE_MEASURE) - self._scope = QButtonGroup(self) - self._scope.addButton(self.onward_option) - self._scope.addButton(self.measure_option) - self.onward_option.setChecked(True) - self.onward_option.setVisible(show_scope) - self.measure_option.setVisible(show_scope) - - self.button_box = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, - self, - ) - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.reject) - - self.layout().addLayout(form) - self.layout().addWidget(self.preview) - self.layout().addWidget(self.onward_option) - self.layout().addWidget(self.measure_option) - self.layout().addWidget(self.button_box) - - self.denominator_edit.valueChanged.connect(self._on_input_changed) - self.units_edit.textChanged.connect(self._on_input_changed) - self._on_input_changed() - - @property - def ok_button(self) -> QPushButton: - return self.button_box.button(QDialogButtonBox.StandardButton.Ok) - - @property - def result_input(self) -> BeatUnitInput: - return BeatUnitInput( - self.denominator_edit.value(), - format_units(parse_units(self.units_edit.text()).units), - self.measure_option.isChecked(), - ) - - def _on_input_changed(self, *_) -> None: - self.preview.set_content( - get_units_preview( - self.denominator_edit.value(), - self.units_edit.text(), - self._beat_count, - ) - ) - self.ok_button.setEnabled(parse_units(self.units_edit.text()).is_valid) - - @classmethod - def ask( - cls, - denominator: int, - units: str, - beat_count: int | None = None, - show_scope: bool = True, - ) -> tuple[bool, BeatUnitInput | None]: - dialog = cls(denominator, units, beat_count, show_scope) - if dialog.exec() == QDialog.DialogCode.Accepted: - return True, dialog.result_input - return False, None def ask_for_beat_unit_scope() -> tuple[bool, bool]: """ - Asks where an edit made in the inspector applies. Returns whether the - user answered, and whether the edit applies to this measure only. + Asks where a time signature edited in the inspector applies. Returns + whether the user answered, and whether the edit applies to this measure + only. """ box = QMessageBox(get(Get.MAIN_WINDOW)) - box.setWindowTitle(tilia.ui.strings.BEAT_UNIT_DIALOG_TITLE) + box.setWindowTitle(tilia.ui.strings.TIME_SIGNATURE_SCOPE_TITLE) box.setText(tilia.ui.strings.BEAT_UNIT_SCOPE_PROMPT) onward = box.addButton( tilia.ui.strings.BEAT_UNIT_SCOPE_ONWARD, QMessageBox.ButtonRole.AcceptRole diff --git a/tilia/ui/windows/pattern_preview.py b/tilia/ui/windows/pattern_preview.py deleted file mode 100644 index 52bebd5f8..000000000 --- a/tilia/ui/windows/pattern_preview.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -A scrollable table previewing what a typed pattern expands to, with a footer -about how it repeats. Used by the beat pattern and beat unit dialogs. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor -from PySide6.QtWidgets import ( - QAbstractItemView, - QHeaderView, - QLabel, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -# The table scrolls past this height rather than growing the dialog. -MAX_TABLE_HEIGHT = 160 -HIGHLIGHT_COLOR = QColor("#ffe2b3") - - -@dataclass(frozen=True) -class PreviewContent: - headers: list[str] = field(default_factory=list) - rows: list[tuple[str, ...]] = field(default_factory=list) - footer: str = "" - # Shown instead of the table, e.g. while the pattern is half-typed. - error: str = "" - highlighted_rows: frozenset[int] = frozenset() - - -class PatternPreview(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - - self.table = QTableWidget(self) - self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) - self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) - self.table.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.table.verticalHeader().setVisible(False) - self.table.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeMode.Stretch - ) - self.table.setMaximumHeight(MAX_TABLE_HEIGHT) - - self.footer = QLabel(self) - self.footer.setTextFormat(Qt.TextFormat.PlainText) - self.footer.setWordWrap(True) - - self.error = QLabel(self) - self.error.setTextFormat(Qt.TextFormat.PlainText) - self.error.setWordWrap(True) - - self.layout().addWidget(self.table) - self.layout().addWidget(self.footer) - self.layout().addWidget(self.error) - - def set_content(self, content: PreviewContent) -> None: - self.error.setText(content.error) - self.error.setVisible(bool(content.error)) - self.table.setVisible(not content.error) - self.footer.setVisible(not content.error) - if content.error: - return - - self.table.clear() - self.table.setColumnCount(len(content.headers)) - self.table.setHorizontalHeaderLabels(content.headers) - self.table.setRowCount(len(content.rows)) - for row_index, row in enumerate(content.rows): - for column_index, text in enumerate(row): - item = QTableWidgetItem(text) - if row_index in content.highlighted_rows: - item.setBackground(HIGHLIGHT_COLOR) - self.table.setItem(row_index, column_index, item) - self.footer.setText(content.footer) - - def rows(self) -> list[tuple[str, ...]]: - return [ - tuple( - self.table.item(row, column).text() - for column in range(self.table.columnCount()) - ) - for row in range(self.table.rowCount()) - ] - - def highlighted_rows(self) -> set[int]: - return { - row - for row in range(self.table.rowCount()) - if self.table.item(row, 0).background().color() == HIGHLIGHT_COLOR - } From b08bba4828f620987696683fde48c9554b82742b Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:52:42 +0100 Subject: [PATCH 37/56] fix: let a time signature set by hand carry on while tapping A measure tapped after the last was given the pattern's time signature whenever it differed from the one in force, so a time signature set by hand from some measure on stopped at the next measure tapped. Now a new measure gets a beat unit only where the pattern's own time signature changes, as a beat unit set by hand applies until the next change. --- tests/ui/timelines/beat/test_beat_timeline_ui.py | 9 +++++++++ tilia/timelines/beat/timeline.py | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 3599aa0da..af56e3b62 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -666,6 +666,15 @@ def test_tapping_past_the_pattern_starts_it_again(self, tluis): assert timeline.beats_in_measure == [4, 3, 4, 3] assert time_signatures(timeline) == [("4", 4), ("3", 4), ("4", 4), ("3", 4)] + def test_a_time_signature_set_by_hand_carries_on_while_tapping(self, tluis): + add_beat_timeline_with_pattern("2/4") + add_beats(range(4)) + set_beat_unit(tluis[0], 1, 8, "3") + + add_beats(range(4, 6)) + + assert time_signatures(tluis[0].timeline) == [("2", 4), ("6", 8), ("6", 8)] + def test_numbers_of_beats_alone_are_assumed(self, tluis): add_beat_timeline_with_pattern("3") add_beats(range(6)) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 6928f9569..abea03472 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -968,7 +968,8 @@ def lay_planned_beat_units(self, from_measure: int) -> None: """ Gives the measures from `from_measure` on the beat units the pattern plans for them, replacing theirs: one on the first beat of each - measure whose planned time signature differs from the one before. + measure where the pattern's time signature changes. Where it + doesn't, a time signature set on an earlier measure carries on. Does nothing when the pattern gives no time signatures. """ measures = self.measures @@ -977,8 +978,8 @@ def lay_planned_beat_units(self, from_measure: int) -> None: previous = None if from_measure: - meter = self.get_measure_meter(from_measure - 1) - previous = (meter.denominator, meter.units, meter.is_assumed) + bar = self.get_planned_bar(from_measure - 1) + previous = (bar.denominator, bar.units, bar.assumed) beats = self.components planned: dict[int | str, tuple[int, str, bool]] = {} From 11910af23fbcb3f19ec74da8ec1f51ce15c166a3 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:09:02 +0100 Subject: [PATCH 38/56] fix: refuse a stray number or an oversized numerator in a pattern at once - After an added numerator, anything but "/" now makes the pattern invalid: "3+2 4/8" said "Enter a denominator after '/'", though no typing could complete it. "3+2" alone still waits, asking for "/" and a denominator. - A numerator above the beats a bar can have is refused before its taps are listed. The validator parses on every keystroke, so "999999999/4" built a list of a billion taps before saying it was too many. --- tests/timelines/beat/test_pattern.py | 17 +++++++++++++++++ tilia/timelines/beat/pattern.py | 15 +++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/timelines/beat/test_pattern.py b/tests/timelines/beat/test_pattern.py index d2ee5e127..f7f139850 100644 --- a/tests/timelines/beat/test_pattern.py +++ b/tests/timelines/beat/test_pattern.py @@ -325,3 +325,20 @@ def test_more_than_one_bar_is_refused(self): None, "Enter one time signature, such as 3/4.", ) + + +class TestReviewedEdgeCases: + def test_a_number_after_an_added_numerator_is_unexpected(self): + result = parse("3+2 4/8") + assert result.status == ParseStatus.INVALID + assert result.error == "Unexpected '4'." + + def test_an_added_numerator_waits_for_its_denominator(self): + result = parse("3+2") + assert result.status == ParseStatus.INCOMPLETE + assert result.error == "Enter '/' and a denominator." + + @pytest.mark.timeout(1) + def test_a_huge_numerator_is_refused_before_its_taps_are_listed(self): + result = parse("999999999/4") + assert result.error == f"Bars can have at most {MAX_BEATS_PER_BAR} beats." diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py index 4c74aa83a..4738d39ef 100644 --- a/tilia/timelines/beat/pattern.py +++ b/tilia/timelines/beat/pattern.py @@ -275,8 +275,10 @@ def parse_time_signature(self, start: int, first: int) -> PlannedBar: parts.append(Fraction(self.expect_int("Enter a number after '+'."))) self.skip_whitespace() continue + if self.at_end(): + raise _Incomplete("Enter '/' and a denominator.", self.pos) if not self.at("/"): - raise _Incomplete("Enter a denominator after '/'.", self.pos) + raise _Invalid(f"Unexpected '{self.peek()}'.", self.pos) self.pos += 1 number = self.expect_int("Enter a denominator after '/'.") # A second "/" makes the number a part's denominator: the last @@ -312,10 +314,15 @@ def parse_time_signature(self, start: int, first: int) -> PlannedBar: self.pos = taps_start if len(parts) > 1: values = parts - elif parts[0].denominator == 1: - values = [Fraction(1)] * int(parts[0]) - else: + elif parts[0].denominator != 1: raise _Invalid("Give the taps of a fractional numerator.", start) + elif parts[0] > MAX_BEATS_PER_BAR: + # Checked before the taps are listed, as they may be many. + raise _Invalid( + f"Bars can have at most {MAX_BEATS_PER_BAR} beats.", start + ) + else: + values = [Fraction(1)] * int(parts[0]) if len(values) > MAX_BEATS_PER_BAR: raise _Invalid(f"Bars can have at most {MAX_BEATS_PER_BAR} beats.", start) From a448d17714a60794339b5b241e6e60be2fbd3478 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:09:05 +0100 Subject: [PATCH 39/56] fix: give beats imported from CSV the pattern's time signatures A CSV of times alone made its measures from the beat pattern but left out the pattern's time signatures, so with a pattern of 6/8 tapped in two the measures showed an assumed 2/4. The planned beat units are now laid as when tapping; a row's own denominator and units still replace the one on its beat. --- tests/parsers/csv/test_beats_from_csv.py | 9 +++++++++ tilia/parsers/csv/beat.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 518b0933a..4e3eb6903 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -188,3 +188,12 @@ def test_values_from_the_file_are_not_assumed(self, beat_tl): (beat_unit,) = beat_tl.beat_units assert (beat_unit.denominator, beat_unit.units) == (4, "1") assert not beat_unit.assumed + + +def test_times_alone_follow_the_patterns_time_signatures(beat_tl): + beat_tl.beat_pattern = "6/8(3 3)" + + _import_with_patch(beat_tl, "time\n0\n1\n2\n3") + + assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)] + assert not any(m.is_assumed for m in beat_tl.measure_meters) diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index 7709fcd05..860ebc144 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -163,6 +163,9 @@ def check_params() -> bool: created.append((component, row)) else: errors.append(fail_reason) + # The time signatures the beat pattern gives, if any. A row's own + # beat unit, below, replaces the one on its beat. + timeline.lay_planned_beat_units(0) # Gives the first beat its default beat unit, which a row of the file can # then edit. timeline.refresh_measures() From 180410d6dea635ced8c8fe707877d94fc983e662 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:09:21 +0100 Subject: [PATCH 40/56] fix: edit tapped bars apart from the pattern, and other review fixes - The bars dialog kept one list for the pattern and the bars. Pressing OK wrote every bar into the pattern, so a pickup or a bar set by hand came back each time tapping went past the end of the pattern. The dialog now starts from the timeline's pattern as written. Editing a row of tapped bars changes those bars only, and editing the pattern or a row of bars not tapped yet changes the pattern. OK lays the bars over the beats with `BeatTimeline.set_bars`. Beats left over follow the pattern from the bar after the last. - Because bars no longer go through pattern text, an assumed time signature keeps its denominator; an assumed 3/8 pickup was written as "1" and came back as 1/4. A bar of more than 1000 beats no longer makes the dialog's text invalid either. - "Set amount in measure" kept the count of a last measure still being tapped, so the beats left over started new measures and a short bar stayed in the middle. That measure can now fill up to the beats the pattern plans for it, and measures made from the pattern get its time signatures. - A beat added before a pickup (measure 0) joins it, rather than starting a second measure numbered 1. - Time signature labels are drawn once a bulk change ends, not once per beat unit created during it. Setting a pattern with a time signature change in every bar took 39 s for 600 beats; it now takes about 3 s for 4000. - Undo and redo restore beats and beat units by id, editing those that changed rather than deleting and recreating them. Finding the beat unit on a beat and checking a new beat's time no longer scan every component. - A bar number range is counted before it is listed, so a huge one is refused at once. --- tests/timelines/beat/test_beat_timeline.py | 13 ++ .../timelines/beat/test_beat_timeline_ui.py | 75 ++++++- tests/ui/windows/test_beat_pattern_dialog.py | 74 ++++++- tilia/timelines/beat/timeline.py | 199 +++++++++++++----- tilia/ui/timelines/beat/timeline.py | 11 +- tilia/ui/windows/beat_pattern.py | 110 +++++++--- 6 files changed, 374 insertions(+), 108 deletions(-) diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index b4729ca28..57f2f6a78 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -56,6 +56,19 @@ def test_create_beat_before_the_first_starts_measure_zero(self, beat_tlui): assert beat_tlui.timeline.beats_in_measure == [1, 3] assert beat_tlui.timeline.measure_numbers == [0, 1] + def test_create_beat_before_a_pickup_joins_it(self, beat_tlui): + for time in [10, 11, 12, 13, 14]: + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + commands.execute("media.seek", 9) + commands.execute("timeline.beat.add") + + commands.execute("media.seek", 8) + commands.execute("timeline.beat.add") + + assert beat_tlui.timeline.beats_in_measure == [2, 4, 1] + assert beat_tlui.timeline.measure_numbers == [0, 1, 2] + def test_recalculate_measures_added_one_measure(self, beat_tl): beat_tl.create_beat(time=1) beat_tl.create_beat(time=2) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index af56e3b62..e08d32ae7 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -537,9 +537,10 @@ def record(dialog): ("2", "3/4", "1 1 1"), ("3", "4/4", "1 1 1 1"), ] - assert seen["text"] == "4/4 3/4 4/4" + assert seen["text"] == "4/4" # Pressing OK on what is shown changes nothing. assert tluis[0].timeline.beats_in_measure == [4, 3, 4] + assert tluis[0].timeline.beat_pattern == "4/4" def test_correcting_a_bar_moves_the_later_bars(self, tluis): add_beat_timeline_with_pattern("4/4") @@ -556,7 +557,7 @@ def test_correcting_a_bar_moves_the_later_bars(self, tluis): timeline = tluis[0].timeline assert timeline.beats_in_measure == [4, 4, 4, 1] - assert timeline.beat_pattern == "3[4/4]" + assert timeline.beat_pattern == "4/4" def test_changing_a_time_signature(self, tluis): add_beat_timeline_with_pattern("4/4") @@ -569,9 +570,12 @@ def edit(dialog): with undoable(): self.edit_bars(tluis[0], edit) + # The three bars now take two beats each, and the beats left over + # make bars from the pattern. timeline = tluis[0].timeline - assert timeline.beats_in_measure == [2, 2, 2, 2, 2, 2] - assert time_signatures(timeline) == [("6", 8)] * 6 + assert timeline.beats_in_measure == [2, 2, 2, 4, 2] + # The last bar has two of its four beats so far. + assert time_signatures(timeline) == [("6", 8)] * 3 + [("4", 4), ("2", 4)] def test_numbering_a_repeat(self, tluis, tmp_path): add_beat_timeline_with_pattern("4/4") @@ -616,6 +620,21 @@ def record(dialog): assert seen["tapped"] == [True, False, False] assert seen["text"] == "2[4/4] 3/4 2/4" + def test_ok_without_changes_keeps_the_pattern_for_later_bars(self, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(1, 9)) + # A pickup, which the pattern doesn't plan. + add_beats([0.5]) + assert tluis[0].timeline.beats_in_measure == [1, 4, 4] + + self.edit_bars(tluis[0], lambda dialog: None) + + timeline = tluis[0].timeline + assert timeline.beat_pattern == "4/4" + assert timeline.beats_in_measure == [1, 4, 4] + add_beats(range(9, 17)) + assert timeline.beats_in_measure == [1, 4, 4, 4, 4] + def test_assumed_time_signatures_stay_assumed(self, tluis): add_beat_timeline_with_pattern("4") add_beats(range(8)) @@ -1238,6 +1257,35 @@ def test_distribute_beats_on_last_measure(self, beat_tlui): class TestSetBeatAmountInMeasure: + def test_the_measure_being_tapped_can_still_fill_up(self, beat_tlui): + add_beats(range(10)) + assert beat_tlui.timeline.beats_in_measure == [4, 4, 2] + beat_tlui.select_element(beat_tlui[0]) + + commands.execute("timeline.beat.set_amount_in_measure", amount=3) + + assert beat_tlui.timeline.beats_in_measure == [3, 4, 3] + add_beats(range(10, 13)) + assert beat_tlui.timeline.beats_in_measure == [3, 4, 4, 2] + assert beat_tlui.timeline.get_hand_set_bars() == [0] + + def test_measures_made_from_the_pattern_get_its_time_signatures(self, tluis): + add_beat_timeline_with_pattern("2/4 6/8(3 3)") + add_beats(range(8)) + tluis[0].select_element(tluis[0][0]) + + commands.execute("timeline.beat.set_amount_in_measure", amount=1) + + timeline = tluis[0].timeline + assert timeline.beats_in_measure == [1, 2, 2, 2, 1] + assert time_signatures(timeline) == [ + ("1", 4), + ("6", 8), + ("2", 4), + ("6", 8), + ("1", 4), + ] + def test_set_beat_amount_in_measure(self, beat_tlui): beat_tlui.create_beat(0) @@ -2980,3 +3028,22 @@ def test_tapping_on_after_reopening(self, tluis, tmp_path): assert snapshot["bars"] == [2, 3, 2] assert snapshot["time_signatures"] == [(2, 4), (3, 8), (2, 4)] assert snapshot["stamps"][-1] == F(9, 2) + + +class TestManyBeats: + @pytest.mark.timeout(15) + def test_setting_a_pattern_with_changing_time_signatures(self, tluis, tilia_state): + tilia_state.set_duration(10000, scale_timelines="no") + add_beat_timeline_with_pattern("4") + with patch_fill_beat_timeline_dialog( + tluis[0].timeline, BeatTimeline.FillMethod.BY_AMOUNT, 2000 + ): + commands.execute("timeline.beat.fill") + + with undoable(): + commands.execute( + "timeline.beat.set_pattern", tluis[0], pattern="2/4 3/8(3)" + ) + + # A beat unit on every measure: 666 pairs and one more 2/4. + assert len(tluis[0].timeline.beat_units) == 1333 diff --git a/tests/ui/windows/test_beat_pattern_dialog.py b/tests/ui/windows/test_beat_pattern_dialog.py index 7b8b2ef5b..ed66e5145 100644 --- a/tests/ui/windows/test_beat_pattern_dialog.py +++ b/tests/ui/windows/test_beat_pattern_dialog.py @@ -62,13 +62,28 @@ def test_state(self, text, state): class TestLayOut: def test_without_beats(self): - assert lay_out(bars("4/4 3/4"), 0) == (bars("4/4 3/4"), 0) + assert lay_out([], bars("4/4 3/4"), 0) == (bars("4/4 3/4"), 0) def test_beats_fill_part_of_the_pattern(self): - assert lay_out(bars("4/4 3/4 2/4"), 5) == (bars("4/4 3/4 2/4"), 2) + assert lay_out([], bars("4/4 3/4 2/4"), 5) == (bars("4/4 3/4 2/4"), 2) def test_beats_go_past_the_pattern(self): - assert lay_out(bars("4/4 3/4"), 12) == (bars("4/4 3/4 4/4 3/4"), 4) + assert lay_out([], bars("4/4 3/4"), 12) == (bars("4/4 3/4 4/4 3/4"), 4) + + def test_bars_come_first(self): + assert lay_out(bars("2/4 2/4"), bars("4/4 3/4 6/8"), 6) == ( + bars("2/4 2/4") + bars("6/8"), + 3, + ) + + def test_bars_past_the_beats_go(self): + assert lay_out(bars("4/4 4/4 4/4"), bars("3/4"), 6) == (bars("4/4 4/4"), 2) + + def test_the_pattern_goes_on_from_the_bar_after_the_last(self): + assert lay_out(bars("5/4"), bars("4/4 3/4"), 12) == ( + bars("5/4 3/4 4/4"), + 3, + ) class TestBarNumbers: @@ -106,6 +121,13 @@ def test_valid(self, text, count, numbers): def test_invalid(self, text, count, error): assert parse_bar_numbers(text, count) == (None, error) + @pytest.mark.timeout(1) + def test_a_huge_range_is_counted_not_listed(self): + assert parse_bar_numbers("1-999999999", 3) == ( + None, + "'1-999999999' numbers 999999999 bars, not 3 bars.", + ) + class TestTable: def test_rows_group_alike_bars(self, qtui): @@ -201,15 +223,19 @@ def test_time_signature(self, qtui): assert dialog.text == "4[6/8(3 3)] 3/4" - def test_editing_a_row_of_a_repeated_pattern(self, qtui): + def test_editing_tapped_bars_leaves_the_pattern(self, qtui): dialog = BeatPatternDialog("4/4", beat_count=12) edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "1 1 1") - # The three bars edited become the pattern, which the twelve beats - # go through again for a fourth bar. - assert dialog.text == "3[3/4]" - assert dialog.get_table_rows() == [("1–4", "3/4", "1 1 1")] + # The three bars edited keep what they now say. The beats left over + # make a bar from the pattern, which bars tapped later follow too. + assert dialog.text == "4/4" + assert dialog.get_table_rows() == [ + ("1–3", "3/4", "1 1 1"), + ("4", "4/4", "1 1 1 1"), + ] + assert dialog.get_result().bars == bars("3[3/4] 4/4") def test_a_tapped_bar_keeps_its_taps(self, qtui): dialog = BeatPatternDialog("2/4", beat_count=4) @@ -217,14 +243,15 @@ def test_a_tapped_bar_keeps_its_taps(self, qtui): edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "6/8") assert dialog.get_table_rows() == [("1–2", "6/8", "3 3")] - assert dialog.text == "2[6/8(3 3)]" + assert dialog.text == "2/4" def test_a_tapped_bar_takes_the_taps_given(self, qtui): dialog = BeatPatternDialog("2/4", beat_count=4) edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "6/8(1 1 1 1 1 1)") - assert dialog.text == "2[6/8]" + # One bar of six taps takes all four beats. + assert dialog.get_table_rows() == [("1", "6/8", "1 1 1 1 1 1")] def test_a_bar_not_tapped_yet_takes_the_time_signatures_taps(self, qtui): dialog = BeatPatternDialog("2/4 2/4", beat_count=2) @@ -238,9 +265,34 @@ def test_confirming_an_assumed_time_signature(self, qtui): edit_bars_dialog_cell(dialog, 0, TIME_SIGNATURE_COLUMN, "4/4") - assert dialog.text == "2[4/4]" + assert dialog.text == "4" assert not dialog.rows[0].bar.assumed + def test_starts_from_the_bars_as_they_are(self, qtui): + current = bars("4/4 5/4 4/4") + dialog = BeatPatternDialog("4/4", beat_count=13, current_bars=current) + + assert [row[1] for row in dialog.get_table_rows()] == ["4/4", "5/4", "4/4"] + assert dialog.get_highlighted_rows() == set() + assert dialog.get_result().bars == current + + def test_editing_the_pattern_lays_it_over_every_beat(self, qtui): + current = bars("4/4 5/4 4/4") + dialog = BeatPatternDialog("4/4", beat_count=13, current_bars=current) + + dialog.line_edit.setText("4/4 ") + + assert dialog.get_table_rows()[0][0] == "1–4" + assert dialog.get_highlighted_rows() == {0} + + def test_an_assumed_time_signature_keeps_its_denominator(self, qtui): + current = [PlannedBar.from_values([F(3)], 8, assumed=True)] + bars("6/8(3 3)") + dialog = BeatPatternDialog("6/8(3 3)", beat_count=3, current_bars=current) + + assert dialog.get_table_rows()[0][1] == "3/8 (assumed)" + assert dialog.get_highlighted_rows() == set() + assert dialog.get_result().bars == current + def test_invalid_cell_is_reported_and_reverted(self, qtui): dialog = BeatPatternDialog("4/4") diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index abea03472..9127cc1b9 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -2,7 +2,7 @@ import itertools import math -from bisect import bisect +from bisect import bisect, bisect_left from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import dataclass @@ -31,7 +31,7 @@ fit_legacy_lists, get_extension_from_beat_pattern, ) -from tilia.timelines.beat.pattern import PlannedBar, format_planned_bars, parse +from tilia.timelines.beat.pattern import PlannedBar, parse from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, DEFAULT_UNITS, @@ -61,6 +61,20 @@ class TimeStampTable: seconds_per_quarter: list[float] +class _BeatTimes: + """ + Whether a beat is at a time, found by bisection in the beats, which are + kept in time order, rather than by building a set of every time. + """ + + def __init__(self, beats: list[Beat]) -> None: + self._beats = beats + + def __contains__(self, time: float) -> bool: + index = bisect_left(self._beats, time, key=lambda beat: beat.time) + return index < len(self._beats) and self._beats[index].time == time + + class BeatTLComponentManager(TimelineComponentManager): def __init__(self, timeline: BeatTimeline): super().__init__(timeline, [ComponentKind.BEAT, ComponentKind.BEAT_UNIT]) @@ -69,6 +83,7 @@ def __init__(self, timeline: BeatTimeline): # timeline's components as its beats (len, indexing, iteration) keeps # doing so. self._beat_units: list[BeatUnit] = [] + self._beat_unit_by_beat_id: dict[int | str, BeatUnit] = {} # Set while undo/redo rebuilds components from a snapshot, which # already says which beats start measures and where beat units are. self.is_restoring = False @@ -100,20 +115,17 @@ def bulk(self) -> Iterator[bool]: finally: self.is_bulk = was_bulk - @property - def beat_times(self): - return {b.time for b in self._components} - @property def beat_units(self) -> list[BeatUnit]: return list(self._beat_units) def get_beat_unit_on_beat(self, beat_id: int) -> BeatUnit | None: - return next((u for u in self._beat_units if u.beat_id == beat_id), None) + return self._beat_unit_by_beat_id.get(beat_id) def _add_to_components(self, component: TC) -> None: if component.KIND == ComponentKind.BEAT_UNIT: self._beat_units.append(component) + self._beat_unit_by_beat_id[component.beat_id] = component self.id_to_component[component.id] = component else: super()._add_to_components(component) @@ -121,6 +133,8 @@ def _add_to_components(self, component: TC) -> None: def _remove_from_components_set(self, component: TC) -> None: if component.KIND == ComponentKind.BEAT_UNIT: self._beat_units.remove(component) + if self._beat_unit_by_beat_id.get(component.beat_id) is component: + del self._beat_unit_by_beat_id[component.beat_id] self.id_to_component.pop(component.id) else: super()._remove_from_components_set(component) @@ -144,12 +158,19 @@ def _validate_component_creation( if kind == ComponentKind.BEAT_UNIT: return BeatUnit.validate_creation( *args, - beat_ids={b.id for b in self._components}, - taken_beat_ids={u.beat_id for u in self._beat_units}, + beat_ids=self._get_beat_ids(), + taken_beat_ids=self._beat_unit_by_beat_id, **kwargs, ) time = args[0] if args else kwargs["time"] - return Beat.validate_creation(time, self.beat_times) + return Beat.validate_creation(time, _BeatTimes(self._components)) + + def _get_beat_ids(self) -> set[int | str]: + return { + id + for id, component in self.id_to_component.items() + if component.KIND == ComponentKind.BEAT + } def move_measure_start(self, beat: Beat, destination: Beat) -> None: """ @@ -220,9 +241,15 @@ def delete_component(self, component: TC) -> None: self.bump_version() def set_component_data(self, id: int, attr: str, value: Any): + component = self.get_component(id) + old_beat_id = getattr(component, "beat_id", None) value, success = super().set_component_data(id, attr, value) if success: self.bump_version() + if success and component.KIND == ComponentKind.BEAT_UNIT: + if self._beat_unit_by_beat_id.get(old_beat_id) is component: + del self._beat_unit_by_beat_id[old_beat_id] + self._beat_unit_by_beat_id[component.beat_id] = component return value, success def update_component_order(self, component: TC): @@ -328,28 +355,49 @@ def deserialize_components(self, serialized_components: dict[int, dict[str]]): post(Post.BEAT_TIMELINE_COMPONENTS_DESERIALIZED, self.timeline.id) def restore_state(self, prev_state: dict): + """ + Restores beats and beat units by id: those the snapshot lacks are + deleted, those it adds are created, and those both have get the + snapshot's values. Remeasuring changes many beats' measure data, so + they are edited rather than recreated. + """ beats, beat_units = self._split_beat_units(prev_state) with self.bulk(): - # Beat units are few, so they are rebuilt from the snapshot rather - # than diffed. Removing them first also keeps beat deletions below - # from moving them. self.is_restoring = True try: + # Beat units go first, so no beat deletion below moves them. for beat_unit in self._beat_units.copy(): - self.delete_component(beat_unit) - super().restore_state(beats) - for id, data in beat_units.items(): - data = data.copy() - data.pop("kind") - data.pop("hash", None) - self.timeline.create_component( - ComponentKind.BEAT_UNIT, id=id, **data - ) + data = beat_units.get(beat_unit.id) + if data is None or data.get("hash") != beat_unit.hash: + self.delete_component(beat_unit) + self._restore_components(ComponentKind.BEAT, beats) + self._restore_components(ComponentKind.BEAT_UNIT, beat_units) finally: self.is_restoring = False self.bump_version() self.timeline.refresh_measures() + def _restore_components( + self, kind: ComponentKind, prev_state: dict[int | str, dict[str, Any]] + ) -> None: + current = { + component.id: component + for component in self.id_to_component.values() + if component.KIND == kind + } + self.timeline.delete_components( + [component for id, component in current.items() if id not in prev_state] + ) + for id, data in prev_state.items(): + data = {k: v for k, v in data.items() if k not in ("kind", "hash")} + component = current.get(id) + if component is None: + self.timeline.create_component(kind, id=id, **data) + continue + for attr, value in data.items(): + if getattr(component, attr) != value: + self.set_component_data(id, attr, value) + class BeatTimeline(Timeline): SERIALIZABLE = [ @@ -476,22 +524,6 @@ def get_measures_as_planned_bars(self) -> list[PlannedBar]: bars.append(bar) return bars - def get_bars_to_edit(self) -> tuple[str, list[PlannedBar]]: - """ - The pattern and the bars to edit it from: the measures as they are, - then the bars the pattern plans after them, up to its end. The - pattern is the timeline's when the measures follow it, or else one - written from the bars. - """ - measures = self.get_measures_as_planned_bars() - follow_pattern = all( - bar == self.get_planned_bar(index) for index, bar in enumerate(measures) - ) - bars = measures + self._planned_bars[len(measures) :] - if follow_pattern: - return self.beat_pattern, bars - return format_planned_bars(bars), bars - @property def default_height(self): return settings.get("beat_timeline", "default_height") @@ -951,11 +983,14 @@ def _place_new_beat(self, beat: Beat) -> None: if old_first.measure_number_override is None else old_first.measure_number_override ) + if number < 1: + # Before a pickup (measure 0), the beat is the pickup's start. + self.component_manager.move_measure_start(old_first, beat) + return if not old_first.is_first_in_measure: self.set_component_data(old_first.id, "is_first_in_measure", True) self.set_component_data(beat.id, "is_first_in_measure", True) - if number >= 1: - self.set_component_data(beat.id, "measure_number_override", number - 1) + self.set_component_data(beat.id, "measure_number_override", number - 1) elif index == len(self) - 1: measures = self.measures last_measure = measures.count - 1 @@ -964,32 +999,50 @@ def _place_new_beat(self, beat: Beat) -> None: self.set_component_data(beat.id, "is_first_in_measure", True) self.lay_planned_beat_units(last_measure + 1) - def lay_planned_beat_units(self, from_measure: int) -> None: + def lay_planned_beat_units( + self, from_measure: int, bars: list[PlannedBar] | None = None + ) -> None: """ - Gives the measures from `from_measure` on the beat units the pattern - plans for them, replacing theirs: one on the first beat of each - measure where the pattern's time signature changes. Where it - doesn't, a time signature set on an earlier measure carries on. - Does nothing when the pattern gives no time signatures. + Gives the measures from `from_measure` on the beat units of `bars`, + one per measure from the first, and of the pattern for the measures + past them, replacing theirs. A measure gets a beat unit on its first + beat where its time signature differs from the measure before's. + Past `bars`, that is only where the pattern's own time signature + changes, so a time signature set on an earlier measure carries on, + and nowhere if the pattern gives no time signatures. """ + bars = bars or [] measures = self.measures - if not self.plans_time_signatures or from_measure >= measures.count: + if from_measure >= measures.count or ( + not bars and not self.plans_time_signatures + ): return - previous = None - if from_measure: - bar = self.get_planned_bar(from_measure - 1) - previous = (bar.denominator, bar.units, bar.assumed) + def get_bar(measure_index: int) -> PlannedBar: + if measure_index < len(bars): + return bars[measure_index] + return self.get_planned_bar(measure_index) + + def get_meter(bar: PlannedBar) -> tuple[int, tuple[Fraction, ...], bool]: + return bar.denominator, bar.units, bar.assumed beats = self.components + managed = set() planned: dict[int | str, tuple[int, str, bool]] = {} for measure_index in range(from_measure, measures.count): - bar = self.get_planned_bar(measure_index) - meter = (bar.denominator, bar.units, bar.assumed) - if meter != previous: + if measure_index >= len(bars) and not self.plans_time_signatures: + break + managed.add(measure_index) + bar = get_bar(measure_index) + if measure_index == 0: + previous = None + elif measure_index >= len(bars): + previous = get_meter(self.get_planned_bar(measure_index - 1)) + else: + previous = get_meter(get_bar(measure_index - 1)) + if get_meter(bar) != previous: beat_id = beats[measures.starts[measure_index]].id planned[beat_id] = (bar.denominator, bar.units_text, bar.assumed) - previous = meter # Beat units already as planned are left in place, so the change # touches only what it changes. @@ -997,7 +1050,7 @@ def lay_planned_beat_units(self, from_measure: int) -> None: for beat_unit in self.beat_units: beat_index = self.get_beat_index(self.get_component(beat_unit.beat_id)) if ( - measures.measure_of_beat[beat_index] >= from_measure + measures.measure_of_beat[beat_index] in managed and beat_unit.beat_id not in planned ): self.component_manager.delete_component(beat_unit) @@ -1235,6 +1288,11 @@ def set_beat_amount_in_measure(self, measure_index: int, beat_amount: int) -> No """ counts = self.beats_in_measure.copy() counts[measure_index] = beat_amount + last = len(counts) - 1 + if last != measure_index: + # The last measure may not have all its beats yet. It can take + # as many as the pattern plans for it. + counts[last] = max(counts[last], self.get_planned_bar(last).beat_count) self._remeasure(counts, measure_index) def _remeasure(self, counts: list[int], from_measure: int) -> None: @@ -1315,6 +1373,10 @@ def _remeasure(self, counts: list[int], from_measure: int) -> None: if values is not None: self.put_beat_unit_on_beat(beats[start].id, *values) + if len(new_starts) > len(kept_data): + # Measures made from the pattern for the beats left over. + self.lay_planned_beat_units(from_measure + len(kept_data)) + self.refresh_measures() def distribute_beats(self, measure_index: int) -> None: @@ -1354,6 +1416,31 @@ def get_bars_overwritten_by(self, pattern_bars: list[int]) -> list[int]: if self.beats_in_measure[i] != pattern_bars[i % len(pattern_bars)] ] + def set_bars( + self, + pattern: str, + bars: list[PlannedBar], + numbers: list[int] | None = None, + ) -> bool: + """ + Sets the pattern, which measures tapped from now on follow, and lays + `bars` over the beats from the first: each bar takes its beats and + its time signature. Beats left over make measures from the pattern, + going on from the measure after the last bar; bars past the last + beat go. Then numbers the measures, if `numbers` are given. + """ + _, success = self.set_data("beat_pattern", pattern) + if not success: + return False + + if len(self): + self._remeasure([bar.beat_count for bar in bars], 0) + self.lay_planned_beat_units(0, bars) + if numbers is not None: + self.set_measure_numbers(numbers) + self.refresh_measures() + return True + def set_beat_pattern(self, pattern: str) -> bool: """ Sets the pattern and remeasures every beat from it, discarding bars diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 7a1db9291..a2887630b 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -292,6 +292,9 @@ def get_time_signature_label_specs( return specs def update_time_signatures(self) -> None: + if self.timeline.component_manager.is_bulk: + # Redrawn once the bulk change is done. + return specs = self.get_time_signature_label_specs() for beat_unit_ui in self.beat_unit_uis: beat_unit_ui.set_labels(specs.get(beat_unit_ui.get_data("beat_id"), [])) @@ -367,19 +370,17 @@ def edit_bars(self, measure_index: int | None = None) -> bool: changes, then remeasures the beats from it and numbers the measures. """ timeline = self.timeline - text, _ = timeline.get_bars_to_edit() accepted, edit = get( Get.FROM_USER_BARS, - text, + timeline.beat_pattern, len(timeline), timeline.get_measures_as_planned_bars(), timeline.measure_numbers, measure_index, ) - if not accepted or not timeline.set_beat_pattern(edit.pattern): + if not accepted: return False - timeline.set_measure_numbers(edit.numbers) - return True + return timeline.set_bars(edit.pattern, edit.bars, edit.numbers) def on_remove_other_beat_units(self, measure_index: int | None = None) -> bool: """ diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index 3e6214805..26770fa02 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -1,9 +1,14 @@ """ The dialog where a beat timeline's bars are written: a pattern of time signatures on one line (see `tilia.timelines.beat.pattern`), and a table of -the bars it makes over the tapped beats, where each row's bar numbers, time -signature and taps can be edited. Editing a row rewrites the pattern, and -editing the pattern rewrites the rows. +the bars, where each row's bar numbers, time signature and taps can be +edited. + +The table lists the bars the tapped beats make, as they are, then the bars +the pattern plans after them. Editing the pattern lays it over every beat +again. Editing a row of tapped bars changes those bars only; the pattern, +which bars tapped later follow, stays as it is. Editing a row of bars not +tapped yet changes the pattern. """ from __future__ import annotations @@ -105,10 +110,13 @@ def keyPressEvent(self, event: QKeyEvent) -> None: @dataclass(frozen=True) class BarsEdit: - """What the dialog was left with: the pattern, and the number of each - bar the tapped beats fill.""" + """ + What the dialog was left with: the pattern, and the bars the tapped + beats fill, with their numbers. + """ pattern: str + bars: list[PlannedBar] numbers: list[int] @@ -126,20 +134,28 @@ def count(self) -> int: return len(self.numbers) -def lay_out(planned: list[PlannedBar], beat_count: int) -> tuple[list[PlannedBar], int]: +def lay_out( + bars: list[PlannedBar], plan: list[PlannedBar], beat_count: int +) -> tuple[list[PlannedBar], int]: """ - The bars `planned` makes over `beat_count` beats, repeating it from its - start as often as the beats need, then the rest of it; and how many of - them the beats reach. + The bars `beat_count` beats make: `bars` in order while there are beats + for them, then bars of `plan`, going on from the bar after the last and + starting it again from its first as often as the beats need; then the + rest of `plan`. Also returns how many of them the beats reach. """ - bars: list[PlannedBar] = [] + laid: list[PlannedBar] = [] beats = 0 + for bar in bars: + if beats >= beat_count: + break + laid.append(bar) + beats += bar.beat_count while beats < beat_count: - bar = planned[len(bars) % len(planned)] - bars.append(bar) + bar = plan[len(laid) % len(plan)] + laid.append(bar) beats += bar.beat_count - tapped = len(bars) - return bars + planned[tapped:], tapped + tapped = len(laid) + return laid + plan[tapped:], tapped def get_numbers(count: int, overrides: dict[int, int]) -> list[int]: @@ -201,6 +217,7 @@ def parse_bar_numbers(text: str, count: int) -> tuple[list[int] | None, str]: repeat or a jump. """ numbers: list[int] = [] + total = 0 for item in text.replace("–", "-").split(","): item = item.strip() bounds = [bound.strip() for bound in item.split("-")] @@ -211,15 +228,17 @@ def parse_bar_numbers(text: str, count: int) -> tuple[list[int] | None, str]: first, last = int(bounds[0]), int(bounds[-1]) if last < first: return None, f"'{item}' counts down." - numbers += range(first, last + 1) - if len(numbers) > count: - break + total += last - first + 1 + # Counted rather than listed past the row's bars, as a range may be + # huge. + if total <= count: + numbers += range(first, last + 1) - if len(numbers) == 1: + if total == 1: return list(range(numbers[0], numbers[0] + count)), "" - if len(numbers) != count: + if total != count: bars = "bar" if count == 1 else "bars" - return None, f"'{text}' numbers {len(numbers)} bars, not {count} {bars}." + return None, f"'{text}' numbers {total} bars, not {count} {bars}." return numbers, "" @@ -260,7 +279,12 @@ def __init__( self._beat_count = beat_count self._current_bars = current_bars or [] self._overrides = get_overrides(numbers or []) - self._planned: list[PlannedBar] = [] + self._plan: list[PlannedBar] = [] + # The tapped bars, where they don't simply follow the pattern. + self._bars: list[PlannedBar] = [] + # Set while a row edit rewrites the pattern, which then keeps the + # tapped bars rather than laying the pattern over them. + self._is_writing_plan = False self._rows: list[BarRow] = [] # The bar the dialog was opened at has a row of its own, so it can # be edited apart from the alike bars around it. @@ -322,6 +346,9 @@ def __init__( self.line_edit.textChanged.connect(self._on_text_changed) self._on_text_changed(initial_text) + if self._current_bars and parse(initial_text).is_complete: + self._bars = list(self._current_bars) + self._update_table() if focus_bar is not None: self.focus_bar(focus_bar) @@ -367,9 +394,12 @@ def focus_bar(self, bar_index: int) -> None: def current_row(self) -> int: return self.table.currentRow() + def _lay_out(self) -> tuple[list[PlannedBar], int]: + return lay_out(self._bars, self._plan, self._beat_count) + def get_result(self) -> BarsEdit: - _, tapped = lay_out(self._planned, self._beat_count) - return BarsEdit(self.text, get_numbers(tapped, self._overrides)) + bars, tapped = self._lay_out() + return BarsEdit(self.text, bars[:tapped], get_numbers(tapped, self._overrides)) def _show_error(self, message: str) -> None: self.error.setText(message) @@ -388,11 +418,13 @@ def _on_text_changed(self, text: str) -> None: return self._show_error("") - self._planned = result.planned_bars + self._plan = result.planned_bars + if not self._is_writing_plan: + self._bars = [] self._update_table() def _update_table(self) -> None: - bars, tapped = lay_out(self._planned, self._beat_count) + bars, tapped = self._lay_out() numbers = get_numbers(len(bars), self._overrides) self._rows = get_rows(bars, tapped, numbers, self._row_breaks) @@ -433,7 +465,7 @@ def _is_changed(self, row: BarRow) -> bool: return False def _get_footer(self, bars: list[PlannedBar], tapped: int) -> str: - length = len(self._planned) + length = len(self._plan) lines = [ tilia.ui.strings.BEAT_PATTERN_SUMMARY.format( length, "" if length == 1 else "s" @@ -471,12 +503,26 @@ def _on_item_changed(self, item: QTableWidgetItem) -> None: return self._show_error("") - bars, _ = lay_out(self._planned, self._beat_count) + if row.is_tapped: + bars, tapped = self._lay_out() + bars = bars[:tapped] + for index in range(row.start, row.start + row.count): + bars[index] = bar + self._bars = bars + self._update_table() + return + + # Bars not tapped yet are the pattern's, by position. + plan = list(self._plan) for index in range(row.start, row.start + row.count): - bars[index] = bar - # Setting the text parses it back into the same bars and redraws the - # table. - self.line_edit.setText(format_planned_bars(bars)) + plan[index] = bar + self._is_writing_plan = True + try: + # Setting the text parses it back into the same bars and redraws + # the table. + self.line_edit.setText(format_planned_bars(plan)) + finally: + self._is_writing_plan = False def _edit_numbers(self, row: BarRow, text: str) -> None: numbers, error = parse_bar_numbers(text, row.count) @@ -486,7 +532,7 @@ def _edit_numbers(self, row: BarRow, text: str) -> None: return self._show_error("") - bars, tapped = lay_out(self._planned, self._beat_count) + _, tapped = self._lay_out() all_numbers = get_numbers(tapped, self._overrides) all_numbers[row.start : row.start + row.count] = numbers # The bars after the row count on from it, unless numbered by hand. From 3dac64dcc2044bd62c425a69b10830542ec6f7a1 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:05:41 +0100 Subject: [PATCH 41/56] fix: keep every bar's length when a beat is added or deleted Adding or deleting a beat on the timeline left the barlines after it where they were, so the bar it was in changed length. A stray tap made while tapping, after which every later bar starts a beat early, then took two steps to fix: delete it, then give its bar back its length in the dialog. On dev, deleting it was enough. Now every bar keeps its length, its time signature and its number, and the barlines after the beat move, as "Set amount in measure" and the bars dialog already have it. So deleting a stray tap, or adding a missed one, puts every later barline back. The time signature moves with its bar; in #621's first version it stayed on its beat and ended up in the middle of a bar. A bar whose beats are all deleted goes. A beat added before the first still starts a pickup, or joins one, and moves no barline. --- tests/timelines/beat/test_beat_timeline.py | 31 ++++++++--- tests/timelines/beat/test_beat_units.py | 11 ++-- .../timelines/beat/test_beat_timeline_ui.py | 55 ++++++++++++++++--- tilia/timelines/beat/timeline.py | 55 ++++++++++++++++--- 4 files changed, 124 insertions(+), 28 deletions(-) diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 57f2f6a78..4845a7e3f 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -25,7 +25,7 @@ def test_create_beat_at_time_bigger_than_media_duration_fails( commands.execute("timeline.beat.add") assert len(beat_tlui) == 0 - def test_create_beat_at_middle_joins_the_measure_it_falls_in(self, beat_tlui): + def test_create_beat_at_middle_keeps_the_measures_lengths(self, beat_tlui): beat_tlui.timeline.beat_pattern = [2] commands.execute("media.seek", 0) commands.execute("timeline.beat.add") @@ -37,13 +37,15 @@ def test_create_beat_at_middle_joins_the_measure_it_falls_in(self, beat_tlui): commands.execute("media.seek", 5) commands.execute("timeline.beat.add") + # The first measure keeps two beats, so the beat at 10 starts the + # second, which the beat at 20 fills. assert [b.get_data("is_first_in_measure") for b in beat_tlui] == [ True, False, - False, True, + False, ] - assert beat_tlui.timeline.beats_in_measure == [3, 1] + assert beat_tlui.timeline.beats_in_measure == [2, 2] def test_create_beat_before_the_first_starts_measure_zero(self, beat_tlui): for time in [10, 11, 12]: @@ -467,7 +469,7 @@ def test_get_times_by_fraction_in_repeat_measure_number(self, beat_tl): assert beat_tl.get_time_by_measure(3, 1.0) == [] assert beat_tl.get_time_by_measure(3, 1.0, True) == [7] - def test_delete_beat_leaves_the_other_measures_as_they_are(self, beat_tl): + def test_delete_beat_keeps_the_measures_lengths(self, beat_tl): beat_tl.beat_pattern = [2] for time in range(5): beat_tl.create_beat(time) @@ -475,14 +477,14 @@ def test_delete_beat_leaves_the_other_measures_as_they_are(self, beat_tl): beat_tl.delete_components([beat_tl[1]]) assert [b.get_data("is_first_in_measure") for b in beat_tl] == [ - True, True, False, True, + False, ] - assert beat_tl.beats_in_measure == [1, 2, 1] + assert beat_tl.beats_in_measure == [2, 2] - def test_delete_first_beat_of_a_measure_hands_it_to_the_next(self, beat_tl): + def test_delete_first_beat_of_a_measure_keeps_its_number(self, beat_tl): beat_tl.beat_pattern = [2] for time in range(5): beat_tl.create_beat(time) @@ -490,10 +492,21 @@ def test_delete_first_beat_of_a_measure_hands_it_to_the_next(self, beat_tl): beat_tl.delete_components([beat_tl[2]]) - assert beat_tl.beats_in_measure == [2, 1, 1] - assert beat_tl.measure_numbers == [1, 10, 11] + assert beat_tl.beats_in_measure == [2, 2] + assert beat_tl.measure_numbers == [1, 10] assert beat_tl[2].measure_number_override == 10 + def test_delete_every_beat_of_a_measure_removes_it(self, beat_tl): + beat_tl.beat_pattern = [2] + for time in range(7): + beat_tl.create_beat(time) + beat_tl.set_measure_number(2, 10) + + beat_tl.delete_components([beat_tl[2], beat_tl[3]]) + + assert beat_tl.beats_in_measure == [2, 2, 1] + assert beat_tl.measure_numbers == [1, 10, 11] + @pytest.mark.timeout(5) def test_open_file_with_many_beats(self, resources, tilia): commands.execute("file.open", path=resources / "tla" / "many_beats.tla") diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index 1895a7162..da1d8c67b 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -298,19 +298,20 @@ def test_two_beat_units_in_a_measure_first_wins(self, beat_tl): assert meter.beat_unit_id == first.beat_id assert meter.denominator == 8 - def test_inserted_beats_leave_later_measures_as_they_are(self, beat_tl): + def test_inserted_beats_keep_each_measures_length_and_beat_unit(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) add_beat_unit(beat_tl, 4, 8, "3") assert time_signatures(beat_tl) == [(2, 4), (2, 4), (6, 8)] - # A forgotten bar's beats join the measure they fall in, and the - # beat unit stays on the measure it starts. + # A forgotten bar's beats go in the first measure, which keeps its + # two beats, so the barlines after them move. The beat unit moves + # with the third measure, and the beats left over make a fourth. beat_tl.create_beat(0.25) beat_tl.create_beat(0.5) - assert time_signatures(beat_tl) == [(4, 4), (2, 4), (6, 8)] - assert beat_unit_values(beat_tl) == [(0, 4, "1"), (6, 8, "3")] + assert time_signatures(beat_tl) == [(2, 4), (2, 4), (6, 8), (6, 8)] + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (4, 8, "3")] def test_remeasuring_keeps_each_measures_beat_unit(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index e08d32ae7..71a81e5e5 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -521,9 +521,9 @@ def edit_bars(timeline_ui, edit): def test_a_bar_with_another_number_of_beats_has_a_row_of_its_own(self, tluis): add_beat_timeline_with_pattern("4/4") - add_beats(range(12)) + add_beats(range(11)) tluis[0].select_element(tluis[0][5]) - commands.execute("timeline.component.delete") + commands.execute("timeline.beat.set_amount_in_measure", amount=3) seen = {} def record(dialog): @@ -544,9 +544,9 @@ def record(dialog): def test_correcting_a_bar_moves_the_later_bars(self, tluis): add_beat_timeline_with_pattern("4/4") - add_beats(range(12)) - # A tap too many in the first bar. - add_beats([0.5]) + add_beats(range(13)) + tluis[0].select_element(tluis[0][0]) + commands.execute("timeline.beat.set_amount_in_measure", amount=5) assert tluis[0].timeline.beats_in_measure == [5, 4, 4] with undoable(): @@ -787,7 +787,7 @@ def test_delete_update_next_measures_numbers(self, beat_tlui): assert get_displayed_measure_number(beat_tlui[0]) == "1" assert get_displayed_measure_number(beat_tlui[1]) == "2" - def test_create_in_a_measure_keeps_the_next_measures_numbers(self, beat_tlui): + def test_create_in_a_measure_moves_the_later_barlines(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] settings.set("beat_timeline", "display_measure_periodicity", 1) beat_tlui.create_beat(0) @@ -797,11 +797,12 @@ def test_create_in_a_measure_keeps_the_next_measures_numbers(self, beat_tlui): commands.execute("media.seek", 0.5) commands.execute("timeline.beat.add") + # Each measure keeps its one beat. assert [get_displayed_measure_number(beat) for beat in beat_tlui] == [ "1", - "", "2", "3", + "4", ] def test_create_after_the_last_beat_numbers_the_new_measure(self, beat_tlui): @@ -819,6 +820,46 @@ def test_create_after_the_last_beat_numbers_the_new_measure(self, beat_tlui): "3", ] + def test_deleting_a_stray_tap_puts_the_later_barlines_back(self, tluis): + add_beat_timeline_with_pattern("4/4") + # A stray tap at 1.5 makes every later bar start a beat early. + add_beats([0, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + timeline = tluis[0].timeline + assert [b.time for b in timeline if b.is_first_in_measure] == [0, 3, 7, 11] + + tluis[0].select_element(tluis[0][2]) + with undoable(): + commands.execute("timeline.component.delete") + + assert [b.time for b in timeline if b.is_first_in_measure] == [0, 4, 8] + assert timeline.beats_in_measure == [4, 4, 4] + + def test_adding_a_missed_tap_puts_the_later_barlines_back(self, tluis): + add_beat_timeline_with_pattern("4/4") + # Without a tap at 2, every later bar starts a beat late. + add_beats([0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + timeline = tluis[0].timeline + assert [b.time for b in timeline if b.is_first_in_measure] == [0, 5, 9] + + with undoable(): + add_beats([2]) + + assert [b.time for b in timeline if b.is_first_in_measure] == [0, 4, 8] + + def test_a_time_signature_moves_with_its_bar(self, tluis): + add_beat_timeline_with_pattern("2[2/4] 6/8(3 3)") + add_beats(range(7)) + timeline = tluis[0].timeline + assert time_signatures(timeline) == [("2", 4), ("2", 4), ("6", 8), ("1", 4)] + + tluis[0].select_element(tluis[0][1]) + with undoable(): + commands.execute("timeline.component.delete") + + assert time_signatures(timeline) == [("2", 4), ("2", 4), ("6", 8)] + assert timeline.get_beat_unit_on_beat(timeline[4].id).denominator == 8 + assert not any(meter.is_conflicting for meter in timeline.measure_meters) + class TestSelect: def test_deselect_all_but_last(self, beat_tlui): diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 9127cc1b9..4c94cc298 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -3,6 +3,7 @@ import itertools import math from bisect import bisect, bisect_left +from collections import Counter from collections.abc import Callable, Iterator from contextlib import contextmanager from dataclasses import dataclass @@ -205,7 +206,8 @@ def _on_beat_deleted(self, beat: Beat) -> None: the measure's only beat, the measure goes, and its beat unit moves to the next measure, so the measures it governed keep it. A beat unit on a beat that doesn't start a measure moves to the first beat of the - measure. + measure. Outside bulk changes, `BeatTimeline.delete_components` then + gives the measures back their lengths. """ beats = self._components index = beats.index(beat) @@ -947,7 +949,8 @@ def create_component( last one has as many beats as the beat pattern gives it; before the first, it starts a measure of its own, numbered one less than the first, so a pickup is measure 0; anywhere else, it joins the measure - it falls in. No other beat changes measure. + it falls in, and every measure keeps its length, so the measures + after it start one beat later. """ is_placed = kwargs.get("is_first_in_measure") is not None component, reason = super().create_component(kind, *args, id=id, **kwargs) @@ -998,6 +1001,12 @@ def _place_new_beat(self, beat: Beat) -> None: if beats_before >= self.get_planned_bar(last_measure).beat_count: self.set_component_data(beat.id, "is_first_in_measure", True) self.lay_planned_beat_units(last_measure + 1) + else: + measures = self.measures + measure_index = measures.measure_of_beat[index] + counts = measures.counts.copy() + counts[measure_index] -= 1 + self._remeasure(self._with_last_measure_open(counts), measure_index) def lay_planned_beat_units( self, from_measure: int, bars: list[PlannedBar] | None = None @@ -1288,13 +1297,20 @@ def set_beat_amount_in_measure(self, measure_index: int, beat_amount: int) -> No """ counts = self.beats_in_measure.copy() counts[measure_index] = beat_amount - last = len(counts) - 1 - if last != measure_index: - # The last measure may not have all its beats yet. It can take - # as many as the pattern plans for it. - counts[last] = max(counts[last], self.get_planned_bar(last).beat_count) + if measure_index != len(counts) - 1: + counts = self._with_last_measure_open(counts) self._remeasure(counts, measure_index) + def _with_last_measure_open(self, counts: list[int]) -> list[int]: + """ + `counts`, with the last measure able to take as many beats as the + pattern plans for it, as it may not have all its beats yet. + """ + last = len(counts) - 1 + return counts[:last] + [ + max(counts[last], self.get_planned_bar(last).beat_count) + ] + def _remeasure(self, counts: list[int], from_measure: int) -> None: """ Makes the measures from `from_measure` on hold `counts[from_measure:]` @@ -1461,6 +1477,10 @@ def set_beat_pattern(self, pattern: str) -> bool: return True def delete_components(self, components: list[TC]) -> None: + """ + Every measure keeps its length, so the measures after a deleted beat + start one beat earlier. A measure whose beats are all deleted goes. + """ self._validate_delete_components(components) manager = self.component_manager @@ -1472,6 +1492,9 @@ def delete_components(self, components: list[TC]) -> None: key=self.get_beat_index, reverse=True, ) + lengths = None + if beats and not (manager.is_restoring or manager.is_bulk): + lengths = self._get_lengths_after_deleting(beats) for component in beat_units + beats: manager.delete_component(component) @@ -1483,8 +1506,26 @@ def delete_components(self, components: list[TC]) -> None: if beat_units: self._ensure_first_beat_unit(copy_next=False) + if lengths is not None and len(self): + self._remeasure(*lengths) self.refresh_measures() + def _get_lengths_after_deleting(self, beats: list[Beat]) -> tuple[list[int], int]: + """ + The lengths of the measures left once `beats` go, as they are now, + and the first measure one of them is in. + """ + measures = self.measures + lost = Counter( + measures.measure_of_beat[self.get_beat_index(beat)] for beat in beats + ) + counts = [ + count + for measure_index, count in enumerate(measures.counts) + if lost[measure_index] < count + ] + return counts, min(lost) + class FillMethod(Enum): BY_AMOUNT = 0 BY_INTERVAL = 1 From 06ae068e335725ecf9eac2ab8e57d30fc6f2ce40 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:09:58 +0100 Subject: [PATCH 42/56] refactor: move the bars table's rows out of the Qt dialog The rows the bars dialog shows (runs of alike bars with their numbers, time signature and taps) move to `tilia/ui/timelines/beat_bars.py`, which doesn't import Qt, with `format_row` for a row's three cells. The CLI can then list the bars as the dialog shows them. --- tilia/ui/timelines/beat_bars.py | 79 ++++++++++++++++++++++++++++++++ tilia/ui/windows/beat_pattern.py | 64 +------------------------- 2 files changed, 81 insertions(+), 62 deletions(-) create mode 100644 tilia/ui/timelines/beat_bars.py diff --git a/tilia/ui/timelines/beat_bars.py b/tilia/ui/timelines/beat_bars.py new file mode 100644 index 000000000..92320b0de --- /dev/null +++ b/tilia/ui/timelines/beat_bars.py @@ -0,0 +1,79 @@ +""" +The rows of a beat timeline's bars table: runs of consecutive bars that are +alike, each with its bar numbers, time signature and taps. Free of Qt, so +the CLI lists the bars as the bars dialog shows them. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import tilia.ui.strings +from tilia.timelines.beat.pattern import ( + PlannedBar, + format_taps, + format_time_signature, +) + + +@dataclass(frozen=True) +class BarRow: + """Consecutive bars that are alike. `start` is a 0-based bar index.""" + + start: int + bar: PlannedBar + numbers: tuple[int, ...] + is_tapped: bool + + @property + def count(self) -> int: + return len(self.numbers) + + +def get_rows( + bars: list[PlannedBar], + tapped: int, + numbers: list[int], + breaks: frozenset[int] = frozenset(), +) -> list[BarRow]: + """ + Groups consecutive bars that are alike and numbered one after another. + A bar whose index is in `breaks` starts a row of its own. + """ + rows: list[BarRow] = [] + for index, (bar, number) in enumerate(zip(bars, numbers, strict=True)): + is_tapped = index < tapped + if rows and index not in breaks: + last = rows[-1] + if ( + last.bar == bar + and last.is_tapped == is_tapped + and last.numbers[-1] + 1 == number + ): + rows[-1] = BarRow(last.start, bar, last.numbers + (number,), is_tapped) + continue + rows.append(BarRow(index, bar, (number,), is_tapped)) + return rows + + +def format_bar_numbers(numbers: tuple[int, ...] | list[int]) -> str: + """Writes consecutive bar numbers as a range: "1–4", or "5" alone.""" + if len(numbers) == 1: + return str(numbers[0]) + return f"{numbers[0]}–{numbers[-1]}" + + +def format_time_signature_cell(bar: PlannedBar) -> str: + text = format_time_signature(bar) + if bar.assumed: + return tilia.ui.strings.BEAT_PATTERN_ASSUMED_TIME_SIGNATURE.format(text) + return text + + +def format_row(row: BarRow) -> tuple[str, str, str]: + """A row's bars, time signature and taps, as the table shows them.""" + return ( + format_bar_numbers(row.numbers), + format_time_signature_cell(row.bar), + format_taps(row.bar), + ) diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index 26770fa02..48391f1d6 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -35,13 +35,12 @@ from tilia.timelines.beat.pattern import ( PlannedBar, format_planned_bars, - format_taps, - format_time_signature, parse, parse_time_signature, parse_time_signature_over_taps, ) from tilia.timelines.beat.units import parse_units +from tilia.ui.timelines.beat_bars import BarRow, format_row, get_rows _PATTERN_CHARACTERS = set("0123456789[]/+()") # The table scrolls past this height rather than growing the dialog. @@ -120,20 +119,6 @@ class BarsEdit: numbers: list[int] -@dataclass(frozen=True) -class BarRow: - """Consecutive bars that are alike. `start` is a 0-based bar index.""" - - start: int - bar: PlannedBar - numbers: tuple[int, ...] - is_tapped: bool - - @property - def count(self) -> int: - return len(self.numbers) - - def lay_out( bars: list[PlannedBar], plan: list[PlannedBar], beat_count: int ) -> tuple[list[PlannedBar], int]: @@ -176,39 +161,6 @@ def get_overrides(numbers: list[int]) -> dict[int, int]: } -def get_rows( - bars: list[PlannedBar], - tapped: int, - numbers: list[int], - breaks: frozenset[int] = frozenset(), -) -> list[BarRow]: - """ - Groups consecutive bars that are alike and numbered one after another. - A bar whose index is in `breaks` starts a row of its own. - """ - rows: list[BarRow] = [] - for index, (bar, number) in enumerate(zip(bars, numbers, strict=True)): - is_tapped = index < tapped - if rows and index not in breaks: - last = rows[-1] - if ( - last.bar == bar - and last.is_tapped == is_tapped - and last.numbers[-1] + 1 == number - ): - rows[-1] = BarRow(last.start, bar, last.numbers + (number,), is_tapped) - continue - rows.append(BarRow(index, bar, (number,), is_tapped)) - return rows - - -def format_bar_numbers(numbers: tuple[int, ...] | list[int]) -> str: - """Writes consecutive bar numbers as a range: "1–4", or "5" alone.""" - if len(numbers) == 1: - return str(numbers[0]) - return f"{numbers[0]}–{numbers[-1]}" - - def parse_bar_numbers(text: str, count: int) -> tuple[list[int] | None, str]: """ Reads the numbers of `count` bars: a first number to count on from @@ -250,13 +202,6 @@ def parse_taps(text: str, denominator: int) -> tuple[PlannedBar | None, str]: return PlannedBar.from_values(result.units, denominator), "" -def format_time_signature_cell(bar: PlannedBar) -> str: - text = format_time_signature(bar) - if bar.assumed: - return tilia.ui.strings.BEAT_PATTERN_ASSUMED_TIME_SIGNATURE.format(text) - return text - - class BeatPatternDialog(QDialog): def __init__( self, @@ -431,13 +376,8 @@ def _update_table(self) -> None: self._is_updating_table = True self.table.setRowCount(len(self._rows)) for row_index, row in enumerate(self._rows): - texts = [ - format_bar_numbers(row.numbers), - format_time_signature_cell(row.bar), - format_taps(row.bar), - ] is_changed = self._is_changed(row) - for column, text in enumerate(texts): + for column, text in enumerate(format_row(row)): item = QTableWidgetItem(text) flags = item.flags() if column == BARS_COLUMN and not row.is_tapped: From 59f0f0b6db530f519e19771093af3d66cdea65ce Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:12:14 +0100 Subject: [PATCH 43/56] feat: set time signatures from the CLI, not beat units The CLI spoke of beat units (a denominator and units per tap) after the app stopped showing them. Its commands now take what the inspector and the bars dialog take: - `timelines beat time-signatures set -m 12 6/8` sets a measure's time signature over the taps it has, as the inspector does, until the next change or, with `--scope measure`, for that measure only. Taps can be given in brackets: `4/4(3/2 3/2 1)`. - `timelines beat time-signatures remove -m 12` removes the change at a measure, as deleting its label does. - `timelines beat time-signatures list` prints the bars as the dialog's table shows them: bars, time signature and taps. - `show` and `hide` move under the same group, with the same syntax. `timelines beat unit` goes. It never shipped, so nothing else is kept for it. The help of `timelines add beat --beat-pattern` and `timelines beat pattern set` describes the time signature syntax. --- ... => test_cli_timelines_time_signatures.py} | 88 ++++++---- tilia/ui/cli/timelines/add.py | 14 +- tilia/ui/cli/timelines/beat.py | 154 ++++++++---------- 3 files changed, 129 insertions(+), 127 deletions(-) rename tests/ui/cli/timelines/{test_cli_timelines_beat_units.py => test_cli_timelines_time_signatures.py} (55%) diff --git a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py similarity index 55% rename from tests/ui/cli/timelines/test_cli_timelines_beat_units.py rename to tests/ui/cli/timelines/test_cli_timelines_time_signatures.py index 06d426cc1..18889cd2d 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_beat_units.py +++ b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py @@ -26,41 +26,63 @@ def time_signatures(tl): return [(m.numerator, m.denominator) for m in tl.measure_meters] -class TestSetUnit: +class TestSetTimeSignature: def test_by_measure_number(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -o 1 --measure 2 -d 8 -u 3") + cli.parse_and_run("timelines beat time-signatures set -o 1 --measure 2 6/8") assert time_signatures(beat_tl) == [(2, 4), (6, 8), (6, 8), (6, 8)] def test_by_measure_index(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -n B1 --measure-index 1 -d 8 -u 3") + cli.parse_and_run( + "timelines beat time-signatures set -n B1 --measure-index 1 6/8" + ) assert time_signatures(beat_tl)[1] == (6, 8) def test_this_measure_only(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3 --scope measure") + cli.parse_and_run( + "timelines beat time-signatures set -o 1 -m 2 6/8 --scope measure" + ) assert time_signatures(beat_tl) == [(2, 4), (6, 8), (2, 4), (2, 4)] - def test_fractional_and_grouped_units(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -o 1 -m 1 -d 4 -u 2+1/2") + def test_the_measure_keeps_its_taps(self, cli, beat_tl): + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 5/4") - assert time_signatures(beat_tl)[0] == (pytest.approx(2.5), 4) + assert time_signatures(beat_tl)[0] == (5, 4) + assert beat_tl.beats_in_measure[0] == 2 + + def test_taps_in_brackets(self, cli, beat_tl): + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 5/8(3 2)") + + assert time_signatures(beat_tl)[0] == (5, 8) + assert [str(value) for value in beat_tl.measure_meters[0].beat_values] == [ + "3", + "2", + ] + + def test_confirms_an_assumed_time_signature(self, cli, beat_tl): + assert beat_tl.measure_meters[0].is_assumed + + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 2/4") + + assert not beat_tl.measure_meters[0].is_assumed @pytest.mark.parametrize( "args", [ - "-m 9 -d 4 -u 1", - "--measure-index 9 -d 4 -u 1", - "-m 1 -d 0 -u 1", - "-m 1 -d 129 -u 1", - "-m 1 -d 4 -u 2+", - "-m 1 -d 4 -u 1.5", + "-m 9 3/4", + "--measure-index 9 3/4", + "-m 1 3/0", + "-m 1 3/129", + "-m 1 3/", + "-m 1 x", + "-m 1 3/4(1 1 1)", ], ) def test_invalid(self, cli, beat_tl, args): with assert_error(): - cli.parse_and_run(f"timelines beat unit set -o 1 {args}") + cli.parse_and_run(f"timelines beat time-signatures set -o 1 {args}") assert_only_the_default_beat_unit(beat_tl) @@ -68,58 +90,52 @@ def test_repeated_measure_number_is_refused(self, cli, beat_tl): beat_tl.set_measure_number(2, 1) with assert_error(): - cli.parse_and_run("timelines beat unit set -o 1 -m 1 -d 8 -u 1") + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 3/8") assert_only_the_default_beat_unit(beat_tl) -class TestRemoveUnit: +class TestRemoveTimeSignature: def test_remove(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 2 6/8") beat_count = len(beat_tl) - cli.parse_and_run("timelines beat unit remove -o 1 -m 2") + cli.parse_and_run("timelines beat time-signatures remove -o 1 -m 2") + assert time_signatures(beat_tl) == [(2, 4)] * 4 assert_only_the_default_beat_unit(beat_tl) assert len(beat_tl) == beat_count - def test_no_unit_at_measure(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + def test_no_change_at_measure(self, cli, beat_tl): + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 2 6/8") with assert_error(): - cli.parse_and_run("timelines beat unit remove -o 1 -m 3") + cli.parse_and_run("timelines beat time-signatures remove -o 1 -m 3") assert len(beat_tl.beat_units) == 2 -class TestListUnits: +class TestListBars: def test_list(self, cli, beat_tl): - cli.parse_and_run("timelines beat unit set -o 1 -m 2 -d 8 -u 3") + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 2 6/8") with patch("tilia.ui.cli.io.tabulate") as tabulate: - cli.parse_and_run("timelines beat unit list -o 1") + cli.parse_and_run("timelines beat time-signatures list -o 1") headers, data = tabulate.call_args.args - assert headers == [ - "measure", - "beat time", - "denominator", - "units", - "time signature", - ] + assert headers == ["bars", "time signature", "taps"] assert data == [ - ("1", "0.0", "4", "1", "2/4 (assumed)"), - ("2", "2.0", "8", "3", "6/8"), + ("1", "2/4 (assumed)", "1 1"), + ("2–4", "6/8", "3 3"), ] def test_empty(self, cli, tls): - # Only a timeline without beats has no beat units. cli.parse_and_run("timelines add beat --name Empty") with patch("tilia.ui.cli.io.output") as output: - cli.parse_and_run("timelines beat unit list -o 1") + cli.parse_and_run("timelines beat time-signatures list -o 1") - assert "no beat units" in output.call_args.args[0] + assert "no bars" in output.call_args.args[0] class TestTimeSignatureVisibility: diff --git a/tilia/ui/cli/timelines/add.py b/tilia/ui/cli/timelines/add.py index 69bad97e3..18b2c15af 100644 --- a/tilia/ui/cli/timelines/add.py +++ b/tilia/ui/cli/timelines/add.py @@ -21,8 +21,8 @@ def setup_parser(subparser): help="Add a new timeline", epilog=""" Examples: - timelines add beat --name "Measures" --beat-pattern 4 - timelines add beat --name "Measures" --beat-pattern 10[4] 3 15[4] + timelines add beat --name "Measures" --beat-pattern 4/4 + timelines add beat --name "Measures" --beat-pattern 10[4/4] 3/4 15[4/4] timelines add hierarchy --name "Form" timelines add marker --name "Cadences" """, @@ -56,10 +56,12 @@ def setup_parser(subparser): type=str, nargs="+", default=None, - help="Beats per measure, separated by spaces (beat timelines only). " - "The pattern repeats after its last measure: '3 4' alternates measures " - "of 3 and 4 beats. Use n[...] to repeat a group: '10[4] 3' is ten " - "measures of 4, then one of 3. Defaults to 4.", + help="Time signatures of the bars, separated by spaces (beat timelines " + "only). The pattern repeats after its last bar: '3/4 4/4' alternates " + "bars of 3/4 and 4/4. Use n[...] to repeat a group: '10[4/4] 3/4' is " + "ten bars of 4/4, then one of 3/4. Taps default to one per beat; " + "otherwise give their values in brackets, e.g. 6/8(3 3). A number " + "alone is that many taps of an assumed quarter note. Defaults to 4.", ) add_subp.add_argument( "--row-height", diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index 4a5d07925..aed60cf8a 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -4,19 +4,14 @@ from collections.abc import Callable from functools import wraps -from tilia.timelines.beat.pattern import parse +from tilia.timelines.beat.pattern import parse, parse_time_signature_over_taps from tilia.timelines.beat.timeline import BeatTimeline -from tilia.timelines.beat.units import ( - MAX_DENOMINATOR, - parse_units, - validate_denominator, -) from tilia.ui.cli import io from tilia.ui.cli.timelines.utils import ( get_timeline_by_name, get_timeline_by_ordinal, ) -from tilia.ui.format import format_numerator +from tilia.ui.timelines.beat_bars import format_row, get_rows from tilia.ui.timelines.beat_time_signatures import set_time_signatures_shown @@ -29,7 +24,6 @@ def setup_parser(subparser: argparse._SubParsersAction) -> None: beat_subp = beat_parser.add_subparsers(dest="beat_command", required=True) _setup_pattern_parser(beat_subp) - _setup_unit_parser(beat_subp) _setup_time_signatures_parser(beat_subp) @@ -58,11 +52,13 @@ def _setup_pattern_set(subparser: argparse._SubParsersAction) -> None: parser = subparser.add_parser( "set", exit_on_error=False, - help="Set the beat pattern and recompute every measure from it.", + help="Set the time signatures of the bars and recompute every measure " + "from them.", epilog=""" Examples: - timelines beat pattern set -o 1 4 - timelines beat pattern set -o 1 10[4] 3 15[4] + timelines beat pattern set -o 1 4/4 + timelines beat pattern set -o 1 4[4/4] 3/4 3[4/4] + timelines beat pattern set -o 1 6/8(3 3) """, formatter_class=argparse.RawDescriptionHelpFormatter, ) @@ -71,7 +67,11 @@ def _setup_pattern_set(subparser: argparse._SubParsersAction) -> None: "pattern", type=str, nargs="+", - help="Beats per measure, separated by spaces. Use n[...] to repeat a group.", + help="Time signatures of the bars, separated by spaces, starting again " + "from the first after the last. Use n[...] to repeat a group. Taps " + "default to one per beat; otherwise give their values in brackets, " + "e.g. 6/8(3 3). A number alone is that many taps of an assumed " + "quarter note.", ) parser.add_argument( "--yes", @@ -82,37 +82,36 @@ def _setup_pattern_set(subparser: argparse._SubParsersAction) -> None: parser.set_defaults(func=set_pattern) -def _setup_unit_parser(subparser: argparse._SubParsersAction) -> None: - unit_parser = subparser.add_parser( - "unit", +def _setup_time_signatures_parser(subparser: argparse._SubParsersAction) -> None: + time_signatures_parser = subparser.add_parser( + "time-signatures", exit_on_error=False, - help="Beat unit operations: what one tapped beat is worth in notation.", + help="Time signature operations.", + ) + time_signatures_subp = time_signatures_parser.add_subparsers( + dest="time_signatures_command", required=True ) - unit_subp = unit_parser.add_subparsers(dest="unit_command", required=True) - parser = unit_subp.add_parser( + parser = time_signatures_subp.add_parser( "set", exit_on_error=False, - help="Set the beat unit of a measure.", + help="Set the time signature of a measure, over the taps it has.", epilog=""" Examples: - timelines beat unit set -o 1 --measure 12 --denominator 8 --units 3 - timelines beat unit set -o 1 --measure 5 -d 4 -u 2+3 --scope measure + timelines beat time-signatures set -o 1 --measure 12 6/8 + timelines beat time-signatures set -o 1 -m 5 4/4(3/2 3/2 1) --scope measure """, formatter_class=argparse.RawDescriptionHelpFormatter, ) _add_target_args(parser) _add_measure_args(parser) parser.add_argument( - "--denominator", "-d", type=int, required=True, help="Denominator" - ) - parser.add_argument( - "--units", - "-u", + "time_signature", type=str, - required=True, - help="Units per tap, '+'-separated whole numbers or fractions " - "(e.g. 1, 3, 2+3, 1/2).", + nargs="+", + help="A time signature such as 3/4. The measure keeps its taps, each " + "worth the same, so 6/8 over two taps is two dotted quarters. To give " + "the taps other values, put them in brackets: 4/4(3/2 3/2 1).", ) parser.add_argument( "--scope", @@ -120,35 +119,35 @@ def _setup_unit_parser(subparser: argparse._SubParsersAction) -> None: default="onward", help="Apply until the next change (default) or to this measure only.", ) - parser.set_defaults(func=set_unit) + parser.set_defaults(func=set_time_signature) - parser = unit_subp.add_parser( + parser = time_signatures_subp.add_parser( "remove", exit_on_error=False, - help="Remove the beat unit that starts at a measure.", + help="Remove the time signature change at a measure, so the one " + "before carries on.", ) _add_target_args(parser) _add_measure_args(parser) - parser.set_defaults(func=remove_unit) + parser.set_defaults(func=remove_time_signature) - parser = unit_subp.add_parser( + parser = time_signatures_subp.add_parser( "list", exit_on_error=False, - help="List the beat units of a beat timeline.", + help="List the bars of a beat timeline, with their time signatures and " + "taps.", ) _add_target_args(parser) - parser.set_defaults(func=list_units) - + parser.set_defaults(func=list_bars) -def _setup_time_signatures_parser(subparser: argparse._SubParsersAction) -> None: - parser = subparser.add_parser( - "time-signatures", - exit_on_error=False, - help="Show or hide the time signatures of a beat timeline.", - ) - parser.add_argument("action", choices=["show", "hide"]) - _add_target_args(parser) - parser.set_defaults(func=set_time_signatures_visibility) + for action in ("show", "hide"): + parser = time_signatures_subp.add_parser( + action, + exit_on_error=False, + help=f"{action.capitalize()} the time signatures of a beat timeline.", + ) + _add_target_args(parser) + parser.set_defaults(func=set_time_signatures_visibility, action=action) def _add_measure_args(parser: argparse.ArgumentParser) -> None: @@ -244,69 +243,54 @@ def set_pattern(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: @with_timeline -def set_unit(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: +def set_time_signature(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: measure_index = _resolve_measure(timeline, namespace) if measure_index is None: return - result = parse_units(namespace.units) - if not result.is_valid: - io.error(f"Invalid units '{namespace.units}': {result.error}") - return - if not validate_denominator(namespace.denominator): - io.error( - f"Invalid denominator: {namespace.denominator}. " - f"Use 1 to {MAX_DENOMINATOR}." - ) + text = " ".join(namespace.time_signature) + beat_count = timeline.beats_in_measure[measure_index] + bar, error = parse_time_signature_over_taps(text, beat_count) + if bar is not None and bar.beat_count != beat_count: + error = f"It gives {bar.beat_count} taps, but the measure has {beat_count}." + if bar is None or error: + io.error(f"Invalid time signature '{text}': {error}") return timeline.set_beat_unit( measure_index, - namespace.denominator, - namespace.units, + bar.denominator, + bar.units_text, only_this_measure=namespace.scope == "measure", ) @with_timeline -def remove_unit(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: +def remove_time_signature( + timeline: BeatTimeline, namespace: argparse.Namespace +) -> None: measure_index = _resolve_measure(timeline, namespace) if measure_index is None: return meter = timeline.get_measure_meter(measure_index) if not meter.starts_here: - io.error("No beat unit starts at that measure.") + io.error("The time signature doesn't change at that measure.") return timeline.delete_components([timeline.get_beat_unit_on_beat(meter.beat_unit_id)]) @with_timeline -def list_units(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: - beat_units = timeline.beat_units - if not beat_units: - io.output(f"Timeline {timeline.name!r} has no beat units.") +def list_bars(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + if not len(timeline): + io.output(f"Timeline {timeline.name!r} has no bars.") return - headers = ["measure", "beat time", "denominator", "units", "time signature"] - data = [] - for beat_unit in beat_units: - beat = timeline.get_component(beat_unit.beat_id) - measure_index, _ = timeline.get_measure_index(timeline.get_beat_index(beat)) - numerator, denominator = timeline.get_measure_meter( - measure_index - ).time_signature - time_signature = f"{format_numerator(numerator)}/{denominator}" - if beat_unit.assumed: - time_signature += " (assumed)" - data.append( - ( - str(timeline.measure_numbers[measure_index]), - str(beat.time), - str(beat_unit.denominator), - beat_unit.units, - time_signature, - ) - ) - io.tabulate(headers, data, title=f"Beat units in {timeline.name!r}") + bars = timeline.get_measures_as_planned_bars() + rows = get_rows(bars, len(bars), timeline.measure_numbers) + io.tabulate( + ["bars", "time signature", "taps"], + [format_row(row) for row in rows], + title=f"Bars of {timeline.name!r} (pattern: {timeline.beat_pattern})", + ) @with_timeline From ed75d2d669af61f768d15f715535d29f24c10ddd Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:14:52 +0100 Subject: [PATCH 44/56] feat: import time signatures from CSV, not beat units Beat CSV files took a beat unit in two columns, `denominator` and `units`, put on the row's own beat. They now take a `time_signature` column, written as in the beat pattern ("6/8", "4/4(3/2 3/2 1)"). A row's time signature applies to its measure, over the taps the measure has, until the next row that gives one, as setting it in the inspector does. A row inside a measure sets that measure, rather than leaving a second beat unit in it. `parse_time_signature_of_tapped_bar` refuses taps in brackets that aren't as many as the bar has; the CLI's `time-signatures set` uses it too. The two columns never shipped, so nothing reads them any more. --- tests/parsers/csv/test_beats_from_csv.py | 71 +++++++++++---------- tilia/parsers/csv/beat.py | 80 ++++++++---------------- tilia/timelines/beat/pattern.py | 14 +++++ tilia/ui/cli/timelines/beat.py | 8 +-- 4 files changed, 82 insertions(+), 91 deletions(-) diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 4e3eb6903..87e409378 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -130,64 +130,69 @@ def test_measure_number_on_every_beat_is_forced_once(beat_tlui): assert not beat_tlui[4].label.isVisible() -class TestBeatUnitColumns: - def test_rows_with_values_get_beat_units(self, beat_tl): +def beat_unit_values(beat_tl): + return [ + (beat_tl.get_component(u.beat_id).time, u.denominator, u.units) + for u in beat_tl.beat_units + ] + + +class TestTimeSignatureColumn: + def test_rows_give_their_measures_time_signatures(self, beat_tl): beat_tl.beat_pattern = [2] - data = "time,denominator,units\n0,8,3\n1,,\n2,4,2+3\n3,,\n4,,1\n5,," + data = "time,time_signature\n0,6/8\n1,\n2,5/8(3 2)\n3,\n4,2/4\n5," success, errors = _import_with_patch(beat_tl, data) assert success assert errors == [] - values = [ - (beat_tl.get_component(u.beat_id).time, u.denominator, u.units) - for u in beat_tl.beat_units + assert [m.time_signature for m in beat_tl.measure_meters] == [ + (6, 8), + (5, 8), + (2, 4), ] - assert values == [(0, 8, "3"), (2, 4, "2+3"), (4, 4, "1")] + assert beat_unit_values(beat_tl) == [(0, 8, "3"), (2, 8, "3+2"), (4, 4, "1")] - def test_empty_values_carry_over(self, beat_tl): - beat_tl.beat_pattern = [1] - data = "time,denominator,units\n0,8,\n1,,1/2" + def test_a_time_signature_carries_on(self, beat_tl): + beat_tl.beat_pattern = [2] - _import_with_patch(beat_tl, data) + _import_with_patch(beat_tl, "time,time_signature\n0,6/8\n1,\n2,\n3,") - values = [(u.denominator, u.units) for u in beat_tl.beat_units] - assert values == [(8, "1"), (8, "1/2")] + assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)] - def test_only_one_of_the_columns(self, beat_tl): - data = "time,units\n0,2+3" + def test_a_row_inside_a_measure_sets_that_measure(self, beat_tl): + beat_tl.beat_pattern = [2] - _import_with_patch(beat_tl, data) + _import_with_patch(beat_tl, "time,time_signature\n0,\n1,6/8\n2,\n3,") - (beat_unit,) = beat_tl.beat_units - assert (beat_unit.denominator, beat_unit.units) == (4, "2+3") - assert beat_unit.beat_id == beat_tl[0].id + assert beat_unit_values(beat_tl) == [(0, 8, "3")] def test_invalid_values_are_reported(self, beat_tl): - data = "time,denominator,units\n0,0,1\n1,4,2+\n2,8,1\n3,129,1" + beat_tl.beat_pattern = [2] + data = "time,time_signature\n0,3/0\n1,\n2,4/4(2 1 1)\n3,\n4,x\n5,\n6,3/8\n7," success, errors = _import_with_patch(beat_tl, data) - assert_in_errors("'0' is not a valid denominator", errors) - assert_in_errors("2+", errors) - assert_in_errors("'129' is not a valid denominator", errors) - # The first beat keeps its default beat unit; only the valid row adds one. - values = [(u.denominator, u.units) for u in beat_tl.beat_units] - assert values == [(4, "1"), (8, "1")] + assert_in_errors("'3/0' is not a valid time signature", errors) + assert_in_errors("It gives 3 taps, but the bar has 2.", errors) + assert_in_errors("'x' is not a valid time signature", errors) + # The first beat keeps its default time signature; only the valid row + # adds one. + assert beat_unit_values(beat_tl) == [(0, 4, "1"), (6, 8, "3/2")] - def test_without_the_columns(self, beat_tl): + def test_without_the_column(self, beat_tl): _import_with_patch(beat_tl, "time\n0\n1") - values = [(u.denominator, u.units) for u in beat_tl.beat_units] - assert values == [(4, "1")] + assert beat_unit_values(beat_tl) == [(0, 4, "1")] assert beat_tl.beat_units[0].assumed def test_values_from_the_file_are_not_assumed(self, beat_tl): - _import_with_patch(beat_tl, "time,denominator\n0,4\n1,") + beat_tl.beat_pattern = [2] + + _import_with_patch(beat_tl, "time,time_signature\n0,2/4\n1,") - (beat_unit,) = beat_tl.beat_units - assert (beat_unit.denominator, beat_unit.units) == (4, "1") - assert not beat_unit.assumed + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + assert not beat_tl.beat_units[0].assumed def test_times_alone_follow_the_patterns_time_signatures(beat_tl): diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index 860ebc144..75950909c 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -7,16 +7,11 @@ get_params_indices, ) from tilia.timelines.beat.measures import get_extension_from_beat_pattern -from tilia.timelines.beat.pattern import format_bars -from tilia.timelines.beat.timeline import BeatTimeline -from tilia.timelines.beat.units import ( - DEFAULT_DENOMINATOR, - DEFAULT_UNITS, - MAX_DENOMINATOR, - format_units, - parse_units, - validate_denominator, +from tilia.timelines.beat.pattern import ( + format_bars, + parse_time_signature_of_tapped_bar, ) +from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.component_kinds import ComponentKind @@ -31,9 +26,10 @@ def beats_from_csv( Assumes the first row of the file will contain headers. At least 'time' should be present. 'measure' and 'is_first_in_measure' are optional. - 'denominator' and 'units' are optional and only need filling on rows - where the beat unit changes; a filled row puts a beat unit on its beat, - with any value left empty carried over from the previous one. + 'time_signature' is optional and only needs filling where the time + signature changes: it applies to the measure of its row's beat, over + that measure's taps, until the next row that gives one. It is written + as in the beat pattern, e.g. '6/8', or '4/4(3/2 3/2 1)' with taps. Returns a boolean indicating if the process was successful and an array with descriptions of any errors during the process. @@ -141,7 +137,9 @@ def check_params() -> bool: ) with TiliaCSVReader(path, file_kwargs, reader_kwargs) as reader: - beat_unit_indices = get_params_indices(["denominator", "units"], next(reader)) + time_signature_index = get_params_indices(["time_signature"], next(reader)).get( + "time_signature" + ) rows = [row for row in reader if row] if measure_marks is None: @@ -163,30 +161,29 @@ def check_params() -> bool: created.append((component, row)) else: errors.append(fail_reason) - # The time signatures the beat pattern gives, if any. A row's own - # beat unit, below, replaces the one on its beat. + # The time signatures the beat pattern gives, if any. A row's own, + # below, replaces the one of its measure. timeline.lay_planned_beat_units(0) - # Gives the first beat its default beat unit, which a row of the file can - # then edit. + # Gives the first beat its default time signature, which a row of the + # file can then replace. timeline.refresh_measures() - denominator, units = DEFAULT_DENOMINATOR, DEFAULT_UNITS + if time_signature_index is None: + return True, errors + for component, row in created: - row_values = { - param: row[index].strip() - for param, index in beat_unit_indices.items() - if index < len(row) and row[index].strip() - } - if not row_values: + if time_signature_index >= len(row) or not row[time_signature_index].strip(): continue - beat_unit_values, error = _parse_beat_unit_values( - row_values, denominator, units + text = row[time_signature_index].strip() + measure_index, _ = timeline.get_measure_index( + timeline.get_beat_index(component) ) - if error: - errors.append(f"{row}: {error}") + beat_count = timeline.beats_in_measure[measure_index] + bar, error = parse_time_signature_of_tapped_bar(text, beat_count) + if bar is None: + errors.append(f"{row}: '{text}' is not a valid time signature. {error}") continue - denominator, units = beat_unit_values - timeline.put_beat_unit_on_beat(component.id, denominator, units) + timeline.set_beat_unit(measure_index, bar.denominator, bar.units_text) return True, errors @@ -211,26 +208,3 @@ def _get_measure_marks( ) row_index += count return marks - - -def _parse_beat_unit_values( - row_values: dict[str, str], denominator: int, units: str -) -> tuple[tuple[int, str], str]: - if "denominator" in row_values: - try: - denominator = int(row_values["denominator"]) - except ValueError: - denominator = 0 - if not validate_denominator(denominator): - return (denominator, units), ( - f"'{row_values['denominator']}' is not a valid denominator " - f"(1 to {MAX_DENOMINATOR})" - ) - if "units" in row_values: - result = parse_units(row_values["units"]) - if not result.is_valid: - return (denominator, units), ( - f"'{row_values['units']}' is not valid units: {result.error}" - ) - units = format_units(result.units) - return (denominator, units), "" diff --git a/tilia/timelines/beat/pattern.py b/tilia/timelines/beat/pattern.py index 4738d39ef..536ed3e3e 100644 --- a/tilia/timelines/beat/pattern.py +++ b/tilia/timelines/beat/pattern.py @@ -393,6 +393,20 @@ def parse_time_signature_over_taps( return PlannedBar.from_values([value] * beat_count, bar.denominator), "" +def parse_time_signature_of_tapped_bar( + text: str, beat_count: int +) -> tuple[PlannedBar | None, str]: + """ + Reads the time signature of a bar that has `beat_count` taps, as + `parse_time_signature_over_taps` does, refusing taps in brackets that + aren't as many. + """ + bar, error = parse_time_signature_over_taps(text, beat_count) + if bar is not None and bar.beat_count != beat_count: + return None, f"It gives {bar.beat_count} taps, but the bar has {beat_count}." + return bar, error + + def group_runs(bars: list[int]) -> list[BarRun]: """Groups consecutive bars of equal length, e.g. for previewing a pattern.""" runs: list[BarRun] = [] diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index aed60cf8a..6dfb11011 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -4,7 +4,7 @@ from collections.abc import Callable from functools import wraps -from tilia.timelines.beat.pattern import parse, parse_time_signature_over_taps +from tilia.timelines.beat.pattern import parse, parse_time_signature_of_tapped_bar from tilia.timelines.beat.timeline import BeatTimeline from tilia.ui.cli import io from tilia.ui.cli.timelines.utils import ( @@ -249,10 +249,8 @@ def set_time_signature(timeline: BeatTimeline, namespace: argparse.Namespace) -> return text = " ".join(namespace.time_signature) beat_count = timeline.beats_in_measure[measure_index] - bar, error = parse_time_signature_over_taps(text, beat_count) - if bar is not None and bar.beat_count != beat_count: - error = f"It gives {bar.beat_count} taps, but the measure has {beat_count}." - if bar is None or error: + bar, error = parse_time_signature_of_tapped_bar(text, beat_count) + if bar is None: io.error(f"Invalid time signature '{text}': {error}") return From 751adbd8eafa9c316c83cfda1605f76153bdd62d Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:17:59 +0100 Subject: [PATCH 45/56] feat: export time signatures to JSON, not beat units The JSON export listed each beat unit with its denominator and units. It now lists a TIME_SIGNATURE at each measure where the time signature or its taps change, as the bars dialog and `timelines beat time-signatures list` show them: {"time": 0, "measure": 1, "time_signature": "6/8", "taps": "3 3", "assumed": false, "kind": "TIME_SIGNATURE"} A beat unit can govern measures with different numbers of taps, so exporting it showed neither their time signatures nor where they change. `BeatUnit.get_export_attributes` had no other caller and goes. --- tests/test_export.py | 49 +++++++++++++++++++++--------- tilia/timelines/beat/components.py | 5 --- tilia/timelines/beat/timeline.py | 44 +++++++++++++++++---------- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/tests/test_export.py b/tests/test_export.py index dd84e694d..195af9622 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -182,25 +182,46 @@ def test_image_export_resize_rejected(self, qtui, monkeypatch, tmp_path): assert not image_path.exists() -class TestExportBeatUnits: - def test_beat_units_are_exported(self, tilia, beat_tlui, tmp_path): +class TestExportTimeSignatures: + @staticmethod + def export(tmp_path): + commands.execute("file.export.json", path=tmp_path / "test.json") + with open(tmp_path / "test.json", encoding="utf-8") as f: + return json.load(f)["timelines"][0]["components"] + + def test_time_signatures_are_exported(self, tilia, beat_tlui, tmp_path): for time in range(4): commands.execute("media.seek", time) commands.execute("timeline.beat.add") commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="6/8(2 1 2 1)") - commands.execute("file.export.json", path=tmp_path / "test.json") - with open(tmp_path / "test.json", encoding="utf-8") as f: - components = json.load(f)["timelines"][0]["components"] - - assert [c["kind"] for c in components] == ["BEAT"] * 4 + ["BEAT_UNIT"] - beat_unit = components[-1] - assert beat_unit == { - "beat_id": beat_tlui.timeline[0].id, - "denominator": 8, - "units": "2+1", - "assumed": False, + components = self.export(tmp_path) + + assert [c["kind"] for c in components] == ["BEAT"] * 4 + ["TIME_SIGNATURE"] + assert components[-1] == { "time": 0, "measure": 1, - "kind": "BEAT_UNIT", + "time_signature": "6/8", + "taps": "2 1 2 1", + "assumed": False, + "kind": "TIME_SIGNATURE", } + + def test_one_per_change(self, tilia, beat_tlui, tmp_path): + commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="2[2/4] 3/4") + for time in range(9): + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + + components = self.export(tmp_path) + + time_signatures = [ + (c["time"], c["measure"], c["time_signature"], c["assumed"]) + for c in components + if c["kind"] == "TIME_SIGNATURE" + ] + assert time_signatures == [ + (0, 1, "2/4", False), + (4, 3, "3/4", False), + (7, 4, "2/4", False), + ] diff --git a/tilia/timelines/beat/components.py b/tilia/timelines/beat/components.py index 3833f1b7f..16d1da9c2 100644 --- a/tilia/timelines/beat/components.py +++ b/tilia/timelines/beat/components.py @@ -168,11 +168,6 @@ def set_data(self, attr: str, value: Any) -> tuple[Any, bool]: value = format_units(parse_units(value).units) return super().set_data(attr, value) - @classmethod - def get_export_attributes(cls) -> list[str]: - # Like Staff, a beat unit has no time of its own to export. - return cls.SERIALIZABLE - @property def ordinal(self) -> tuple[float, str]: # Sorts after every beat (whose ordinal is its time), so beat units diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 4c94cc298..8420ce9fc 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -32,7 +32,12 @@ fit_legacy_lists, get_extension_from_beat_pattern, ) -from tilia.timelines.beat.pattern import PlannedBar, parse +from tilia.timelines.beat.pattern import ( + PlannedBar, + format_taps, + format_time_signature, + parse, +) from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, DEFAULT_UNITS, @@ -861,22 +866,29 @@ def _compute_measure_meters(self) -> list[MeasureMeter]: return meters def get_export_data(self) -> dict[str, Any]: - # Beat units are kept apart from beats in the component manager, so - # the base export, which iterates it, only sees beats. They have no - # time of their own; their beat's time and measure are added so the - # export reads without looking the beat up. + """ + The beats, then a time signature at each measure where it or its + taps change, as the bars dialog lists them. Beat units, which store + them, aren't exported: the base export iterates the component + manager, which keeps them apart from beats. + """ result = super().get_export_data() - for beat_unit in self.beat_units: - beat = self.get_component(beat_unit.beat_id) - measure_index, _ = self.get_measure_index(self.get_beat_index(beat)) - data = { - attr: getattr(beat_unit, attr) - for attr in beat_unit.get_export_attributes() - } - data["time"] = beat.time - data["measure"] = self.measure_numbers[measure_index] - data["kind"] = beat_unit.KIND.name - result["components"].append(data) + previous = None + bars = self.get_measures_as_planned_bars() + for measure_index, bar in enumerate(bars): + if bar == previous: + continue + previous = bar + result["components"].append( + { + "time": self._get_first_beat_of_measure(measure_index).time, + "measure": self.measure_numbers[measure_index], + "time_signature": format_time_signature(bar), + "taps": format_taps(bar), + "assumed": bar.assumed, + "kind": "TIME_SIGNATURE", + } + ) return result def set_beat_unit( From 0863b925716e1d1f4356dc6e246cb7795ea3110a Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:51:24 +0100 Subject: [PATCH 46/56] fix: add a beat before the first as anywhere else A beat added before the first started a measure of its own, numbered one less than the first, unless the first was numbered 0, in which case it joined it. Where a beat went depended on a measure's number, and adding a missed first beat left every later barline a beat out. A beat before the first now joins the first measure, which keeps its length, as a beat added anywhere else does, whatever the measures are numbered. Tapping late and adding the missed first beat puts every later barline back. A score's measure 0 is still added as a measure of its own (`add_measure_zero`), since the score has one. --- tests/timelines/beat/test_beat_timeline.py | 26 ++++++++++--------- tests/timelines/beat/test_beat_units.py | 15 ++++++----- .../timelines/beat/test_beat_timeline_ui.py | 12 ++++----- tilia/timelines/beat/timeline.py | 24 ++++------------- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 4845a7e3f..83f40359e 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -47,29 +47,31 @@ def test_create_beat_at_middle_keeps_the_measures_lengths(self, beat_tlui): ] assert beat_tlui.timeline.beats_in_measure == [2, 2] - def test_create_beat_before_the_first_starts_measure_zero(self, beat_tlui): - for time in [10, 11, 12]: + def test_create_beat_before_the_first_keeps_the_measures_lengths(self, beat_tlui): + for time in range(10, 18): commands.execute("media.seek", time) commands.execute("timeline.beat.add") commands.execute("media.seek", 5) commands.execute("timeline.beat.add") - assert beat_tlui.timeline.beats_in_measure == [1, 3] - assert beat_tlui.timeline.measure_numbers == [0, 1] + # It starts the first measure, which keeps its four beats. + starts = [b.time for b in beat_tlui.timeline if b.is_first_in_measure] + assert starts == [5, 13, 17] + assert beat_tlui.timeline.measure_numbers == [1, 2, 3] - def test_create_beat_before_a_pickup_joins_it(self, beat_tlui): - for time in [10, 11, 12, 13, 14]: + def test_deleting_a_measures_only_beat_removes_the_measure(self, beat_tlui): + for time in range(9, 18): commands.execute("media.seek", time) commands.execute("timeline.beat.add") - commands.execute("media.seek", 9) - commands.execute("timeline.beat.add") + beat_tlui.select_element(beat_tlui[0]) + commands.execute("timeline.beat.set_amount_in_measure", amount=1) - commands.execute("media.seek", 8) - commands.execute("timeline.beat.add") + beat_tlui.select_element(beat_tlui[0]) + commands.execute("timeline.component.delete") - assert beat_tlui.timeline.beats_in_measure == [2, 4, 1] - assert beat_tlui.timeline.measure_numbers == [0, 1, 2] + assert beat_tlui.timeline.beats_in_measure == [4, 4] + assert beat_tlui.timeline.measure_numbers == [1, 2] def test_recalculate_measures_added_one_measure(self, beat_tl): beat_tl.create_beat(time=1) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index da1d8c67b..d43339f67 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -409,23 +409,24 @@ class TestFirstBeatUnit: def test_none_without_beats(self, beat_tl): assert beat_tl.beat_units == [] - def test_pickup_copies_the_next_beat_unit(self, beat_tl): - beat_tl.set_data("beat_pattern", [1, 2]) + def test_a_beat_before_the_first_takes_over_its_beat_unit(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 4, start=1) add_beat_unit(beat_tl, 0, 8, "3") - # A pickup before the old first beat, in a measure of its own. + # It starts the first measure, which keeps its two beats. beat_tl.create_beat(0) - assert beat_unit_values(beat_tl) == [(0, 8, "3"), (1, 8, "3")] - assert time_signatures(beat_tl)[0] == (3, 8) + assert beat_tl.beats_in_measure == [2, 2, 1] + assert beat_unit_values(beat_tl) == [(0, 8, "3")] - def test_pickup_leaves_the_other_measures_as_they_are(self, beat_tl): + def test_measure_zero_copies_the_next_beat_unit(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 4, start=1) add_beat_unit(beat_tl, 0, 8, "3") - beat_tl.create_beat(0) + # A measure of its own before the first, as a score's measure 0 adds. + assert beat_tl.add_measure_zero(0.5) == (True, "") assert beat_tl.beats_in_measure == [1, 2, 2] assert beat_unit_values(beat_tl) == [(0, 8, "3"), (1, 8, "3")] diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 71a81e5e5..1e7703b90 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -622,9 +622,10 @@ def record(dialog): def test_ok_without_changes_keeps_the_pattern_for_later_bars(self, tluis): add_beat_timeline_with_pattern("4/4") - add_beats(range(1, 9)) + add_beats(range(9)) # A pickup, which the pattern doesn't plan. - add_beats([0.5]) + tluis[0].select_element(tluis[0][0]) + commands.execute("timeline.beat.set_amount_in_measure", amount=1) assert tluis[0].timeline.beats_in_measure == [1, 4, 4] self.edit_bars(tluis[0], lambda dialog: None) @@ -2579,14 +2580,13 @@ def test_deleting_the_first_beat_unit_makes_it_assumed_again(self, tluis): assert assumed_flags(tluis[0].timeline) == [True] - def test_pickup_copy_of_the_next_beat_unit_is_assumed(self, tluis): + def test_measure_zero_copy_of_the_next_beat_unit_is_assumed(self, tluis): add_beat_timeline_with_pattern("1 2") add_beats(range(1, 5)) set_beat_unit(tluis[0], 0, 8, "3") - # A pickup before the old first beat, in a measure of its own. - commands.execute("media.seek", 0) - commands.execute("timeline.beat.add") + # A measure of its own before the first, as a score's measure 0 adds. + tluis[0].timeline.add_measure_zero(0.5) # It gets the next beat unit's values, but nobody set them there. timeline = tluis[0].timeline diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 8420ce9fc..b5c5633e9 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -958,10 +958,9 @@ def create_component( """ A beat created without saying whether it starts a measure is placed by where it falls: after the last beat, it starts a measure once the - last one has as many beats as the beat pattern gives it; before the - first, it starts a measure of its own, numbered one less than the - first, so a pickup is measure 0; anywhere else, it joins the measure - it falls in, and every measure keeps its length, so the measures + last one has as many beats as the beat pattern gives it; anywhere + else, it joins the measure it falls in (before the first beat, the + first measure), and every measure keeps its length, so the measures after it start one beat later. """ is_placed = kwargs.get("is_first_in_measure") is not None @@ -991,21 +990,6 @@ def _place_new_beat(self, beat: Beat) -> None: if len(self) == 1: self.set_component_data(beat.id, "is_first_in_measure", True) self.lay_planned_beat_units(0) - elif index == 0: - old_first = self[1] - number = ( - 1 - if old_first.measure_number_override is None - else old_first.measure_number_override - ) - if number < 1: - # Before a pickup (measure 0), the beat is the pickup's start. - self.component_manager.move_measure_start(old_first, beat) - return - if not old_first.is_first_in_measure: - self.set_component_data(old_first.id, "is_first_in_measure", True) - self.set_component_data(beat.id, "is_first_in_measure", True) - self.set_component_data(beat.id, "measure_number_override", number - 1) elif index == len(self) - 1: measures = self.measures last_measure = measures.count - 1 @@ -1014,6 +998,8 @@ def _place_new_beat(self, beat: Beat) -> None: self.set_component_data(beat.id, "is_first_in_measure", True) self.lay_planned_beat_units(last_measure + 1) else: + if index == 0: + self.component_manager.move_measure_start(self[1], beat) measures = self.measures measure_index = measures.measure_of_beat[index] counts = measures.counts.copy() From a04ebbcf4601a2742d8728dbef1f921e18448ae4 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:52:22 +0100 Subject: [PATCH 47/56] perf: place pasted beats once, and refresh once per beat edit Pasted beats carry only their times, so each went through the rule for a single new beat: every one pasted before the last beat re-laid every measure after it and relabelled the whole timeline. Pasting 100 beats near the start of a 2,000-beat timeline did that 100 times. `BeatTimeline.adding_beats_together` creates them in bulk and places them once at the end, with the same result: every measure keeps its length. Their beat units are put on them after that, as before. The same paste now takes under a second, and `TestManyBeats` guards it. `_remeasure` refreshed the measures, and so did the callers that run it, so deleting a beat or adding one before the last relabelled every beat twice. It now leaves the refresh to its callers. --- .../timelines/beat/test_beat_timeline_ui.py | 36 +++++++++++++++++++ tilia/timelines/beat/timeline.py | 36 +++++++++++++++++-- tilia/ui/timelines/beat/timeline.py | 35 ++++++++++-------- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 1e7703b90..51e5824bf 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -1030,6 +1030,23 @@ def test_paste_single_into_timeline(self, beat_tlui, tluis): assert len(beat_tlui) == 2 assert beat_tlui[0].time == 0 + def test_pasted_beats_keep_the_measures_lengths(self, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(10, 22)) + for beat_ui in tluis[0][0:2]: + tluis[0].select_element(beat_ui) + commands.execute("timeline.component.copy") + tluis[0].deselect_all_elements() + commands.execute("media.seek", 14.5) + + with undoable(): + commands.execute("timeline.component.paste") + + # As adding them one at a time would: the second measure keeps its + # four beats, so the barlines after it move. + starts = [b.time for b in tluis[0].timeline if b.is_first_in_measure] + assert starts == [10, 14, 16, 20] + def test_paste_multiple_into_timeline(self, beat_tlui, tluis): beat_tlui.create_beat(10) beat_tlui.create_beat(11) @@ -3088,3 +3105,22 @@ def test_setting_a_pattern_with_changing_time_signatures(self, tluis, tilia_stat # A beat unit on every measure: 666 pairs and one more 2/4. assert len(tluis[0].timeline.beat_units) == 1333 + + @pytest.mark.timeout(15) + def test_pasting_many_beats_near_the_start(self, tluis, tilia_state): + tilia_state.set_duration(10000, scale_timelines="no") + add_beat_timeline_with_pattern("4/4") + with patch_fill_beat_timeline_dialog( + tluis[0].timeline, BeatTimeline.FillMethod.BY_AMOUNT, 2000 + ): + commands.execute("timeline.beat.fill") + for beat_ui in tluis[0][0:100]: + tluis[0].select_element(beat_ui) + commands.execute("timeline.component.copy") + tluis[0].deselect_all_elements() + commands.execute("media.seek", 2.5) + + commands.execute("timeline.component.paste") + + assert len(tluis[0].timeline) == 2100 + assert tluis[0].timeline.beats_in_measure == [4] * 525 diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index b5c5633e9..b0039fb71 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -985,6 +985,36 @@ def create_component( ) return component, reason + @contextmanager + def adding_beats_together(self) -> Iterator[None]: + """ + Places the beats created in the block once, at its end, as adding + them one at a time would (see `create_component`): every measure + keeps its length. Into an empty timeline, they are added one at a + time, as that only appends them. + """ + if not len(self): + yield + return + + counts = self.beats_in_measure.copy() + old_ids = {beat.id for beat in self} + old_first = self[0] + manager = self.component_manager + with manager.bulk() as was_bulk: + yield + new_indices = [ + index for index, beat in enumerate(self) if beat.id not in old_ids + ] + if new_indices: + if self[0] is not old_first: + # Beats before the first join the first measure. + manager.move_measure_start(old_first, self[0]) + from_measure = self.measures.measure_of_beat[new_indices[0]] + self._remeasure(self._with_last_measure_open(counts), from_measure) + if not was_bulk: + self.refresh_measures() + def _place_new_beat(self, beat: Beat) -> None: index = self.get_beat_index(beat) if len(self) == 1: @@ -1298,6 +1328,7 @@ def set_beat_amount_in_measure(self, measure_index: int, beat_amount: int) -> No if measure_index != len(counts) - 1: counts = self._with_last_measure_open(counts) self._remeasure(counts, measure_index) + self.refresh_measures() def _with_last_measure_open(self, counts: list[int]) -> list[int]: """ @@ -1314,7 +1345,8 @@ def _remeasure(self, counts: list[int], from_measure: int) -> None: Makes the measures from `from_measure` on hold `counts[from_measure:]` beats each. Each keeps its number, display and the beat unit on its first beat, by position. Beats left over at the end make measures - from the beat pattern; measures past the last beat go. + from the beat pattern; measures past the last beat go. The caller + refreshes the measures (see `refresh_measures`) once it is done. """ measures = self.measures if not 0 <= from_measure < measures.count: @@ -1391,8 +1423,6 @@ def _remeasure(self, counts: list[int], from_measure: int) -> None: # Measures made from the pattern for the beats left over. self.lay_planned_beat_units(from_measure + len(kept_data)) - self.refresh_measures() - def distribute_beats(self, measure_index: int) -> None: self.component_manager.distribute_beats(measure_index) diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index a2887630b..50ae1e028 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -623,21 +623,26 @@ def paste_multiple_into_timeline(self, paste_data: list[dict] | dict): def create_pasted_beats( self, paste_data: list[dict], reference_time: float, target_time: float ) -> None: - for beat_data in copy.deepcopy( - paste_data - ): # deepcopying so popping won't affect original data - beat_time = beat_data["context"].pop("time") - beat_unit_data = beat_data.pop("beat_unit", None) - - beat, _ = self.timeline.create_component( - ComponentKind.BEAT, - target_time + (beat_time - reference_time), - **beat_data["values"], - **beat_data["context"], - ) - if beat and beat_unit_data: - # A pasted first beat already has a beat unit of its own. - self.timeline.put_beat_unit_on_beat(beat.id, **beat_unit_data) + pasted_beat_units = [] + with self.timeline.adding_beats_together(): + for beat_data in copy.deepcopy( + paste_data + ): # deepcopying so popping won't affect original data + beat_time = beat_data["context"].pop("time") + beat_unit_data = beat_data.pop("beat_unit", None) + + beat, _ = self.timeline.create_component( + ComponentKind.BEAT, + target_time + (beat_time - reference_time), + **beat_data["values"], + **beat_data["context"], + ) + if beat and beat_unit_data: + pasted_beat_units.append((beat, beat_unit_data)) + # Once the pasted beats are in their measures. A pasted first beat + # already has a beat unit of its own. + for beat, beat_unit_data in pasted_beat_units: + self.timeline.put_beat_unit_on_beat(beat.id, **beat_unit_data) def update_beat_pattern(self): pass # not implemented From be40af5549fc74f2f5124fffa35d1438086c1973 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:52:52 +0100 Subject: [PATCH 48/56] fix: set a time signature until the time signature changes Setting a time signature from a measure on (the inspector, the CLI's `time-signatures set` and CSV import) put a beat unit there, which carries a value per tap, not a time signature. Bars after it with other numbers of taps read other time signatures: with the pattern `2[2/4] 3/4`, setting bar 1 to 6/8 made the planned 3/4 bar 9/8. It was also spread over the taps an unfinished last bar had so far, so the bar read wrong once it filled up. `BeatTimeline.set_time_signature(text, start, stop)` sets the measures from `start` up to `stop`, each over its own beats, and the measure at `stop` keeps the time signature it had. All three callers use it: - The inspector and the CLI apply it to the measure and the alike measures after it ("From this measure until the next change"), or to the measure only. Nothing asks when the next measure isn't alike. - A CSV row applies it to every measure up to the next row that gives one, each over its own taps, as tilia-core's WP9 (FR-072) sets a time signature on measures. The last measure counts as many beats as it will have once tapped (`get_full_beat_count`), in `get_measures_as_planned_bars` too, so the JSON export and `time-signatures list` no longer show a change at an unfinished last bar. A number alone, which the pattern syntax reads as taps of an assumed quarter note, stays assumed. `time-signatures remove` explains when a measure reads another time signature only because of its number of taps. --- tests/parsers/csv/test_beats_from_csv.py | 24 ++++ tests/test_export.py | 20 ++++ .../test_cli_timelines_time_signatures.py | 34 ++++++ .../timelines/beat/test_beat_timeline_ui.py | 37 +++++- tilia/parsers/csv/beat.py | 32 ++--- tilia/timelines/beat/timeline.py | 109 +++++++++++++++--- tilia/ui/cli/timelines/beat.py | 33 +++--- tilia/ui/timelines/beat/beat_unit.py | 42 +++---- 8 files changed, 263 insertions(+), 68 deletions(-) diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 87e409378..a53f5c597 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -180,6 +180,30 @@ def test_invalid_values_are_reported(self, beat_tl): # adds one. assert beat_unit_values(beat_tl) == [(0, 4, "1"), (6, 8, "3/2")] + def test_a_time_signature_carries_on_over_other_numbers_of_taps(self, beat_tl): + data = "time,is_first_in_measure,time_signature\n0,True,6/8\n1,,\n2,True,\n3,,\n4,," + + _import_with_patch(beat_tl, data) + + assert beat_tl.beats_in_measure == [2, 3] + assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)] + + def test_a_time_signature_replaces_the_patterns_later_ones(self, beat_tl): + beat_tl.beat_pattern = "2[2/4] 3/4" + rows = "\n".join(f"{time}," for time in range(1, 9)) + + _import_with_patch(beat_tl, f"time,time_signature\n0,6/8\n{rows}") + + assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8)] * 4 + + def test_a_number_alone_is_assumed(self, beat_tl): + beat_tl.beat_pattern = [2] + + _import_with_patch(beat_tl, "time,time_signature\n0,2\n1,") + + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + assert beat_tl.beat_units[0].assumed + def test_without_the_column(self, beat_tl): _import_with_patch(beat_tl, "time\n0\n1") diff --git a/tests/test_export.py b/tests/test_export.py index 195af9622..5c59e3683 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -207,6 +207,26 @@ def test_time_signatures_are_exported(self, tilia, beat_tlui, tmp_path): "kind": "TIME_SIGNATURE", } + @pytest.mark.parametrize( + "pattern, beat_count, time_signature", + [("2", 7, "6/8"), ("4", 10, "4/4")], + ) + def test_an_unfinished_last_measure_is_no_change( + self, tilia, beat_tlui, tmp_path, pattern, beat_count, time_signature + ): + commands.execute("timeline.beat.set_pattern", beat_tlui, pattern=pattern) + for time in range(beat_count): + commands.execute("media.seek", time) + commands.execute("timeline.beat.add") + timeline = beat_tlui.timeline + timeline.set_time_signature(time_signature, 0, timeline.measure_count) + + components = self.export(tmp_path) + + assert [c["time_signature"] for c in components if "time_signature" in c] == [ + time_signature + ] + def test_one_per_change(self, tilia, beat_tlui, tmp_path): commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="2[2/4] 3/4") for time in range(9): diff --git a/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py index 18889cd2d..8a5116314 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py +++ b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py @@ -68,6 +68,32 @@ def test_confirms_an_assumed_time_signature(self, cli, beat_tl): assert not beat_tl.measure_meters[0].is_assumed + def test_a_number_alone_is_assumed(self, cli, beat_tl): + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 6/8") + + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 2") + + assert time_signatures(beat_tl)[0] == (2, 4) + assert beat_tl.measure_meters[0].is_assumed + + def test_onward_stops_where_the_time_signature_changes(self, cli, tls): + cli.parse_and_run("timelines add beat --name B -b 2[2/4] 3/4") + tl = tls.get_timelines()[0] + for time in range(9): + cli.parse_and_run(f"components beat -o 1 --time {time}") + + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 1 6/8") + + assert time_signatures(tl) == [(6, 8), (6, 8), (3, 4), (2, 4)] + + def test_an_unfinished_last_measure_is_set_over_all_its_beats(self, cli, beat_tl): + cli.parse_and_run("components beat -o 1 --time 8") + + cli.parse_and_run("timelines beat time-signatures set -o 1 -m 5 6/8") + cli.parse_and_run("components beat -o 1 --time 9") + + assert time_signatures(beat_tl)[4] == (6, 8) + @pytest.mark.parametrize( "args", [ @@ -114,6 +140,14 @@ def test_no_change_at_measure(self, cli, beat_tl): assert len(beat_tl.beat_units) == 2 + def test_a_change_from_the_number_of_taps_is_explained(self, cli, beat_tl): + beat_tl.set_beat_amount_in_measure(1, 3) + + with patch("tilia.ui.cli.io.error") as error: + cli.parse_and_run("timelines beat time-signatures remove -o 1 -m 2") + + assert "3/4 because it has 3 taps" in error.call_args.args[0] + class TestListBars: def test_list(self, cli, beat_tl): diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 51e5824bf..3ca42512c 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -2408,16 +2408,49 @@ def test_edit_from_an_inherited_label_starts_there(self, qtui, tluis): tluis[0], beat_unit_ui.get_label(2), qtui ) - with patch_beat_unit_scope_prompt("onward"): + # The next measure, a 3/8, isn't alike, so nothing asks how far the + # edit goes, and it keeps its time signature. + with patch_beat_unit_scope_prompt("onward") as shown: self.type_into(inspector.field_name_to_widgets["Time signature"][1], "2/4") + assert shown == [] assert label_texts(all_labels(tluis[0])) == [ ("2", "8"), ("3", "8"), ("2", "4"), - ("3", "4"), + ("3", "8"), ] + def test_onward_stops_where_the_time_signature_changes(self, qtui, tluis): + add_beat_timeline_with_pattern("2[2/4] 3/4") + add_beats(range(9)) + inspector = self.open_inspector_for_label( + tluis[0], all_labels(tluis[0])[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward"): + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "6/8") + + assert time_signatures(tluis[0].timeline) == [ + ("6", 8), + ("6", 8), + ("3", 4), + ("2", 4), + ] + + def test_an_unfinished_last_measure_is_set_over_all_its_beats(self, qtui, tluis): + add_beat_timeline_with_pattern("2/4") + add_beats(range(5)) + inspector = self.open_inspector_for_label( + tluis[0], all_labels(tluis[0])[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward"): + self.type_into(inspector.field_name_to_widgets["Time signature"][1], "6/8") + add_beats(range(5, 8)) + + assert time_signatures(tluis[0].timeline) == [("6", 8)] * 4 + def test_half_typed_time_signature_is_not_applied(self, qtui, tluis): add_beat_timeline_with_pattern("2") add_beats(range(4)) diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index 75950909c..ca98c2db0 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -7,10 +7,7 @@ get_params_indices, ) from tilia.timelines.beat.measures import get_extension_from_beat_pattern -from tilia.timelines.beat.pattern import ( - format_bars, - parse_time_signature_of_tapped_bar, -) +from tilia.timelines.beat.pattern import format_bars from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.component_kinds import ComponentKind @@ -27,9 +24,10 @@ def beats_from_csv( At least 'time' should be present. 'measure' and 'is_first_in_measure' are optional. 'time_signature' is optional and only needs filling where the time - signature changes: it applies to the measure of its row's beat, over - that measure's taps, until the next row that gives one. It is written - as in the beat pattern, e.g. '6/8', or '4/4(3/2 3/2 1)' with taps. + signature changes: it applies to the measures from its row's up to + the next row that gives one, each over its own taps. A later row in + the same measure replaces an earlier one. It is written as in the + beat pattern, e.g. '6/8', or '4/4(3/2 3/2 1)' with taps. Returns a boolean indicating if the process was successful and an array with descriptions of any errors during the process. @@ -171,19 +169,27 @@ def check_params() -> bool: if time_signature_index is None: return True, errors + changes: list[tuple[int, str, list[str]]] = [] for component, row in created: if time_signature_index >= len(row) or not row[time_signature_index].strip(): continue - text = row[time_signature_index].strip() measure_index, _ = timeline.get_measure_index( timeline.get_beat_index(component) ) - beat_count = timeline.beats_in_measure[measure_index] - bar, error = parse_time_signature_of_tapped_bar(text, beat_count) - if bar is None: + change = (measure_index, row[time_signature_index].strip(), row) + if changes and changes[-1][0] == measure_index: + changes[-1] = change + else: + changes.append(change) + + for index, (measure_index, text, row) in enumerate(changes): + if index + 1 < len(changes): + stop = changes[index + 1][0] + else: + stop = timeline.measure_count + error = timeline.set_time_signature(text, measure_index, stop) + if error: errors.append(f"{row}: '{text}' is not a valid time signature. {error}") - continue - timeline.set_beat_unit(measure_index, bar.denominator, bar.units_text) return True, errors diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index b0039fb71..99507b996 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -37,6 +37,7 @@ format_taps, format_time_signature, parse, + parse_time_signature_of_tapped_bar, ) from tilia.timelines.beat.units import ( DEFAULT_DENOMINATOR, @@ -513,24 +514,30 @@ def get_planned_bar(self, measure_index: int) -> PlannedBar: def get_measures_as_planned_bars(self) -> list[PlannedBar]: """ Each measure as a bar of a pattern: its beats, with the values its - beat unit gives them. The last measure, while it has only the first - of the beats the pattern plans for it, is the planned bar. + beat unit gives them. The last measure counts as many beats as it + will have once tapped (see `get_full_beat_count`). """ - bars = [] + bars: list[PlannedBar] = [] for measure_index, meter in enumerate(self.measure_meters): - values = list(meter.beat_values) - bar = PlannedBar.from_values(values, meter.denominator, meter.is_assumed) - planned = self.get_planned_bar(measure_index) - if ( - measure_index == self.measure_count - 1 - and len(values) < planned.beat_count - and planned.denominator == meter.denominator - and planned.values[: len(values)] == values - ): - bar = planned - bars.append(bar) + values = fit_units( + list(meter.units), self.get_full_beat_count(measure_index) + ) + bars.append( + PlannedBar.from_values(values, meter.denominator, meter.is_assumed) + ) return bars + def get_full_beat_count(self, measure_index: int) -> int: + """ + A measure's number of beats; for the last measure, while it has + fewer than the pattern plans for it, that many, as tapping adds + beats to it until it has them. + """ + count = self.beats_in_measure[measure_index] + if measure_index == self.measure_count - 1: + return max(count, self.get_planned_bar(measure_index).beat_count) + return count + @property def default_height(self): return settings.get("beat_timeline", "default_height") @@ -932,15 +939,85 @@ def set_beat_unit( return True def _put_beat_unit( - self, measure_index: int, denominator: int, units: str, assumed: bool + self, + measure_index: int, + denominator: int, + units: str, + assumed: bool, + meter: MeasureMeter | None = None, ) -> None: - meter = self.get_measure_meter(measure_index) + """ + Puts a beat unit on a measure, editing the one that starts there if + any. `meter` is the measure's, if the caller already has it. + """ + meter = meter or self.get_measure_meter(measure_index) if meter.starts_here: beat_id = meter.beat_unit_id else: beat_id = self[self.beats_that_start_measures[measure_index]].id self.put_beat_unit_on_beat(beat_id, denominator, units, assumed) + def get_alike_measures_end(self, measure_index: int) -> int: + """ + The index past the measures from `measure_index` on that are alike + (the same time signature and taps), as the bars dialog groups them. + """ + bars = self.get_measures_as_planned_bars() + end = measure_index + 1 + while end < len(bars) and bars[end] == bars[measure_index]: + end += 1 + return end + + def set_time_signature(self, text: str, start: int, stop: int) -> str: + """ + Gives the measures from `start` up to `stop` the time signature + `text`, each over its own beats (see `get_full_beat_count`), as + `parse_time_signature_of_tapped_bar` reads it. The measure at `stop` + keeps the time signature it had. Returns why `text` can't be set + there, or "" once it is. + """ + if not 0 <= start < stop <= self.measure_count: + return "There are no such measures." + + bars: list[PlannedBar] = [] + for measure_index in range(start, stop): + bar, error = parse_time_signature_of_tapped_bar( + text, self.get_full_beat_count(measure_index) + ) + if bar is None: + if stop - start > 1: + number = self.measure_numbers[measure_index] + error = f"Measure {number}: {error}" + return error + bars.append(bar) + + meters = self.measure_meters + previous = None + with self.component_manager.bulk(): + for measure_index, bar in zip(range(start, stop), bars, strict=True): + meter = meters[measure_index] + values = (bar.denominator, bar.units_text, bar.assumed) + if values != previous: + self._put_beat_unit(measure_index, *values, meter=meter) + elif meter.starts_here: + self.component_manager.delete_component( + self.get_beat_unit_on_beat(meter.beat_unit_id) + ) + previous = values + + if stop < self.measure_count: + after = meters[stop] + values = ( + after.denominator, + format_units(after.units), + after.is_assumed, + ) + if not after.starts_here and values != previous: + self._put_beat_unit(stop, *values, meter=after) + + self.refresh_measures() + return "" + @staticmethod def get_extension_from_beat_pattern( beat_pattern: list[int], diff --git a/tilia/ui/cli/timelines/beat.py b/tilia/ui/cli/timelines/beat.py index 6dfb11011..852f96cfb 100644 --- a/tilia/ui/cli/timelines/beat.py +++ b/tilia/ui/cli/timelines/beat.py @@ -4,7 +4,7 @@ from collections.abc import Callable from functools import wraps -from tilia.timelines.beat.pattern import parse, parse_time_signature_of_tapped_bar +from tilia.timelines.beat.pattern import format_time_signature, parse from tilia.timelines.beat.timeline import BeatTimeline from tilia.ui.cli import io from tilia.ui.cli.timelines.utils import ( @@ -117,7 +117,8 @@ def _setup_time_signatures_parser(subparser: argparse._SubParsersAction) -> None "--scope", choices=["onward", "measure"], default="onward", - help="Apply until the next change (default) or to this measure only.", + help="Apply to this measure and the alike measures after it (default), " + "or to this measure only.", ) parser.set_defaults(func=set_time_signature) @@ -248,18 +249,13 @@ def set_time_signature(timeline: BeatTimeline, namespace: argparse.Namespace) -> if measure_index is None: return text = " ".join(namespace.time_signature) - beat_count = timeline.beats_in_measure[measure_index] - bar, error = parse_time_signature_of_tapped_bar(text, beat_count) - if bar is None: + if namespace.scope == "measure": + stop = measure_index + 1 + else: + stop = timeline.get_alike_measures_end(measure_index) + error = timeline.set_time_signature(text, measure_index, stop) + if error: io.error(f"Invalid time signature '{text}': {error}") - return - - timeline.set_beat_unit( - measure_index, - bar.denominator, - bar.units_text, - only_this_measure=namespace.scope == "measure", - ) @with_timeline @@ -271,7 +267,16 @@ def remove_time_signature( return meter = timeline.get_measure_meter(measure_index) if not meter.starts_here: - io.error("The time signature doesn't change at that measure.") + bars = timeline.get_measures_as_planned_bars() + bar = bars[measure_index] + if measure_index and bar.numerator != bars[measure_index - 1].numerator: + io.error( + f"No time signature was set at that measure: it reads " + f"{format_time_signature(bar)} because it has {bar.beat_count} " + "taps." + ) + else: + io.error("The time signature doesn't change at that measure.") return timeline.delete_components([timeline.get_beat_unit_on_beat(meter.beat_unit_id)]) diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py index 2104c0270..3ea4895ea 100644 --- a/tilia/ui/timelines/beat/beat_unit.py +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -208,10 +208,9 @@ def inspected_measure_index(self) -> int: return self.start_measure_index def _get_inspected_bar(self) -> PlannedBar: - meter = self.timeline_ui.timeline.get_measure_meter( - self.inspected_measure_index - ) - return PlannedBar.from_values(list(meter.beat_values), meter.denominator) + timeline = self.timeline_ui.timeline + bar = timeline.get_measures_as_planned_bars()[self.inspected_measure_index] + return PlannedBar.from_values(bar.values, bar.denominator) def get_inspector_dict(self) -> dict: timeline = self.timeline_ui.timeline @@ -226,15 +225,15 @@ def get_inspector_dict(self) -> dict: def on_inspector_edit(self, field_name: str, value: str) -> None: """ Sets the time signature of the measure whose label was clicked, over - its taps, asking first whether it applies there only or until the - next change. + its taps, asking first whether it applies there only or to the alike + measures after it too. """ measure_index = self.inspected_measure_index timeline = self.timeline_ui.timeline if field_name != "Time signature" or measure_index >= timeline.measure_count: return - beat_count = timeline.beats_in_measure[measure_index] + beat_count = timeline.get_full_beat_count(measure_index) bar, error = parse_time_signature_over_taps(value, beat_count) if bar is not None and bar.beat_count != beat_count: error = tilia.ui.strings.TIME_SIGNATURE_TAPS_ERROR.format(beat_count) @@ -244,9 +243,7 @@ def on_inspector_edit(self, field_name: str, value: str) -> None: return edit = (bar.denominator, bar.units_text) - meter = timeline.get_measure_meter(measure_index) - current = PlannedBar.from_values(list(meter.beat_values), meter.denominator) - if bar == current or self._pending_edit == edit: + if bar == self._get_inspected_bar() or self._pending_edit == edit: return self._pending_edit = edit @@ -255,12 +252,10 @@ def on_inspector_edit(self, field_name: str, value: str) -> None: # keeps the view from ever seeing the release (its selection box # stays up). Ask once the mouse is released instead. _call_once_mouse_is_released( - lambda: self._apply_inspector_edit(*edit, measure_index) + lambda: self._apply_inspector_edit(value, measure_index) ) - def _apply_inspector_edit( - self, denominator: int, units: str, measure_index: int - ) -> None: + def _apply_inspector_edit(self, value: str, measure_index: int) -> None: try: if self.id not in self.timeline_ui.id_to_element: return # deleted in the meantime @@ -268,19 +263,20 @@ def _apply_inspector_edit( if measure_index >= timeline.measure_count: return - only_this_measure = False - next_index = measure_index + 1 - if ( - next_index < timeline.measure_count - and not timeline.get_measure_meter(next_index).starts_here - ): + stop = timeline.get_alike_measures_end(measure_index) + if stop > measure_index + 1: accepted, only_this_measure = get(Get.FROM_USER_BEAT_UNIT_SCOPE) if not accepted: self._refresh_inspector() return - - timeline.set_beat_unit(measure_index, denominator, units, only_this_measure) - post(Post.APP_STATE_RECORD, "time signature edit via inspect") + if only_this_measure: + stop = measure_index + 1 + + error = timeline.set_time_signature(value, measure_index, stop) + if error: + tilia.errors.display(tilia.errors.INVALID_TIME_SIGNATURE, error) + else: + post(Post.APP_STATE_RECORD, "time signature edit via inspect") self._refresh_inspector() finally: self._pending_edit = None From 8d1378fa9a7535a2eac6f365add6f71d3f07cb6a Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:47:41 +0100 Subject: [PATCH 49/56] docs: stop calling shorter measures pickups A measure numbered 0 is placed, filled and read like any other, so the docstrings, test names and comments that singled out pickups now speak of shorter measures, or of a score's measure 0 where that is what they mean. --- tests/timelines/beat/test_beat_units.py | 2 +- tests/timelines/beat/test_quarter_stamps.py | 4 ++-- tests/ui/timelines/beat/test_beat_timeline_ui.py | 2 +- tilia/timelines/beat/timeline.py | 11 +++++------ tilia/timelines/beat/units.py | 8 ++++---- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index d43339f67..d947323ff 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -280,7 +280,7 @@ def test_short_measures_take_the_start_of_the_pattern(self, beat_tl): assert [m.beat_values for m in meters] == [(1, 2)] * 3 assert all(m.is_ambiguous for m in meters) - def test_pickup_also_takes_the_start_of_the_pattern(self, beat_tl): + def test_a_shorter_measure_takes_the_start_of_the_units(self, beat_tl): beat_tl.set_data("beat_pattern", [1, 2]) add_beats(beat_tl, 3) add_beat_unit(beat_tl, 0, 4, "3+2") diff --git a/tests/timelines/beat/test_quarter_stamps.py b/tests/timelines/beat/test_quarter_stamps.py index 150d60dfe..567a217c3 100644 --- a/tests/timelines/beat/test_quarter_stamps.py +++ b/tests/timelines/beat/test_quarter_stamps.py @@ -54,12 +54,12 @@ def test_triplets_are_exact(self, beat_tl): assert beat_tl.quarter_stamps[6] == 2 assert beat_tl.quarter_stamps[1] == F(1, 3) - def test_pickup_starts_at_zero(self, beat_tl): + def test_a_shorter_first_measure_starts_at_zero(self, beat_tl): beat_tl.set_data("beat_pattern", [1, 2]) add_beats(beat_tl, range(3)) add_beat_unit(beat_tl, 0, 4, "3+2") - # One pickup tap, read from the start of 3+2, then a full bar. + # One tap, read from the start of 3+2, then a full bar. assert beat_tl.quarter_stamps == [0, 3, 6] def test_stamps_follow_inserted_beats(self, beat_tl): diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 3ca42512c..e3ae7b698 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -623,7 +623,7 @@ def record(dialog): def test_ok_without_changes_keeps_the_pattern_for_later_bars(self, tluis): add_beat_timeline_with_pattern("4/4") add_beats(range(9)) - # A pickup, which the pattern doesn't plan. + # A one-beat measure, which the pattern doesn't plan. tluis[0].select_element(tluis[0][0]) commands.execute("timeline.beat.set_amount_in_measure", amount=1) assert tluis[0].timeline.beats_in_measure == [1, 4, 4] diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 99507b996..e0465cd02 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -774,8 +774,7 @@ def _cached(self, name: str, build: Callable[[], Any]) -> Any: def quarter_stamps(self) -> list[Fraction]: """ Each beat's position in quarter notes, counted in playback order from - the first beat (pickup included), as Verovio's and the Measure Map's - "qstamp". + the first beat, as Verovio's and the Measure Map's "qstamp". """ return self._cached("quarter_stamps", self._build_quarter_stamps) @@ -1216,10 +1215,10 @@ def _ensure_first_beat_unit(self, copy_next: bool = True) -> None: Keeps a beat unit in the first measure, so every measure is governed by one and every time signature label can be selected and edited. - When one is missing, e.g. because a pickup was added before the old - first beat, it copies the next beat unit's values (a pickup in a 6/8 - piece starts as 6/8); with no other beat units, or `copy_next` False, - it gets the default. Either way it is assumed: nobody set a time + When one is missing, e.g. because a measure was added before the old + first measure (a score's measure 0), it copies the next beat unit's + values, as measures before the first beat unit do in tilia_core; with + no other beat units, or `copy_next` False, it gets the default. Either way it is assumed: nobody set a time signature on the measures before the next beat unit. Skipped while beats are being changed in bulk, which refreshes and calls this once at the end. diff --git a/tilia/timelines/beat/units.py b/tilia/timelines/beat/units.py index 2ef3be79c..9d8b573cc 100644 --- a/tilia/timelines/beat/units.py +++ b/tilia/timelines/beat/units.py @@ -95,9 +95,9 @@ def format_units(units: Sequence[Fraction]) -> str: def fit_units(units: list[Fraction], beat_count: int) -> list[Fraction]: """ The value of each of `beat_count` tapped beats in a measure. The pattern - is read from its start in every measure, pickups included: a shorter - measure takes its first entries and a longer one repeats it. A pickup - that holds the end of the pattern needs a beat unit of its own. + is read from its start in every measure: a shorter measure takes its + first entries and a longer one repeats it. A shorter measure that holds + the end of the pattern needs a beat unit of its own. """ return [units[i % len(units)] for i in range(beat_count)] @@ -106,7 +106,7 @@ def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool: """ Whether the measure doesn't hold a whole number of patterns: the pattern is not uniform and the beat count is not a multiple of its length. One - tap under 2+3 is read as 2, but could be a pickup worth 3. + tap under 2+3 is read as 2, but could be the 3 that ends a bar. """ return len(set(units)) > 1 and beat_count % len(units) != 0 From 9f4d28266a65a7ae9d32a51f6cc8acbd4b283c5b Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:41:53 +0100 Subject: [PATCH 50/56] fix: read the last measure from its beats, like any other `be40af55` counted an unfinished last measure as the beats it will have once tapped, both when setting its time signature and when reading it in the inspector, the bars dialog, `time-signatures list` and the JSON export. Its label still read the beats it has, so the two disagreed: a 4/4 bar with one beat tapped had a 1/4 label and a 4/4 inspector. And a short last bar couldn't be given a time signature that fits its beats: "2/4" on a bar of two beats planned as 4/4 was spread over four, making it read 1/4, and "2/4(1 1)" was refused. Every measure, the last included, now reads the beats it has, as its label does, in the inspector, the dialog, `list` and the export. Only setting a time signature on the last measure counts the beats it will have (`get_beat_count_to_set`), so "6/8" on one tap of two is still 3 and 3, unless the time signature as written fits the beats it has: "2/4" on two beats makes a bar of 2/4. "From this measure on" still reaches the last measure while it is being tapped. How many beats an unfinished last measure takes is now worked out in one place (`_get_open_beat_count`), and the inspector reads only the measure it shows rather than every measure. --- tests/test_export.py | 15 ++-- .../test_cli_timelines_time_signatures.py | 13 +++ .../timelines/beat/test_beat_timeline_ui.py | 16 +++- tilia/timelines/beat/timeline.py | 79 ++++++++++++++----- tilia/ui/timelines/beat/beat_unit.py | 11 ++- 5 files changed, 102 insertions(+), 32 deletions(-) diff --git a/tests/test_export.py b/tests/test_export.py index 5c59e3683..35af0f1c7 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -208,11 +208,11 @@ def test_time_signatures_are_exported(self, tilia, beat_tlui, tmp_path): } @pytest.mark.parametrize( - "pattern, beat_count, time_signature", - [("2", 7, "6/8"), ("4", 10, "4/4")], + "pattern, beat_count, time_signature, exported", + [("2", 7, "6/8", ["6/8", "3/8"]), ("4", 10, "4/4", ["4/4", "2/4"])], ) - def test_an_unfinished_last_measure_is_no_change( - self, tilia, beat_tlui, tmp_path, pattern, beat_count, time_signature + def test_an_unfinished_last_measure_is_exported_as_tapped( + self, tilia, beat_tlui, tmp_path, pattern, beat_count, time_signature, exported ): commands.execute("timeline.beat.set_pattern", beat_tlui, pattern=pattern) for time in range(beat_count): @@ -223,9 +223,10 @@ def test_an_unfinished_last_measure_is_no_change( components = self.export(tmp_path) - assert [c["time_signature"] for c in components if "time_signature" in c] == [ - time_signature - ] + # As the timeline's labels show it. + assert [c["time_signature"] for c in components if "time_signature" in c] == ( + exported + ) def test_one_per_change(self, tilia, beat_tlui, tmp_path): commands.execute("timeline.beat.set_pattern", beat_tlui, pattern="2[2/4] 3/4") diff --git a/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py index 8a5116314..aff7b9b29 100644 --- a/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py +++ b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py @@ -94,6 +94,19 @@ def test_an_unfinished_last_measure_is_set_over_all_its_beats(self, cli, beat_tl assert time_signatures(beat_tl)[4] == (6, 8) + @pytest.mark.parametrize("text", ["2/4", "2/4(1 1)"]) + def test_a_short_last_measure_takes_one_that_fits_its_taps(self, cli, tls, text): + cli.parse_and_run("timelines add beat --name B -b 4/4") + tl = tls.get_timelines()[0] + for time in range(10): + cli.parse_and_run(f"components beat -o 1 --time {time}") + + cli.parse_and_run(f"timelines beat time-signatures set -o 1 -m 3 {text}") + + assert time_signatures(tl) == [(4, 4), (4, 4), (2, 4)] + assert tl.measure_meters[2].starts_here + assert tl.measure_meters[2].units == (1,) + @pytest.mark.parametrize( "args", [ diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index e3ae7b698..e9cb0915f 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -617,7 +617,9 @@ def record(dialog): self.edit_bars(tluis[0], record) - assert seen["tapped"] == [True, False, False] + # The second 4/4, with one of its beats tapped, is listed as tapped, + # as its label shows it. + assert seen["tapped"] == [True, True, False, False] assert seen["text"] == "2[4/4] 3/4 2/4" def test_ok_without_changes_keeps_the_pattern_for_later_bars(self, tluis): @@ -2451,6 +2453,18 @@ def test_an_unfinished_last_measure_is_set_over_all_its_beats(self, qtui, tluis) assert time_signatures(tluis[0].timeline) == [("6", 8)] * 4 + def test_an_unfinished_last_measure_shows_as_its_label(self, qtui, tluis): + add_beat_timeline_with_pattern("4/4") + add_beats(range(9)) + label = all_labels(tluis[0])[-1] + + inspector = self.open_inspector_for_label(tluis[0], label, qtui) + + widgets = inspector.field_name_to_widgets + assert label_texts([label]) == [("1", "4")] + assert widgets["Time signature"][1].text() == "1/4" + assert widgets["Taps"][1].text() == "1" + def test_half_typed_time_signature_is_not_applied(self, qtui, tluis): add_beat_timeline_with_pattern("2") add_beats(range(4)) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index e0465cd02..cb4039d57 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -37,6 +37,7 @@ format_taps, format_time_signature, parse, + parse_time_signature, parse_time_signature_of_tapped_bar, ) from tilia.timelines.beat.units import ( @@ -514,18 +515,25 @@ def get_planned_bar(self, measure_index: int) -> PlannedBar: def get_measures_as_planned_bars(self) -> list[PlannedBar]: """ Each measure as a bar of a pattern: its beats, with the values its - beat unit gives them. The last measure counts as many beats as it - will have once tapped (see `get_full_beat_count`). + beat unit gives them. """ - bars: list[PlannedBar] = [] - for measure_index, meter in enumerate(self.measure_meters): - values = fit_units( - list(meter.units), self.get_full_beat_count(measure_index) - ) - bars.append( - PlannedBar.from_values(values, meter.denominator, meter.is_assumed) - ) - return bars + return [ + self.get_measure_as_planned_bar(measure_index) + for measure_index in range(self.measure_count) + ] + + def get_measure_as_planned_bar( + self, measure_index: int, beat_count: int | None = None + ) -> PlannedBar: + """ + A measure as a bar of a pattern: `beat_count` beats, by default the + ones it has, with the values its beat unit gives them. + """ + meter = self.measure_meters[measure_index] + if beat_count is None: + beat_count = self.beats_in_measure[measure_index] + values = fit_units(list(meter.units), beat_count) + return PlannedBar.from_values(values, meter.denominator, meter.is_assumed) def get_full_beat_count(self, measure_index: int) -> int: """ @@ -535,9 +543,33 @@ def get_full_beat_count(self, measure_index: int) -> int: """ count = self.beats_in_measure[measure_index] if measure_index == self.measure_count - 1: - return max(count, self.get_planned_bar(measure_index).beat_count) + return self._get_open_beat_count(measure_index, count) return count + def _get_open_beat_count(self, measure_index: int, count: int) -> int: + """ + The beats a last measure that has `count` takes: as many as the + pattern plans for it, if that is more. + """ + return max(count, self.get_planned_bar(measure_index).beat_count) + + def get_beat_count_to_set(self, text: str, measure_index: int) -> int: + """ + The beats the time signature `text` is read over when it is set on a + measure: the ones it has, or, for the last measure, the ones it will + have once tapped (see `get_full_beat_count`), unless `text` fits the + ones it has as it is written. So "6/8" on a last bar with one of its + two taps is 3 and 3, and "2/4" on one with two taps of four makes it + a bar of 2/4. + """ + count = self.beats_in_measure[measure_index] + if measure_index != self.measure_count - 1: + return count + as_written, _ = parse_time_signature(text) + if as_written is not None and as_written.beat_count == count: + return count + return self._get_open_beat_count(measure_index, count) + @property def default_height(self): return settings.get("beat_timeline", "default_height") @@ -959,18 +991,27 @@ def _put_beat_unit( def get_alike_measures_end(self, measure_index: int) -> int: """ The index past the measures from `measure_index` on that are alike - (the same time signature and taps), as the bars dialog groups them. + (the same time signature and taps), as the bars dialog groups them, + but with the last measure as it will be once tapped (see + `get_full_beat_count`), so a time signature set on the measures + being tapped carries on to the one being tapped. """ - bars = self.get_measures_as_planned_bars() + + def get_bar(index: int) -> PlannedBar: + return self.get_measure_as_planned_bar( + index, self.get_full_beat_count(index) + ) + + bar = get_bar(measure_index) end = measure_index + 1 - while end < len(bars) and bars[end] == bars[measure_index]: + while end < self.measure_count and get_bar(end) == bar: end += 1 return end def set_time_signature(self, text: str, start: int, stop: int) -> str: """ Gives the measures from `start` up to `stop` the time signature - `text`, each over its own beats (see `get_full_beat_count`), as + `text`, each over its own beats (see `get_beat_count_to_set`), as `parse_time_signature_of_tapped_bar` reads it. The measure at `stop` keeps the time signature it had. Returns why `text` can't be set there, or "" once it is. @@ -981,7 +1022,7 @@ def set_time_signature(self, text: str, start: int, stop: int) -> str: bars: list[PlannedBar] = [] for measure_index in range(start, stop): bar, error = parse_time_signature_of_tapped_bar( - text, self.get_full_beat_count(measure_index) + text, self.get_beat_count_to_set(text, measure_index) ) if bar is None: if stop - start > 1: @@ -1412,9 +1453,7 @@ def _with_last_measure_open(self, counts: list[int]) -> list[int]: pattern plans for it, as it may not have all its beats yet. """ last = len(counts) - 1 - return counts[:last] + [ - max(counts[last], self.get_planned_bar(last).beat_count) - ] + return counts[:last] + [self._get_open_beat_count(last, counts[last])] def _remeasure(self, counts: list[int], from_measure: int) -> None: """ diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py index 3ea4895ea..bde590472 100644 --- a/tilia/ui/timelines/beat/beat_unit.py +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -207,9 +207,12 @@ def inspected_measure_index(self) -> int: return self.active_measure_index return self.start_measure_index - def _get_inspected_bar(self) -> PlannedBar: + def _get_inspected_bar(self, beat_count: int | None = None) -> PlannedBar: + """The inspected measure, over `beat_count` beats or the ones it has.""" timeline = self.timeline_ui.timeline - bar = timeline.get_measures_as_planned_bars()[self.inspected_measure_index] + bar = timeline.get_measure_as_planned_bar( + self.inspected_measure_index, beat_count + ) return PlannedBar.from_values(bar.values, bar.denominator) def get_inspector_dict(self) -> dict: @@ -233,7 +236,7 @@ def on_inspector_edit(self, field_name: str, value: str) -> None: if field_name != "Time signature" or measure_index >= timeline.measure_count: return - beat_count = timeline.get_full_beat_count(measure_index) + beat_count = timeline.get_beat_count_to_set(value, measure_index) bar, error = parse_time_signature_over_taps(value, beat_count) if bar is not None and bar.beat_count != beat_count: error = tilia.ui.strings.TIME_SIGNATURE_TAPS_ERROR.format(beat_count) @@ -243,7 +246,7 @@ def on_inspector_edit(self, field_name: str, value: str) -> None: return edit = (bar.denominator, bar.units_text) - if bar == self._get_inspected_bar() or self._pending_edit == edit: + if bar == self._get_inspected_bar(beat_count) or self._pending_edit == edit: return self._pending_edit = edit From 17ff7be9b1fbc8781d559703095767076d4691eb Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:43:07 +0100 Subject: [PATCH 51/56] fix: carry a CSV time signature on over measures it doesn't fit Since `be40af55` a CSV time signature covers every measure up to the next row that gives one, and `set_time_signature` checked each of them before setting any. One measure with other taps refused the whole row, its own measure included: over measures of 2, 3 and 2 taps, "6/8(3 3)" left all three at 2/4, where before it gave 6/8, 9/8 and 6/8. A number alone failed the same way. Only the row's own measure must fit now. A later one that doesn't carries on the beat unit before it, as a measure with other taps does, so the result is 6/8, 9/8 and 6/8 again. The inspector and the CLI only set measures that are alike, so they are unaffected. --- tests/parsers/csv/test_beats_from_csv.py | 23 +++++++++++++++++++++++ tilia/timelines/beat/timeline.py | 21 ++++++++++++--------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index a53f5c597..330544333 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -1,6 +1,8 @@ from pathlib import Path from unittest.mock import mock_open, patch +import pytest + from tests.parsers.csv.common import assert_in_errors from tilia.parsers.csv.beat import beats_from_csv from tilia.settings import settings @@ -188,6 +190,27 @@ def test_a_time_signature_carries_on_over_other_numbers_of_taps(self, beat_tl): assert beat_tl.beats_in_measure == [2, 3] assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)] + @pytest.mark.parametrize( + "text, time_signatures", + [ + ("6/8(3 3)", [(6, 8), (9, 8), (6, 8)]), + ("2", [(2, 4), (3, 4), (2, 4)]), + ], + ) + def test_a_time_signature_carries_on_over_measures_it_does_not_fit( + self, beat_tl, text, time_signatures + ): + data = ( + "time,is_first_in_measure,time_signature\n" + f"0,True,{text}\n1,,\n2,True,\n3,,\n4,,\n5,True,\n6,," + ) + + success, errors = _import_with_patch(beat_tl, data) + + assert errors == [] + assert beat_tl.beats_in_measure == [2, 3, 2] + assert [m.time_signature for m in beat_tl.measure_meters] == time_signatures + def test_a_time_signature_replaces_the_patterns_later_ones(self, beat_tl): beat_tl.beat_pattern = "2[2/4] 3/4" rows = "\n".join(f"{time}," for time in range(1, 9)) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index cb4039d57..f8c3d584c 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -1012,22 +1012,22 @@ def set_time_signature(self, text: str, start: int, stop: int) -> str: """ Gives the measures from `start` up to `stop` the time signature `text`, each over its own beats (see `get_beat_count_to_set`), as - `parse_time_signature_of_tapped_bar` reads it. The measure at `stop` - keeps the time signature it had. Returns why `text` can't be set - there, or "" once it is. + `parse_time_signature_of_tapped_bar` reads it. A measure after + `start` whose beats `text` doesn't fit, such as one of three taps + under 6/8(3 3), carries on the beat unit before it, as a measure + with other taps does: there, 9/8. The measure at `stop` keeps the + time signature it had. Returns why `text` can't be set on the + measure at `start`, or "" once it is. """ if not 0 <= start < stop <= self.measure_count: return "There are no such measures." - bars: list[PlannedBar] = [] + bars: list[PlannedBar | None] = [] for measure_index in range(start, stop): bar, error = parse_time_signature_of_tapped_bar( text, self.get_beat_count_to_set(text, measure_index) ) - if bar is None: - if stop - start > 1: - number = self.measure_numbers[measure_index] - error = f"Measure {number}: {error}" + if bar is None and measure_index == start: return error bars.append(bar) @@ -1036,7 +1036,10 @@ def set_time_signature(self, text: str, start: int, stop: int) -> str: with self.component_manager.bulk(): for measure_index, bar in zip(range(start, stop), bars, strict=True): meter = meters[measure_index] - values = (bar.denominator, bar.units_text, bar.assumed) + if bar is None: + values = previous + else: + values = (bar.denominator, bar.units_text, bar.assumed) if values != previous: self._put_beat_unit(measure_index, *values, meter=meter) elif meter.starts_here: From 6afa32e45e0c29e4cdefe8ab9a0099a9d2dd7327 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:44:35 +0100 Subject: [PATCH 52/56] fix: replace every beat unit in a measure a time signature is set on A measure can hold two beat units after its barlines move. Where a measure in the range already had the new values, `set_time_signature` deleted only the first one, which governs it. The other then governed that measure and the ones after it with the old values, though the call reported success. Each measure in the range now keeps only the beat unit that governs it, and only where its values change. --- tests/timelines/beat/test_beat_units.py | 15 +++++++++++++++ tilia/timelines/beat/timeline.py | 23 +++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index d947323ff..f0ff0cba1 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -516,6 +516,21 @@ def test_invalid(self, beat_tl, measure_index, denominator, units): assert beat_unit_values(beat_tl) == [(0, 4, "1")] +class TestSetTimeSignature: + def test_replaces_every_beat_unit_in_a_measure(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + add_beat_unit(beat_tl, 2, 8, "3") + # A second one in the same measure, which governs the measures after. + add_beat_unit(beat_tl, 3, 4, "1/2") + assert beat_tl.get_measure_meter(1).is_conflicting + + assert beat_tl.set_time_signature("2/4", 0, 3) == "" + + assert time_signatures(beat_tl) == [(2, 4)] * 3 + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + + class TestDeletingBeats: def test_beat_unit_moves_to_another_beat_of_its_measure(self, beat_tl): add_beats(beat_tl, 8) diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index f8c3d584c..5e1c07e68 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -1032,7 +1032,15 @@ def set_time_signature(self, text: str, start: int, stop: int) -> str: bars.append(bar) meters = self.measure_meters - previous = None + measure_of_beat = self.measures.measure_of_beat + beat_units_in: dict[int, list[BeatUnit]] = {} + for beat_unit in self.beat_units: + beat = self.get_component(beat_unit.beat_id) + measure_index = measure_of_beat[self.get_beat_index(beat)] + if start <= measure_index < stop: + beat_units_in.setdefault(measure_index, []).append(beat_unit) + + previous: tuple[int, str, bool] | None = None with self.component_manager.bulk(): for measure_index, bar in zip(range(start, stop), bars, strict=True): meter = meters[measure_index] @@ -1040,12 +1048,15 @@ def set_time_signature(self, text: str, start: int, stop: int) -> str: values = previous else: values = (bar.denominator, bar.units_text, bar.assumed) - if values != previous: + changes = values is not None and values != previous + # A measure keeps only the beat unit that governs it, and only + # where its values change: another one would carry on past it. + kept = meter.beat_unit_id if meter.starts_here and changes else None + for beat_unit in beat_units_in.get(measure_index, []): + if beat_unit.beat_id != kept: + self.component_manager.delete_component(beat_unit) + if changes: self._put_beat_unit(measure_index, *values, meter=meter) - elif meter.starts_here: - self.component_manager.delete_component( - self.get_beat_unit_on_beat(meter.beat_unit_id) - ) previous = values if stop < self.measure_count: From ec6c6d2def43935dcf01a7ba55863635f6f4eadb Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:46:47 +0100 Subject: [PATCH 53/56] fix: import CSV times in any order Without a `measure` or `is_first_in_measure` column, the importer marked the barlines by row, but the timeline orders beats by time. Times out of order put the barlines on the wrong beats, and since `be40af55` a time signature row whose measure came before the previous row's was dropped with "There are no such measures". `dev` measured the beats after adding each one, so their order didn't matter there. The rows are now sorted by time first. With the other columns, times must still be sorted, as before. --- tests/parsers/csv/test_beats_from_csv.py | 14 ++++++++++++++ tilia/parsers/csv/beat.py | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 330544333..3d818a497 100644 --- a/tests/parsers/csv/test_beats_from_csv.py +++ b/tests/parsers/csv/test_beats_from_csv.py @@ -219,6 +219,20 @@ def test_a_time_signature_replaces_the_patterns_later_ones(self, beat_tl): assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8)] * 4 + def test_times_in_any_order(self, beat_tl): + beat_tl.beat_pattern = [2] + data = "time,time_signature\n4,3/4\n0,6/8\n1,\n2,\n3,\n5," + + success, errors = _import_with_patch(beat_tl, data) + + assert errors == [] + assert beat_tl.beats_in_measure == [2, 2, 2] + assert [m.time_signature for m in beat_tl.measure_meters] == [ + (6, 8), + (6, 8), + (3, 4), + ] + def test_a_number_alone_is_assumed(self, beat_tl): beat_tl.beat_pattern = [2] diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index ca98c2db0..09496df28 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -141,6 +141,10 @@ def check_params() -> bool: rows = [row for row in reader if row] if measure_marks is None: + # Times alone may come in any order. The beats follow their times, + # so the measures the pattern makes of them, and the time signatures + # given for those, must too. + rows.sort(key=lambda row: float(row[params_to_indices["time"]])) counts = get_extension_from_beat_pattern(timeline.beat_pattern_bars, len(rows)) measure_marks = _get_measure_marks(counts, list(range(1, len(counts) + 1)), []) From 4084d2dcd704a0b39ed6d2bd3b4c211929c931d5 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:48:00 +0100 Subject: [PATCH 54/56] fix: tell an assumed time signature from a written one in the inspector The inspector checked whether an edit changed anything against the measure without its assumed flag. Writing the time signature an assumed measure already showed, such as "2/4", changed nothing, so it didn't confirm it. Typing its number alone, "2", which is assumed, wrote the same values again and recorded an undo step that did nothing. The check now includes the flag: "2/4" confirms the measure and "2" leaves it as it is. A number alone over a written time signature still makes it assumed, as a number alone means taps of an assumed quarter note everywhere else. --- .../timelines/beat/test_beat_timeline_ui.py | 32 +++++++++++++++++++ tilia/ui/timelines/beat/beat_unit.py | 4 +-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index e9cb0915f..6bc02e47b 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -2465,6 +2465,38 @@ def test_an_unfinished_last_measure_shows_as_its_label(self, qtui, tluis): assert widgets["Time signature"][1].text() == "1/4" assert widgets["Taps"][1].text() == "1" + def test_writing_an_assumed_time_signature_confirms_it(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + inspector = self.open_inspector_for_label( + tluis[0], all_labels(tluis[0])[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward"): + with undoable(): + self.type_into( + inspector.field_name_to_widgets["Time signature"][1], "2/4" + ) + + assert assumed_flags(tluis[0].timeline) == [False] + + def test_retyping_an_assumed_number_changes_nothing(self, qtui, tluis): + add_beat_timeline_with_pattern("2") + add_beats(range(4)) + inspector = self.open_inspector_for_label( + tluis[0], all_labels(tluis[0])[0], qtui + ) + + with patch_beat_unit_scope_prompt("onward") as prompts: + with patch("tilia.ui.timelines.beat.beat_unit.post") as post_: + self.type_into( + inspector.field_name_to_widgets["Time signature"][1], "2" + ) + + assert prompts == [] + assert Post.APP_STATE_RECORD not in [c.args[0] for c in post_.call_args_list] + assert assumed_flags(tluis[0].timeline) == [True] + def test_half_typed_time_signature_is_not_applied(self, qtui, tluis): add_beat_timeline_with_pattern("2") add_beats(range(4)) diff --git a/tilia/ui/timelines/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py index bde590472..d1d50fd48 100644 --- a/tilia/ui/timelines/beat/beat_unit.py +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -209,11 +209,9 @@ def inspected_measure_index(self) -> int: def _get_inspected_bar(self, beat_count: int | None = None) -> PlannedBar: """The inspected measure, over `beat_count` beats or the ones it has.""" - timeline = self.timeline_ui.timeline - bar = timeline.get_measure_as_planned_bar( + return self.timeline_ui.timeline.get_measure_as_planned_bar( self.inspected_measure_index, beat_count ) - return PlannedBar.from_values(bar.values, bar.denominator) def get_inspector_dict(self) -> dict: timeline = self.timeline_ui.timeline From 2a0f8759a8a812a4bb1dfb1c660175541a41aafd Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:52:03 +0100 Subject: [PATCH 55/56] perf: set time signatures without relabelling every beat `set_time_signature` ended by refreshing the measures, which relabels every beat, though setting a time signature moves no beat and renumbers no measure. It did so even inside a bulk change, so CSV import, which sets one per change, relabelled every beat once per change: 2,000 beats with 500 changes took 36.5 s. Each inspector or CLI edit paid about 70 ms on 2,000 beats for it too. It no longer refreshes. The UI redraws the time signatures as their beat units change, as for any other beat unit change, and CSV import sets all its time signatures in one bulk change and refreshes once at the end. Importing 2,000 beats with a change in each of their 500 measures now takes about 4.5 s with the UI attached, which `TestManyBeats` guards. `set_time_signature` also reads the measures once, before changing any beat unit, rather than again after each change, and parses the time signature once per number of beats rather than once per measure. --- .../timelines/beat/test_beat_timeline_ui.py | 23 ++++- tilia/parsers/csv/beat.py | 19 +++-- tilia/timelines/beat/timeline.py | 83 +++++++++++-------- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 6bc02e47b..f48191806 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -1,6 +1,7 @@ import json from fractions import Fraction as F -from unittest.mock import MagicMock, patch +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch import pytest from PySide6.QtCore import Qt @@ -3203,3 +3204,23 @@ def test_pasting_many_beats_near_the_start(self, tluis, tilia_state): assert len(tluis[0].timeline) == 2100 assert tluis[0].timeline.beats_in_measure == [4] * 525 + + @pytest.mark.timeout(15) + def test_importing_many_time_signatures_from_csv(self, tluis, tilia_state): + tilia_state.set_duration(10000, scale_timelines="no") + add_beat_timeline_with_pattern("4") + rows = ["time,is_first_in_measure,time_signature"] + for time in range(2000): + if time % 4: + rows.append(f"{time},,") + else: + rows.append(f"{time},True,{('4/4', '2/2')[time // 4 % 2]}") + + with ( + Serve(Get.FROM_USER_FILE_PATH, (True, Path())), + patch("builtins.open", mock_open(read_data="\n".join(rows))), + ): + commands.execute("timelines.import.beat") + + assert tluis[0].timeline.beats_in_measure == [4] * 500 + assert len(all_labels(tluis[0])) == 500 diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index 09496df28..16e7aae55 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -186,14 +186,17 @@ def check_params() -> bool: else: changes.append(change) - for index, (measure_index, text, row) in enumerate(changes): - if index + 1 < len(changes): - stop = changes[index + 1][0] - else: - stop = timeline.measure_count - error = timeline.set_time_signature(text, measure_index, stop) - if error: - errors.append(f"{row}: '{text}' is not a valid time signature. {error}") + # The time signatures are drawn once, when the measures are refreshed. + with timeline.component_manager.bulk(): + for index, (measure_index, text, row) in enumerate(changes): + if index + 1 < len(changes): + stop = changes[index + 1][0] + else: + stop = timeline.measure_count + error = timeline.set_time_signature(text, measure_index, stop) + if error: + errors.append(f"{row}: '{text}' is not a valid time signature. {error}") + timeline.refresh_measures() return True, errors diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 5e1c07e68..75abc67ce 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -1018,58 +1018,71 @@ def set_time_signature(self, text: str, start: int, stop: int) -> str: with other taps does: there, 9/8. The measure at `stop` keeps the time signature it had. Returns why `text` can't be set on the measure at `start`, or "" once it is. + + No beat moves, so no beat is relabelled: the UI redraws the time + signatures as their beat units change, or, in a bulk change, once + the caller refreshes the measures (see `refresh_measures`). """ - if not 0 <= start < stop <= self.measure_count: + measure_count = self.measure_count + if not 0 <= start < stop <= measure_count: return "There are no such measures." + # Measures with as many beats read `text` alike. + parsed: dict[int, tuple[PlannedBar | None, str]] = {} bars: list[PlannedBar | None] = [] for measure_index in range(start, stop): - bar, error = parse_time_signature_of_tapped_bar( - text, self.get_beat_count_to_set(text, measure_index) - ) + beat_count = self.get_beat_count_to_set(text, measure_index) + if beat_count not in parsed: + parsed[beat_count] = parse_time_signature_of_tapped_bar( + text, beat_count + ) + bar, error = parsed[beat_count] if bar is None and measure_index == start: return error bars.append(bar) + # Read before any beat unit changes, as each change makes the + # measures be computed again the next time they are read. meters = self.measure_meters - measure_of_beat = self.measures.measure_of_beat + measures = self.measures + beats = self.components beat_units_in: dict[int, list[BeatUnit]] = {} for beat_unit in self.beat_units: beat = self.get_component(beat_unit.beat_id) - measure_index = measure_of_beat[self.get_beat_index(beat)] + measure_index = measures.measure_of_beat[self.get_beat_index(beat)] if start <= measure_index < stop: beat_units_in.setdefault(measure_index, []).append(beat_unit) - previous: tuple[int, str, bool] | None = None - with self.component_manager.bulk(): - for measure_index, bar in zip(range(start, stop), bars, strict=True): - meter = meters[measure_index] - if bar is None: - values = previous - else: - values = (bar.denominator, bar.units_text, bar.assumed) - changes = values is not None and values != previous - # A measure keeps only the beat unit that governs it, and only - # where its values change: another one would carry on past it. - kept = meter.beat_unit_id if meter.starts_here and changes else None - for beat_unit in beat_units_in.get(measure_index, []): - if beat_unit.beat_id != kept: - self.component_manager.delete_component(beat_unit) - if changes: - self._put_beat_unit(measure_index, *values, meter=meter) - previous = values - - if stop < self.measure_count: - after = meters[stop] - values = ( - after.denominator, - format_units(after.units), - after.is_assumed, - ) - if not after.starts_here and values != previous: - self._put_beat_unit(stop, *values, meter=after) + def get_beat_id(measure_index: int) -> int | str: + """Where a measure's beat unit goes: on the one governing it.""" + meter = meters[measure_index] + if meter.starts_here and meter.beat_unit_id is not None: + return meter.beat_unit_id + return beats[measures.starts[measure_index]].id - self.refresh_measures() + previous: tuple[int, str, bool] | None = None + for measure_index, bar in zip(range(start, stop), bars, strict=True): + meter = meters[measure_index] + if bar is None: + values = previous + else: + values = (bar.denominator, bar.units_text, bar.assumed) + changes = values is not None and values != previous + # A measure keeps only the beat unit that governs it, and only + # where its values change: another one would carry on past it. + kept = meter.beat_unit_id if meter.starts_here and changes else None + for beat_unit in beat_units_in.get(measure_index, []): + if beat_unit.beat_id != kept: + self.component_manager.delete_component(beat_unit) + if changes: + self.put_beat_unit_on_beat(get_beat_id(measure_index), *values) + previous = values + + if stop < measure_count: + after = meters[stop] + values = (after.denominator, format_units(after.units), after.is_assumed) + if not after.starts_here and values != previous: + self.put_beat_unit_on_beat(get_beat_id(stop), *values) return "" @staticmethod From b3f53c06a66802313b82d142bd392c5fdcbe68af Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:54:43 +0100 Subject: [PATCH 56/56] refactor: remove set_beat_unit The inspector, the CLI and CSV import set time signatures with `set_time_signature` since `be40af55`, so `BeatTimeline.set_beat_unit`, with its own way of setting a measure only and restoring the next one, was left with only tests calling it: two implementations to keep in step, one of them out of users' reach. Its tests now set time signatures as those callers do. The UI tests' helper that arranges a beat unit puts it on the measure directly, or sets a time signature on one measure as the inspector does. --- tests/timelines/beat/test_beat_units.py | 51 ++++++++++------ .../timelines/beat/test_beat_timeline_ui.py | 26 ++++++-- tilia/timelines/beat/timeline.py | 60 ------------------- 3 files changed, 52 insertions(+), 85 deletions(-) diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py index f0ff0cba1..dfca8178f 100644 --- a/tests/timelines/beat/test_beat_units.py +++ b/tests/timelines/beat/test_beat_units.py @@ -441,12 +441,21 @@ def test_deleting_the_first_beat_keeps_the_values(self, beat_tl): assert beat_unit_values(beat_tl) == [(0, 8, "3")] -class TestSetBeatUnit: +class TestSetTimeSignature: + @staticmethod + def set_onward(beat_tl, measure_index, text): + stop = beat_tl.get_alike_measures_end(measure_index) + return beat_tl.set_time_signature(text, measure_index, stop) + + @staticmethod + def set_measure(beat_tl, measure_index, text): + return beat_tl.set_time_signature(text, measure_index, measure_index + 1) + def test_onward_creates_on_the_first_beat(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) - assert beat_tl.set_beat_unit(1, 8, "3") + assert self.set_onward(beat_tl, 1, "6/8") == "" assert time_signatures(beat_tl) == [(2, 4), (6, 8), (6, 8)] assert beat_unit_values(beat_tl) == [(0, 4, "1"), (2, 8, "3")] @@ -454,9 +463,9 @@ def test_onward_creates_on_the_first_beat(self, beat_tl): def test_onward_edits_in_place(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) - beat_tl.set_beat_unit(1, 8, "3") + self.set_onward(beat_tl, 1, "6/8") - beat_tl.set_beat_unit(1, 4, "1/2") + self.set_onward(beat_tl, 1, "1/4") assert beat_unit_values(beat_tl) == [(0, 4, "1"), (2, 4, "1/2")] assert time_signatures(beat_tl) == [(2, 4), (1, 4), (1, 4)] @@ -464,18 +473,18 @@ def test_onward_edits_in_place(self, beat_tl): def test_onward_from_a_later_measure_splits(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 8) - beat_tl.set_beat_unit(1, 8, "3") + self.set_onward(beat_tl, 1, "6/8") - beat_tl.set_beat_unit(2, 4, "1") + self.set_onward(beat_tl, 2, "2/4") assert time_signatures(beat_tl) == [(2, 4), (6, 8), (2, 4), (2, 4)] def test_this_measure_only(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 8) - beat_tl.set_beat_unit(1, 8, "3") + self.set_onward(beat_tl, 1, "6/8") - beat_tl.set_beat_unit(2, 4, "1", only_this_measure=True) + self.set_measure(beat_tl, 2, "2/4") assert time_signatures(beat_tl) == [(2, 4), (6, 8), (2, 4), (6, 8)] @@ -483,16 +492,16 @@ def test_this_measure_only_restores_the_default(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) - beat_tl.set_beat_unit(0, 8, "3", only_this_measure=True) + self.set_measure(beat_tl, 0, "6/8") assert time_signatures(beat_tl) == [(6, 8), (2, 4), (2, 4)] def test_this_measure_only_keeps_a_following_change(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) - beat_tl.set_beat_unit(1, 2, "1") + self.set_onward(beat_tl, 1, "2/2") - beat_tl.set_beat_unit(0, 8, "3", only_this_measure=True) + self.set_measure(beat_tl, 0, "6/8") assert time_signatures(beat_tl) == [(6, 8), (2, 2), (2, 2)] assert len(beat_tl.beat_units) == 2 @@ -500,23 +509,27 @@ def test_this_measure_only_keeps_a_following_change(self, beat_tl): def test_this_measure_only_on_the_last_measure(self, beat_tl): add_beats(beat_tl, 4) - beat_tl.set_beat_unit(0, 8, "1", only_this_measure=True) + self.set_measure(beat_tl, 0, "4/8") assert time_signatures(beat_tl) == [(4, 8)] assert len(beat_tl.beat_units) == 1 @pytest.mark.parametrize( - "measure_index, denominator, units", - [(5, 4, "1"), (-1, 4, "1"), (0, 0, "1"), (0, MAX_DENOMINATOR + 1, "1")], + "start, stop, text", + [ + (5, 6, "4/4"), + (-1, 0, "4/4"), + (0, 1, "4/0"), + (0, 1, f"4/{MAX_DENOMINATOR + 1}"), + (0, 1, "4/4(1 1 1 1 1)"), + ], ) - def test_invalid(self, beat_tl, measure_index, denominator, units): + def test_invalid(self, beat_tl, start, stop, text): add_beats(beat_tl, 4) - assert not beat_tl.set_beat_unit(measure_index, denominator, units) + assert beat_tl.set_time_signature(text, start, stop) != "" assert beat_unit_values(beat_tl) == [(0, 4, "1")] - -class TestSetTimeSignature: def test_replaces_every_beat_unit_in_a_measure(self, beat_tl): beat_tl.set_data("beat_pattern", [2]) add_beats(beat_tl, 6) @@ -607,7 +620,7 @@ def test_restore_state(self, beat_tl): state = beat_tl.get_state()["components"] beat_tl.delete_components([beat_tl[4], beat_tl[5], beat_tl[6], beat_tl[7]]) - beat_tl.set_beat_unit(0, 2, "1") + add_beat_unit(beat_tl, 0, 2, "1") assert beat_unit_values(beat_tl) == [(0, 2, "1")] beat_tl.component_manager.restore_state(state) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index f48191806..f9c34f09b 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -30,8 +30,9 @@ ) from tilia.requests import Get, Post, get, post from tilia.settings import settings +from tilia.timelines.beat.pattern import PlannedBar, format_planned_bar from tilia.timelines.beat.timeline import BeatTimeline -from tilia.timelines.beat.units import format_units +from tilia.timelines.beat.units import fit_units, format_units, parse_units from tilia.timelines.component_kinds import ComponentKind from tilia.ui import commands from tilia.ui.commands import get_qaction @@ -1735,12 +1736,25 @@ def set_beat_unit( timeline_ui, measure_index, denominator, units, only_this_measure=False ): """ - Gives a measure a beat unit, as editing its time signature in the - inspector does, and records the state, so it can be undone. + Gives a measure the beat unit `denominator` and `units`, which carries on + until the next one, or, with `only_this_measure`, sets the time signature + that has them on that measure only, as the inspector does. Records the + state, so it can be undone. """ - timeline_ui.timeline.set_beat_unit( - measure_index, denominator, units, only_this_measure - ) + timeline = timeline_ui.timeline + if only_this_measure: + values = fit_units( + parse_units(units).units, timeline.beats_in_measure[measure_index] + ) + text = format_planned_bar(PlannedBar.from_values(values, denominator)) + assert timeline.set_time_signature(text, measure_index, measure_index + 1) == "" + else: + meter = timeline.get_measure_meter(measure_index) + if meter.starts_here: + beat_id = meter.beat_unit_id + else: + beat_id = timeline[timeline.beats_that_start_measures[measure_index]].id + timeline.put_beat_unit_on_beat(beat_id, denominator, units) post(Post.APP_STATE_RECORD, "set beat unit") diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 75abc67ce..2ee547c29 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -48,7 +48,6 @@ format_units, is_fit_ambiguous, parse_units, - validate_denominator, ) from tilia.timelines.beat.validators import validate_beat_pattern from tilia.timelines.component_kinds import ComponentKind @@ -929,65 +928,6 @@ def get_export_data(self) -> dict[str, Any]: ) return result - def set_beat_unit( - self, - measure_index: int, - denominator: int, - units: str, - only_this_measure: bool = False, - ) -> bool: - """ - Sets the beat unit from `measure_index` until the next change, or for - that measure only, restoring the previous values from the next one. - Edits the beat unit starting in that measure if there is one, and - otherwise creates one on the measure's first beat. The values count - as set by the user even when unchanged, which confirms assumed ones. - """ - result = parse_units(units) - if not result.is_valid or not validate_denominator(denominator): - return False - if not 0 <= measure_index < self.measure_count: - return False - - previous = self.get_measure_meter(measure_index) - self._put_beat_unit( - measure_index, denominator, format_units(result.units), assumed=False - ) - - next_index = measure_index + 1 - if ( - only_this_measure - and next_index < self.measure_count - and not self.get_measure_meter(next_index).starts_here - ): - self._put_beat_unit( - next_index, - previous.denominator, - format_units(previous.units), - assumed=previous.is_assumed, - ) - - return True - - def _put_beat_unit( - self, - measure_index: int, - denominator: int, - units: str, - assumed: bool, - meter: MeasureMeter | None = None, - ) -> None: - """ - Puts a beat unit on a measure, editing the one that starts there if - any. `meter` is the measure's, if the caller already has it. - """ - meter = meter or self.get_measure_meter(measure_index) - if meter.starts_here: - beat_id = meter.beat_unit_id - else: - beat_id = self[self.beats_that_start_measures[measure_index]].id - self.put_beat_unit_on_beat(beat_id, denominator, units, assumed) - def get_alike_measures_end(self, measure_index: int) -> int: """ The index past the measures from `measure_index` on that are alike