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 diff --git a/tests/parsers/csv/test_beats_from_csv.py b/tests/parsers/csv/test_beats_from_csv.py index 7e5f7433d..3d818a497 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 @@ -128,3 +130,136 @@ 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() + + +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,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 == [] + assert [m.time_signature for m in beat_tl.measure_meters] == [ + (6, 8), + (5, 8), + (2, 4), + ] + assert beat_unit_values(beat_tl) == [(0, 8, "3"), (2, 8, "3+2"), (4, 4, "1")] + + def test_a_time_signature_carries_on(self, beat_tl): + beat_tl.beat_pattern = [2] + + _import_with_patch(beat_tl, "time,time_signature\n0,6/8\n1,\n2,\n3,") + + assert [m.time_signature for m in beat_tl.measure_meters] == [(6, 8), (6, 8)] + + def test_a_row_inside_a_measure_sets_that_measure(self, beat_tl): + beat_tl.beat_pattern = [2] + + _import_with_patch(beat_tl, "time,time_signature\n0,\n1,6/8\n2,\n3,") + + assert beat_unit_values(beat_tl) == [(0, 8, "3")] + + def test_invalid_values_are_reported(self, beat_tl): + 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("'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_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)] + + @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)) + + _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_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] + + _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") + + 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): + beat_tl.beat_pattern = [2] + + _import_with_patch(beat_tl, "time,time_signature\n0,2/4\n1,") + + 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): + 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/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/test_export.py b/tests/test_export.py index d82c1bf5b..35af0f1c7 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -180,3 +180,69 @@ def test_image_export_resize_rejected(self, qtui, monkeypatch, tmp_path): commands.execute("file.export.img", image_path) assert not image_path.exists() + + +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)") + + components = self.export(tmp_path) + + assert [c["kind"] for c in components] == ["BEAT"] * 4 + ["TIME_SIGNATURE"] + assert components[-1] == { + "time": 0, + "measure": 1, + "time_signature": "6/8", + "taps": "2 1 2 1", + "assumed": False, + "kind": "TIME_SIGNATURE", + } + + @pytest.mark.parametrize( + "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_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): + 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) + + # 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") + 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/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 79087ee15..83f40359e 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -1,7 +1,6 @@ -from unittest.mock import patch - 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 @@ -26,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_keeps_the_measures_lengths(self, beat_tlui): beat_tlui.timeline.beat_pattern = [2] commands.execute("media.seek", 0) commands.execute("timeline.beat.add") @@ -40,10 +37,41 @@ 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 + # 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, + True, + False, + ] + assert beat_tlui.timeline.beats_in_measure == [2, 2] + + 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") + + # 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_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") + beat_tlui.select_element(beat_tlui[0]) + commands.execute("timeline.beat.set_amount_in_measure", amount=1) + + beat_tlui.select_element(beat_tlui[0]) + commands.execute("timeline.component.delete") + + 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) @@ -156,11 +184,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) @@ -239,74 +262,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) @@ -424,8 +461,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] @@ -435,20 +471,43 @@ 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_keeps_the_measures_lengths(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, + False, + True, + False, + ] + assert beat_tl.beats_in_measure == [2, 2] + + 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) + beat_tl.set_measure_number(1, 10) + + beat_tl.delete_components([beat_tl[2]]) + + 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): @@ -519,36 +578,82 @@ 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_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 pytest.raises(ValueError): + with component_manager.bulk(): + raise ValueError + + 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]) + self._add_beats(range(10)) + beat_tlui.timeline.set_measure_number(3, 20) + + tilia_state.set_duration(5.5, scale_timelines="no") - 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 + 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 + ): + 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") - with patch.object( - component_manager, "delete_component", side_effect=ValueError - ): - with pytest.raises(ValueError): - timeline.delete_components(list(timeline.components)) + # 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 component_manager.compute_is_first_in_measure is True + assert len(beat_tlui) == 0 + assert beat_tlui.timeline.beats_in_measure == [] + assert beat_tlui.timeline.measure_numbers == [] class TestGetExtensionFromBeatPattern: diff --git a/tests/timelines/beat/test_beat_units.py b/tests/timelines/beat/test_beat_units.py new file mode 100644 index 000000000..dfca8178f --- /dev/null +++ b/tests/timelines/beat/test_beat_units.py @@ -0,0 +1,679 @@ +import sys +from fractions import Fraction as F + +import pytest + +from tilia.timelines.beat.units import ( + MAX_DENOMINATOR, + MAX_UNIT_LENGTH, + MeasureMeter, + 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 == [] + + @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 + + 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 longer than {MAX_UNIT_LENGTH} characters." + ) + + @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 longer than {MAX_UNIT_LENGTH} characters." + ) + + 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 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 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): + 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.beat_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.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) + 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_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") + + 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.beat_id + assert meter.denominator == 8 + + 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 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) == [(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]) + 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]) + 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} + + @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") + 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"), (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) + 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_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") + + # It starts the first measure, which keeps its two beats. + beat_tl.create_beat(0) + + assert beat_tl.beats_in_measure == [2, 2, 1] + assert beat_unit_values(beat_tl) == [(0, 8, "3")] + + 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") + + # 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")] + + 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 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 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")] + + def test_onward_edits_in_place(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 6) + self.set_onward(beat_tl, 1, "6/8") + + 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)] + + def test_onward_from_a_later_measure_splits(self, beat_tl): + beat_tl.set_data("beat_pattern", [2]) + add_beats(beat_tl, 8) + self.set_onward(beat_tl, 1, "6/8") + + 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) + self.set_onward(beat_tl, 1, "6/8") + + self.set_measure(beat_tl, 2, "2/4") + + 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) + + 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) + self.set_onward(beat_tl, 1, "2/2") + + 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 + + def test_this_measure_only_on_the_last_measure(self, beat_tl): + add_beats(beat_tl, 4) + + 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( + "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, start, stop, text): + add_beats(beat_tl, 4) + + assert beat_tl.set_time_signature(text, start, stop) != "" + assert beat_unit_values(beat_tl) == [(0, 4, "1")] + + 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) + 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]]) + add_beat_unit(beat_tl, 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_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() + + 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/tests/timelines/beat/test_pattern.py b/tests/timelines/beat/test_pattern.py new file mode 100644 index 000000000..f7f139850 --- /dev/null +++ b/tests/timelines/beat/test_pattern.py @@ -0,0 +1,344 @@ +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, + parse_time_signature_over_taps, +) + + +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, "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 '[]'."), + ], + ) + 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 + + @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): + 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), + ] + + +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 + + +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.", + ) + + +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/tests/timelines/beat/test_quarter_stamps.py b/tests/timelines/beat/test_quarter_stamps.py new file mode 100644 index 000000000..567a217c3 --- /dev/null +++ b/tests/timelines/beat/test_quarter_stamps.py @@ -0,0 +1,237 @@ +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): + 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_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 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) + + 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) + + 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) + + +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/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_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/cli/timelines/test_cli_timelines_time_signatures.py b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py new file mode 100644 index 000000000..aff7b9b29 --- /dev/null +++ b/tests/ui/cli/timelines/test_cli_timelines_time_signatures.py @@ -0,0 +1,208 @@ +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 TestSetTimeSignature: + def test_by_measure_number(self, cli, beat_tl): + 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 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 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_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] == (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 + + 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("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", + [ + "-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 time-signatures 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 time-signatures set -o 1 -m 1 3/8") + + assert_only_the_default_beat_unit(beat_tl) + + +class TestRemoveTimeSignature: + def test_remove(self, cli, beat_tl): + 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 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_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 time-signatures remove -o 1 -m 3") + + 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): + 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 time-signatures list -o 1") + + headers, data = tabulate.call_args.args + assert headers == ["bars", "time signature", "taps"] + assert data == [ + ("1", "2/4 (assumed)", "1 1"), + ("2–4", "6/8", "3 3"), + ] + + def test_empty(self, cli, tls): + cli.parse_and_run("timelines add beat --name Empty") + + with patch("tilia.ui.cli.io.output") as output: + cli.parse_and_run("timelines beat time-signatures list -o 1") + + assert "no bars" 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 e2838f4bd..dfaaea2c1 100644 --- a/tests/ui/timelines/beat/interact.py +++ b/tests/ui/timelines/beat/interact.py @@ -1,5 +1,23 @@ +from contextlib import contextmanager +from unittest.mock import patch + +from PySide6.QtCore import QEvent +from PySide6.QtTest import QTest +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 +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.fill_beat_timeline import FillBeatTimeline def click_beat_ui(beat_ui, button="left", modifier=None, double=False): @@ -12,3 +30,120 @@ def click_beat_ui(beat_ui, button="left", modifier=None, double=False): modifier, double, ) + + +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 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 +): + """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_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_) + + +@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 d156701fe..f9c34f09b 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -1,24 +1,115 @@ -from unittest.mock import MagicMock +import json +from fractions import Fraction as F +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch import pytest - -from tests.mock import Serve, patch_yes_or_no_dialog -from tests.ui.timelines.beat.interact import click_beat_ui -from tests.ui.timelines.interact import drag_mouse_in_timeline_view +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, + edit_bars_dialog_cell, + hover_time_signature_label, + patch_bars_dialog, + patch_beat_pattern_dialog, + patch_beat_unit_scope_prompt, + patch_fill_beat_timeline_dialog, +) +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.pattern import PlannedBar, format_planned_bar from tilia.timelines.beat.timeline import BeatTimeline +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 from tilia.ui.coords import time_x_converter -from tilia.ui.timelines.beat.context_menu import BeatContextMenu +from tilia.ui.strings import BEAT_UNIT_AMBIGUOUS_TOOLTIP, 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 +from tilia.ui.windows.beat_pattern import ( + BARS_COLUMN, + TAPS_COLUMN, + TIME_SIGNATURE_COLUMN, +) + + +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): @@ -61,13 +152,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] - - tmp_file = tmp_path / "test.tla" - - post(Post.REQUEST_SAVE_TO_PATH, tmp_file) + for i in [1, 3, 5, 7]: + beat_tlui.timeline.force_display_measure_number(i) - commands.execute("file.open", tmp_file) + save_and_reopen(tmp_path) assert [x.label.toPlainText() for x in tluis[0]] == [ "1", @@ -83,18 +171,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]) @@ -109,6 +196,568 @@ 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) + + +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["text"] = dialog.text + seen["footer"] = dialog.footer.text() + + with patch_bars_dialog(record): + commands.execute("timeline.beat.set_pattern", tluis[0]) + + 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") + 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_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["rows"] = dialog.get_table_rows() + seen["highlighted"] = dialog.get_highlighted_rows() + + with patch_beat_pattern_dialog("4", on_exec=record): + commands.execute("timeline.beat.set_pattern", tluis[0]) + + 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") + 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] + + +def time_signatures(timeline): + return [ + (format_units([meter.numerator]), meter.denominator) + for meter in timeline.measure_meters + ] + + +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(11)) + tluis[0].select_element(tluis[0][5]) + commands.execute("timeline.beat.set_amount_in_measure", amount=3) + 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" + # 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") + 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(): + 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 == "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) + + # 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, 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") + 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) + + # 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): + add_beat_timeline_with_pattern("4/4") + add_beats(range(9)) + # 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] + + 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)) + + 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)]") + 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_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)) + + 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) @@ -132,7 +781,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) @@ -143,9 +792,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_moves_the_later_barlines(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) @@ -153,6 +802,7 @@ def test_create_update_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", @@ -160,6 +810,61 @@ def test_create_update_next_measures_numbers(self, beat_tlui): "4", ] + 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", + ] + + 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): @@ -280,9 +985,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": {}, @@ -327,6 +1034,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) @@ -383,7 +1107,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): @@ -596,26 +1320,57 @@ 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) 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) 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) 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"] @@ -649,9 +1404,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") @@ -661,9 +1415,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") @@ -674,8 +1427,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") @@ -684,13 +1438,39 @@ 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") 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): @@ -738,7 +1518,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) @@ -950,3 +1730,1511 @@ 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, only_this_measure=False +): + """ + 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 = 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") + + +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() == 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") + 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 TestEditBarsCommand: + @staticmethod + def record_dialog(seen): + def record(dialog): + seen["rows"] = dialog.get_table_rows() + seen["current_row"] = dialog.current_row + + return record + + 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") + # A beat in the middle of measure 3. + tluis[0].select_element(tluis[0][5]) + seen = {} + + with patch_bars_dialog(self.record_dialog(seen)): + commands.execute("timeline.beat.edit_bars") + + # 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_pattern_dialog(None): + commands.execute("timeline.beat.edit_bars") + + assert_only_the_default_beat_unit(tluis[0].timeline) + + 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]) + + 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 undoable(): + with patch_bars_dialog(edit): + commands.execute("timeline.beat.edit_bars") + + 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_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_bars_dialog(self.record_dialog(seen)): + click_time_signature_label(tluis[0], beat_unit_ui.get_label(2), double=True) + + assert seen["current_row"] == 2 + + 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.edit_bars" in get_command_names(menu) + assert "timeline.beat.set_beat_unit" not in get_command_names(menu) + + def test_edit_bars_from_beat_context_menu(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(8)) + tluis[0].select_element(tluis[0][0]) + action = get_command_action( + BeatContextMenu(tluis[0][0]), "timeline.beat.edit_bars" + ) + + with patch_bars_dialog( + lambda dialog: edit_bars_dialog_cell( + dialog, 0, TIME_SIGNATURE_COLUMN, "8/8" + ) + ): + action.trigger() + + # 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") + 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.edit_bars", "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_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") + 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.edit_bars" + ) + seen = {} + + with patch_bars_dialog(self.record_dialog(seen)): + action.trigger() + + assert seen["current_row"] == 1 + + 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 TestTimeSignatureInspector: + @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["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_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["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_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") + 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"): + 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")] + + 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["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, "2/8") + line_edit.editingFinished.emit() + QApplication.processEvents() + + assert tluis[0].timeline.beat_units[-1].units == "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") + 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["Time signature"][1], "2/8") + + 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 + ) + + # 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", "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_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_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)) + 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["Time signature"][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["Time signature"][1] + with patch_beat_unit_scope_prompt("onward") as prompts: + line_edit.selectAll() + QTest.keyClicks(line_edit, "2/8") + 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["Time signature"][1] + + with patch_beat_unit_scope_prompt("onward"): + line_edit.selectAll() + 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]) + 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_an_assumed_time_signature_in_the_dialog(self, tluis): + add_beat_timeline_with_pattern("4") + add_beats(range(4)) + + with patch_bars_dialog( + lambda dialog: edit_bars_dialog_cell( + dialog, 0, TIME_SIGNATURE_COLUMN, "4/4" + ) + ): + with undoable(): + 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") + 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_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 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 + 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") + 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_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)) + 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 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") + 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): + 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.edit_bars", + "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, qtui, tluis): + add_beat_timeline_with_pattern("2 3") + add_beats(range(6)) + 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 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) + 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) + + +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 + + @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 + + @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/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/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/tests/ui/timelines/test_timelineui_collection.py b/tests/ui/timelines/test_timelineui_collection.py index 1bbd6efd7..fc59af6c6 100644 --- a/tests/ui/timelines/test_timelineui_collection.py +++ b/tests/ui/timelines/test_timelineui_collection.py @@ -53,13 +53,36 @@ 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") + + 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")` # 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..ed66e5145 --- /dev/null +++ b/tests/ui/windows/test_beat_pattern_dialog.py @@ -0,0 +1,470 @@ +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 ( + 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_numbers, + get_overrides, + lay_out, + parse_bar_numbers, +) + + +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="") + + +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), + ("4,4", QValidator.State.Invalid), + ("-4", QValidator.State.Invalid), + ], + ) + def test_state(self, text, state): + assert BeatPatternValidator().validate(text, len(text))[0] == state + + +class TestLayOut: + def test_without_beats(self): + 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) + + 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) + + 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: + 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) + + @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): + 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"), + ] + + def test_rows_of_bars_given_only_their_beats(self, qtui): + dialog = BeatPatternDialog("2[3] 4") + + assert dialog.get_table_rows() == [ + ("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): + 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_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 + + def test_numbers_split_rows(self, qtui): + dialog = BeatPatternDialog("4/4", beat_count=16, numbers=[1, 2, 1, 2]) + + 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 dialog.table.rowCount() == 100 + assert dialog.table.maximumHeight() == MAX_TABLE_HEIGHT + + 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") + assert dialog.text == "4[4/4] 3/4(3)" + + def test_taps_that_add_up_to_another_numerator(self, qtui): + dialog = BeatPatternDialog("4/4") + + edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "1 1 1") + + assert dialog.text == "3/4" + + def test_fractional_taps(self, qtui): + dialog = BeatPatternDialog("2[4/4]") + + edit_bars_dialog_cell(dialog, 0, TAPS_COLUMN, "3/2 3/2 1") + + 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_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 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) + + 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/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)") + + # 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) + + 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 == "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") + + 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] 4/4") + assert dialog.ok_button.isEnabled() + 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.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.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() + 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.footer.text().endswith("7 beats tapped, in 2 bars.") + + 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] 4/4"): + add_beat_timeline() + + assert len(tluis) == 1 + 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): + 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]" + + +def test_planned_bars_compare_by_value(): + assert PlannedBar.from_values([F(1)] * 4, 4) == bars("4/4")[0] 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/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/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() diff --git a/tilia/errors.py b/tilia/errors.py index 0fceb78e3..f212c39f9 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,8 @@ 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_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/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 = { diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index c722047f1..16e7aae55 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -6,6 +6,8 @@ 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.component_kinds import ComponentKind @@ -21,6 +23,11 @@ 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. + 'time_signature' is optional and only needs filling where the time + 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. @@ -28,6 +35,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] @@ -41,10 +49,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,25 +129,95 @@ 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("measure_numbers", measure_numbers) - timeline.set_data("measures_to_force_display", measures_to_force_display) + timeline.set_data("beat_pattern", format_bars(beats_in_measure)) + measure_marks = _get_measure_marks( + beats_in_measure, measure_numbers, measures_to_force_display + ) with TiliaCSVReader(path, file_kwargs, reader_kwargs) as reader: - next(reader) - for row in reader: - if not row: - continue - - constructor_kwargs = {} - index = params_to_indices["time"] - constructor_kwargs["time"] = float(row[index]) - + 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: + # 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)), []) + + 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() + # 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 time signature, which a row of the + # file can then replace. + timeline.refresh_measures() + + 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 + measure_index, _ = timeline.get_measure_index( + timeline.get_beat_index(component) + ) + change = (measure_index, row[time_signature_index].strip(), row) + if changes and changes[-1][0] == measure_index: + changes[-1] = change + else: + changes.append(change) + + # 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 + + +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 diff --git a/tilia/requests/get.py b/tilia/requests/get.py index 561a89122..56c23e4f1 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -12,7 +12,9 @@ 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_SCOPE = auto() FROM_USER_BEAT_TIMELINE_FILL_METHOD = auto() FROM_USER_COLOR = auto() FROM_USER_EXPORT_PATH = auto() diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index 36c253a12..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()} @@ -285,8 +296,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 diff --git a/tilia/timelines/beat/components.py b/tilia/timelines/beat/components.py index 794385ba2..16d1da9c2 100644 --- a/tilia/timelines/beat/components.py +++ b/tilia/timelines/beat/components.py @@ -1,36 +1,77 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import math +from fractions import Fraction +from typing import TYPE_CHECKING, Any 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_read_only, + validate_time, +) +from tilia.timelines.beat.units import ( + DEFAULT_DENOMINATOR, + DEFAULT_UNITS, + format_units, + parse_units, + validate_denominator, +) 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, +) + + +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) @@ -40,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): @@ -67,3 +99,102 @@ 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_denominator, + "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, + **_, + ) -> None: + 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) -> str: + assumed = ", assumed" if self.assumed else "" + return ( + f"BeatUnit({self.denominator} / {self.units} on beat {self.beat_id}" + f"{assumed})" + ) + + def __repr__(self) -> str: + return str(self) + + 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) + + @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_denominator(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/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/pattern.py b/tilia/timelines/beat/pattern.py new file mode 100644 index 000000000..536ed3e3e --- /dev/null +++ b/tilia/timelines/beat/pattern.py @@ -0,0 +1,476 @@ +""" +Parsing for the beat pattern syntax. + +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") + + +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 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 + planned_bars: list[PlannedBar] = field(default_factory=list) + error: str = "" + position: int | None = None + + @property + 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: + """Consecutive bars with the same length. `start` is a 0-based bar index.""" + + start: int + count: int + 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) + self.message = message + self.position = position + + +class _Invalid(Exception): + def __init__(self, message: str, position: int) -> None: + super().__init__(message) + self.message = message + self.position = position + + +class _Parser: + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + self.depth = 0 + + def at_end(self) -> bool: + return self.pos >= len(self.text) + + 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 + + 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 self.at_digit(): + self.pos += 1 + 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 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[PlannedBar] = [] + 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.", 0) + + return bars + + def parse_item(self) -> list[PlannedBar]: + start = self.pos + count = self.parse_int() if self.at_digit() else None + self.skip_whitespace() + + 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("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 self.at_end(): + raise _Incomplete("Enter '/' and a denominator.", self.pos) + if not self.at("/"): + 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 + # 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: + 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) + 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 bars 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 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 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] = [] + 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) + ) + + +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_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 + 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 651f77a2a..2ee547c29 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -2,132 +2,273 @@ import itertools import math -from bisect import bisect -from collections.abc import Iterator +from bisect import bisect, bisect_left +from collections import Counter +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.base.component.pointlike import crop_pointlike +from tilia.timelines import serialize +from tilia.timelines.base.metric_position import MetricPosition from tilia.timelines.base.timeline import ( TC, Timeline, 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 +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 ( + PlannedBar, + format_taps, + format_time_signature, + parse, + parse_time_signature, + parse_time_signature_of_tapped_bar, +) +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 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 _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]) + 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] = [] + 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 + # 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 (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]: + def bulk(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. + 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_is_first_in_measure - self.compute_is_first_in_measure = False + was_bulk = self.is_bulk + self.is_bulk = True try: - yield was_computing + yield was_bulk finally: - self.compute_is_first_in_measure = was_computing + self.is_bulk = was_bulk @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, - ) + def beat_units(self) -> list[BeatUnit]: + return list(self._beat_units) - self.compute_metric_fraction_dict = True - self.timeline.update_metric_fraction_dicts() + def get_beat_unit_on_beat(self, beat_id: int) -> BeatUnit | 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) + + 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) 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: - 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 + self.bump_version() + return success, component, reason def _validate_component_creation( self, - _: ComponentKind, - time: float, + kind: ComponentKind, *args, **kwargs, ): - return Beat.validate_creation(time, self.beat_times) + if kind == ComponentKind.BEAT_UNIT: + return BeatUnit.validate_creation( + *args, + 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, _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: + """ + 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: + """ + 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. Outside bulk changes, `BeatTimeline.delete_components` then + gives the measures back their lengths. + """ + 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: - component_idx = self.get_components().index(component) + if component.KIND == ComponentKind.BEAT_UNIT: + super().delete_component(component) + self.bump_version() + return + + if not self.is_restoring: + self._on_beat_deleted(component) + super().delete_component(component) - if self.compute_is_first_in_measure: - self.update_is_first_in_measure_of_subsequent_beats(component_idx - 1) + 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 and self.compute_metric_fraction_dict: - self.timeline.update_metric_fraction_dicts() + 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): 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: @@ -150,70 +291,126 @@ 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() - self.timeline.update_metric_fraction_dicts() + # Times were written directly, so nothing else bumps the version. + self.bump_version() def crop(self, length: float) -> None: - self.compute_is_first_in_measure = False - crop_pointlike(self, length) - self.compute_is_first_in_measure = True + # 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): - 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.bulk() as was_bulk: + for beat_unit in self._beat_units.copy(): + self.delete_component(beat_unit) + for component in reversed(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. - 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() + if not was_bulk: + self.timeline.refresh_measures() - self.compute_is_first_in_measure = False + 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 - # This call will change the attributes above. - super().deserialize_components(serialized_components) + def hash_components(self) -> str: + str_to_hash = "" + for component in self._get_beats_and_beat_units(): + str_to_hash += component.hash + "|" - # 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) + return hash_function(str_to_hash) - self.compute_is_first_in_measure = True + def serialize_components(self) -> dict[int | str, dict[str, Any]]: + return serialize.serialize_components(self._get_beats_and_beat_units()) - 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 - ] + @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]]): + 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. + super().deserialize_components(beats) + if is_legacy: + self.timeline.mark_measures_from_legacy_lists() + super().deserialize_components(beat_units) + + 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() - self.compute_is_first_in_measure = False - super().restore_state(prev_state) - self.compute_is_first_in_measure = True - 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) + """ + 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(): + self.is_restoring = True + try: + # Beat units go first, so no beat deletion below moves them. + for beat_unit in self._beat_units.copy(): + 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 = [ "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", "name", @@ -223,16 +420,30 @@ 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: 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]] = {} + self._metric_fraction_version: int | None = None super().__init__( name=name, height=height, @@ -240,16 +451,123 @@ def __init__( ) self.validators = self.validators | { - "beat_pattern": validate_integer_list, - "beats_in_measure": validate_integer_list, - "measure_numbers": validate_integer_list, - "measures_to_force_display": validate_integer_list, + "beat_pattern": validate_beat_pattern, + "show_time_signatures": validate_bool, } - self.beat_pattern = beat_pattern or [4] - self._beats_in_measure = beats_in_measure or [] - self.measure_numbers = measure_numbers or [] - self.measures_to_force_display = measures_to_force_display or [] + self.beat_pattern = beat_pattern or DEFAULT_BEAT_PATTERN + # 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 + + @property + def beat_pattern(self) -> str: + """The pattern as the user wrote it, e.g. "10[4/4] 3/4".""" + 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._planned_bars = result.planned_bars + + @property + def beat_pattern_bars(self) -> list[int]: + """The bar lengths the pattern expands to.""" + 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)] + + 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. + """ + 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: + """ + 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 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): @@ -260,14 +578,29 @@ def display_measure_number_period(self): return settings.get("beat_timeline", "display_measure_periodicity") @property - def beats_in_measure(self): - return self._beats_in_measure + 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.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 - @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(self) -> list[int]: + return self.measures.starts + + @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 @@ -282,7 +615,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 @@ -298,6 +631,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 [] @@ -386,6 +721,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()) @@ -402,23 +738,292 @@ 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 + 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], + ) - def clear_cached_metric_positions(self): - for beat in self: - beat.clear_cached_metric_position() + @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 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() + def get_beat_unit_on_beat(self, beat_id: int) -> BeatUnit | None: + return self.component_manager.get_beat_unit_on_beat(beat_id) - self.clear_cached_metric_positions() - self.update_beats_that_start_measures() + @property + def measure_meters(self) -> list[MeasureMeter]: + """The meter of every measure, from the beat units in effect.""" + return self._cached("measure_meters", self._compute_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, 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 = [ + (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], + 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} + measures = self.measures + default_units = parse_units(DEFAULT_UNITS).units + + meters = [] + governing: BeatUnit | None = None + 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] + 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=tuple(units), + beat_values=tuple(fit_units(units, beat_count)), + 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, + is_assumed=governing is None or governing.assumed, + ) + ) + return meters + + def get_export_data(self) -> dict[str, Any]: + """ + 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() + 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 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, + 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. + """ + + 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 < 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_beat_count_to_set`), as + `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. + + 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`). + """ + 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): + 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 + 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 = measures.measure_of_beat[self.get_beat_index(beat)] + if start <= measure_index < stop: + beat_units_in.setdefault(measure_index, []).append(beat_unit) + + 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 + + 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 def get_extension_from_beat_pattern( @@ -427,130 +1032,256 @@ 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}") + return get_extension_from_beat_pattern( + beat_pattern, amount, start_index, beats_on_starting_measure + ) - beat_pattern_cycle = itertools.cycle(beat_pattern) + 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; 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 + 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 + + 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), + ) + return component, reason - # iterate up to start index - for _ in range(start_index): - next(beat_pattern_cycle) + @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 - 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 + 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: + self.set_component_data(beat.id, "is_first_in_measure", True) + self.lay_planned_beat_units(0) + elif index == len(self) - 1: + measures = self.measures + last_measure = measures.count - 1 + beats_before = measures.counts[last_measure] - 1 + 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: - 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 + 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() + 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 + ) -> None: + """ + 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 from_measure >= measures.count or ( + not bars and not self.plans_time_signatures + ): + return - return extension + 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_beats_in_measure_extension(self, amount: int): - if not self.beat_pattern: - raise ValueError("Beat pattern is empty, can't get measure extension.") + def get_meter(bar: PlannedBar) -> tuple[int, tuple[Fraction, ...], bool]: + return bar.denominator, bar.units, bar.assumed - 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 + beats = self.components + managed = set() + planned: dict[int | str, tuple[int, str, bool]] = {} + for measure_index in range(from_measure, measures.count): + 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) + + # 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] in managed + and beat_unit.beat_id not in planned + ): + self.component_manager.delete_component(beat_unit) + + 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: + 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 - if start_index == -1 and beats_on_starting_measure == self.beat_pattern[-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, - amount, - start_index=start_index, - beats_on_starting_measure=beats_on_starting_measure, + counts, numbers = fit_legacy_lists( + len(self), self.beat_pattern_bars, beats_in_measure, measure_numbers ) + 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 extend_beats_in_measure(self, amount: int) -> None: - extension = self._get_beats_in_measure_extension(amount) + 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 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. + """ + manager = self.component_manager + if manager.is_restoring or manager.is_bulk or not len(self): + return - if not self._beats_in_measure: - self._beats_in_measure += extension + 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 - bp_index = (self.measure_count % len(self.beat_pattern)) - 1 - is_last_measure_complete = ( - self._beats_in_measure[-1] >= self.beat_pattern[bp_index] + beat_units = self.beat_units + if copy_next and beat_units: + denominator, units = beat_units[0].denominator, beat_units[0].units + else: + denominator, units = DEFAULT_DENOMINATOR, DEFAULT_UNITS + self.create_component( + ComponentKind.BEAT_UNIT, + beat_id=self[0].id, + denominator=denominator, + units=units, + assumed=True, ) - if is_last_measure_complete: - self._beats_in_measure += extension + 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._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 - 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 - ] + 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_beats_that_start_measures(self): - # noinspection PyAttributeOutsideInit - self.beats_that_start_measures = [0] + list( - itertools.accumulate(self.beats_in_measure[:-1]) - ) - self.beats_that_start_measures_set = set(self.beats_that_start_measures) - self.update_metric_fraction_dicts() + 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 = {} @@ -591,100 +1322,319 @@ 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 + 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]: + """ + `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] + [self._get_open_beat_count(last, counts[last])] + + 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. The caller + refreshes the measures (see `refresh_measures`) once it is done. + """ + 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 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) + ) + 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) + + 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)) def distribute_beats(self, measure_index: int) -> None: self.component_manager.distribute_beats(measure_index) + def refresh_measures(self) -> None: + """ + 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.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 + 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_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 + 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: + return False + + if len(self): + 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: + """ + 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) - 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, + ) + 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) - 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 - # 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) + # Deleting the first measure's beat unit resets it to the default, + # rather than copying the next one as a structural change would. + 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 @@ -693,23 +1643,22 @@ 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 - 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) + 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.lay_planned_beat_units(0) + + self.refresh_measures() def add_measure_zero(self, fraction_of_measure_one: float) -> tuple[bool, str]: if self.measure_count < 2: @@ -731,9 +1680,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/units.py b/tilia/timelines/beat/units.py new file mode 100644 index 000000000..9d8b573cc --- /dev/null +++ b/tilia/timelines/beat/units.py @@ -0,0 +1,152 @@ +""" +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". 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. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from fractions import Fraction + +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. +# 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 +# limit), so a unit reads the same on every Python. +MAX_UNIT_LENGTH = 32 + +_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] + 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 '+'.") + 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"'{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 + # 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) + + return UnitsParseResult(units) + + +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) + + +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: 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)] + + +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 the 3 that ends a bar. + """ + 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 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 + units: tuple[Fraction, ...] + beat_values: tuple[Fraction, ...] + beat_unit_id: str | None + starts_here: bool + is_ambiguous: bool + 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)) + + @property + 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. 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 diff --git a/tilia/timelines/beat/validators.py b/tilia/timelines/beat/validators.py index 5ce3b9623..7d58349eb 100644 --- a/tilia/timelines/beat/validators.py +++ b/tilia/timelines/beat/validators.py @@ -1,2 +1,13 @@ -def validate_integer_list(value): - return all([isinstance(x, int) for x in value]) +from tilia.timelines.beat.pattern import parse + + +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/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): 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..18b2c15af 100644 --- a/tilia/ui/cli/timelines/add.py +++ b/tilia/ui/cli/timelines/add.py @@ -3,12 +3,15 @@ import tilia.errors from tilia.requests import Get, get 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.timelines.beat_time_signatures import get_creation_args def setup_parser(subparser): @@ -18,7 +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 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" """, @@ -49,12 +53,15 @@ 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="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", @@ -118,8 +125,17 @@ 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: + kwargs |= get_creation_args(kwargs.get("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..852f96cfb --- /dev/null +++ b/tilia/ui/cli/timelines/beat.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import argparse +from collections.abc import Callable +from functools import wraps + +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 ( + get_timeline_by_name, + get_timeline_by_ordinal, +) +from tilia.ui.timelines.beat_bars import format_row, get_rows +from tilia.ui.timelines.beat_time_signatures import set_time_signatures_shown + + +def setup_parser(subparser: argparse._SubParsersAction) -> None: + 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_time_signatures_parser(beat_subp) + + +def _setup_pattern_parser(subparser: argparse._SubParsersAction) -> None: + 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: argparse._SubParsersAction) -> None: + parser = subparser.add_parser( + "set", + exit_on_error=False, + help="Set the time signatures of the bars and recompute every measure " + "from them.", + epilog=""" +Examples: + 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, + ) + _add_target_args(parser) + parser.add_argument( + "pattern", + type=str, + nargs="+", + 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", + "-y", + action="store_true", + help="Overwrite measures set by hand without asking.", + ) + parser.set_defaults(func=set_pattern) + + +def _setup_time_signatures_parser(subparser: argparse._SubParsersAction) -> None: + time_signatures_parser = subparser.add_parser( + "time-signatures", + exit_on_error=False, + help="Time signature operations.", + ) + time_signatures_subp = time_signatures_parser.add_subparsers( + dest="time_signatures_command", required=True + ) + + parser = time_signatures_subp.add_parser( + "set", + exit_on_error=False, + help="Set the time signature of a measure, over the taps it has.", + epilog=""" +Examples: + 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( + "time_signature", + type=str, + 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", + choices=["onward", "measure"], + default="onward", + help="Apply to this measure and the alike measures after it (default), " + "or to this measure only.", + ) + parser.set_defaults(func=set_time_signature) + + parser = time_signatures_subp.add_parser( + "remove", + exit_on_error=False, + 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_time_signature) + + parser = time_signatures_subp.add_parser( + "list", + exit_on_error=False, + help="List the bars of a beat timeline, with their time signatures and " + "taps.", + ) + _add_target_args(parser) + parser.set_defaults(func=list_bars) + + 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: + 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_time_signature(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + measure_index = _resolve_measure(timeline, namespace) + if measure_index is None: + return + text = " ".join(namespace.time_signature) + 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}") + + +@with_timeline +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: + 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)]) + + +@with_timeline +def list_bars(timeline: BeatTimeline, namespace: argparse.Namespace) -> None: + if not len(timeline): + io.output(f"Timeline {timeline.name!r} has no bars.") + return + + 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 +def set_time_signatures_visibility( + timeline: BeatTimeline, namespace: argparse.Namespace +) -> None: + set_time_signatures_shown(timeline.id, namespace.action == "show") 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..a66d22dc3 100644 --- a/tilia/ui/dialog_manager.py +++ b/tilia/ui/dialog_manager.py @@ -24,8 +24,10 @@ 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, ) +from tilia.ui.windows.beat_unit import ask_for_beat_unit_scope class DialogManager: @@ -42,7 +44,9 @@ 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_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/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/strings.py b/tilia/ui/strings.py index 557632cf0..30c675081 100644 --- a/tilia/ui/strings.py +++ b/tilia/ui/strings.py @@ -1,10 +1,48 @@ 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_TITLE = "Bars and time signatures" +BEAT_PATTERN_DIALOG_PROMPT = ( + "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' = 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. '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" +BEAT_PATTERN_OVERWRITE_PROMPT = ( + "This pattern will overwrite bars set by hand (bars {})." + "\nDo you want to continue?" +) +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 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_ASSUMED_TOOLTIP = ( + "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 = ( "Node is at already lowest level." 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/beat/beat_unit.py b/tilia/ui/timelines/beat/beat_unit.py new file mode 100644 index 000000000..d1d50fd48 --- /dev/null +++ b/tilia/ui/timelines/beat/beat_unit.py @@ -0,0 +1,318 @@ +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 QGraphicsItem, QGraphicsLineItem, QGraphicsScene + +import tilia.errors +import tilia.ui.strings +from tilia.requests import Get, Post, get, post +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 +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_pattern import BeatPatternValidator +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, 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"] + CONTEXT_MENU_CLASS = BeatUnitContextMenu + + INSPECTOR_FIELDS = [ + ( + "Time signature", + InspectRowKind.SINGLE_LINE_EDIT_ON_FINISH, + lambda: {"validator": BeatPatternValidator}, + ), + ("Taps", InspectRowKind.LABEL, None), + ("Measure", InspectRowKind.LABEL, None), + ("Assumed", InspectRowKind.LABEL, None), + ] + FIELD_NAMES_TO_ATTRIBUTES: dict[str, str] = {} + + def __init__( + self, + id: int, + timeline_ui: BeatTimelineUI, + scene: QGraphicsScene, + get_data: Callable[[str], Any], + set_data: Callable[[str, Any], None], + **_, + ) -> 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: QGraphicsItem) -> 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: QGraphicsItem) -> None: + self._set_active_label(item) + + def on_double_left_click(self, item: QGraphicsItem) -> None: + self._set_active_label(item) + commands.execute("timeline.beat.edit_bars") + + def on_right_click(self, x: int, y: int, item: QGraphicsItem) -> 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] + + @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, beat_count: int | None = None) -> PlannedBar: + """The inspected measure, over `beat_count` beats or the ones it has.""" + return self.timeline_ui.timeline.get_measure_as_planned_bar( + self.inspected_measure_index, beat_count + ) + + def get_inspector_dict(self) -> dict: + timeline = self.timeline_ui.timeline + bar = self._get_inspected_bar() + return { + "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: str) -> None: + """ + Sets the time signature of the measure whose label was clicked, over + 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.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) + 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) + if bar == self._get_inspected_bar(beat_count) 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(value, measure_index) + ) + + 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 + timeline = self.timeline_ui.timeline + if measure_index >= timeline.measure_count: + return + + 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 + 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 + + 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..e179c3f92 100644 --- a/tilia/ui/timelines/beat/context_menu.py +++ b/tilia/ui/timelines/beat/context_menu.py @@ -1,9 +1,17 @@ +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 ( TimelineUIContextMenu, TimelineUIElementContextMenu, ) +if TYPE_CHECKING: + from tilia.ui.timelines.beat.beat_unit import BeatUnitUI + class BeatContextMenu(TimelineUIElementContextMenu): name = "Beat" @@ -14,6 +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.edit_bars"), (MenuItemKind.SEPARATOR, None), (MenuItemKind.COMMAND, "timeline.component.copy"), (MenuItemKind.COMMAND, "timeline.component.cut"), @@ -23,6 +32,35 @@ class BeatContextMenu(TimelineUIElementContextMenu): ] +class BeatUnitContextMenu(TimelineUIElementContextMenu): + name = "Time signature" + items = [ + (MenuItemKind.COMMAND, "timeline.beat.edit_bars"), + (MenuItemKind.SEPARATOR, None), + (MenuItemKind.COMMAND, "timeline.component.delete"), + ] + + 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. + 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..6ff0b1f69 100644 --- a/tilia/ui/timelines/beat/dialogs.py +++ b/tilia/ui/timelines/beat/dialogs.py @@ -1,24 +1,28 @@ -import tilia.errors -from tilia.ui.windows.beat_pattern import AskBeatPattern +from tilia.timelines.beat.pattern import PlannedBar +from tilia.ui.windows.beat_pattern import BarsEdit, BeatPatternDialog 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]) +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 "" - result, accept = AskBeatPattern().ask() - 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_bars( + initial_text: str, + 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, 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/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..758b0575a --- /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, + ) -> 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 4d5b2a794..50ae1e028 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -1,18 +1,44 @@ +from __future__ import annotations + import copy +from collections import defaultdict +from collections.abc import Callable, Iterator +from typing import Any -from tilia.requests import Get, Post, get, listen +import tilia.errors +from tilia.requests import Get, Post, get, listen, post +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.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 from tilia.ui.timelines.beat.element import BeatUI +from tilia.ui.timelines.beat.time_signature import ( + 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, @@ -21,24 +47,26 @@ 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", - "measure_numbers", - "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 +125,279 @@ def register_commands(cls, collection: TimelineUIs): text="Set amount in measure", ) + commands.register( + "timeline.beat.set_pattern", + cls.on_set_pattern, + text="Edit bars and time signatures", + ) + + cls.register_timeline_command( + collection, + "edit_bars", + cls.on_edit_bars, + TimelineSelector.SELECTED, + text="Edit bars and time signatures", + ) + + cls.register_timeline_command( + collection, + "remove_other_beat_units", + cls.on_remove_other_beat_units, + TimelineSelector.SELECTED, + text="Keep only the first time signature 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) -> Iterator[BeatUI]: + return iter(self.beat_uis) + + def __getitem__(self, item: int | slice) -> BeatUI | list[BeatUI]: + return self.beat_uis[item] + + def __len__(self) -> int: + return len(self.beat_uis) + + 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: 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) -> None: + 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: 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) -> None: + 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 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) + 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: + 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"), [])) + + 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: TimelineUIElement, + field_name: str, + value: Any, + 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: + return set_time_signatures_shown( + timeline_ui.id, not timeline_ui.get_data("show_time_signatures") + ) + + def _get_beat_unit_target_measure(self) -> int | None: + """ + 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 ( + 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_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() + return self.edit_bars(measure_index) + + 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 + accepted, edit = get( + Get.FROM_USER_BARS, + timeline.beat_pattern, + len(timeline), + timeline.get_measures_as_planned_bars(), + timeline.measure_numbers, + measure_index, + ) + if not accepted: + return False + return timeline.set_bars(edit.pattern, edit.bars, edit.numbers) + + 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: @@ -106,12 +407,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( @@ -158,13 +453,49 @@ 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, + 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: + return timeline_ui.edit_bars() + + 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 + if numbers is not None: + timeline.set_measure_numbers(numbers) + return True + 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 @@ -193,11 +524,22 @@ 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 + ) -> tuple[bool, dict[str, Any]]: + 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} | get_creation_args() def on_settings_updated(self, updated_settings): if "beat_timeline" in updated_settings: @@ -215,6 +557,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) @@ -223,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 = [] @@ -234,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) -> 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: + 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: 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)] + ) + 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) @@ -253,41 +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") - - self.timeline.create_component( - ComponentKind.BEAT, - target_time + (beat_time - reference_time), - **beat_data["values"], - **beat_data["context"], - ) + 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 - - 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() - - def beats_that_start_measures(self): - for beat_ui in self: - beat_ui.update_is_first_in_measure() 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/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 diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index e4043dc68..3d9689131 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 + ) -> bool | None: + """ + `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 @@ -724,7 +739,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]: 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/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index df1724bc9..a573884c6 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -55,9 +55,13 @@ 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 + ) -> tuple[bool, dict[str, str]]: + 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")) 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..1927234ff --- /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, + ) -> 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() diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index f8ee2faa0..48391f1d6 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -1,22 +1,497 @@ -from PySide6.QtWidgets import QInputDialog +""" +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, 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 + +from dataclasses import dataclass + +from PySide6.QtCore import Qt +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 ( + PlannedBar, + format_planned_bars, + 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. +MAX_TABLE_HEIGHT = 240 +CHANGED_COLOR = QColor("#ffe2b3") +UNTAPPED_COLOR = QColor("gray") +BARS_COLUMN, TIME_SIGNATURE_COLUMN, TAPS_COLUMN = range(3) + + +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) + +@dataclass(frozen=True) +class BarsEdit: + """ + What the dialog was left with: the pattern, and the bars the tapped + beats fill, with their numbers. + """ -class AskBeatPattern(QInputDialog): - def __init__(self): - super().__init__() - self.setOption(QInputDialog.InputDialogOption.UsePlainTextEditForTextInput) + pattern: str + bars: list[PlannedBar] + numbers: list[int] - def ask(self): - result, accept = self.getMultiLineText( + +def lay_out( + bars: list[PlannedBar], plan: list[PlannedBar], beat_count: int +) -> tuple[list[PlannedBar], int]: + """ + 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. + """ + 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 = plan[len(laid) % len(plan)] + laid.append(bar) + beats += bar.beat_count + tapped = len(laid) + return laid + plan[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 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] = [] + total = 0 + 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." + 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 total == 1: + return list(range(numbers[0], numbers[0] + count)), "" + if total != count: + bars = "bar" if count == 1 else "bars" + return None, f"'{text}' numbers {total} bars, not {count} {bars}." + return numbers, "" + + +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), "" + + +class BeatPatternDialog(QDialog): + def __init__( + self, + initial_text: str = "", + 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. The table + starts scrolled to the row of `focus_bar`. + """ + 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._current_bars = current_bars or [] + self._overrides = get_overrides(numbers or []) + 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. + self._row_breaks = ( + frozenset() if focus_bar is None else frozenset({focus_bar, focus_bar + 1}) + ) + 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.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, + 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.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) + 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) + + @property + def ok_button(self) -> QPushButton: + return self.button_box.button(QDialogButtonBox.StandardButton.Ok) + + @property + 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 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 _lay_out(self) -> tuple[list[PlannedBar], int]: + return lay_out(self._bars, self._plan, self._beat_count) + + def get_result(self) -> BarsEdit: + 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) + self.error.setVisible(bool(message)) + + def _on_text_changed(self, text: str) -> None: + result = parse(text) + 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._plan = result.planned_bars + if not self._is_writing_plan: + self._bars = [] + self._update_table() + + def _update_table(self) -> None: + bars, tapped = self._lay_out() + numbers = get_numbers(len(bars), self._overrides) + 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): + is_changed = self._is_changed(row) + for column, text in enumerate(format_row(row)): + 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._plan) + 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 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) + if bar is None: + self._show_error(error) + self._update_table() + return + + self._show_error("") + 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): + 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) + if numbers is None: + self._show_error(error) + self._update_table() + return - if accept: - result = result.split() + self._show_error("") + _, 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. + 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() - return result, accept + @classmethod + def ask( + cls, + initial_text: str = "", + 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, 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 new file mode 100644 index 000000000..33fd265d1 --- /dev/null +++ b/tilia/ui/windows/beat_unit.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QMessageBox + +import tilia.ui.strings +from tilia.requests import Get, get + + +def ask_for_beat_unit_scope() -> tuple[bool, bool]: + """ + 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.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 + ) + 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/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"]: