From b8d15ef49cfcb0cb904de54c0c9db368a0d62dfd Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 26 Jul 2025 19:55:22 -0300 Subject: [PATCH 01/47] refactor: hardcoded values --- tilia/timelines/timeline_kinds.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tilia/timelines/timeline_kinds.py b/tilia/timelines/timeline_kinds.py index 1cd90bd0c..a36f02f23 100644 --- a/tilia/timelines/timeline_kinds.py +++ b/tilia/timelines/timeline_kinds.py @@ -20,15 +20,7 @@ class TimelineKind(Enum): TimelineKind.SCORE_TIMELINE, ] -NOT_SLIDER = [ - TimelineKind.HIERARCHY_TIMELINE, - TimelineKind.MARKER_TIMELINE, - TimelineKind.BEAT_TIMELINE, - TimelineKind.HARMONY_TIMELINE, - TimelineKind.PDF_TIMELINE, - TimelineKind.AUDIOWAVE_TIMELINE, - TimelineKind.SCORE_TIMELINE, -] +NOT_SLIDER = [kind for kind in TimelineKind if kind != TimelineKind.SLIDER_TIMELINE] ALL = list(TimelineKind) From 27f7e75172aa26e045e48931756493a4e99b6bf0 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sun, 27 Jul 2025 14:45:00 -0300 Subject: [PATCH 02/47] chore: auto-format --- tests/timelines/harmony/fixtures.py | 6 ++++-- tilia/media/player/youtube.css | 2 +- tilia/parsers/score/timewise_to_partwise.xsl | 2 +- tilia/ui/timelines/base/element.py | 9 ++++++--- tilia/ui/timelines/beat/element.py | 6 +++--- tilia/ui/windows/__init__.py | 3 ++- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/timelines/harmony/fixtures.py b/tests/timelines/harmony/fixtures.py index 476125865..6dfc50533 100644 --- a/tests/timelines/harmony/fixtures.py +++ b/tests/timelines/harmony/fixtures.py @@ -10,11 +10,13 @@ class TestHarmonyTimelineUI(HarmonyTimelineUI): def create_harmony( self, time=0, step=0, accidental=0, quality="major", **kwargs - ) -> tuple[Harmony, HarmonyUI]: ... + ) -> tuple[Harmony, HarmonyUI]: + ... def create_mode( self, time=0, step=0, accidental=0, type="major", **kwargs - ) -> tuple[Mode, ModeUI]: ... + ) -> tuple[Mode, ModeUI]: + ... @pytest.fixture diff --git a/tilia/media/player/youtube.css b/tilia/media/player/youtube.css index 1a0777224..8d0fdb80e 100644 --- a/tilia/media/player/youtube.css +++ b/tilia/media/player/youtube.css @@ -9,4 +9,4 @@ iframe { body { margin: 0px; -} \ No newline at end of file +} diff --git a/tilia/parsers/score/timewise_to_partwise.xsl b/tilia/parsers/score/timewise_to_partwise.xsl index 18ee3abc3..f0e0925e4 100644 --- a/tilia/parsers/score/timewise_to_partwise.xsl +++ b/tilia/parsers/score/timewise_to_partwise.xsl @@ -179,4 +179,4 @@ - \ No newline at end of file + diff --git a/tilia/ui/timelines/base/element.py b/tilia/ui/timelines/base/element.py index c1d922fe6..59c54181c 100644 --- a/tilia/ui/timelines/base/element.py +++ b/tilia/ui/timelines/base/element.py @@ -64,7 +64,8 @@ def is_selected(self): return self in self.timeline_ui.selected_elements @abstractmethod - def child_items(self): ... + def child_items(self): + ... def selection_triggers(self): return self.child_items() @@ -85,9 +86,11 @@ def on_right_click(self, x, y, _): menu = self.CONTEXT_MENU_CLASS(self) menu.exec(QPoint(x, y)) - def on_select(self): ... + def on_select(self): + ... - def on_deselect(self): ... + def on_deselect(self): + ... def delete(self): for item in self.child_items(): diff --git a/tilia/ui/timelines/beat/element.py b/tilia/ui/timelines/beat/element.py index d4d33ce24..26e70d0cd 100644 --- a/tilia/ui/timelines/beat/element.py +++ b/tilia/ui/timelines/beat/element.py @@ -40,9 +40,9 @@ class BeatUI(TimelineUIElement): ("Beat", InspectRowKind.LABEL, None), ] - FIELD_NAMES_TO_ATTRIBUTES: dict[str, str] = ( - {} - ) # only needed if attrs will be set by Inspect + FIELD_NAMES_TO_ATTRIBUTES: dict[ + str, str + ] = {} # only needed if attrs will be set by Inspect DEFAULT_COPY_ATTRIBUTES = CopyAttributes( by_element_value=[], diff --git a/tilia/ui/windows/__init__.py b/tilia/ui/windows/__init__.py index 8ddb788a0..4f38b8747 100644 --- a/tilia/ui/windows/__init__.py +++ b/tilia/ui/windows/__init__.py @@ -2,4 +2,5 @@ class UniqueWindowDuplicate(Exception): - def __init__(self, window_kind: WindowKind): ... + def __init__(self, window_kind: WindowKind): + ... From 914a0bd3a1cc925d644abe6540fca32f730cfb84 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 14 Aug 2025 11:10:29 -0300 Subject: [PATCH 03/47] refactor: remove unused code --- tilia/requests/get.py | 1 - tilia/ui/cli/server.py | 22 --------------------- tilia/ui/timelines/collection/collection.py | 4 ---- 3 files changed, 27 deletions(-) delete mode 100644 tilia/ui/cli/server.py diff --git a/tilia/requests/get.py b/tilia/requests/get.py index 8b569fce8..15fef1e70 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -63,7 +63,6 @@ class Get(Enum): TIMELINE_UI_BY_ATTR = auto() TIMELINE_UI_ELEMENT = auto() TIMELINE_WIDTH = auto() - TIMELINES_FROM_CLI = auto() VERIFIED_PATH = auto() WINDOW_GEOMETRY = auto() WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_CURRENT = auto() diff --git a/tilia/ui/cli/server.py b/tilia/ui/cli/server.py deleted file mode 100644 index 62343c5bd..000000000 --- a/tilia/ui/cli/server.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from tilia.requests import serve, Get, stop_serving_all - -if TYPE_CHECKING: - from tilia.ui.timelines.base.timeline import TimelineUI - - -class ServeTimelineUIFromCLI: - def __init__(self, timeline_ui: TimelineUI): - self.timeline_ui = timeline_ui - - def serve(self): - return self.timeline_ui - - def __enter__(self): - serve(self, Get.TIMELINES_FROM_CLI, self.serve) - - def __exit__(self, type, value, traceback): - stop_serving_all(self) diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index 4ecafbc41..6bbf1e000 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -1015,9 +1015,6 @@ def filter_if_from_manage_timelines_to_permute(_): def filter_if_from_manage_timelines_current(_): return [get(Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_CURRENT)] - def filter_if_from_cli(_): - return [get(Get.TIMELINES_FROM_CLI)] - def filter_if_from_context_menu(_): return get(Get.CONTEXT_MENU_TIMELINE_UI) @@ -1031,7 +1028,6 @@ def filter_if_from_context_menu_to_permute(_): TimelineSelector.PASTE: filter_for_pasting, TimelineSelector.FROM_MANAGE_TIMELINES_TO_PERMUTE: filter_if_from_manage_timelines_to_permute, TimelineSelector.FROM_MANAGE_TIMELINES_CURRENT: filter_if_from_manage_timelines_current, - TimelineSelector.FROM_CLI: filter_if_from_cli, TimelineSelector.ANY: filter_if_first_on_select_order, TimelineSelector.FROM_CONTEXT_MENU: filter_if_from_context_menu, TimelineSelector.FROM_CONTEXT_MENU_TO_PERMUTE: filter_if_from_context_menu_to_permute, From 6964fdd66faa38910addf783eac7710ffc37db30 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 26 Aug 2025 16:49:41 -0300 Subject: [PATCH 04/47] fix: do not usable mutable default argument --- tilia/timelines/score/timeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tilia/timelines/score/timeline.py b/tilia/timelines/score/timeline.py index c8604fdf1..f24d005bf 100644 --- a/tilia/timelines/score/timeline.py +++ b/tilia/timelines/score/timeline.py @@ -72,7 +72,7 @@ class ScoreTimeline(Timeline): def __init__( self, svg_data: str = "", - viewer_beat_x: dict[float, float] = {}, + viewer_beat_x: dict[float, float] = None, **kwargs, ): super().__init__(**kwargs) @@ -81,7 +81,7 @@ def __init__( "svg_data": validate_string, "viewer_beat_x": validate_pre_validated, } - self._viewer_beat_x = viewer_beat_x + self._viewer_beat_x = viewer_beat_x or {} self.svg_data = svg_data @property From 7c3f2336ad1155f9d457ad64467fed3a5f795aaa Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 5 Sep 2025 17:48:21 -0300 Subject: [PATCH 05/47] fix: crash when creating score annotation --- tilia/timelines/score/timeline.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tilia/timelines/score/timeline.py b/tilia/timelines/score/timeline.py index f24d005bf..ef6f58f08 100644 --- a/tilia/timelines/score/timeline.py +++ b/tilia/timelines/score/timeline.py @@ -41,11 +41,14 @@ def _validate_component_creation(self, kind, *args, **kwargs): start = kwargs["start"] if "start" in kwargs else args[0] end = kwargs["end"] if "end" in kwargs else args[1] return SegmentLikeTimelineComponent.validate_times(start, end) - case ComponentKind.CLEF | ComponentKind.BAR_LINE | ComponentKind.TIME_SIGNATURE | ComponentKind.KEY_SIGNATURE | ComponentKind.SCORE_ANNOTATION: + case ComponentKind.CLEF | ComponentKind.BAR_LINE | ComponentKind.TIME_SIGNATURE | ComponentKind.KEY_SIGNATURE: time = kwargs["time"] if "time" in kwargs else args[0] return PointLikeTimelineComponent.validate_time_is_inbounds(time) - case _: + case ComponentKind.SCORE_ANNOTATION | ComponentKind.STAFF: + # Score annotations do not have a time property. return True, "" + case _: + return False, "Invalid component kind." def restore_state(self, prev_state: dict): super().restore_state(prev_state) From 3be8cee41664cc6157d9bb775ca0bf9f495fa942 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 7 Nov 2025 17:45:31 +0100 Subject: [PATCH 06/47] fix: crash when loading svg viewer without beat timeline This is a hacky, temporary solution. This state can be achieved by: - Creating a score timeline and then deleting the beat timeline used to create it - Restoring the app state (e.g. after an exception during a timeline command), if the svg viewer happens to be loaded before the beat timeline is restored --- tilia/timelines/collection/collection.py | 10 +++++++++- tilia/ui/windows/svg_viewer.py | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tilia/timelines/collection/collection.py b/tilia/timelines/collection/collection.py index eedc580bd..6d25c5f58 100644 --- a/tilia/timelines/collection/collection.py +++ b/tilia/timelines/collection/collection.py @@ -299,12 +299,20 @@ def crop_timeline_components(self, new_length: float) -> None: post(Post.TIMELINES_CROP_DONE) def get_beat_timeline_for_measure_calculation(self): - return sorted(self.get_timelines_by_attr("KIND", TimelineKind.BEAT_TIMELINE))[0] + beat_tls = sorted( + self.get_timelines_by_attr("KIND", TimelineKind.BEAT_TIMELINE) + ) + if beat_tls: + return beat_tls[0] + else: + return None def get_metric_position(self, time: float) -> MetricPosition | None: if not self.get_timelines_by_attr("KIND", TimelineKind.BEAT_TIMELINE): return None tl = self.get_beat_timeline_for_measure_calculation() + if not tl: + return None beats = tl.components if not beats: return None diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index c571a908f..bda832280 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -423,6 +423,10 @@ def _get_time_from_scene_x(self, xs: dict[int, float]) -> dict[int, list[float]] beat_tl = get( Get.TIMELINE_COLLECTION ).get_beat_timeline_for_measure_calculation() + + if not beat_tl: + return {} + for key, beat in beat_pos.items(): t = beat_tl.get_time_by_measure(*beat) if not t: @@ -439,6 +443,8 @@ def _get_scene_x_from_time(self, time: float) -> float: beat_tl = get( Get.TIMELINE_COLLECTION ).get_beat_timeline_for_measure_calculation() + if not beat_tl: + return 0 beat = beat_tl.get_metric_fraction_by_time(time) if x := self.beat_x_position.get(beat): return x From 24576bddb619ab9e8c9ca3559988aa0e85621846 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 6 Nov 2025 17:25:50 +0100 Subject: [PATCH 07/47] fix: restore state with identical components but different ids --- tilia/timelines/base/timeline.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index 4a7dbccf7..172fe22d7 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -469,14 +469,24 @@ def restore_state(self, prev_state: dict): } cur_hashes = set(cur_hash_to_id.keys()) prev_hashes = set(prev_hash_to_data.keys()) - hashes_to_delete = cur_hashes.difference(prev_hashes) + hashes_to_create = prev_hashes.difference(cur_hashes) + + # States may have components that share hashes but have different ids + # (e.g. a beat was deleted and later created at the exact same timestamp). + # In those cases, we want to restore (i.e. recreate) the component + # so that its original id is used and references are not broken. + hashes_in_common = cur_hashes.intersection(prev_hashes) + for hash in hashes_in_common: + if cur_hash_to_id[hash] != prev_hash_to_data[hash]["id"]: + hashes_to_delete.add(hash) + hashes_to_create.add(hash) + ids_to_delete = [cur_hash_to_id[id] for id in hashes_to_delete] self.timeline.delete_components( [self.timeline.get_component(id) for id in ids_to_delete] ) - hashes_to_create = prev_hashes.difference(cur_hashes) components_to_create = [prev_hash_to_data[hash] for hash in hashes_to_create] for component_data in components_to_create: component_data = component_data.copy() From f07fd4da9304bac1695986d4ceeabce24e5edf3b Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 13 Nov 2025 17:25:45 +0100 Subject: [PATCH 08/47] test: record state after creating timelines with fixtures --- tests/timelines/audiowave/fixtures.py | 2 ++ tests/timelines/beat/fixtures.py | 2 ++ tests/timelines/harmony/fixtures.py | 2 ++ tests/timelines/hierarchy/fixtures.py | 2 ++ tests/timelines/marker/fixtures.py | 2 ++ tests/timelines/pdf/fixtures.py | 3 ++- tests/timelines/score/fixtures.py | 2 ++ tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py | 3 --- tests/ui/timelines/beat/test_beat_timeline_ui.py | 4 ---- tests/ui/timelines/slider/test_slider_timeline_ui.py | 2 -- 10 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/timelines/audiowave/fixtures.py b/tests/timelines/audiowave/fixtures.py index a1d4974d2..b716e30df 100644 --- a/tests/timelines/audiowave/fixtures.py +++ b/tests/timelines/audiowave/fixtures.py @@ -2,6 +2,7 @@ import pytest +from tilia.requests import post, Post from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.audiowave.timeline import AudioWaveTimeline from tilia.timelines.timeline_kinds import TimelineKind @@ -9,6 +10,7 @@ @pytest.fixture def audiowave_tlui(tilia, audiowave_tl, tluis): + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(audiowave_tl.id) ui.create_amplitudebar = audiowave_tl.create_amplitudebar diff --git a/tests/timelines/beat/fixtures.py b/tests/timelines/beat/fixtures.py index 599a6f77a..65f33b980 100644 --- a/tests/timelines/beat/fixtures.py +++ b/tests/timelines/beat/fixtures.py @@ -1,5 +1,6 @@ import pytest +from tilia.requests import Post, post from tilia.timelines.beat.timeline import BeatTimeline from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind as TlKind @@ -7,6 +8,7 @@ @pytest.fixture def beat_tlui(beat_tl, tluis): + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(beat_tl.id) ui.create_beat = beat_tl.create_beat ui.create_component = beat_tl.create_component diff --git a/tests/timelines/harmony/fixtures.py b/tests/timelines/harmony/fixtures.py index 6dfc50533..dc95c2de7 100644 --- a/tests/timelines/harmony/fixtures.py +++ b/tests/timelines/harmony/fixtures.py @@ -1,5 +1,6 @@ import pytest +from tilia.requests import post, Post from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.harmony.components import Harmony, Mode from tilia.timelines.harmony.timeline import HarmonyTimeline @@ -21,6 +22,7 @@ def create_mode( @pytest.fixture def harmony_tlui(harmony_tl, tluis) -> TestHarmonyTimelineUI: + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(harmony_tl.id) ui.create_harmony = harmony_tl.create_harmony diff --git a/tests/timelines/hierarchy/fixtures.py b/tests/timelines/hierarchy/fixtures.py index 94a1745e2..4da7cb44c 100644 --- a/tests/timelines/hierarchy/fixtures.py +++ b/tests/timelines/hierarchy/fixtures.py @@ -2,6 +2,7 @@ import pytest +from tilia.requests import post, Post from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.hierarchy.components import Hierarchy from tilia.timelines.hierarchy.timeline import HierarchyTimeline @@ -18,6 +19,7 @@ def create_hierarchy( @pytest.fixture def hierarchy_tlui(hierarchy_tl, tluis) -> TestHierarchyTimelineUI: + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(hierarchy_tl.id) ui.create_hierarchy = hierarchy_tl.create_hierarchy diff --git a/tests/timelines/marker/fixtures.py b/tests/timelines/marker/fixtures.py index 9a831b341..1041195bc 100644 --- a/tests/timelines/marker/fixtures.py +++ b/tests/timelines/marker/fixtures.py @@ -2,6 +2,7 @@ import pytest +from tilia.requests import post, Post from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.marker.timeline import MarkerTimeline from tilia.timelines.timeline_kinds import TimelineKind as TlKind @@ -9,6 +10,7 @@ @pytest.fixture def marker_tlui(marker_tl, tluis): + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(marker_tl.id) ui.create_marker = marker_tl.create_marker diff --git a/tests/timelines/pdf/fixtures.py b/tests/timelines/pdf/fixtures.py index 6a59783e2..c281949cb 100644 --- a/tests/timelines/pdf/fixtures.py +++ b/tests/timelines/pdf/fixtures.py @@ -3,7 +3,7 @@ import pytest from tests.mock import Serve -from tilia.requests import Get +from tilia.requests import Get, post, Post from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.pdf.components import PdfMarker from tilia.timelines.pdf.timeline import PdfTimeline @@ -21,6 +21,7 @@ def create_pdf_marker(self, time=0, **kwargs) -> tuple[PdfMarker, PdfMarkerUI]: @pytest.fixture def pdf_tlui(pdf_tl, tluis): + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(pdf_tl.id) diff --git a/tests/timelines/score/fixtures.py b/tests/timelines/score/fixtures.py index d84466b68..5facdd706 100644 --- a/tests/timelines/score/fixtures.py +++ b/tests/timelines/score/fixtures.py @@ -1,5 +1,6 @@ import pytest +from tilia.requests import post, Post from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.score.components import Clef from tilia.timelines.score.timeline import ScoreTimeline @@ -8,6 +9,7 @@ @pytest.fixture def score_tlui(score_tl, tluis): + post(Post.APP_STATE_RECORD, "tlui fixture") ui = tluis.get_timeline_ui(score_tl.id) ui.create_component = score_tl.create_component diff --git a/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py b/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py index 285920c3b..09cae7c0d 100644 --- a/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py +++ b/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py @@ -3,9 +3,6 @@ def test_undo_redo(audiowave_tlui, marker_tlui, user_actions): - - post(Post.APP_RECORD_STATE, "test state") - # using marker tl to trigger an actions that can be undone user_actions.trigger(TiliaAction.MARKER_ADD) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 6d3bc684a..e6f76fec9 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -564,8 +564,6 @@ def test_reject_delete_existing_beats(self, beat_tlui, user_actions): class TestUndoRedo: def test_undo_redo_add_beat(self, beat_tlui, tluis, tilia_state, user_actions): - post(Post.APP_RECORD_STATE, "test state") - tilia_state.current_time = 10 user_actions.trigger(TiliaAction.BEAT_ADD) @@ -599,8 +597,6 @@ def test_undo_redo_delete_beat(self, beat_tlui, tluis, user_actions): beat_tlui.create_beat(0) - post(Post.APP_RECORD_STATE, "test state") - beat_tlui.select_element(beat_tlui[0]) user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) diff --git a/tests/ui/timelines/slider/test_slider_timeline_ui.py b/tests/ui/timelines/slider/test_slider_timeline_ui.py index 50ca81444..f957dc76e 100644 --- a/tests/ui/timelines/slider/test_slider_timeline_ui.py +++ b/tests/ui/timelines/slider/test_slider_timeline_ui.py @@ -4,8 +4,6 @@ def test_undo_redo(slider_tlui, marker_tlui, user_actions): - post(Post.APP_RECORD_STATE, "test state") - # using marker tl to trigger an actions that can be undone user_actions.trigger(TiliaAction.MARKER_ADD) From 974e71508f46ed562fc854746f0c9b68c6f1bc6a Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 13 Nov 2025 17:36:25 +0100 Subject: [PATCH 09/47] refactor: rename Post.APP_RECORD_STATE to Post.APP_STATE_RECORD --- .../timelines/beat/test_beat_timeline_ui.py | 4 ++-- .../hierarchy/test_hierarchy_timeline_ui.py | 20 +++++++++---------- tilia/app.py | 5 ++--- tilia/requests/post.py | 2 +- tilia/ui/qtui.py | 2 +- tilia/ui/timelines/audiowave/element.py | 2 +- tilia/ui/timelines/base/timeline.py | 2 +- tilia/ui/timelines/beat/element.py | 2 +- tilia/ui/timelines/collection/collection.py | 6 +++--- .../ui/timelines/harmony/elements/harmony.py | 2 +- tilia/ui/timelines/harmony/elements/mode.py | 2 +- tilia/ui/timelines/hierarchy/drag.py | 2 +- tilia/ui/timelines/marker/element.py | 2 +- tilia/ui/timelines/pdf/element.py | 2 +- tilia/ui/windows/svg_viewer.py | 14 ++++++------- 15 files changed, 34 insertions(+), 35 deletions(-) diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index e6f76fec9..a7ecab97e 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -581,7 +581,7 @@ def test_undo_redo_delete_beat_multiple_beats(self, beat_tlui, tluis, user_actio for beat_ui in beat_tlui: beat_tlui.select_element(beat_ui) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) @@ -613,7 +613,7 @@ def test_undo_redo_updates_displayed_measure_numbers(self, beat_tlui, user_actio beat_tlui.create_beat(1) beat_tlui.create_beat(2) - post(Post.APP_RECORD_STATE, "test") + post(Post.APP_STATE_RECORD, "test") beat_tlui.select_element(beat_tlui[0]) user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py index eeaae4594..705b762c2 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py @@ -383,7 +383,7 @@ class TestUndoRedo: def test_split(self, tlui, tluis, user_actions): tlui.create_hierarchy(0, 1, 1) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") with PatchGet( "tilia.ui.timelines.hierarchy.request_handlers", Get.SELECTED_TIME, 0.5 @@ -403,7 +403,7 @@ def test_merge(self, tlui, tluis, user_actions): tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.HIERARCHY_MERGE) @@ -417,7 +417,7 @@ def test_increase_level(self, tlui, tluis, user_actions): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.HIERARCHY_INCREASE_LEVEL) @@ -431,7 +431,7 @@ def test_decrease_level(self, tlui, tluis, user_actions): tlui.create_hierarchy(0, 1, 2) tlui.select_element(tlui[0]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.HIERARCHY_DECREASE_LEVEL) @@ -448,7 +448,7 @@ def test_group(self, tlui, tluis, user_actions): tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.HIERARCHY_GROUP) @@ -463,7 +463,7 @@ def test_delete(self, tlui, tluis, user_actions): tlui.select_element(tlui[0]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) @@ -480,7 +480,7 @@ def test_delete_parent_and_child(self, tlui, user_actions): tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) @@ -495,7 +495,7 @@ def test_create_unit_below(self, tlui, tluis, user_actions): tlui.select_element(tlui[0]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) @@ -508,7 +508,7 @@ def test_create_unit_below(self, tlui, tluis, user_actions): def test_paste(self, tlui, tluis, user_actions): tlui.create_hierarchy(0, 1, 1, label="paste test") tlui.create_hierarchy(0, 1, 2) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") tlui.select_element(tlui[0]) user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) @@ -555,7 +555,7 @@ def test_create_child(self, tlui, tluis, user_actions): tlui.select_element(tlui[0]) - post(Post.APP_RECORD_STATE, "test state") + post(Post.APP_STATE_RECORD, "test state") user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) diff --git a/tilia/app.py b/tilia/app.py index f3224d101..085d97be8 100644 --- a/tilia/app.py +++ b/tilia/app.py @@ -62,8 +62,7 @@ def _setup_requests(self): (Post.APP_STATE_RESTORE, self.on_restore_state), (Post.APP_STATE_RECOVER, self.recover_to_state), (Post.APP_SETUP_FILE, self.setup_file), - (Post.APP_RECORD_STATE, self.on_record_state), - (Post.FILE_OPEN, self.on_open), + (Post.APP_STATE_RECORD, self.on_record_state), (Post.FILE_EXPORT, self.on_export), (Post.PLAYER_DURATION_AVAILABLE, self.set_file_media_duration), # Listening on tilia.dirs would need to be top-level. @@ -205,7 +204,7 @@ def load_media( if success and record: post(Post.PLAYER_CANCEL_LOOP) - post(Post.APP_RECORD_STATE, "media load") + post(Post.APP_STATE_RECORD, "media load") return success diff --git a/tilia/requests/post.py b/tilia/requests/post.py index 230c6becb..2b4bf7b73 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -12,8 +12,8 @@ class Post(Enum): APP_CLEAR = auto() APP_FILE_LOAD = auto() APP_MEDIA_LOAD = auto() - APP_RECORD_STATE = auto() APP_SETUP_FILE = auto() + APP_STATE_RECORD = auto() APP_STATE_RECOVER = auto() APP_STATE_RESTORE = auto() BEAT_ADD = auto() diff --git a/tilia/ui/qtui.py b/tilia/ui/qtui.py index a777a6720..8698022ef 100644 --- a/tilia/ui/qtui.py +++ b/tilia/ui/qtui.py @@ -419,7 +419,7 @@ def on_import_to_timeline(self, tl_kind: TlKind): tilia.errors.display( tilia.errors.CSV_IMPORT_SUCCESS_ERRORS, "\n".join(errors) ) - post(Post.APP_RECORD_STATE, "Import from csv file") + post(Post.APP_STATE_RECORD, "Import from csv file") @staticmethod def show_crash_dialog(exception_info): diff --git a/tilia/ui/timelines/audiowave/element.py b/tilia/ui/timelines/audiowave/element.py index 68425477d..949158e6a 100644 --- a/tilia/ui/timelines/audiowave/element.py +++ b/tilia/ui/timelines/audiowave/element.py @@ -91,7 +91,7 @@ def after_each_drag(self, drag_x: int): def on_drag_end(self): if self.dragged: - post(Post.APP_RECORD_STATE, "AudioWave drag") + post(Post.APP_STATE_RECORD, "AudioWave drag") post(Post.ELEMENT_DRAG_END) self.dragged = False diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index 885f89ba5..f4b717858 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -456,7 +456,7 @@ def on_inspector_field_edited( element.set_data(attr, value) post( - Post.APP_RECORD_STATE, + Post.APP_STATE_RECORD, "attribute edit via inspect", no_repeat=True, repeat_identifier=f"{attr}_{element.id}_{inspector_id}", diff --git a/tilia/ui/timelines/beat/element.py b/tilia/ui/timelines/beat/element.py index 26e70d0cd..9a5e6b5ab 100644 --- a/tilia/ui/timelines/beat/element.py +++ b/tilia/ui/timelines/beat/element.py @@ -190,7 +190,7 @@ def after_each_drag(self, drag_x: int): def on_drag_end(self): if self.dragged: - post(Post.APP_RECORD_STATE, "beat drag") + post(Post.APP_STATE_RECORD, "beat drag") post(Post.ELEMENT_DRAG_END) self.dragged = False diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index 6bbf1e000..9dbbe64da 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -911,7 +911,7 @@ def on_timeline_element_request( self.validate_request_return_value(request, result) if any(result): - post(Post.APP_RECORD_STATE, f"timeline element request: {request.name}") + post(Post.APP_STATE_RECORD, f"timeline element request: {request.name}") def on_timeline_ui_request( self, request: Post, *args: tuple[Any], **kwargs: dict[str, Any] @@ -940,7 +940,7 @@ def on_timeline_ui_request( self.validate_request_return_value(request, success) if success: - post(Post.APP_RECORD_STATE, f"timeline element request: {request.name}") + post(Post.APP_STATE_RECORD, f"timeline element request: {request.name}") def on_timeline_request( self, @@ -978,7 +978,7 @@ def on_timeline_request( self.validate_request_return_value(request, result) if request: - post(Post.APP_RECORD_STATE, f"timeline request: {request.name}") + post(Post.APP_STATE_RECORD, f"timeline request: {request.name}") def get_timelines_uis_for_request( self, kinds: list[TlKind], selector: TimelineSelector diff --git a/tilia/ui/timelines/harmony/elements/harmony.py b/tilia/ui/timelines/harmony/elements/harmony.py index 6bae2a5a4..ec5bf195d 100644 --- a/tilia/ui/timelines/harmony/elements/harmony.py +++ b/tilia/ui/timelines/harmony/elements/harmony.py @@ -250,7 +250,7 @@ def after_each_drag(self, drag_x: int): def on_drag_end(self): if self.dragged: - post(Post.APP_RECORD_STATE, "harmony drag") + post(Post.APP_STATE_RECORD, "harmony drag") post(Post.ELEMENT_DRAG_END) self.dragged = False diff --git a/tilia/ui/timelines/harmony/elements/mode.py b/tilia/ui/timelines/harmony/elements/mode.py index 3d2dead73..05df9a375 100644 --- a/tilia/ui/timelines/harmony/elements/mode.py +++ b/tilia/ui/timelines/harmony/elements/mode.py @@ -122,7 +122,7 @@ def after_each_drag(self, drag_x: int): def on_drag_end(self): if self.dragged: - post(Post.APP_RECORD_STATE, "harmony drag") + post(Post.APP_STATE_RECORD, "harmony drag") post(Post.ELEMENT_DRAG_END) self.timeline_ui.on_element_drag_done() diff --git a/tilia/ui/timelines/hierarchy/drag.py b/tilia/ui/timelines/hierarchy/drag.py index 2b6db2614..2d5474f2e 100644 --- a/tilia/ui/timelines/hierarchy/drag.py +++ b/tilia/ui/timelines/hierarchy/drag.py @@ -116,7 +116,7 @@ def on_drag_end(hierarchy_ui): if hierarchy_ui.dragged: drag_x = hierarchy_ui.get_data(hierarchy_ui.drag_extremity.value) post( - Post.APP_RECORD_STATE, + Post.APP_STATE_RECORD, f"hierarchy {hierarchy_ui.drag_extremity} drag", no_repeat=True, repeat_identifier=f"{hierarchy_ui.timeline_ui}_drag_to_{drag_x}", diff --git a/tilia/ui/timelines/marker/element.py b/tilia/ui/timelines/marker/element.py index 668d68bac..6871c7856 100644 --- a/tilia/ui/timelines/marker/element.py +++ b/tilia/ui/timelines/marker/element.py @@ -142,7 +142,7 @@ def after_each_drag(self, drag_x: int): def on_drag_end(self): if self.dragged: - post(Post.APP_RECORD_STATE, "marker drag") + post(Post.APP_STATE_RECORD, "marker drag") post(Post.ELEMENT_DRAG_END) self.dragged = False diff --git a/tilia/ui/timelines/pdf/element.py b/tilia/ui/timelines/pdf/element.py index 55069e811..0a1c8c739 100644 --- a/tilia/ui/timelines/pdf/element.py +++ b/tilia/ui/timelines/pdf/element.py @@ -123,7 +123,7 @@ def after_each_drag(self, drag_x: int): def on_drag_end(self): if self.dragged: - post(Post.APP_RECORD_STATE, "page marker drag") + post(Post.APP_STATE_RECORD, "page marker drag") post(Post.ELEMENT_DRAG_END) self.dragged = False diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index bda832280..d00b573b6 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -226,7 +226,7 @@ def _after_drag(final_pos: QPointF) -> None: for item in self.scene.selectedItems(): item.moveBy(d_pos.x(), d_pos.y()) self.save_tla_annotation(item) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") return {"press": _start_drag, "move": _while_drag, "release": _after_drag} @@ -307,7 +307,7 @@ def annotation_add(self) -> None: "annotation": new_annotation, } self.save_tla_annotation(new_annotation) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") def annotation_delete(self) -> None: self.filter_selection(SvgTlaAnnotation) @@ -320,7 +320,7 @@ def annotation_delete(self) -> None: for item in to_delete ] ) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") def annotation_edit(self) -> None: self.filter_selection(SvgTlaAnnotation) @@ -345,13 +345,13 @@ def annotation_edit(self) -> None: self.timeline.delete_components( [tl.get_component(self.tla_annotations[item.id]["component"])] ) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") continue item.setText(annotation) item.setSelected(False) self.save_tla_annotation(item) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") def annotation_font_dec(self) -> None: self.filter_selection(SvgTlaAnnotation) @@ -364,7 +364,7 @@ def annotation_font_dec(self) -> None: font.setPointSize(font.pointSize() // 2) item.setFont(font) self.save_tla_annotation(item) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") def annotation_font_inc(self) -> None: self.filter_selection(SvgTlaAnnotation) @@ -375,7 +375,7 @@ def annotation_font_inc(self) -> None: font.setPointSize(font.pointSize() * 2) item.setFont(font) self.save_tla_annotation(item) - post(Post.APP_RECORD_STATE, "score annotation") + post(Post.APP_STATE_RECORD, "score annotation") def _get_time_from_scene_x(self, xs: dict[int, float]) -> dict[int, list[float]]: output = {} From 467cb5ed048ae5443218caa366ec066164c6980b Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 7 Nov 2025 18:41:57 +0100 Subject: [PATCH 10/47] test: set timeline visibility --- tests/ui/timelines/test_timeline_ui.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/ui/timelines/test_timeline_ui.py b/tests/ui/timelines/test_timeline_ui.py index d4cf9b556..772e024be 100644 --- a/tests/ui/timelines/test_timeline_ui.py +++ b/tests/ui/timelines/test_timeline_ui.py @@ -5,6 +5,7 @@ click_timeline_ui_element_body, click_timeline_ui, ) +from tests.utils import undoable from tilia.requests import Post, post, Get from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind @@ -276,3 +277,15 @@ def test_set_empty_string(self, tls, tluis, user_actions): assert tls[0].get_data("name") == "" assert tluis[0].displayed_name == "" + + +def test_set_is_visible(tls, marker_tlui): + with undoable(): + commands.execute("timeline.set_is_visible", marker_tlui, False) + + assert marker_tlui.view.isVisible() is False + + with undoable(): + commands.execute("timeline.set_is_visible", marker_tlui, True) + + assert marker_tlui.view.isVisible() is True From 4f4be1366079daaaa2f575ffdeaa042e75f0172d Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 7 Nov 2025 18:42:47 +0100 Subject: [PATCH 11/47] fix: color validator "" is a valid value for the color property, it means "use the default color", just as None. --- tilia/timelines/base/validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilia/timelines/base/validators.py b/tilia/timelines/base/validators.py index 2ed6e63a4..ed3a6e1d2 100644 --- a/tilia/timelines/base/validators.py +++ b/tilia/timelines/base/validators.py @@ -17,7 +17,7 @@ def validate_bool(value): def validate_color(value): - if value is None: + if value is None or value == "": return True return QColor(value).isValid() From 769e30ba35b73b763eb91cb08de12c2c00a0d462 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 26 Aug 2025 16:48:43 -0300 Subject: [PATCH 12/47] refactor: start using {Timeline,TimelineUI}.subclasses instead of timeline kinds We should leverage classes as first-class citizens to use them instead of relying on an additional TimelineKind abstraction (TimelineKind). This is a first step towards that. --- tilia/timelines/base/timeline.py | 29 +++++++++++++++++++++ tilia/timelines/timeline_kinds.py | 22 +++------------- tilia/ui/timelines/base/timeline.py | 19 ++++++++++++++ tilia/ui/timelines/collection/collection.py | 26 +++++------------- 4 files changed, 57 insertions(+), 39 deletions(-) diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index 172fe22d7..551f2c739 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -2,8 +2,11 @@ import functools import bisect +import importlib +import os from abc import ABC from enum import Enum, auto +from pathlib import Path from typing import Any, Callable, TYPE_CHECKING, TypeVar, Generic, Set from tilia.timelines import serialize @@ -68,6 +71,8 @@ def __init__( else: self.component_manager = None + self.ensure_subclasses_are_available() + def __iter__(self): return iter(self.components) @@ -110,6 +115,30 @@ def components(self): def default_height(self): return None + @classmethod + def subclasses(cls): + cls.ensure_subclasses_are_available() + return cls.__subclasses__() + + @classmethod + def ensure_subclasses_are_available(cls): + timelines_dir = Path(os.path.dirname(__file__)).parent + packages = [ + "tilia.timelines." + d.name + ".timeline" + for d in timelines_dir.iterdir() + if d.is_dir() and d.name not in ["base", "__pycache__", "collection"] + ] + for pkg in packages: + print(pkg) + importlib.import_module(pkg) + print(Timeline.__subclasses__()) + + @classmethod + def get_kinds_by_flag(cls, flag: TimelineFlag | list[TimelineFlag]): + if isinstance(flag, TimelineFlag): + flag = [flag] + return [c.KIND for c in cls.__subclasses__() if any(f in c.FLAGS for f in flag)] + def validate_set_data(self, attr, value): if not hasattr(self, attr): raise SetTimelineDataError( diff --git a/tilia/timelines/timeline_kinds.py b/tilia/timelines/timeline_kinds.py index a36f02f23..546c67d26 100644 --- a/tilia/timelines/timeline_kinds.py +++ b/tilia/timelines/timeline_kinds.py @@ -33,22 +33,6 @@ def get_timeline_kind_from_string(string): def get_timeline_class_from_kind(kind: TimelineKind) -> type[Timeline]: - from tilia.timelines.marker.timeline import MarkerTimeline - from tilia.timelines.beat.timeline import BeatTimeline - from tilia.timelines.hierarchy.timeline import HierarchyTimeline - from tilia.timelines.pdf.timeline import PdfTimeline - from tilia.timelines.slider.timeline import SliderTimeline - from tilia.timelines.audiowave.timeline import AudioWaveTimeline - from tilia.timelines.harmony.timeline import HarmonyTimeline - from tilia.timelines.score.timeline import ScoreTimeline - - return { - TimelineKind.MARKER_TIMELINE: MarkerTimeline, - TimelineKind.BEAT_TIMELINE: BeatTimeline, - TimelineKind.HIERARCHY_TIMELINE: HierarchyTimeline, - TimelineKind.PDF_TIMELINE: PdfTimeline, - TimelineKind.SLIDER_TIMELINE: SliderTimeline, - TimelineKind.AUDIOWAVE_TIMELINE: AudioWaveTimeline, - TimelineKind.HARMONY_TIMELINE: HarmonyTimeline, - TimelineKind.SCORE_TIMELINE: ScoreTimeline, - }[kind] + classes = Timeline.subclasses() + kind_to_class = {c.KIND: c for c in classes} + return kind_to_class[kind] diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index f4b717858..316a5791b 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -1,6 +1,9 @@ from __future__ import annotations import functools +import importlib +import os from abc import ABC +from pathlib import Path from typing import ( Any, TYPE_CHECKING, @@ -84,6 +87,22 @@ def __bool__(self): def __lt__(self, other): return self.get_data("ordinal") < other.get_data("ordinal") + @classmethod + def subclasses(cls): + cls.ensure_subclasses_are_available() + return cls.__subclasses__() + + @classmethod + def ensure_subclasses_are_available(cls): + timelines_dir = Path(os.path.dirname(__file__)).parent + packages = [ + "tilia.ui.timelines." + d.name + for d in timelines_dir.iterdir() + if d.is_dir() and d.name not in ["base", "__pycache__"] + ] + for pkg in packages: + importlib.import_module(pkg) + @property def is_empty(self): return len(self) == 0 diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index 9dbbe64da..dafcc916d 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -47,7 +47,9 @@ ) from tilia.ui.timelines.collection.requests.element import TlElmRequestSelector from .view import TimelineUIsView +from ..base.element import TimelineUIElement from ..beat import BeatTimelineUI +from ..hierarchy import HierarchyTimelineUI from ..selection_box import SelectionBoxQt from ..slider.timeline import SliderTimelineUI from ...actions import TiliaAction @@ -396,27 +398,11 @@ def update_timeline_times(tlui: TimelineUI): @staticmethod def get_timeline_ui_class(kind: TlKind) -> type[TimelineUI]: - from tilia.ui.timelines.hierarchy import HierarchyTimelineUI - from tilia.ui.timelines.slider.timeline import SliderTimelineUI - from tilia.ui.timelines.audiowave.timeline import AudioWaveTimelineUI - from tilia.ui.timelines.marker.timeline import MarkerTimelineUI - from tilia.ui.timelines.harmony.timeline import HarmonyTimelineUI - from tilia.ui.timelines.beat import BeatTimelineUI - from tilia.ui.timelines.pdf import PdfTimelineUI - from tilia.ui.timelines.score.timeline import ScoreTimelineUI - - kind_to_class = { - TlKind.HIERARCHY_TIMELINE: HierarchyTimelineUI, - TlKind.SLIDER_TIMELINE: SliderTimelineUI, - TlKind.AUDIOWAVE_TIMELINE: AudioWaveTimelineUI, - TlKind.MARKER_TIMELINE: MarkerTimelineUI, - TlKind.BEAT_TIMELINE: BeatTimelineUI, - TlKind.HARMONY_TIMELINE: HarmonyTimelineUI, - TlKind.PDF_TIMELINE: PdfTimelineUI, - TlKind.SCORE_TIMELINE: ScoreTimelineUI, - } + for cls in TimelineUI.subclasses(): + if cls.TIMELINE_KIND == kind: + return cls - return kind_to_class[kind] + raise ValueError(f"No TimelineUI class found for kind: {kind}") @staticmethod def create_timeline_scene(id: int, width: int, height: int): From f13fa08eb1255014fdb8033398c0b7213fc78287 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 26 Aug 2025 16:20:49 -0300 Subject: [PATCH 13/47] refactor: use Timeline.flags instead of individual class attributes --- tilia/timelines/base/timeline.py | 26 +++++++++++++++++--------- tilia/timelines/hierarchy/timeline.py | 3 ++- tilia/timelines/marker/timeline.py | 3 ++- tilia/timelines/score/timeline.py | 7 ++++++- tilia/timelines/slider/timeline.py | 1 + 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index 551f2c739..a70ff65cd 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -36,11 +36,27 @@ T = TypeVar("T", bound="Timeline") +class TimelineFlag(Enum): + COMPONENTS_COLORED = auto() + COMPONENTS_IMPORTABLE = auto() + COMPONENTS_COPYABLE = auto() + HAS_COMPONENTS = auto() + COMPONENTS_NOT_DELETABLE = auto() + COMPONENTS_NOT_EDITABLE = auto() + NOT_CLEARABLE = auto() + NOT_DELETABLE = auto() + NOT_EXPORTABLE = auto() + + class Timeline(ABC, Generic[TC]): SERIALIZABLE = ["name", "height", "is_visible", "ordinal"] NOT_EXPORTABLE_ATTRS = [] KIND: TimelineKind | None = None - FLAGS = [] + FLAGS = [ + TimelineFlag.HAS_COMPONENTS, + TimelineFlag.COMPONENTS_COPYABLE, + TimelineFlag.COMPONENTS_IMPORTABLE, + ] COMPONENT_MANAGER_CLASS = None validators = { @@ -129,9 +145,7 @@ def ensure_subclasses_are_available(cls): if d.is_dir() and d.name not in ["base", "__pycache__", "collection"] ] for pkg in packages: - print(pkg) importlib.import_module(pkg) - print(Timeline.__subclasses__()) @classmethod def get_kinds_by_flag(cls, flag: TimelineFlag | list[TimelineFlag]): @@ -532,9 +546,3 @@ def crop(self, length: float) -> None: def scale(self, length: float) -> None: raise NotImplementedError - - -class TimelineFlag(Enum): - NOT_CLEARABLE = auto() - NOT_DELETABLE = auto() - NOT_EXPORTABLE = auto() diff --git a/tilia/timelines/hierarchy/timeline.py b/tilia/timelines/hierarchy/timeline.py index 37ce50d16..aff745558 100644 --- a/tilia/timelines/hierarchy/timeline.py +++ b/tilia/timelines/hierarchy/timeline.py @@ -9,7 +9,7 @@ scale_segmentlike, crop_segmentlike, ) -from ..base.timeline import Timeline, TimelineComponentManager +from ..base.timeline import Timeline, TimelineComponentManager, TimelineFlag from tilia.timelines.component_kinds import ComponentKind from tilia.requests import post, Post, get, Get from tilia.timelines.timeline_kinds import TimelineKind @@ -422,6 +422,7 @@ def delete_component(self, component: Hierarchy, **kwargs) -> None: class HierarchyTimeline(Timeline): KIND = TimelineKind.HIERARCHY_TIMELINE COMPONENT_MANAGER_CLASS = HierarchyTLComponentManager + FLAGS = Timeline.FLAGS + [TimelineFlag.COMPONENTS_COLORED] @property def default_height(self): diff --git a/tilia/timelines/marker/timeline.py b/tilia/timelines/marker/timeline.py index 935bc3ec6..eed5df500 100644 --- a/tilia/timelines/marker/timeline.py +++ b/tilia/timelines/marker/timeline.py @@ -8,7 +8,7 @@ from tilia.timelines.marker.components import Marker from tilia.timelines.timeline_kinds import TimelineKind from tilia.timelines.base.component import TimelineComponent -from tilia.timelines.base.timeline import Timeline, TimelineComponentManager +from tilia.timelines.base.timeline import Timeline, TimelineComponentManager, TimelineFlag class MarkerTLComponentManager(TimelineComponentManager): @@ -24,6 +24,7 @@ def _validate_component_creation(self, _, time, *args, **kwargs): class MarkerTimeline(Timeline): KIND = TimelineKind.MARKER_TIMELINE COMPONENT_MANAGER_CLASS = MarkerTLComponentManager + FLAGS = Timeline.FLAGS + [TimelineFlag.COMPONENTS_COLORED] @property def default_height(self): diff --git a/tilia/timelines/score/timeline.py b/tilia/timelines/score/timeline.py index ef6f58f08..ec38f14ec 100644 --- a/tilia/timelines/score/timeline.py +++ b/tilia/timelines/score/timeline.py @@ -12,7 +12,7 @@ from tilia.timelines.base.validators import validate_string, validate_pre_validated from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind -from tilia.timelines.base.timeline import Timeline, TimelineComponentManager +from tilia.timelines.base.timeline import Timeline, TimelineComponentManager, TimelineFlag class ScoreTLComponentManager(TimelineComponentManager): @@ -71,6 +71,11 @@ class ScoreTimeline(Timeline): ] NOT_EXPORTABLE_ATTRS = ["svg_data", "viewer_beat_x"] COMPONENT_MANAGER_CLASS = ScoreTLComponentManager + FLAGS = [ + TimelineFlag.HAS_COMPONENTS, + TimelineFlag.COMPONENTS_NOT_DELETABLE, + TimelineFlag.COMPONENTS_IMPORTABLE, + ] def __init__( self, diff --git a/tilia/timelines/slider/timeline.py b/tilia/timelines/slider/timeline.py index 04728781e..83d386e88 100644 --- a/tilia/timelines/slider/timeline.py +++ b/tilia/timelines/slider/timeline.py @@ -16,6 +16,7 @@ class SliderTimeline(Timeline): TimelineFlag.NOT_CLEARABLE, TimelineFlag.NOT_DELETABLE, TimelineFlag.NOT_EXPORTABLE, + TimelineFlag.NOT_CLEARABLE, ] @property From 4c534a8f87e608ebe7544e692116c84169ff2943 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 25 Nov 2025 18:13:24 +0100 Subject: [PATCH 14/47] refactor: commands APIS [MAJOR] This commits introduces the commands API, a much simpler way to handle user interactions. Commands make all the following obsolete: - All timeline and timeline component `RequestHandlers` - `TimelineComponentSelector` - Several `TimelineSelectors` - `TiliaActions` - `user_action` fixture - `TimelineUi.on_timeline_request`, `TimelineUI.on_timeline_component_request` (substituted by `TimelineUIs.on_timeline_command`) - `TimelineUIs.pre_process_timeline_request()` - `tilia\ui\timelines\collection\requests\args.py` - Several `Post` and `Get` members - Some `TimelineSelector` members The core functionality is given by `command.py`, along with fuller documentation. Usage of commands: ``` # Register a new command commands.register( 'example.command', callback_function, text='Menu Text', # Optional: for display in Qt interface shortcut='Ctrl+E', # Optional: keyboard shortcut icon='example_icon' # Optional: icon name in IMG_DIR (without extension) ) # Execute a command commands.execute('example.command', arg1, arg2, kwarg1=value1) ``` Examples of commands: - File commands: 'file.open', 'file.save', 'file.export.img' - Timeline commands: 'timeline.delete', 'timeline.component.copy' - View commands: 'view.zoom.in', 'view.zoom.out' Note that we can pass arguments to commands directly, so we don't need (horrible) things like `TimelineSelector.FROM_MANAGE_TIMELINES_TO_PERMUTE`. Commands also set the stage for plugins, as they provide a simpler interface to modify application state programatically. --- TESTING.md | 8 +- tests/conftest.py | 48 -- tests/file/test_file_manager.py | 40 +- tests/mock.py | 50 -- tests/parsers/csv/test_markers_from_csv.py | 14 +- tests/test_app.py | 173 ++--- tests/test_export.py | 58 +- tests/timelines/beat/test_beat_timeline.py | 28 +- tests/timelines/pdf/test_pdf_timeline.py | 60 +- .../ui/cli/metadata/test_cli_show_metadata.py | 8 +- tests/ui/cli/test_clear.py | 12 +- tests/ui/cli/test_load_media.py | 14 +- tests/ui/cli/timelines/test_cli_timelines.py | 15 +- tests/ui/dialogs/test_inspect.py | 10 +- tests/ui/test_qtui.py | 22 +- .../audiowave/test_audiowave_timeline_ui.py | 22 +- .../timelines/beat/test_beat_timeline_ui.py | 198 ++--- .../harmony/test_harmony_timeline_ui.py | 14 +- tests/ui/timelines/harmony/test_harmony_ui.py | 19 +- tests/ui/timelines/harmony/test_mode_ui.py | 19 +- .../hierarchy/test_hierarchy_timeline_ui.py | 225 +++--- .../marker/test_marker_timeline_ui.py | 290 ++++--- .../timelines/score/test_score_timeline_ui.py | 38 +- .../slider/test_slider_timeline_ui.py | 11 +- tests/ui/timelines/test_timeline_ui.py | 28 +- .../timelines/test_timelineui_collection.py | 224 +++--- tests/ui/windows/test_manage_timelines.py | 46 +- tests/utils.py | 20 +- tilia/app.py | 43 +- tilia/errors.py | 5 +- tilia/exceptions.py | 4 - tilia/file/file_manager.py | 28 +- tilia/media/player/base.py | 9 +- tilia/parsers/__init__.py | 21 +- tilia/requests/get.py | 3 - tilia/requests/post.py | 82 +- tilia/timelines/audiowave/timeline.py | 6 +- tilia/timelines/beat/timeline.py | 8 +- tilia/timelines/harmony/timeline.py | 8 +- tilia/timelines/pdf/timeline.py | 7 +- tilia/timelines/slider/timeline.py | 1 + tilia/timelines/timeline_kinds.py | 30 +- tilia/ui/actions.py | 398 ---------- tilia/ui/cli/export.py | 4 +- tilia/ui/cli/open.py | 4 +- tilia/ui/commands.py | 134 ++++ tilia/ui/enums.py | 10 - tilia/ui/menus.py | 90 +-- tilia/ui/player.py | 5 +- tilia/ui/qtui.py | 92 +-- tilia/ui/request_handler.py | 14 - .../timelines/audiowave/request_handlers.py | 30 - tilia/ui/timelines/audiowave/timeline.py | 9 - tilia/ui/timelines/base/context_menus.py | 56 +- tilia/ui/timelines/base/request_handlers.py | 131 ---- tilia/ui/timelines/base/timeline.py | 112 ++- tilia/ui/timelines/beat/context_menu.py | 19 +- tilia/ui/timelines/beat/request_handlers.py | 88 --- tilia/ui/timelines/beat/timeline.py | 158 +++- tilia/ui/timelines/beat/toolbar.py | 11 +- tilia/ui/timelines/collection/collection.py | 728 +++++++++++++----- .../collection/import_.py} | 10 +- .../timelines/collection/request_handler.py | 114 --- .../timelines/collection/requests/__init__.py | 0 .../ui/timelines/collection/requests/args.py | 135 ---- .../timelines/collection/requests/element.py | 120 --- .../ui/timelines/collection/requests/enums.py | 21 - .../timelines/collection/requests/timeline.py | 48 -- .../collection/requests/timeline_uis.py | 3 - tilia/ui/timelines/collection/validators.py | 29 - tilia/ui/timelines/collection/view.py | 6 +- tilia/ui/timelines/harmony/context_menu.py | 33 +- .../ui/timelines/harmony/request_handlers.py | 137 ---- tilia/ui/timelines/harmony/timeline.py | 150 +++- tilia/ui/timelines/harmony/toolbar.py | 11 +- tilia/ui/timelines/hierarchy/context_menu.py | 25 +- tilia/ui/timelines/hierarchy/drag.py | 20 +- tilia/ui/timelines/hierarchy/element.py | 93 ++- tilia/ui/timelines/hierarchy/extremity.py | 10 - .../timelines/hierarchy/request_handlers.py | 178 ----- tilia/ui/timelines/hierarchy/timeline.py | 224 +++++- tilia/ui/timelines/hierarchy/toolbar.py | 15 +- tilia/ui/timelines/marker/context_menu.py | 15 +- tilia/ui/timelines/marker/request_handlers.py | 44 -- tilia/ui/timelines/marker/timeline.py | 28 +- tilia/ui/timelines/marker/toolbar.py | 3 +- tilia/ui/timelines/pdf/context_menu.py | 11 +- tilia/ui/timelines/pdf/request_handlers.py | 44 -- tilia/ui/timelines/pdf/timeline.py | 34 +- tilia/ui/timelines/pdf/toolbar.py | 3 +- tilia/ui/timelines/score/context_menu.py | 9 +- tilia/ui/timelines/score/request_handlers.py | 39 - tilia/ui/timelines/score/timeline.py | 11 - tilia/ui/timelines/score/toolbar.py | 2 +- tilia/ui/timelines/toolbar.py | 8 +- tilia/ui/windows/manage_timelines.py | 45 +- tilia/ui/windows/svg_viewer.py | 58 +- tilia/undo_manager.py | 11 +- 98 files changed, 2528 insertions(+), 3281 deletions(-) delete mode 100644 tilia/ui/actions.py create mode 100644 tilia/ui/commands.py delete mode 100644 tilia/ui/request_handler.py delete mode 100644 tilia/ui/timelines/audiowave/request_handlers.py delete mode 100644 tilia/ui/timelines/base/request_handlers.py delete mode 100644 tilia/ui/timelines/beat/request_handlers.py rename tilia/ui/{ui_import.py => timelines/collection/import_.py} (96%) delete mode 100644 tilia/ui/timelines/collection/request_handler.py delete mode 100644 tilia/ui/timelines/collection/requests/__init__.py delete mode 100644 tilia/ui/timelines/collection/requests/args.py delete mode 100644 tilia/ui/timelines/collection/requests/enums.py delete mode 100644 tilia/ui/timelines/collection/requests/timeline.py delete mode 100644 tilia/ui/timelines/collection/requests/timeline_uis.py delete mode 100644 tilia/ui/timelines/collection/validators.py delete mode 100644 tilia/ui/timelines/harmony/request_handlers.py delete mode 100644 tilia/ui/timelines/hierarchy/extremity.py delete mode 100644 tilia/ui/timelines/hierarchy/request_handlers.py delete mode 100644 tilia/ui/timelines/marker/request_handlers.py delete mode 100644 tilia/ui/timelines/pdf/request_handlers.py delete mode 100644 tilia/ui/timelines/score/request_handlers.py diff --git a/TESTING.md b/TESTING.md index a46250945..8593df908 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,9 +8,10 @@ The test suite is written in pytest. Below are some things to keep in my mind wh Unfortunately, we can't simulate input to modal dialogs, as they block execution. To work around that, we can: - Mock methods of the modal dialogs (e.g. `QInputDialog.getInt`). There are utility functions that do that in some cases (e.g. `tests.utils.patch_file_dialog`) - If the dialog is called in response to a `Get` request, the `Serve` context manager can be used to mock the return value of the request. E.g.: + ```python with Serve(Get.FROM_USER_INT, (True, 150)): - user_actions.trigger(TiliaAction.TIMELINE_HEIGHT_SET) + commands.execute("timeline_height_set") ``` We should prefer the first option as it makes the test cover more code, but the second is more resilient to changes in implementation details. @@ -31,10 +32,11 @@ def test_me(tlui, marker_tlui): ``` can be rewritten as: + ```python -def test_me(marker_tlui, user_actions, tilia_state): +def test_me(marker_tlui, tilia_state): tilia_state.current_time = 0 - user_action.trigger(TiliaAction.MARKER_ADD) + commands.execute("marker_add") assert not len(marker_tlui) == 1 ``` You will find many examples of the former in the test suite, though. Refactors are welcome. diff --git a/tests/conftest.py b/tests/conftest.py index 040bbe066..24ab48ce5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,6 @@ import tilia.settings as settings_module from tilia.dirs import PROJECT_ROOT from tilia.media.player.base import MediaTimeChangeReason -from tilia.ui import actions as tilia_actions_module from tilia.app import App from tilia.boot import setup_logic from tilia.requests import ( @@ -25,7 +24,6 @@ get, listen, ) -from tilia.ui.actions import TiliaAction, setup_actions from tilia.ui.qtui import QtUI, TiliaMainWindow from tilia.ui.cli.ui import CLI from tilia.ui.windows import WindowKind @@ -227,8 +225,6 @@ def qtui(tilia, cleanup_requests, qapplication, use_test_settings, use_test_logg # noinspection PyProtectedMember @pytest.fixture(scope="module") def tilia(cleanup_requests): - mw = TiliaMainWindow() - setup_actions(mw) tilia_ = setup_logic(autosaver=False) tilia_.set_file_media_duration(100) tilia_.reset_undo_manager() @@ -278,50 +274,6 @@ def tlui( }[request.param] -class UserActionManager: - """ - Class to simulate and mock user interaction with the GUI. - """ - - def __init__(self): - self.action_to_trigger_count = {} - for action in tilia_actions_module.TiliaAction: - qaction = tilia_actions_module.get_qaction(action) - qaction.triggered.connect( - functools.partial(self._increment_trigger_count, action) - ) - - def trigger(self, action: TiliaAction): - tilia_actions_module.trigger(action) - self._increment_trigger_count(action) - - def _increment_trigger_count(self, action): - if action not in self.action_to_trigger_count: - self.action_to_trigger_count[action] = 1 - else: - self.action_to_trigger_count[action] += 1 - - def assert_triggered(self, action): - assert action in self.action_to_trigger_count - - def assert_not_triggered(self, action): - assert action not in self.action_to_trigger_count - - -@pytest.fixture -def user_actions(): - """ - Fixture to simulate and mock user interaction with the GUI. - This should be used with the _tlui fixtures - and NOT with _tl fixtures, as the latter do not - create corresponding TimelineUIS. Tests with _tl - may still pass if they use this fixture, provided they do - not require a TimelineUI. - """ - action_manager = UserActionManager() - yield action_manager - - def parametrize_tl(func): """Adds a parameter 'tl' to a test that receives the name of a fixture that returns a component. To get the timeline from within the test, add the `request` fixture to its arguments and diff --git a/tests/file/test_file_manager.py b/tests/file/test_file_manager.py index a61fd9f1a..a7cb318cd 100644 --- a/tests/file/test_file_manager.py +++ b/tests/file/test_file_manager.py @@ -14,7 +14,7 @@ from tilia.file.file_manager import FileManager from unittest.mock import patch -from tilia.ui.actions import TiliaAction +from tilia.ui import commands def get_empty_save_params(): @@ -25,23 +25,23 @@ def get_empty_save_params(): } | {"timelines_hash": ""} -class TestUserActions: - def test_save(self, tls, marker_tlui, tmp_path, user_actions): +class Tests: + def test_save(self, tls, marker_tlui, tmp_path): marker_tlui.create_marker(0) tmp_file_path = (tmp_path / "test_save.tla").resolve().__str__() with patch_file_dialog(True, [tmp_file_path]): - user_actions.trigger(TiliaAction.FILE_SAVE_AS) + commands.execute("file.save_as") marker_tlui.create_marker(1) - user_actions.trigger(TiliaAction.FILE_SAVE) + commands.execute("file.save") with patch_yes_or_no_dialog(True): - user_actions.trigger(TiliaAction.TIMELINES_CLEAR) + commands.execute("timelines.clear_all") assert marker_tlui.is_empty with ( patch_file_dialog(True, [tmp_file_path]), patch_yes_or_no_dialog(False), # do not save changes ): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert len(tls[0]) == 2 @@ -93,8 +93,8 @@ def test_is_file_modified_when_modified_timelines( self, tilia, marker_tlui, tmp_path ): with Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, tmp_path / "temp.tla")): - post(Post.FILE_SAVE) - post(Post.MARKER_ADD) + commands.execute("file.save") + commands.execute("timeline.marker.add") assert tilia.file_manager.is_file_modified(get(Get.APP_STATE)) def test_is_file_modified_modified_tile(self, tilia): @@ -144,13 +144,13 @@ def test_import_metadata_file_does_not_exist(self, tilia): @pytest.mark.skipif( not sys.platform.startswith("win"), reason="Windows specific test" ) - def test_is_saved_with_posix_paths(self, qtui, tilia, tmp_path, user_actions): + def test_is_saved_with_posix_paths(self, qtui, tilia, tmp_path): file_path = tmp_path / "test.tla" with patch_file_dialog(True, [str(EXAMPLE_MEDIA_PATH)]): - post(Post.UI_MEDIA_LOAD_LOCAL) + commands.execute("media.load.local") with patch_file_dialog(True, [str(WindowsPath(file_path))]): - user_actions.trigger(TiliaAction.FILE_SAVE) + commands.execute("file.save") with open(file_path, "r") as f: data = json.load(f) @@ -159,30 +159,26 @@ def test_is_saved_with_posix_paths(self, qtui, tilia, tmp_path, user_actions): assert data["media_path"] == Path(EXAMPLE_MEDIA_PATH).as_posix() def test_save_without_set_title_and_different_file_name( - self, qtui, tilia, tmp_path, user_actions + self, qtui, tilia, tmp_path ): tla_path = tmp_path / "Some Title.tla" with patch_file_dialog(True, [str(tla_path)]): - user_actions.trigger(TiliaAction.FILE_SAVE) + commands.execute("file.save") assert tilia.file_manager.file.media_metadata["title"] == "Some Title" - def test_save_with_title_set_and_different_file_name( - self, qtui, tilia, tmp_path, user_actions - ): + def test_save_with_title_set_and_different_file_name(self, qtui, tilia, tmp_path): tilia.file_manager.file.media_metadata["title"] = "Title Already Set" tla_path = tmp_path / "Some Title.tla" with patch_file_dialog(True, [str(tla_path)]): - user_actions.trigger(TiliaAction.FILE_SAVE) + commands.execute("file.save") assert tilia.file_manager.file.media_metadata["title"] == "Title Already Set" - def test_save_fail_reverts_to_original_name( - self, qtui, tilia, tmp_path, user_actions - ): + def test_save_fail_reverts_to_original_name(self, qtui, tilia, tmp_path): tla_path = tmp_path / "Non-existent Path" / "Some Other Title.tla" with patch_file_dialog(True, [str(tla_path)]): - user_actions.trigger(TiliaAction.FILE_SAVE) + commands.execute("file.save") assert tilia.file_manager.file.media_metadata[ "title" diff --git a/tests/mock.py b/tests/mock.py index 8dce3d58c..af1589495 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -5,34 +5,9 @@ from PyQt6.QtWidgets import QFileDialog, QMessageBox, QInputDialog from tilia.requests import Get, Post, serve, server, stop_serving -from tilia.requests import get as get_original from tilia.requests import post as post_original -class PatchGetMultiple: - def __init__(self, module: str, requests_to_return_values: dict[Get, Any]): - self.module = module - self.requests_to_return_values = requests_to_return_values - self.mock_request = None - self.mock = None - - def __enter__(self): - self.patcher = patch(self.module + ".get", self.request_mock) - self.patcher.start() - self.mock = Mock() - return self.mock - - def __exit__(self, exc_type, exc_val, exc_tb): - self.patcher.stop() - - def request_mock(self, request: Get, *args, **kwargs): - if request in self.requests_to_return_values: - self.mock(request, *args, **kwargs) - return self.requests_to_return_values[request] - else: - return get_original(request, *args, **kwargs) - - class Serve: def __init__(self, request: Get, return_value: Any): self.request = request @@ -83,31 +58,6 @@ def _callback(self, *_, **__): return return_value -class PatchGet: - def __init__(self, module: str, request_to_mock: Get, return_value: Any): - self.module = module - self.request_to_mock = request_to_mock - self.return_value = return_value - self.mock_request = None - self.mock = None - - def __enter__(self): - self.patcher = patch(self.module + ".get", self.request_mock) - self.patcher.start() - self.mock = Mock() - return self.mock - - def __exit__(self, exc_type, exc_val, exc_tb): - self.patcher.stop() - - def request_mock(self, request: Get, *args, **kwargs): - if request == self.request_to_mock: - self.mock(request, *args, **kwargs) - return self.return_value - else: - return get_original(request, *args, **kwargs) - - class PatchPost: def __init__(self, module: str, request_to_mock: Post): self.module = module diff --git a/tests/parsers/csv/test_markers_from_csv.py b/tests/parsers/csv/test_markers_from_csv.py index 6d75bca77..7d6d2f9e4 100644 --- a/tests/parsers/csv/test_markers_from_csv.py +++ b/tests/parsers/csv/test_markers_from_csv.py @@ -6,32 +6,24 @@ from PyQt6.QtWidgets import QFileDialog from tests.parsers.csv.common import assert_in_errors -from tilia.ui import actions -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.format import format_media_time -from tilia.ui.ui_import import on_import_to_timeline def patch_import(by: Literal["time", "measure"], tl, data) -> tuple[str, list[str]]: status = "" errors = [] - def mock_import(timeline_uis, tlkind): - nonlocal status, errors - status, errors = on_import_to_timeline(timeline_uis, tlkind) - return status, errors - with ( patch( - "tilia.ui.ui_import._get_by_time_or_by_measure_from_user", + "tilia.ui.timelines.collection.import_._get_by_time_or_by_measure_from_user", return_value=(True, by), ), patch.object(QFileDialog, "exec", return_value=True), patch.object(QFileDialog, "selectedFiles", return_value=[Path()]), - patch("tilia.ui.qtui.on_import_to_timeline", side_effect=mock_import), patch("builtins.open", mock_open(read_data=data)), ): - actions.trigger(TiliaAction.IMPORT_CSV_MARKER_TIMELINE) + status, errors = commands.execute("timelines.import.marker") return status, errors diff --git a/tests/test_app.py b/tests/test_app.py index 5ffda4082..521ec5315 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -13,8 +13,8 @@ from tilia.settings import settings from tilia.requests import Get, Post, post, get -from tilia.ui.actions import TiliaAction from tilia.timelines.timeline_kinds import TimelineKind +from tilia.ui import commands from tilia.ui.windows import WindowKind @@ -35,20 +35,18 @@ def _get_modified_file_state(): "file_path": "", } - def test_no_changes(self, user_actions): + def test_no_changes(self): with ( Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, False)), patch("tilia.file.file_manager.FileManager.save") as save_mock, PatchPost("tilia.app", Post.UI_EXIT) as exit_mock, ): - user_actions.trigger(TiliaAction.APP_CLOSE) + commands.execute("tilia.close") exit_mock.assert_called() save_mock.assert_not_called() - def test_file_modified_and_user_chooses_to_save_changes( - self, user_actions, tmp_path - ): + def test_file_modified_and_user_chooses_to_save_changes(self, tmp_path): tmp_file = tmp_path / "test_file_modified_and_user_chooses_to_save_changes.tla" with ( Serve(Get.APP_STATE, self._get_modified_file_state()), @@ -56,32 +54,32 @@ def test_file_modified_and_user_chooses_to_save_changes( Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, tmp_file)), PatchPost("tilia.app", Post.UI_EXIT) as exit_mock, ): - user_actions.trigger(TiliaAction.APP_CLOSE) + commands.execute("tilia.close") exit_mock.assert_called() assert tmp_file.exists() def test_file_modified_and_user_chooses_to_save_changes_when_file_was_previously_saved( - self, user_actions, tmp_path + self, tmp_path ): tmp_file = tmp_path / "test_file_modified_and_user_chooses_to_save_changes.tla" with ( Serve(Get.APP_STATE, self._get_modified_file_state()), Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, tmp_file)), ): - user_actions.trigger(TiliaAction.FILE_SAVE) + commands.execute("file.save") with ( Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, True)), PatchPost("tilia.app", Post.UI_EXIT) as exit_mock, ): - user_actions.trigger(TiliaAction.APP_CLOSE) + commands.execute("tilia.close") exit_mock.assert_called() assert tmp_file.exists() def test_file_is_modified_and_user_cancels_close_on_should_save_changes_dialog( - self, user_actions + self, ): with ( Serve(Get.APP_STATE, self._get_modified_file_state()), @@ -89,12 +87,12 @@ def test_file_is_modified_and_user_cancels_close_on_should_save_changes_dialog( patch("tilia.file.file_manager.FileManager.save") as save_mock, PatchPost("tilia.app", Post.UI_EXIT) as exit_mock, ): - user_actions.trigger(TiliaAction.APP_CLOSE) + commands.execute("tilia.close") exit_mock.assert_not_called() save_mock.assert_not_called() - def test_file_is_modified_and_user_cancels_file_save_dialog(self, user_actions): + def test_file_is_modified_and_user_cancels_file_save_dialog(self): with ( Serve(Get.APP_STATE, self._get_modified_file_state()), Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, True)), @@ -102,7 +100,7 @@ def test_file_is_modified_and_user_cancels_file_save_dialog(self, user_actions): patch("tilia.file.file_manager.FileManager.save") as save_mock, PatchPost("tilia.app", Post.UI_EXIT) as exit_mock, ): - user_actions.trigger(TiliaAction.APP_CLOSE) + commands.execute("tilia.close") exit_mock.assert_not_called() save_mock.assert_not_called() @@ -110,33 +108,33 @@ def test_file_is_modified_and_user_cancels_file_save_dialog(self, user_actions): class TestFileLoad: def test_media_path_does_not_exist_and_media_length_available( - self, tilia_state, qtui, tmp_path, user_actions + self, tilia_state, qtui, tmp_path ): tilia_state.duration = 101 # set media path to a non-existing file nonexisting_media = tmp_path / "nothere.mp3" with patch_file_dialog(True, [str(nonexisting_media)]): - user_actions.trigger(TiliaAction.MEDIA_LOAD_LOCAL) + commands.execute("media.load.local") # save tilia file tla_path = tmp_path / "test.tla" with patch_file_dialog(True, [str(tla_path)]): - user_actions.trigger(TiliaAction.FILE_SAVE_AS) + commands.execute("file.save_as") # open tilia file with ( patch_file_dialog(True, [str(tla_path)]), patch_yes_or_no_dialog(False), ): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert tilia_state.is_undo_manager_cleared assert tilia_state.media_path == "" assert tilia_state.duration == 101 def test_media_path_does_not_exist_and_media_length_not_available( - self, qtui, tilia_state, tmp_path, user_actions + self, qtui, tilia_state, tmp_path ): tilia_state.duration = 0 file_data = tests.utils.get_blank_file_data() @@ -147,29 +145,27 @@ def test_media_path_does_not_exist_and_media_length_not_available( patch_file_dialog(True, [str(tmp_file)]), patch_yes_or_no_dialog(False), # do no try to load another media ): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert tilia_state.is_undo_manager_cleared assert tilia_state.media_path == "" assert tilia_state.duration == 0 - def test_media_path_exists( - self, tilia, qtui, tilia_state, tmp_path, tls, user_actions - ): + def test_media_path_exists(self, tilia, qtui, tilia_state, tmp_path, tls): tmp_file = tmp_path / "test_file_load.tla" with patch_file_dialog(True, [EXAMPLE_MEDIA_PATH]): - user_actions.trigger(TiliaAction.MEDIA_LOAD_LOCAL) + commands.execute("media.load.local") with patch_file_dialog(True, [str(tmp_file)]): - user_actions.trigger(TiliaAction.FILE_SAVE_AS) + commands.execute("file.save_as") with Serve(Get.FROM_USER_TILIA_FILE_PATH, (True, tmp_file)): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert tilia_state.is_undo_manager_cleared assert tilia_state.media_path == EXAMPLE_MEDIA_PATH assert tilia_state.duration == EXAMPLE_MEDIA_DURATION - def test_media_path_is_youtube_url(self, tilia_state, tmp_path, user_actions): + def test_media_path_is_youtube_url(self, tilia_state, tmp_path): file_data = tests.utils.get_blank_file_data() tmp_file = tmp_path / "test_file_load.tla" media_path = "https://www.youtube.com/watch?v=wBfVsucRe1w" @@ -180,7 +176,7 @@ def test_media_path_is_youtube_url(self, tilia_state, tmp_path, user_actions): Serve(Get.FROM_USER_TILIA_FILE_PATH, (True, tmp_file)), Serve(Get.PLAYER_CLASS, YouTubePlayer), ): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert tilia_state.is_undo_manager_cleared assert tilia_state.media_path == media_path @@ -198,15 +194,15 @@ def test_load_local(self, tilia_state): assert tilia_state.media_path == EXAMPLE_MEDIA_PATH assert tilia_state.duration == EXAMPLE_MEDIA_DURATION - def test_undo(self, tilia_state, user_actions): + def test_undo(self, tilia_state): self._load_media(EXAMPLE_MEDIA_PATH) - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") assert not tilia_state.media_path - def test_redo(self, tilia_state, user_actions): + def test_redo(self, tilia_state): self._load_media(EXAMPLE_MEDIA_PATH) - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.undo") + commands.execute("edit.redo") assert tilia_state.media_path == EXAMPLE_MEDIA_PATH def test_load_invalid_extension(self, tilia_state, tilia_errors): @@ -254,11 +250,11 @@ class TestScaleCropTimeline: ], ) def test_set_duration_twice_without_cropping( - self, scale_timelines, scale_factor, tilia_state, marker_tlui, user_actions + self, scale_timelines, scale_factor, tilia_state, marker_tlui ): marker_time = 50 tilia_state.current_time = marker_time - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") displacement_factor = 1 for factor, should_scale in zip(scale_factor, scale_timelines, strict=True): tilia_state.set_duration( @@ -268,31 +264,31 @@ def test_set_duration_twice_without_cropping( displacement_factor *= factor assert marker_tlui[0].get_data("time") == marker_time * displacement_factor - def test_scale_then_crop(self, marker_tl, tilia_state, user_actions): + def test_scale_then_crop(self, marker_tl, tilia_state): tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.current_time = 50 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.set_duration(200, scale_timelines="yes") tilia_state.set_duration(50, scale_timelines="no") assert len(marker_tl) == 1 assert marker_tl[0].get_data("time") == 20 - def test_crop_then_scale(self, marker_tl, tilia_state, user_actions): + def test_crop_then_scale(self, marker_tl, tilia_state): tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.current_time = 50 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.set_duration(40, scale_timelines="no") tilia_state.set_duration(80, scale_timelines="yes") assert len(marker_tl) == 1 assert marker_tl[0].get_data("time") == 20 - def test_crop_twice(self, marker_tlui, tilia_state, user_actions): + def test_crop_twice(self, marker_tlui, tilia_state): tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.current_time = 50 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.set_duration(80, scale_timelines="no") tilia_state.set_duration(40, scale_timelines="no") assert len(marker_tlui) == 1 @@ -301,7 +297,7 @@ def test_crop_twice(self, marker_tlui, tilia_state, user_actions): class TestFileSetup: def test_slider_timeline_is_created_when_loaded_file_does_not_have_one( - self, tls, tmp_path, user_actions + self, tls, tmp_path ): file_data = tests.utils.get_blank_file_data() file_data["timelines"] = { @@ -317,7 +313,7 @@ def test_slider_timeline_is_created_when_loaded_file_does_not_have_one( tmp_file = tmp_path / "test_file_setup.tla" tmp_file.write_text(json.dumps(file_data)) with Serve(Get.FROM_USER_TILIA_FILE_PATH, (True, tmp_file)): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert len(tls) == 2 assert TimelineKind.SLIDER_TIMELINE in tls.timeline_kinds @@ -330,7 +326,7 @@ def assert_open_failed(tilia, tilia_errors, opened_file_path, prev_file): class TestOpen: - def test_open_with_timeline(self, tilia, tls, tmp_path, user_actions): + def test_open_with_timeline(self, tilia, tls, tmp_path): tl_data = tests.utils.get_dummy_timeline_data() tl_id = list(tl_data.keys())[0] @@ -353,7 +349,7 @@ def test_open_with_timeline(self, tilia, tls, tmp_path, user_actions): tmp_file = tmp_path / "test.tla" tmp_file.write_text(json.dumps(file_data, indent=2)) with Serve(Get.FROM_USER_TILIA_FILE_PATH, (True, tmp_file)): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert Path(settings.get_recent_files()[0]) == tmp_file assert len(tls) == 2 # Slider timeline is also created by default @@ -361,7 +357,7 @@ def test_open_with_timeline(self, tilia, tls, tmp_path, user_actions): def test_open_with_path(self, tilia, tls, tmp_path): tmp_file = tests.utils.get_tmp_file_with_dummy_timeline(tmp_path) - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert Path(settings.get_recent_files()[0]) == tmp_file assert len(tls) == 2 # Slider timeline is also created by default @@ -369,7 +365,7 @@ def test_open_with_path(self, tilia, tls, tmp_path): def test_open_file_does_not_exist(self, tilia, tmp_path, tilia_errors): prev_file = tilia.file_manager.file tmp_file = tmp_path / "test.tla" - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert_open_failed(tilia, tilia_errors, tmp_file, prev_file) @@ -377,7 +373,7 @@ def test_open_file_is_not_valid_json(self, tilia, tmp_path, tilia_errors): prev_file = tilia.file_manager.file tmp_file = tmp_path / "test.tla" tmp_file.write_text("{") - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert_open_failed(tilia, tilia_errors, tmp_file, prev_file) @@ -385,7 +381,7 @@ def test_open_file_is_not_valid_tla(self, tilia, tmp_path, tilia_errors): prev_file = tilia.file_manager.file tmp_file = tmp_path / "test.tla" tmp_file.write_text('{"a": 1, "b": 2}') - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert_open_failed(tilia, tilia_errors, tmp_file, prev_file) @@ -395,7 +391,7 @@ def test_open_file_with_bad_timeline_data(self, tilia, tmp_path, tilia_errors): file_data = tests.utils.get_blank_file_data() file_data["timelines"] = {"nonsense": 404} tmp_file.write_text(json.dumps(file_data)) - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert_open_failed(tilia, tilia_errors, tmp_file, prev_file) @@ -407,7 +403,7 @@ def test_file_not_modified_after_open(self, tilia, tmp_path): file_path.write_text(json.dumps(file_data)) tilia.on_clear() - post(Post.FILE_OPEN, file_path) + commands.execute("file.open", file_path) assert not tilia.file_manager.is_file_modified(tilia.file_manager.file.__dict__) def test_open_file_with_custom_metadata_fields(self, tilia, tmp_path): @@ -435,7 +431,7 @@ def test_open_file_with_custom_metadata_fields(self, tilia, tmp_path): tmp_file = tmp_path / "test.tla" tmp_file.write_text(file_data, encoding="utf-8") - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert list(tilia.file_manager.file.media_metadata.items()) == [ ("test_field1", "a"), @@ -443,21 +439,21 @@ def test_open_file_with_custom_metadata_fields(self, tilia, tmp_path): ("test_field3", "c"), ] - def test_open_saving_changes(self, tilia, tls, marker_tlui, user_actions, tmp_path): + def test_open_saving_changes(self, tilia, tls, marker_tlui, tmp_path): previous_path = tmp_path / "previous.tla" with Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, previous_path)): - post(Post.FILE_SAVE) + commands.execute("file.save") # make change - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") prev_tl_id = marker_tlui.id prev_marker_id = marker_tlui[0].id tmp_file = tests.utils.get_tmp_file_with_dummy_timeline(tmp_path) with Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, True)): - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) with open(previous_path, "r", encoding="utf-8") as f: contents = json.load(f) # read contents @@ -473,7 +469,7 @@ def test_open_saving_changes(self, tilia, tls, marker_tlui, user_actions, tmp_pa def test_open_without_saving_changes(self, tilia, tls, marker_tlui, tmp_path): previous_path = tmp_path / "previous.tla" with Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, previous_path)): - post(Post.FILE_SAVE) + commands.execute("file.save") # make change marker_tlui.create_marker(10) @@ -482,7 +478,7 @@ def test_open_without_saving_changes(self, tilia, tls, marker_tlui, tmp_path): tmp_file = tests.utils.get_tmp_file_with_dummy_timeline(tmp_path) with Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, False)): - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) with open(previous_path, "r", encoding="utf-8") as f: contents = json.load(f) # read contents @@ -495,7 +491,7 @@ def test_open_canceling_should_save_changes_dialog( ): previous_path = tmp_path / "previous.tla" with Serve(Get.FROM_USER_SAVE_PATH_TILIA, (True, previous_path)): - post(Post.FILE_SAVE) + commands.execute("file.save") # make change marker_tlui.create_marker(10) @@ -505,86 +501,81 @@ def test_open_canceling_should_save_changes_dialog( tmp_file = tests.utils.get_tmp_file_with_dummy_timeline(tmp_path) with Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (False, True)): - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert len(tls) == 1 # assert file wasn't opened assert tilia.get_app_state() == prev_state def test_open_then_save(self, tmp_path, tilia_errors): tmp_file = tests.utils.get_tmp_file_with_dummy_timeline(tmp_path) - post(Post.FILE_OPEN, tmp_file) - post(Post.FILE_SAVE) + commands.execute("file.open", tmp_file) + commands.execute("file.save") tilia_errors.assert_no_error() class TestUndoRedo: - def test_undo_fails( - self, tilia, qtui, user_actions, tluis, tilia_state, tilia_errors - ): + def test_undo_fails(self, tilia, qtui, tluis, tilia_state, tilia_errors): with Serve(Get.FROM_USER_STRING, (True, "test")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") # this will record an invalid state that will raise an exception when # we try to restore it with patch.object(tilia, "get_app_state", return_value={}): - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") - # doing another action so the following redo + # executing another command so the following redo # will try to restore the previous, faulty state # Note: this could be improved by providing a state that is actually # similar to a healthy state tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") # as the undo failed, the current state (with both markers) # should be recovered assert len(tluis[0]) == 2 tilia_errors.assert_error() - def test_redo_fails( - self, tilia, qtui, user_actions, tluis, tilia_state, tilia_errors - ): + def test_redo_fails(self, tilia, qtui, tluis, tilia_state, tilia_errors): with Serve(Get.FROM_USER_STRING, (True, "test")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") # this will record an invalid state that will raise an exception when # we try to restore it # Note: this could be improved by providing a state that is actually # similar to a healthy state with patch.object(tilia, "get_app_state", return_value={}): - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") # going back to previous state - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") # restoring state (this will fail) - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.redo") - # as the redo failed, the current state (with no markers) - # should be recovered + # as the redo failed, the current state (with no markers) should be recovered assert tluis[0].is_empty tilia_errors.assert_error() class TestFileNew: - def test_media_is_unloaded(self, tilia, qtui, user_actions): + def test_media_is_unloaded(self, tilia, qtui): with Serve(Get.FROM_USER_MEDIA_PATH, (True, EXAMPLE_MEDIA_PATH)): - user_actions.trigger(TiliaAction.MEDIA_LOAD_LOCAL) + commands.execute("media.load.local") with Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, False)): - user_actions.trigger(TiliaAction.FILE_NEW) + commands.execute("file.new") assert get(Get.MEDIA_DURATION) == 0 assert not tilia.player.media_path - def test_all_windows_are_closed(self, tilia, qtui, user_actions): + def test_all_windows_are_closed(self, tilia, qtui): for kind in WindowKind: post(Post.WINDOW_OPEN, kind) with Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, False)): - user_actions.trigger(TiliaAction.FILE_NEW) + commands.execute("file.new") # this doesn't actaully check if windows are closed # it checks if app._windows[kind] is None. @@ -618,7 +609,7 @@ def test_path_nonexistent(self, tilia, tmp_path): ("folderName/files/tilia.tla", "folderName/files/media/audio/music.mp3"), ], ) - def test_moving_files(self, tla, media, tilia, qtui, tmp_path, user_actions): + def test_moving_files(self, tla, media, tilia, qtui, tmp_path): # create tla and media in old folder old_folder = tmp_path / "old" / "folder" old_tla = old_folder / tla @@ -631,11 +622,11 @@ def test_moving_files(self, tla, media, tilia, qtui, tmp_path, user_actions): # load media with patch_file_dialog(True, [str(old_media.resolve())]): - user_actions.trigger(TiliaAction.MEDIA_LOAD_LOCAL) + commands.execute("media.load.local") # save tla with patch_file_dialog(True, [str(old_tla.resolve())]): - user_actions.trigger(TiliaAction.FILE_SAVE_AS) + commands.execute("file.save_as") tilia.on_clear() # unload media @@ -648,6 +639,6 @@ def test_moving_files(self, tla, media, tilia, qtui, tmp_path, user_actions): # open file at new folder with (patch_file_dialog(True, [str(new_tla)])): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") assert tilia.player.media_path == str(new_media) diff --git a/tests/test_export.py b/tests/test_export.py index b9e8e1fd3..b95f6e8f3 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -11,14 +11,14 @@ from tilia.timelines.base.timeline import TimelineFlag from tilia.timelines.harmony.timeline import HarmonyTimeline from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.dialogs.resize_rect import ResizeRect class TestExportJSON: - def _trigger_export_action(self, user_actions, path): + def _trigger_export_action(self, path): with Serve(Get.FROM_USER_EXPORT_PATH, (True, path)): - user_actions.trigger(TiliaAction.FILE_EXPORT_JSON) + commands.execute("file.export.json") with open(path, encoding="utf-8") as f: data = json.load(f) @@ -26,16 +26,16 @@ def _trigger_export_action(self, user_actions, path): return data def test_timelines_attributes_are_exported( - self, tilia, marker_tlui, user_actions, tmp_path, use_test_settings + self, tilia, marker_tlui, tmp_path, use_test_settings ): # Marker timeline is chosen as an example. Ideally this should be parametrized for all timelines kinds. tl_name = "my name" with patch_ask_for_string_dialog(True, tl_name): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", marker_tlui) tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) timeline_data = data["timelines"][0] assert "hash" not in timeline_data @@ -49,38 +49,38 @@ def test_timelines_attributes_are_exported( assert timeline_data["components"] == [] def test_timeline_not_exportable_attributes_are_not_exported( - self, tilia, harmony_tlui, user_actions, tmp_path + self, tilia, harmony_tlui, tmp_path ): # Harmony timeline is chosen as an example. Ideally this should be parametrized for all timelines kinds that have not exportable attributes. tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) timeline_data = data["timelines"][0] for attr in HarmonyTimeline.NOT_EXPORTABLE_ATTRS: assert attr not in timeline_data def test_not_exportble_timelines_are_not_exported( - self, tilia, marker_tlui, audiowave_tlui, user_actions, tmp_path + self, tilia, marker_tlui, audiowave_tlui, tmp_path ): # Audiowave timeline is chosen as an example. Ideally this should be parametrized for all non-exportable timeline types. tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) assert len(data["timelines"]) == 1 def test_export_timelines( - self, tilia, marker_tlui, harmony_tlui, user_actions, tilia_state, tmp_path + self, tilia, marker_tlui, harmony_tlui, tilia_state, tmp_path ): for i in range(5): tilia_state.current_time = i - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") harmony_tlui.create_harmony(0) harmony_tlui.create_mode(0) tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) assert len(data["timelines"]) == 2 assert data["timelines"][0]["kind"] == "MARKER_TIMELINE" @@ -98,35 +98,35 @@ def test_export_timelines( assert [c["kind"] for c in components] == ["HARMONY", "MODE"] assert [c["time"] for c in components] == [0, 0] - def test_export_has_media_path(self, tilia, user_actions, tmp_path): + def test_export_has_media_path(self, tilia, tmp_path): post(Post.APP_MEDIA_LOAD, EXAMPLE_MEDIA_PATH) tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) assert data["media_path"] == EXAMPLE_MEDIA_PATH - def test_export_has_media_metadata(self, tilia, user_actions, tmp_path): + def test_export_has_media_metadata(self, tilia, tmp_path): post(Post.MEDIA_METADATA_FIELD_SET, "title", "Test Title") tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) assert data["media_metadata"]["title"] == "Test Title" - def test_export_without_path(self, tilia, user_actions, tmp_path): + def test_export_without_path(self, tilia, tmp_path): tmp_file = tmp_path / "test.json" with Serve(Get.FROM_USER_EXPORT_PATH, (True, tmp_file)): - post(Post.FILE_EXPORT) + commands.execute("file.export.json") assert tmp_file.exists() @parametrize_component def test_exported_component_attributes_values_are_correct( - self, tilia, user_actions, comp, tmp_path, request + self, tilia, comp, tmp_path, request ): comp = request.getfixturevalue(comp) if TimelineFlag.NOT_EXPORTABLE in comp.timeline.FLAGS: @@ -134,7 +134,7 @@ def test_exported_component_attributes_values_are_correct( tmp_file = tmp_path / "test.json" - data = self._trigger_export_action(user_actions, tmp_file) + data = self._trigger_export_action(tmp_file) exported_component = data["timelines"][0]["components"][0] @@ -150,12 +150,10 @@ def _get_sample_file(self, tmp_path): return get_tmp_file_with_dummy_timeline(tmp_path).__str__() @pytest.mark.parametrize("scale_factor", [1.0, 0.5, 2.0]) - def test_image_export( - self, qtui, monkeypatch, tmp_path, user_actions, scale_factor - ): + def test_image_export(self, qtui, monkeypatch, tmp_path, scale_factor): image_path = tmp_path / "tl_image.jpg" with patch_file_dialog(True, [self._get_sample_file(tmp_path)]): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") scene = get(Get.MAIN_WINDOW).centralWidget().scene() original_width = scene.sceneRect().width() @@ -166,7 +164,7 @@ def test_image_export( monkeypatch.setattr(ResizeRect, "new_size", lambda *_: [True, new_width]) with Serve(Get.FROM_USER_EXPORT_PATH, (True, image_path)): - user_actions.trigger(TiliaAction.FILE_EXPORT_IMG) + commands.execute("file.export.img") with Image.open(image_path) as img: assert img.size[0] == new_width @@ -175,16 +173,14 @@ def test_image_export( assert scene.sceneRect().width() == original_width assert scene.sceneRect().height() == original_height - def test_image_export_resize_rejected( - self, qtui, user_actions, monkeypatch, tmp_path - ): + def test_image_export_resize_rejected(self, qtui, monkeypatch, tmp_path): image_path = tmp_path / "tl_image.jpg" with patch_file_dialog(True, [self._get_sample_file(tmp_path)]): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") monkeypatch.setattr(ResizeRect, "new_size", lambda *_: [False, None]) with Serve(Get.FROM_USER_EXPORT_PATH, (True, image_path)): - user_actions.trigger(TiliaAction.FILE_EXPORT_IMG) + commands.execute("file.export.img") assert not image_path.exists() diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 75fc84690..767fe92c9 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -1,42 +1,40 @@ import pytest -from tilia.ui.actions import TiliaAction +from tilia.ui import commands class TestBeatTimeline: - def test_create_beat_at_same_time_fails(self, beat_tlui, user_actions): - user_actions.trigger(TiliaAction.BEAT_ADD) - user_actions.trigger(TiliaAction.BEAT_ADD) + def test_create_beat_at_same_time_fails(self, beat_tlui): + commands.execute("timeline.beat.add") + commands.execute("timeline.beat.add") assert len(beat_tlui) == 1 - def test_create_beat_at_negative_time_fails( - self, beat_tlui, tilia_state, user_actions - ): + def test_create_beat_at_negative_time_fails(self, beat_tlui, tilia_state): tilia_state.current_time = -10 - user_actions.trigger(TiliaAction.BEAT_ADD) + commands.execute("timeline.beat.add") assert len(beat_tlui) == 0 def test_create_beat_at_time_bigger_than_media_duration_fails( - self, beat_tlui, tilia_state, user_actions + self, beat_tlui, tilia_state ): tilia_state.duration = 100 tilia_state.current_time = 101 - user_actions.trigger(TiliaAction.BEAT_ADD) + 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, tilia_state, user_actions + self, beat_tlui, tilia_state ): beat_tlui.timeline.beat_pattern = [2] tilia_state.current_time = 0 - user_actions.trigger(TiliaAction.BEAT_ADD) + commands.execute("timeline.beat.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.BEAT_ADD) + commands.execute("timeline.beat.add") tilia_state.current_time = 20 - user_actions.trigger(TiliaAction.BEAT_ADD) + commands.execute("timeline.beat.add") tilia_state.current_time = 5 - user_actions.trigger(TiliaAction.BEAT_ADD) + 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 diff --git a/tests/timelines/pdf/test_pdf_timeline.py b/tests/timelines/pdf/test_pdf_timeline.py index d5ac8c300..d1ee32d35 100644 --- a/tests/timelines/pdf/test_pdf_timeline.py +++ b/tests/timelines/pdf/test_pdf_timeline.py @@ -1,10 +1,10 @@ -from tilia.ui.actions import TiliaAction +from tilia.ui import commands class TestValidateComponentCreation: - def test_marker_at_same_time_fails(self, user_actions, pdf_tlui): - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + def test_marker_at_same_time_fails(self, pdf_tlui): + commands.execute("timeline.pdf.add") + commands.execute("timeline.pdf.add") assert len(pdf_tlui) == 1 @@ -14,69 +14,63 @@ def test_page_total_is_zero_with_invalid_pdf(self, pdf_tl): class TestPageNumber: - def test_marker_page_number_default_is_next_page( - self, user_actions, tilia_state, pdf_tl - ): + def test_marker_page_number_default_is_next_page(self, tilia_state, pdf_tl): pdf_tl.page_total = 2 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") assert pdf_tl[1].get_data("page_number") == 2 - def test_first_marker_page_number_is_one(self, user_actions, pdf_tl): + def test_first_marker_page_number_is_one(self, pdf_tl): pdf_tl.page_total = 1 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") assert pdf_tl[0].get_data("page_number") == 1 - def test_correct_page_is_displayed( - self, user_actions, tilia_state, pdf_tlui, pdf_tl - ): + def test_correct_page_is_displayed(self, tilia_state, pdf_tlui, pdf_tl): pdf_tl.page_total = 2 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 11 assert pdf_tlui.current_page == 2 def test_correct_page_is_displayed_when_marker_is_created( - self, user_actions, tilia_state, pdf_tlui, pdf_tl + self, tilia_state, pdf_tlui, pdf_tl ): pdf_tl.page_total = 2 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") assert pdf_tlui.current_page == 2 def test_correct_page_is_displayed_when_marker_is_deleted( - self, user_actions, tilia_state, pdf_tl, pdf_tlui + self, tilia_state, pdf_tl, pdf_tlui ): - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") pdf_tl.delete_components([pdf_tl[1]]) assert pdf_tlui.current_page == 1 def test_correct_page_is_displayed_when_current_time_is_same_as_marker( - self, user_actions, tilia_state, pdf_tlui, pdf_tl + self, tilia_state, pdf_tlui, pdf_tl ): pdf_tl.page_total = 2 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 20 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 assert pdf_tlui.current_page == 2 - def test_page_number_is_limited_by_page_total( - self, user_actions, tilia_state, pdf_tl - ): + def test_page_number_is_limited_by_page_total(self, tilia_state, pdf_tl): pdf_tl.page_total = 2 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 20 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") tilia_state.current_time = 30 - user_actions.trigger(TiliaAction.PDF_MARKER_ADD) + commands.execute("timeline.pdf.add") assert pdf_tl[-1].get_data("page_number") == 2 diff --git a/tests/ui/cli/metadata/test_cli_show_metadata.py b/tests/ui/cli/metadata/test_cli_show_metadata.py index 874809ecd..8c3495e7b 100644 --- a/tests/ui/cli/metadata/test_cli_show_metadata.py +++ b/tests/ui/cli/metadata/test_cli_show_metadata.py @@ -1,5 +1,5 @@ from unittest.mock import patch -from tests.mock import PatchGet +from tests.mock import Serve from tilia.file.media_metadata import MediaMetadata from tilia.requests.get import Get @@ -9,9 +9,7 @@ class TestShowMetadata: def test_show_metadata_empty_metadata(self): with patch("tilia.ui.cli.io.print") as print_mock: - with PatchGet( - "tilia.ui.cli.metadata.show", Get.MEDIA_METADATA, MediaMetadata() - ): + with Serve(Get.MEDIA_METADATA, MediaMetadata()): show(None) output = print_mock.call_args[0][0] @@ -28,7 +26,7 @@ def test_show_metadata(self): ) with patch("tilia.ui.cli.io.print") as print_mock: - with PatchGet("tilia.ui.cli.metadata.show", Get.MEDIA_METADATA, metadata): + with Serve(Get.MEDIA_METADATA, metadata): show(None) output = print_mock.call_args[0][0] diff --git a/tests/ui/cli/test_clear.py b/tests/ui/cli/test_clear.py index f8838c185..95c216a25 100644 --- a/tests/ui/cli/test_clear.py +++ b/tests/ui/cli/test_clear.py @@ -15,14 +15,14 @@ def assert_cleared(tls): assert len(tls) == 1 assert tls[0].KIND == TimelineKind.SLIDER_TIMELINE - def test_clear(self, cli, tls, tilia_state, user_actions): + def test_clear(self, cli, tls, tilia_state): tilia_state.duration = 1 cli.parse_and_run("timeline add hrc") cli.parse_and_run("clear --force") self.assert_cleared(tls) - def test_clear_twice(self, cli, tls, tilia_state, user_actions): + def test_clear_twice(self, cli, tls, tilia_state): tilia_state.duration = 1 cli.parse_and_run("timeline add hrc") cli.parse_and_run("clear --force") @@ -31,7 +31,7 @@ def test_clear_twice(self, cli, tls, tilia_state, user_actions): self.assert_cleared(tls) def test_clear_saved_does_not_prompt_for_confirmation( - self, cli, tls, tmp_path, tilia_state, user_actions + self, cli, tls, tmp_path, tilia_state ): tilia_state.duration = 1 cli.parse_and_run("timeline add hrc") @@ -41,7 +41,7 @@ def test_clear_saved_does_not_prompt_for_confirmation( self.assert_cleared(tls) def test_clear_unsaved_do_not_confirm( - self, cli, tls, tmp_path, tilia_state, user_actions, monkeypatch + self, cli, tls, tmp_path, tilia_state, monkeypatch ): tilia_state.duration = 1 cli.parse_and_run("timeline add hrc") @@ -50,9 +50,7 @@ def test_clear_unsaved_do_not_confirm( assert len(tls) == 2 - def test_clear_unsaved_confirm( - self, cli, tls, tmp_path, tilia_state, user_actions, monkeypatch - ): + def test_clear_unsaved_confirm(self, cli, tls, tmp_path, tilia_state, monkeypatch): tilia_state.duration = 1 cli.parse_and_run("timeline add hrc") monkeypatch.setattr("sys.stdin", StringIO("yes\n")) diff --git a/tests/ui/cli/test_load_media.py b/tests/ui/cli/test_load_media.py index 7dbef1667..ca1cb9b3d 100644 --- a/tests/ui/cli/test_load_media.py +++ b/tests/ui/cli/test_load_media.py @@ -49,7 +49,7 @@ def test_load_with_spaces(cli, tilia_errors, resources, tmp_path): assert_load_was_successful(EXAMPLE_MEDIA_DURATION) -def test_with_timelines_scale_yes(cli, tilia_state, marker_tl, user_actions): +def test_with_timelines_scale_yes(cli, tilia_state, marker_tl): tilia_state.current_time = tilia_state.duration / 2 marker_tl.create_marker(tilia_state.current_time) @@ -59,7 +59,7 @@ def test_with_timelines_scale_yes(cli, tilia_state, marker_tl, user_actions): assert marker_tl[0].get_data("time") == EXAMPLE_MEDIA_DURATION / 2 -def test_with_timelines_scale_no(cli, tilia_state, marker_tl, user_actions): +def test_with_timelines_scale_no(cli, tilia_state, marker_tl): marker_tl.create_marker(5) cli.parse_and_run(f"load-media {EXAMPLE_MEDIA_PATH} --scale-timelines no") @@ -68,9 +68,7 @@ def test_with_timelines_scale_no(cli, tilia_state, marker_tl, user_actions): assert marker_tl[0].get_data("time") == 5 -def test_with_timelines_scale_not_provided_answer_yes( - cli, tilia_state, marker_tl, user_actions -): +def test_with_timelines_scale_not_provided_answer_yes(cli, tilia_state, marker_tl): marker_tl.create_marker(50) from unittest.mock import patch @@ -83,7 +81,7 @@ def test_with_timelines_scale_not_provided_answer_yes( def test_with_timelines_scale_not_provided_answer_yes_but_dont_confirm_crop( - cli, tilia_state, marker_tl, user_actions + cli, tilia_state, marker_tl ): marker_tl.create_marker(50) @@ -94,9 +92,7 @@ def test_with_timelines_scale_not_provided_answer_yes_but_dont_confirm_crop( assert marker_tl[0].get_data("time") == 50 * EXAMPLE_MEDIA_SCALE_FACTOR -def test_with_timelines_scale_not_provied_answer_crop( - cli, tilia_state, marker_tl, user_actions -): +def test_with_timelines_scale_not_provied_answer_crop(cli, tilia_state, marker_tl): for time in [5, 50]: marker_tl.create_marker(time) diff --git a/tests/ui/cli/timelines/test_cli_timelines.py b/tests/ui/cli/timelines/test_cli_timelines.py index dcfa6037d..a934b3caa 100644 --- a/tests/ui/cli/timelines/test_cli_timelines.py +++ b/tests/ui/cli/timelines/test_cli_timelines.py @@ -1,10 +1,6 @@ from unittest.mock import patch -from tests.mock import Serve - -from tilia.requests import Get from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.actions import TiliaAction class TestTimelineList: @@ -51,10 +47,8 @@ def test_type_not_provided(self, cli, hierarchy_tl, tls): assert not tls.is_empty - def test_by_name_one_timeline(self, cli, tls, user_actions): - with Serve(Get.FROM_USER_STRING, (True, "test")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_HIERARCHY_TIMELINE) - + def test_by_name_one_timeline(self, cli, tls): + cli.parse_and_run("timeline add hierarchy --name test") cli.parse_and_run("timeline remove name test") assert tls.is_empty @@ -71,9 +65,8 @@ def test_remove_by_name_multiple_timelines(self, cli, tls): assert len(tls) == 1 - def test_remove_by_name_not_found(self, cli, tls, user_actions): - with Serve(Get.FROM_USER_STRING, (True, "test")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_HIERARCHY_TIMELINE) + def test_remove_by_name_not_found(self, cli, tls): + cli.parse_and_run("timeline add hierarchy --name test") with patch("builtins.print") as mock_print: cli.parse_and_run("timeline remove name othername") diff --git a/tests/ui/dialogs/test_inspect.py b/tests/ui/dialogs/test_inspect.py index 75307af3e..8d6eb0295 100644 --- a/tests/ui/dialogs/test_inspect.py +++ b/tests/ui/dialogs/test_inspect.py @@ -1,18 +1,18 @@ from tests.conftest import parametrize_ui_element -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.timelines.beat import BeatUI @parametrize_ui_element -def test_inspect_elements(tluis, element, user_actions, request): +def test_inspect_elements(tluis, element, request): element = request.getfixturevalue(element) element.timeline_ui.select_element(element) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_INSPECT) + commands.execute("timeline.element.inspect") @parametrize_ui_element -def test_inspect_elements_with_beat_timeline(element, beat_tlui, user_actions, request): +def test_inspect_elements_with_beat_timeline(element, beat_tlui, request): # some properties are only displayed if a beat timeline is present element = request.getfixturevalue(element) if not isinstance(element, BeatUI): @@ -21,4 +21,4 @@ def test_inspect_elements_with_beat_timeline(element, beat_tlui, user_actions, r element.timeline_ui.select_element(element) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_INSPECT) + commands.execute("timeline.element.inspect") diff --git a/tests/ui/test_qtui.py b/tests/ui/test_qtui.py index 82f2f33f9..0f445ffcf 100644 --- a/tests/ui/test_qtui.py +++ b/tests/ui/test_qtui.py @@ -6,12 +6,16 @@ from tests.utils import get_main_window_menu, get_actions_in_menu from tilia.requests import Post, post, Get from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.actions import get_qaction, TiliaAction +from tilia.ui.commands import get_qaction from tilia.ui.timelines.marker import MarkerTimelineUI from tilia.ui.windows import WindowKind class TestImport: + patch_target = ( + "tilia.ui.timelines.collection.import_._get_by_time_or_by_measure_from_user" + ) + def test_timeline_gets_restored_if_import_fails(self, qtui, marker_tl): for i in range(100): marker_tl.create_marker(i) @@ -19,7 +23,7 @@ def test_timeline_gets_restored_if_import_fails(self, qtui, marker_tl): prev_state = marker_tl.get_state() with patch( - "tilia.ui.ui_import._get_by_time_or_by_measure_from_user", + self.patch_target, return_value=(True, "time"), ): with patch("builtins.open", mock_open(read_data="nonsense")): @@ -36,7 +40,7 @@ def test_raises_error_if_invalid_csv( ): with ( patch( - "tilia.ui.ui_import._get_by_time_or_by_measure_from_user", + self.patch_target, return_value=(True, "time"), ), Serve( @@ -158,12 +162,12 @@ def test_edit_menu_has_right_actions(self, qtui): menu = get_main_window_menu(qtui, "Edit") actions = get_actions_in_menu(menu) expected = [ - TiliaAction.EDIT_UNDO, - TiliaAction.EDIT_REDO, - TiliaAction.TIMELINE_ELEMENT_COPY, - TiliaAction.TIMELINE_ELEMENT_PASTE, - TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE, - TiliaAction.SETTINGS_WINDOW_OPEN, + "edit.undo", + "edit.redo", + "timeline.component.copy", + "timeline.component.paste", + "timeline.component.paste_complete", + "window.open.settings", ] expected = [get_qaction(action) for action in expected] assert set(actions) == set(expected) diff --git a/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py b/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py index 09cae7c0d..37e5a4156 100644 --- a/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py +++ b/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py @@ -1,36 +1,36 @@ -from tilia.requests import post, Post -from tilia.ui.actions import TiliaAction +from tilia.ui import commands -def test_undo_redo(audiowave_tlui, marker_tlui, user_actions): +def test_undo_redo(audiowave_tlui, marker_tlui): + # using marker tl to trigger an actions that can be undone - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(marker_tlui) == 0 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(marker_tlui) == 1 class TestActions: - def test_copy_paste(self, audiowave_tlui, user_actions): + def test_copy_paste(self, audiowave_tlui): audiowave_tlui.create_amplitudebar(0, 1, 1) audiowave_tlui.create_amplitudebar(1, 2, 0) audiowave_tlui.select_element(audiowave_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") audiowave_tlui.deselect_element(0) audiowave_tlui.select_element(audiowave_tlui[1]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert audiowave_tlui[1].get_data("start") != 0 - def test_delete(self, audiowave_tlui, user_actions): + def test_delete(self, audiowave_tlui): audiowave_tlui.create_amplitudebar(0, 1, 1) audiowave_tlui.select_element(audiowave_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert len(audiowave_tlui) == 1 diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index a7ecab97e..d2b377ade 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -1,12 +1,15 @@ from unittest.mock import MagicMock +import pytest + from tests.mock import Serve +from tests.utils import undoable from tilia.requests import Post, post from tilia.enums import Side from tilia.requests import Get from tilia.timelines.beat.timeline import BeatTimeline from tilia.settings import settings -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.windows import WindowKind @@ -37,7 +40,7 @@ def test_measure_numbers_are_loaded(self, beat_tl, tluis, tmp_path): post(Post.REQUEST_SAVE_TO_PATH, tmp_file) - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert [x.label.toPlainText() for x in tluis[0]] == ["1", "3", "5", "7"] @@ -56,7 +59,7 @@ def test_file_with_measures_to_force_display( post(Post.REQUEST_SAVE_TO_PATH, tmp_file) - post(Post.FILE_OPEN, tmp_file) + commands.execute("file.open", tmp_file) assert [x.label.toPlainText() for x in tluis[0]] == [ "1", @@ -82,16 +85,16 @@ def test_create_multiple(self, beat_tlui): beat_tlui.create_beat(0.2) assert len(beat_tlui) == 3 - def test_delete(self, beat_tlui, user_actions): + def test_delete(self, beat_tlui): beat_tlui.create_beat(0) beat_tlui.select_all_elements() - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert len(beat_tlui) == 0 assert not beat_tlui.selected_elements - def test_delete_update_next_measures_numbers(self, beat_tlui, user_actions): + 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] beat_tlui.create_beat(0) @@ -99,14 +102,12 @@ def test_delete_update_next_measures_numbers(self, beat_tlui, user_actions): beat_tlui.create_beat(2) beat_tlui.select_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") 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, user_actions, tilia_state - ): + def test_create_update_next_measures_numbers(self, beat_tlui, tilia_state): beat_tlui.timeline.beat_pattern = [1] beat_tlui.timeline.measures_to_force_display = [0, 1, 2, 3] beat_tlui.create_beat(0) @@ -114,7 +115,7 @@ def test_create_update_next_measures_numbers( beat_tlui.create_beat(2) tilia_state.current_time = 0.5 - user_actions.trigger(TiliaAction.BEAT_ADD) + commands.execute("timeline.beat.add") assert [get_displayed_measure_number(beat) for beat in beat_tlui] == [ "1", @@ -272,33 +273,33 @@ def test_get_copy_data_from_beat_uis(self, beat_tlui): assert beat2_data in copy_data assert beat3_data in copy_data - def test_paste_single_into_selected_elements(self, beat_tlui, tluis, user_actions): + def test_paste_single_into_selected_elements(self, beat_tlui, tluis): beat_tlui.create_beat(0) beat_tlui.select_element(beat_tlui.elements[0]) tl_state0 = beat_tlui.timeline.get_state() - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.copy") + commands.execute("timeline.component.paste") tl_state1 = beat_tlui.timeline.get_state() assert tl_state0 == tl_state1 - def test_paste_single_into_timeline(self, beat_tlui, tluis, user_actions): + def test_paste_single_into_timeline(self, beat_tlui, tluis): beat_tlui.create_beat(10) beat_tlui.select_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") beat_tlui.deselect_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(beat_tlui) == 2 assert beat_tlui[0].time == 0 - def test_paste_multiple_into_timeline(self, beat_tlui, tluis, user_actions): + def test_paste_multiple_into_timeline(self, beat_tlui, tluis): beat_tlui.create_beat(10) beat_tlui.create_beat(11) beat_tlui.create_beat(12) @@ -306,11 +307,11 @@ def test_paste_multiple_into_timeline(self, beat_tlui, tluis, user_actions): beat_tlui.select_all_elements() - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") beat_tlui.deselect_all_elements() - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(beat_tlui) == 8 assert beat_tlui[0].time == 0 @@ -352,88 +353,86 @@ def test_get_measure_number(self, beat_tlui): class TestSetMeasureNumber: @staticmethod - def _set_measure_number(beat_tlui, actions, number=DUMMY_MEASURE_NUMBER): + 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)): - actions.trigger(TiliaAction.BEAT_SET_MEASURE_NUMBER) + commands.execute("timeline.beat.set_measure_number") - def test_set_measure_number_single_measure(self, beat_tlui, user_actions): + def test_set_measure_number_single_measure(self, beat_tlui): beat_tlui.create_beat(0) beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) + self._set_measure_number() assert beat_tlui.timeline.measure_numbers[0] == DUMMY_MEASURE_NUMBER - def test_set_measure_number_twice(self, beat_tlui, user_actions): + def test_set_measure_number_twice(self, beat_tlui): beat_tlui.create_beat(0) beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) - self._set_measure_number(beat_tlui, user_actions, 101) + self._set_measure_number() + self._set_measure_number(101) assert beat_tlui.timeline.measure_numbers[0] == 101 - def test_set_measure_number_multiple_measures(self, beat_tlui, user_actions): + def test_set_measure_number_multiple_measures(self, beat_tlui): beat_tlui.timeline.beat_pattern = [3] for i in range(12): beat_tlui.create_beat(i / 10) beat_tlui.select_element(beat_tlui[3]) - self._set_measure_number(beat_tlui, user_actions) + self._set_measure_number() assert beat_tlui.timeline.measure_numbers[1] == DUMMY_MEASURE_NUMBER - def test_set_measure_number_not_first_beat_in_measure( - self, beat_tlui, user_actions - ): + def test_set_measure_number_not_first_beat_in_measure(self, beat_tlui): beat_tlui.timeline.beat_pattern = [3] for i in range(12): beat_tlui.create_beat(i / 10) beat_tlui.select_element(beat_tlui[1]) - self._set_measure_number(beat_tlui, user_actions) + self._set_measure_number() assert beat_tlui.timeline.measure_numbers[0] == DUMMY_MEASURE_NUMBER - def test_undo_set_measure_number(self, beat_tlui, user_actions): - user_actions.trigger(TiliaAction.BEAT_ADD) + def test_undo_set_measure_number(self, beat_tlui): + commands.execute("timeline.beat.add") beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) - user_actions.trigger(TiliaAction.EDIT_UNDO) + self._set_measure_number() + commands.execute("edit.undo") assert beat_tlui.timeline.measure_numbers[0] == 1 - def test_redo_set_measure_number(self, beat_tlui, user_actions): - user_actions.trigger(TiliaAction.BEAT_ADD) + def test_redo_set_measure_number(self, beat_tlui): + commands.execute("timeline.beat.add") beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + self._set_measure_number() + commands.execute("edit.undo") + commands.execute("edit.redo") assert beat_tlui.timeline.measure_numbers[0] == DUMMY_MEASURE_NUMBER - def test_reset_measure_number(self, beat_tlui, user_actions): - user_actions.trigger(TiliaAction.BEAT_ADD) + def test_reset_measure_number(self, beat_tlui): + commands.execute("timeline.beat.add") beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) - user_actions.trigger(TiliaAction.BEAT_RESET_MEASURE_NUMBER) + self._set_measure_number() + commands.execute("timeline.beat.reset_measure_number") assert beat_tlui.timeline.measure_numbers[0] == 1 - def test_undo_reset_measure_number(self, beat_tlui, user_actions): - user_actions.trigger(TiliaAction.BEAT_ADD) + def test_undo_reset_measure_number(self, beat_tlui): + commands.execute("timeline.beat.add") beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) - user_actions.trigger(TiliaAction.BEAT_RESET_MEASURE_NUMBER) - user_actions.trigger(TiliaAction.EDIT_UNDO) + self._set_measure_number() + commands.execute("timeline.beat.reset_measure_number") + commands.execute("edit.undo") assert beat_tlui.timeline.measure_numbers[0] == DUMMY_MEASURE_NUMBER - def test_redo_reset_measure_number(self, beat_tlui, user_actions): - user_actions.trigger(TiliaAction.BEAT_ADD) + def test_redo_reset_measure_number(self, beat_tlui): + commands.execute("timeline.beat.add") beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions) - user_actions.trigger(TiliaAction.BEAT_RESET_MEASURE_NUMBER) - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + self._set_measure_number() + commands.execute("timeline.beat.reset_measure_number") + commands.execute("edit.undo") + commands.execute("edit.redo") assert beat_tlui.timeline.measure_numbers[0] == 1 - def test_measure_zero_number_is_not_displayed(self, beat_tlui, user_actions): + def test_measure_zero_number_is_not_displayed(self, beat_tlui): settings.set("beat_timeline", "display_measure_periodicity", 2) beat_tlui.timeline.beat_pattern = [1] @@ -446,14 +445,14 @@ def test_measure_zero_number_is_not_displayed(self, beat_tlui, user_actions): beat_tlui.select_element(beat_tlui[0]) - self._set_measure_number(beat_tlui, user_actions, 0) + self._set_measure_number(0) displayed_measure = [get_displayed_measure_number(b) for b in beat_tlui] assert displayed_measure == ["", "1", "", "3"] class TestActions: - def test_inspect(self, qtui, beat_tlui, user_actions, tilia_state): + def test_inspect(self, qtui, beat_tlui, tilia_state): beat_tlui.create_beat(0) beat_tlui.create_beat(0.1) beat_tlui.create_beat(0.2) @@ -461,13 +460,13 @@ def test_inspect(self, qtui, beat_tlui, user_actions, tilia_state): for element in beat_tlui: beat_tlui.select_element(element) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_INSPECT) + commands.execute("timeline.element.inspect") assert tilia_state.is_window_open(qtui, WindowKind.INSPECT) post(Post.WINDOW_CLOSE, WindowKind.INSPECT) - def test_distribute_beats(self, beat_tlui, user_actions): + def test_distribute_beats(self, beat_tlui): beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.create_beat(2) @@ -476,37 +475,37 @@ def test_distribute_beats(self, beat_tlui, user_actions): beat_tlui.select_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.BEAT_DISTRIBUTE) + commands.execute("timeline.beat.distribute") assert beat_tlui[1].get_data("time") == 2 assert beat_tlui[2].get_data("time") == 4 assert beat_tlui[3].get_data("time") == 6 - def test_distribute_beats_on_last_measure(self, beat_tlui, user_actions): + def test_distribute_beats_on_last_measure(self, beat_tlui): beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.select_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.BEAT_DISTRIBUTE) + commands.execute("timeline.beat.distribute") assert beat_tlui[0].get_data("time") == 0 assert beat_tlui[1].get_data("time") == 1 class TestSetBeatAmountInMeasure: - def test_set_beat_amount_in_measure(self, beat_tlui, user_actions): + 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() with Serve(Get.FROM_USER_INT, (True, 11)): - user_actions.trigger(TiliaAction.BEAT_SET_AMOUNT_IN_MEASURE) + 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, user_actions): + def test_updates_measure_numbers(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] beat_tlui.timeline.measures_to_force_display = [0, 1, 2] beat_tlui.create_beat(0) @@ -516,64 +515,73 @@ def test_updates_measure_numbers(self, beat_tlui, user_actions): beat_tlui.select_element(beat_tlui[0]) with Serve(Get.FROM_USER_INT, (True, 2)): - user_actions.trigger(TiliaAction.BEAT_SET_AMOUNT_IN_MEASURE) + commands.execute("timeline.beat.set_amount_in_measure") assert [get_displayed_measure_number(b) for b in beat_tlui] == ["1", "", "2"] class TestFillWithBeats: - def test_by_amount(self, beat_tlui, user_actions): + @pytest.fixture(autouse=True) + def setup(self, beat_tlui): + # required for undoable actions + 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)), ): - user_actions.trigger(TiliaAction.BEAT_TIMELINE_FILL) + with undoable(): + commands.execute("timeline.beat.fill") assert len(beat_tlui) == 100 - def test_by_interval(self, beat_tlui, user_actions, tilia_state): + 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)), ): - user_actions.trigger(TiliaAction.BEAT_TIMELINE_FILL) + with undoable(): + commands.execute("timeline.beat.fill") assert len(beat_tlui) == amount assert beat_tlui[1].get_data("time") - beat_tlui[0].get_data("time") == interval - def test_accept_delete_existing_beats(self, beat_tlui, user_actions): - beat_tlui.create_beat(0) + 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 Serve(Get.FROM_USER_YES_OR_NO, True): - user_actions.trigger(TiliaAction.BEAT_TIMELINE_FILL) + with undoable(): + commands.execute("timeline.beat.fill") assert len(beat_tlui) == 100 - def test_reject_delete_existing_beats(self, beat_tlui, user_actions): + 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 Serve(Get.FROM_USER_YES_OR_NO, False): - user_actions.trigger(TiliaAction.BEAT_TIMELINE_FILL) + commands.execute("timeline.beat.fill") assert len(beat_tlui) == 1 class TestUndoRedo: - def test_undo_redo_add_beat(self, beat_tlui, tluis, tilia_state, user_actions): + def test_undo_redo_add_beat(self, beat_tlui, tluis, tilia_state): tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.BEAT_ADD) + commands.execute("timeline.beat.add") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(beat_tlui) == 0 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(beat_tlui) == 1 - def test_undo_redo_delete_beat_multiple_beats(self, beat_tlui, tluis, user_actions): + def test_undo_redo_delete_beat_multiple_beats(self, beat_tlui, tluis): beat_tlui.create_beat(0) beat_tlui.create_beat(0.1) beat_tlui.create_beat(0.2) @@ -583,30 +591,30 @@ def test_undo_redo_delete_beat_multiple_beats(self, beat_tlui, tluis, user_actio post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(beat_tlui) == 3 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(beat_tlui) == 0 - def test_undo_redo_delete_beat(self, beat_tlui, tluis, user_actions): + def test_undo_redo_delete_beat(self, beat_tlui, tluis): # 'tlui_clct' is needed as it subscriber to toolbar event # and forwards it to beat timeline - beat_tlui.create_beat(0) + commands.execute("timeline.beat.add") beat_tlui.select_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(beat_tlui) == 1 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(beat_tlui) == 0 - def test_undo_redo_updates_displayed_measure_numbers(self, beat_tlui, user_actions): + 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] beat_tlui.create_beat(0) @@ -616,9 +624,9 @@ def test_undo_redo_updates_displayed_measure_numbers(self, beat_tlui, user_actio post(Post.APP_STATE_RECORD, "test") beat_tlui.select_element(beat_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") assert [get_displayed_measure_number(beat_ui) for beat_ui in beat_tlui] == [ "1", @@ -626,7 +634,7 @@ def test_undo_redo_updates_displayed_measure_numbers(self, beat_tlui, user_actio "3", ] - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.redo") assert [get_displayed_measure_number(beat_ui) for beat_ui in beat_tlui] == [ "1", diff --git a/tests/ui/timelines/harmony/test_harmony_timeline_ui.py b/tests/ui/timelines/harmony/test_harmony_timeline_ui.py index 622a9a7dd..75e89199d 100644 --- a/tests/ui/timelines/harmony/test_harmony_timeline_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_timeline_ui.py @@ -1,10 +1,10 @@ import pytest -import tilia.ui.actions +import tilia.ui.commands from tests.mock import Serve from tests.ui.timelines.harmony.interact import click_harmony_ui from tilia.requests import Get -from tilia.ui.actions import TiliaAction +from tilia.ui import commands FLAT_SIGN = "`b" SHARP_SIGN = "`#" @@ -22,7 +22,7 @@ def add_harmony(**kwargs): } default_params.update(kwargs) with Serve(Get.FROM_USER_HARMONY_PARAMS, (True, default_params)): - tilia.ui.actions.trigger(TiliaAction.HARMONY_ADD) + tilia.ui.commands.execute("timeline.harmony.add_harmony") def add_mode(**kwargs): @@ -34,7 +34,7 @@ def add_mode(**kwargs): } default_params.update(kwargs) with Serve(Get.FROM_USER_MODE_PARAMS, (True, default_params)): - tilia.ui.actions.trigger(TiliaAction.MODE_ADD) + tilia.ui.commands.execute("timeline.harmony.add_mode") class TestRomanNumeralDisplay: @@ -127,7 +127,7 @@ def test_roman_label_does_not_start_with_accidental_when_root_is_diatonic_sharp_ class TestCopyPaste: def test_paste_multiple_to_harmony_with_mode_as_first_copied( - self, tilia_state, harmony_tlui, user_actions + self, tilia_state, harmony_tlui ): add_harmony() add_mode() @@ -136,9 +136,9 @@ def test_paste_multiple_to_harmony_with_mode_as_first_copied( click_harmony_ui(harmony_tlui.modes()[0]) click_harmony_ui(harmony_tlui.harmonies()[1], modifier="ctrl") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_harmony_ui(harmony_tlui.harmonies()[1]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(harmony_tlui) == 5 diff --git a/tests/ui/timelines/harmony/test_harmony_ui.py b/tests/ui/timelines/harmony/test_harmony_ui.py index e6a1499da..bb371e70b 100644 --- a/tests/ui/timelines/harmony/test_harmony_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_ui.py @@ -4,8 +4,7 @@ from tests.ui.timelines.harmony.interact import click_harmony_ui from tests.ui.timelines.interact import click_timeline_ui -from tilia.ui import actions -from tilia.ui.actions import TiliaAction +from tilia.ui import commands @pytest.fixture @@ -28,10 +27,10 @@ class TestCopyPaste: def test_paste_single_into_timeline(self, tlui, tilia_state): _, hui = tlui.create_harmony(0) click_harmony_ui(tlui[0]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(tlui, 10) tilia_state.current_time = 50 - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 2 assert tlui[1].get_data("time") == 50 @@ -42,10 +41,10 @@ def test_paste_multiple_into_timeline(self, tlui, tilia_state): click_harmony_ui(tlui[0]) click_harmony_ui(tlui[1], modifier="ctrl") click_harmony_ui(tlui[2], modifier="ctrl") - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(tlui, 10) tilia_state.current_time = 50 - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 6 assert tlui[3].get_data("time") == 50 assert tlui[4].get_data("time") == 60 @@ -76,9 +75,9 @@ def test_paste_single_into_element(self, tlui): ) click_harmony_ui(tlui[0]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_harmony_ui(tlui[1]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 2 for attr, value in attributes_to_copy.items(): assert tlui[1].get_data(attr) == attributes_to_copy[attr] @@ -113,10 +112,10 @@ def test_paste_multiple_into_element(self, tlui): for hui in copied_huis: click_harmony_ui(hui, modifier="ctrl") - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(tlui, 90) click_harmony_ui(target_hui) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 6 for attr, value in attributes_to_copy.items(): assert target_hui.get_data(attr) == attributes_to_copy[attr] diff --git a/tests/ui/timelines/harmony/test_mode_ui.py b/tests/ui/timelines/harmony/test_mode_ui.py index 1a81764a7..0fbadcd51 100644 --- a/tests/ui/timelines/harmony/test_mode_ui.py +++ b/tests/ui/timelines/harmony/test_mode_ui.py @@ -4,8 +4,7 @@ from tests.ui.timelines.harmony.interact import click_mode_ui from tests.ui.timelines.interact import click_timeline_ui -from tilia.ui import actions -from tilia.ui.actions import TiliaAction +from tilia.ui import commands @pytest.fixture @@ -28,10 +27,10 @@ class TestCopyPaste: def test_paste_single_into_timeline(self, tlui, tilia_state): tlui.create_mode(0) click_mode_ui(tlui[0]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(tlui, 10) tilia_state.current_time = 50 - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 2 assert tlui[1].get_data("time") == 50 @@ -42,10 +41,10 @@ def test_paste_multiple_into_timeline(self, tlui, tilia_state): click_mode_ui(tlui[0]) click_mode_ui(tlui[1], modifier="ctrl") click_mode_ui(tlui[2], modifier="ctrl") - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(tlui, 90) tilia_state.current_time = 50 - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 6 assert tlui[3].get_data("time") == 50 assert tlui[4].get_data("time") == 60 @@ -68,9 +67,9 @@ def test_paste_single_into_element(self, tlui): ) click_mode_ui(tlui[0]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_mode_ui(tlui[1]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 2 for attr, value in attributes_to_copy.items(): assert tlui[1].get_data(attr) == attributes_to_copy[attr] @@ -97,10 +96,10 @@ def test_paste_multiple_into_element(self, tlui): for mui in copied_muis: click_mode_ui(mui, modifier="ctrl") - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(tlui, 90) click_mode_ui(target_mui) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(tlui) == 6 for attr, value in attributes_to_copy.items(): assert target_mui.get_data(attr) == attributes_to_copy[attr] diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py index 705b762c2..71094c91b 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py @@ -1,11 +1,11 @@ import pytest from PyQt6.QtGui import QColor -from tests.mock import PatchGet, Serve +from tests.mock import Serve, patch_yes_or_no_dialog from tilia.requests import Post, Get, post from tilia.settings import settings from tilia.timelines.hierarchy.components import Hierarchy -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.timelines.hierarchy import HierarchyUI @@ -37,13 +37,13 @@ def assert_is_copy_data_of(copy_data: dict, hierarchy_ui: HierarchyUI): class TestActions: - def test_increase_level(self, tlui, user_actions): + def test_increase_level(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) tlui.create_hierarchy(3, 4, 1) tlui.select_element(tlui[0]) - user_actions.trigger(TiliaAction.HIERARCHY_INCREASE_LEVEL) + commands.execute("timeline.hierarchy.increase_level") assert tlui[2].get_data("level") == 2 assert tlui[2].get_data("start") == 0 @@ -51,7 +51,7 @@ def test_increase_level(self, tlui, user_actions): assert tlui[0].get_data("level") == 1 assert tlui[1].get_data("level") == 1 - def test_increase_level_multiple_hierarchies(self, tlui, user_actions): + def test_increase_level_multiple_hierarchies(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) tlui.create_hierarchy(3, 4, 1) @@ -59,25 +59,25 @@ def test_increase_level_multiple_hierarchies(self, tlui, user_actions): tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) tlui.select_element(tlui[2]) - user_actions.trigger(TiliaAction.HIERARCHY_INCREASE_LEVEL) + commands.execute("timeline.hierarchy.increase_level") assert tlui[0].get_data("level") == 2 assert tlui[1].get_data("level") == 2 assert tlui[2].get_data("level") == 2 - def test_decrease_level(self, tlui, user_actions): + def test_decrease_level(self, tlui): tlui.create_hierarchy(0, 1, 2) tlui.create_hierarchy(1, 2, 2) tlui.create_hierarchy(3, 4, 2) tlui.select_element(tlui[0]) - user_actions.trigger(TiliaAction.HIERARCHY_DECREASE_LEVEL) + commands.execute("timeline.hierarchy.decrease_level") assert tlui[0].get_data("level") == 1 assert tlui[1].get_data("level") == 2 assert tlui[2].get_data("level") == 2 - def test_decrease_level_multiple_hierarchies(self, tlui, user_actions): + def test_decrease_level_multiple_hierarchies(self, tlui): tlui.create_hierarchy(0, 1, 2) tlui.create_hierarchy(1, 2, 2) tlui.create_hierarchy(3, 4, 2) @@ -85,122 +85,120 @@ def test_decrease_level_multiple_hierarchies(self, tlui, user_actions): tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) tlui.select_element(tlui[2]) - user_actions.trigger(TiliaAction.HIERARCHY_DECREASE_LEVEL) + commands.execute("timeline.hierarchy.decrease_level") assert tlui[0].get_data("level") == 1 assert tlui[1].get_data("level") == 1 assert tlui[2].get_data("level") == 1 - def test_set_color(self, tlui, user_actions): + def test_set_color(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) with Serve(Get.FROM_USER_COLOR, (True, QColor("#000"))): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_SET) + commands.execute("timeline.component.set_color") assert tlui[0].get_data("color") == "#000000" - def test_reset_color(self, tlui, user_actions): + def test_reset_color(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) with Serve(Get.FROM_USER_COLOR, (True, QColor("#000"))): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_SET) + commands.execute("timeline.component.set_color") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_RESET) + commands.execute("timeline.component.reset_color") assert tlui[0].get_data("color") is None - def test_add_pre_start(self, tlui, user_actions): + def test_add_pre_start(self, tlui): tlui.create_hierarchy(0.1, 1, 1) tlui.select_element(tlui[0]) with Serve(Get.FROM_USER_FLOAT, (True, 0.1)): - user_actions.trigger(TiliaAction.HIERARCHY_ADD_PRE_START) + commands.execute("timeline.hierarchy.add_pre_start") assert tlui[0].get_data("pre_start") != tlui[0].get_data("start") assert tlui[0].pre_start_handle - def test_add_post_end(self, tlui, user_actions, tilia_state): + def test_add_post_end(self, tlui, tilia_state): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) with Serve(Get.FROM_USER_FLOAT, (True, 0.1)): - user_actions.trigger(TiliaAction.HIERARCHY_ADD_POST_END) + commands.execute("timeline.hierarchy.add_post_end") assert tlui[0].get_data("post_end") != tlui[0].get_data("end") assert tlui[0].post_end_handle - def test_split(self, tlui, user_actions, tilia_state): + def test_split(self, tlui, tilia_state): tlui.create_hierarchy(0, 1, 1) assert len(tlui) == 1 tilia_state.current_time = 0.5 - user_actions.trigger(TiliaAction.HIERARCHY_SPLIT) + commands.execute("timeline.hierarchy.split") assert len(tlui) == 2 - def test_merge(self, tlui, user_actions): + def test_merge(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) - user_actions.trigger(TiliaAction.HIERARCHY_MERGE) + commands.execute("timeline.hierarchy.merge") assert len(tlui) == 1 - def test_group(self, tlui, user_actions): + def test_group(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) tlui.select_element(tlui[0]) tlui.select_element(tlui[1]) - user_actions.trigger(TiliaAction.HIERARCHY_GROUP) + commands.execute("timeline.hierarchy.group") assert len(tlui) == 3 - def test_group_no_units_selected_does_nothing( - self, tlui, user_actions, tilia_errors - ): + def test_group_no_units_selected_does_nothing(self, tlui, tilia_errors): tlui.create_hierarchy(0, 1, 1) - user_actions.trigger(TiliaAction.HIERARCHY_GROUP) + commands.execute("timeline.hierarchy.group") assert len(tlui) == 1 tilia_errors.assert_no_error() - def test_delete_elements(self, tlui, user_actions): + def test_delete_elements(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert len(tlui) == 0 - def test_create_hierarchy_below(self, tlui, user_actions): + def test_create_hierarchy_below(self, tlui): tlui.create_hierarchy(0, 1, 2) tlui.select_element(tlui[0]) - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 2 class TestCopyPaste: - def test_paste(self, tlui, user_actions): + def test_paste(self, tlui): tlui.create_hierarchy(0, 1, 1, label="paste test") tlui.create_hierarchy(0, 1, 2) tlui.select_element(tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_element(tlui[0]) tlui.select_element(tlui[1]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert tlui[1].get_data("label") == "paste test" @@ -208,19 +206,19 @@ def test_paste_without_children_into_selected_elements(self, tlui): tlui.create_hierarchy(0, 0.5, 1, color="#000000") set_dummy_copy_attributes(tlui[0]) tlui.select_element(tlui[0]) - post(Post.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_all_elements() tlui.create_hierarchy(0.5, 1, 1, color="#000000") hrc1, hrc2 = tlui.timeline[0], tlui.timeline[1] # order will change with paste tlui.select_element(tlui[1]) - post(Post.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert_are_copies(hrc1, hrc2) def test_paste_with_children_into_selected_elements_without_rescaling( - self, tlui, user_actions, tilia_state + self, tlui, tilia_state ): tlui.create_hierarchy(0, 0.5, 1) tlui.create_hierarchy(0.5, 1, 1) @@ -237,11 +235,11 @@ def test_paste_with_children_into_selected_elements_without_rescaling( set_dummy_copy_attributes(hrc2) tlui.select_element(tlui[2]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_all_elements() tlui.select_element(tlui[3]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE) + commands.execute("timeline.component.paste_complete") assert len(tlui.elements) == 6 assert len(hrc4.children) == 2 @@ -259,9 +257,7 @@ def test_paste_with_children_into_selected_elements_without_rescaling( assert_are_copies(copied_children_1, hrc1) assert_are_copies(copied_children_2, hrc2) - def test_paste_with_children_into_selected_elements_with_rescaling( - self, tlui, user_actions - ): + def test_paste_with_children_into_selected_elements_with_rescaling(self, tlui): tlui.create_hierarchy(0, 0.5, 1) tlui.create_hierarchy(0.5, 1, 1) tlui.create_hierarchy(0, 1, 2) @@ -277,11 +273,11 @@ def test_paste_with_children_into_selected_elements_with_rescaling( set_dummy_copy_attributes(hrc2) tlui.select_element(tlui[2]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_all_elements() tlui.select_element(tlui[3]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE) + commands.execute("timeline.component.paste_complete") copied_children_1, copied_children_2 = sorted(hrc4.children) @@ -309,10 +305,10 @@ def test_paste_into_hierarchy_that_has_grandchildren(self, tlui): source, _ = tlui.create_hierarchy(2, 3, 3) # parent tlui.select_element(tlui.get_element(source.id)) - post(Post.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_all_elements() tlui.select_element(tlui.get_element(destination.id)) - post(Post.TIMELINE_ELEMENT_PASTE_COMPLETE) + commands.execute("timeline.component.paste_complete") assert len(destination.children) == 4 for i, child in enumerate(sorted(destination.children)): @@ -320,7 +316,7 @@ def test_paste_into_hierarchy_that_has_grandchildren(self, tlui): assert child.start == i * 0.5 assert child.end == (i + 1) * 0.5 - def test_paste_from_hierarchy_with_grandchildren(self, tlui, user_actions): + def test_paste_from_hierarchy_with_grandchildren(self, tlui): tlui.create_hierarchy(0, 0.5, 1) tlui.create_hierarchy(0.5, 1, 1) tlui.create_hierarchy(0, 0.5, 2) @@ -332,11 +328,11 @@ def test_paste_from_hierarchy_with_grandchildren(self, tlui, user_actions): set_dummy_copy_attributes(tlui.timeline[1]) tlui.select_element(tlui[4]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_all_elements() tlui.select_element(tlui[5]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE) + commands.execute("timeline.component.paste_complete") copied_children_1, copied_children_2 = sorted(hrc6.children) @@ -348,19 +344,19 @@ def test_paste_from_hierarchy_with_grandchildren(self, tlui, user_actions): assert copied_children_2.children[0].start == 1.5 assert copied_children_2.children[0].end == 2.0 - def test_paste_with_children_into_different_level_fails(self, tlui, user_actions): + def test_paste_with_children_into_different_level_fails(self, tlui): tlui.create_hierarchy(0, 0.5, 1) tlui.create_hierarchy(0.5, 1, 1) tlui.create_hierarchy(0, 1, 2) tlui.create_hierarchy(1, 1.5, 3) tlui.select_element(tlui[2]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_all_elements() tlui.select_element(tlui[1]) component_state1 = tlui.timeline.components - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE) + commands.execute("timeline.component.paste_complete") component_state2 = tlui.timeline.components assert component_state1 == component_state2 @@ -380,23 +376,20 @@ def test_create_multiple(self, tlui): class TestUndoRedo: - def test_split(self, tlui, tluis, user_actions): + def test_split(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1) post(Post.APP_STATE_RECORD, "test state") - with PatchGet( - "tilia.ui.timelines.hierarchy.request_handlers", Get.SELECTED_TIME, 0.5 - ): - user_actions.trigger(TiliaAction.HIERARCHY_SPLIT) + commands.execute("timeline.hierarchy.split", time=0.5) - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 1 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 2 - def test_merge(self, tlui, tluis, user_actions): + def test_merge(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) @@ -405,43 +398,43 @@ def test_merge(self, tlui, tluis, user_actions): post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.HIERARCHY_MERGE) + commands.execute("timeline.hierarchy.merge") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 2 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 1 - def test_increase_level(self, tlui, tluis, user_actions): + def test_increase_level(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.HIERARCHY_INCREASE_LEVEL) + commands.execute("timeline.hierarchy.increase_level") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert tlui.elements[0].get_data("level") == 1 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert tlui.elements[0].get_data("level") == 2 - def test_decrease_level(self, tlui, tluis, user_actions): + def test_decrease_level(self, tlui, tluis): tlui.create_hierarchy(0, 1, 2) tlui.select_element(tlui[0]) post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.HIERARCHY_DECREASE_LEVEL) + commands.execute("timeline.hierarchy.decrease_level") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert tlui.elements[0].get_data("level") == 2 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert tlui.elements[0].get_data("level") == 1 - def test_group(self, tlui, tluis, user_actions): + def test_group(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) @@ -450,30 +443,30 @@ def test_group(self, tlui, tluis, user_actions): post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.HIERARCHY_GROUP) + commands.execute("timeline.hierarchy.group") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 2 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 3 - def test_delete(self, tlui, tluis, user_actions): + def test_delete(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 1 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 0 - def test_delete_parent_and_child(self, tlui, user_actions): + def test_delete_parent_and_child(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(0, 1, 2) @@ -482,116 +475,116 @@ def test_delete_parent_and_child(self, tlui, user_actions): post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 2 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 0 - def test_create_unit_below(self, tlui, tluis, user_actions): + def test_create_unit_below(self, tlui, tluis): tlui.create_hierarchy(0, 1, 2) tlui.select_element(tlui[0]) post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 1 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 2 - def test_paste(self, tlui, tluis, user_actions): + def test_paste(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1, label="paste test") tlui.create_hierarchy(0, 1, 2) post(Post.APP_STATE_RECORD, "test state") tlui.select_element(tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_element(tlui[0]) tlui.select_element(tlui[1]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert tlui[1].get_data("label") == "paste test" - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") assert tlui[1].get_data("label") == "" - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.redo") assert tlui[1].get_data("label") == "paste test" - @pytest.mark.skip( - "Paste complete is not being recorded. This has been fixed in another branch" - ) - def test_paste_with_children(self, tlui, tluis, user_actions): + def test_paste_with_children(self, tlui, tluis): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) tlui.create_hierarchy(0, 2, 2) tlui.create_hierarchy(2, 3, 2) + # Must record state explicitly, as we have not executed any command + post(Post.APP_STATE_RECORD, "test state") + tlui.select_element(tlui[2]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tlui.deselect_element(tlui[2]) tlui.select_element(tlui[3]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE) + commands.execute("timeline.component.paste_complete") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 4 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 6 class TestCreateChild: - def test_create_child(self, tlui, tluis, user_actions): + def test_create_child(self, tlui, tluis): tlui.create_hierarchy(0, 1, 2) tlui.select_element(tlui[0]) post(Post.APP_STATE_RECORD, "test state") - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(tlui) == 1 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(tlui) == 2 - def test_at_lowest_level_user_declines_new_level(self, tlui, user_actions): + def test_at_lowest_level_user_declines_new_level(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", True) with Serve(Get.FROM_USER_YES_OR_NO, False): - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 1 assert tlui[0].get_data("level") == 1 class TestUserAcceptsNewLevel: - def test_single_hierarchy(self, tlui, user_actions): + def test_single_hierarchy(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", True) with Serve(Get.FROM_USER_YES_OR_NO, True): - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 2 assert tlui[0].get_data("level") == 1 assert tlui[1].get_data("level") == 2 - def test_with_parent(self, tlui, user_actions): + def test_with_parent(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(0, 1, 2) @@ -599,14 +592,14 @@ def test_with_parent(self, tlui, user_actions): settings.set("hierarchy_timeline", "prompt_create_level_below", True) with Serve(Get.FROM_USER_YES_OR_NO, True): - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 3 assert tlui[0].get_data("level") == 1 assert tlui[1].get_data("level") == 2 assert tlui[2].get_data("level") == 3 - def test_with_siblings(self, tlui, user_actions): + def test_with_siblings(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.create_hierarchy(1, 2, 1) tlui.create_hierarchy(2, 3, 1) @@ -615,7 +608,7 @@ def test_with_siblings(self, tlui, user_actions): settings.set("hierarchy_timeline", "prompt_create_level_below", True) with Serve(Get.FROM_USER_YES_OR_NO, True): - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 4 assert tlui[0].get_data("level") == 1 @@ -623,12 +616,12 @@ def test_with_siblings(self, tlui, user_actions): assert tlui[2].get_data("level") == 2 assert tlui[3].get_data("level") == 2 - def test_prompt_create_level_below_is_false(self, tlui, user_actions): + def test_prompt_create_level_below_is_false(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", False) - user_actions.trigger(TiliaAction.HIERARCHY_CREATE_CHILD) + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 2 diff --git a/tests/ui/timelines/marker/test_marker_timeline_ui.py b/tests/ui/timelines/marker/test_marker_timeline_ui.py index 2c275e420..368c18e84 100644 --- a/tests/ui/timelines/marker/test_marker_timeline_ui.py +++ b/tests/ui/timelines/marker/test_marker_timeline_ui.py @@ -1,6 +1,5 @@ from unittest.mock import patch -import pytest from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor from PyQt6.QtWidgets import QColorDialog, QInputDialog @@ -16,7 +15,8 @@ from tests.ui.timelines.marker.interact import click_marker_ui, get_marker_ui_center from tests.utils import undoable, get_action, get_submenu, get_main_window_menu from tilia.requests import Post, Get, post -from tilia.ui.actions import TiliaAction, get_qaction +from tilia.ui import commands +from tilia.ui.commands import get_qaction from tilia.ui.coords import time_x_converter from tilia.ui.timelines.marker import MarkerTimelineToolbar @@ -28,88 +28,88 @@ class TestCreateDelete: - def test_create(self, marker_tlui, tluis, tilia_state, user_actions): + def test_create(self, marker_tlui, tluis, tilia_state): tilia_state.current_time = 11 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") assert len(marker_tlui) == 1 assert marker_tlui[0].get_data("time") == 11 - def test_create_at_same_time_fails(self, marker_tlui, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_create_at_same_time_fails(self, marker_tlui): + commands.execute("timeline.marker.add") + commands.execute("timeline.marker.add") assert len(marker_tlui) == 1 - def test_delete(self, marker_tlui, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_delete(self, marker_tlui): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert len(marker_tlui) == 0 - def test_delete_multiple(self, marker_tlui, user_actions, tilia_state): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_delete_multiple(self, marker_tlui, tilia_state): + commands.execute("timeline.marker.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) click_marker_ui(marker_tlui[1], modifier="ctrl") with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert len(marker_tlui) == 0 class TestSetResetColor: TEST_COLOR = "#000000" - def set_color_on_all_markers(self, marker_tlui, actions): + def set_color_on_all_markers(self, marker_tlui): """Assumes there is a single marker on timeline""" marker_tlui.select_all_elements() with Serve(Get.FROM_USER_COLOR, (True, QColor(self.TEST_COLOR))): - actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_SET) + commands.execute("timeline.component.set_color") - def test_set_color(self, marker_tlui, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_set_color(self, marker_tlui): + commands.execute("timeline.marker.add") with undoable(): - self.set_color_on_all_markers(marker_tlui, user_actions) + self.set_color_on_all_markers(marker_tlui) assert marker_tlui[0].get_data("color") == self.TEST_COLOR - def test_set_color_multiple_markers(self, marker_tlui, user_actions, tilia_state): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_set_color_multiple_markers(self, marker_tlui, tilia_state): + commands.execute("timeline.marker.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") with undoable(): - self.set_color_on_all_markers(marker_tlui, user_actions) + self.set_color_on_all_markers(marker_tlui) for marker in marker_tlui: assert marker.get_data("color") == self.TEST_COLOR - def test_reset_color(self, marker_tlui, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) - self.set_color_on_all_markers(marker_tlui, user_actions) + def test_reset_color(self, marker_tlui): + commands.execute("timeline.marker.add") + self.set_color_on_all_markers(marker_tlui) with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_RESET) + commands.execute("timeline.component.reset_color") assert marker_tlui[0].get_data("color") is None - def test_reset_color_multiple_markers(self, marker_tlui, user_actions, tilia_state): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_reset_color_multiple_markers(self, marker_tlui, tilia_state): + commands.execute("timeline.marker.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) - self.set_color_on_all_markers(marker_tlui, user_actions) + commands.execute("timeline.marker.add") + self.set_color_on_all_markers(marker_tlui) with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_RESET) + commands.execute("timeline.component.reset_color") for marker in marker_tlui: assert marker.get_data("color") is None - def test_cancel_color_dialog(self, marker_tlui, user_actions, tilia_state): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_cancel_color_dialog(self, marker_tlui, tilia_state): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) with patch.object(QColorDialog, "getColor", return_value=QColor("invalid")): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COLOR_SET) + commands.execute("timeline.component.set_color") assert marker_tlui[0].get_data("color") is None @@ -126,58 +126,59 @@ def test_shortcut(self, marker_tlui, tilia_state): assert len(marker_tlui) == 2 - def test_paste_single_into_timeline(self, marker_tlui, tilia_state, user_actions): + def test_paste_single_into_timeline(self, marker_tlui, tilia_state): marker_tlui.create_marker(0, label="copy me") + # Must record explicitly, as there is no command to set a component's label + post(Post.APP_STATE_RECORD, "set marker label") + click_marker_ui(marker_tlui[0]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") tilia_state.current_time = 10 click_timeline_ui(marker_tlui, 50) with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") - assert len(marker_tlui) == 2 - assert marker_tlui[1].get_data("time") == 10 - assert marker_tlui[1].get_data("label") == "copy me" + assert len(marker_tlui) == 2 + assert marker_tlui[1].get_data("time") == 10 + assert marker_tlui[1].get_data("label") == "copy me" - def test_paste_single_into_selected_element( - self, marker_tlui, tilia_state, user_actions - ): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_paste_single_into_selected_element(self, marker_tlui, tilia_state): + commands.execute("timeline.marker.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) press_key("Enter") type_string("copy me") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_marker_ui(marker_tlui[1]) with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(marker_tlui) == 2 assert marker_tlui[1].get_data("label") == "copy me" - def test_paste_multiple_into_timeline(self, marker_tlui, tilia_state, user_actions): + def test_paste_multiple_into_timeline(self, marker_tlui, tilia_state): for time, label in [(0, "first"), (10, "second"), (20, "third")]: tilia_state.current_time = time - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[-1]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_INSPECT) + commands.execute("timeline.element.inspect") type_string(label) click_marker_ui(marker_tlui[0]) click_marker_ui(marker_tlui[1], modifier="ctrl") click_marker_ui(marker_tlui[2], modifier="ctrl") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_timeline_ui(marker_tlui, 100) # deselect markers tilia_state.current_time = 50 with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(marker_tlui) == 6 for index, time, label in [ @@ -188,25 +189,23 @@ def test_paste_multiple_into_timeline(self, marker_tlui, tilia_state, user_actio assert marker_tlui[index].get_data("time") == time assert marker_tlui[index].get_data("label") == label - def test_paste_multiple_into_selected_element( - self, marker_tlui, user_actions, tilia_state - ): + def test_paste_multiple_into_selected_element(self, marker_tlui, tilia_state): for time, label in [(0, "first"), (10, "second"), (20, "third")]: tilia_state.current_time = time - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[-1]) - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_INSPECT) + commands.execute("timeline.element.inspect") type_string(label) click_marker_ui(marker_tlui[0]) click_marker_ui(marker_tlui[1], modifier="ctrl") click_marker_ui(marker_tlui[2], modifier="ctrl") - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_COPY) + commands.execute("timeline.component.copy") click_marker_ui(marker_tlui[2]) with undoable(): - user_actions.trigger(TiliaAction.TIMELINE_ELEMENT_PASTE) + commands.execute("timeline.component.paste") assert len(marker_tlui) == 5 for index, time, label in [ @@ -219,20 +218,20 @@ def test_paste_multiple_into_selected_element( class TestSelect: - def test_select(self, marker_tlui, tluis, user_actions): + def test_select(self, marker_tlui, tluis): marker_tlui.create_marker(10) click_marker_ui(marker_tlui[0]) assert marker_tlui[0] in marker_tlui.selected_elements - def test_deselect(self, marker_tlui, tluis, user_actions): + def test_deselect(self, marker_tlui, tluis): marker_tlui.create_marker(10) click_marker_ui(marker_tlui[0]) click_timeline_ui(marker_tlui, 0) assert len(marker_tlui.selected_elements) == 0 - def test_box_selection(self, marker_tlui, tluis, user_actions): + def test_box_selection(self, marker_tlui, tluis): marker_tlui.create_marker(10) marker_tlui.create_marker(20) marker_tlui.create_marker(30) @@ -245,7 +244,7 @@ def test_box_selection(self, marker_tlui, tluis, user_actions): assert marker_tlui[0] in marker_tlui.selected_elements assert marker_tlui[1] in marker_tlui.selected_elements - def test_box_deselection(self, marker_tlui, tluis, user_actions): + def test_box_deselection(self, marker_tlui, tluis): marker_tlui.create_marker(10) marker_tlui.create_marker(20) marker_tlui.create_marker(30) @@ -261,10 +260,10 @@ def test_box_deselection(self, marker_tlui, tluis, user_actions): class TestDrag: - def test_drag(self, marker_tlui, tluis, user_actions, tilia_state): + def test_drag(self, marker_tlui, tluis, tilia_state): tilia_state.duration = 100 tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -272,10 +271,10 @@ def test_drag(self, marker_tlui, tluis, user_actions, tilia_state): drag_mouse_in_timeline_view(time_x_converter.get_x_by_time(20), 0) assert marker_tlui[0].get_data("time") == 20 - def test_drag_beyond_start(self, marker_tlui, tluis, user_actions, tilia_state): + def test_drag_beyond_start(self, marker_tlui, tluis, tilia_state): tilia_state.duration = 100 tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -283,10 +282,10 @@ def test_drag_beyond_start(self, marker_tlui, tluis, user_actions, tilia_state): drag_mouse_in_timeline_view(time_x_converter.get_x_by_time(0) - 200, 0) assert marker_tlui[0].get_data("time") == 0 - def test_drag_beyond_end(self, marker_tlui, tluis, user_actions, tilia_state): + def test_drag_beyond_end(self, marker_tlui, tluis, tilia_state): tilia_state.duration = 100 tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -296,9 +295,7 @@ def test_drag_beyond_end(self, marker_tlui, tluis, user_actions, tilia_state): class TestElementContextMenu: - def test_is_shown_on_right_click( - self, marker_tlui, tluis, user_actions, tilia_state - ): + def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): marker_tlui.create_marker(0) with patch.object(MarkerContextMenu, "exec") as mock: @@ -306,46 +303,40 @@ def test_is_shown_on_right_click( mock.assert_called_once() - def test_has_the_right_options(self, marker_tlui, tluis, user_actions, tilia_state): + def test_has_the_right_options(self, marker_tlui, tluis, tilia_state): marker_tlui.create_marker(0) context_menu = marker_tlui[0].CONTEXT_MENU_CLASS((marker_tlui[0])) expected = ( - TiliaAction.TIMELINE_ELEMENT_INSPECT, - TiliaAction.TIMELINE_ELEMENT_DELETE, - TiliaAction.TIMELINE_ELEMENT_COLOR_RESET, - TiliaAction.TIMELINE_ELEMENT_COLOR_SET, - TiliaAction.TIMELINE_ELEMENT_COPY, - TiliaAction.TIMELINE_ELEMENT_PASTE, + "timeline.element.inspect", + "timeline.component.delete", + "timeline.component.reset_color", + "timeline.component.set_color", + "timeline.component.copy", + "timeline.component.paste", ) - for action in expected: - assert get_qaction(action) in context_menu.actions() + for command in expected: + assert get_qaction(command) in context_menu.actions() class TestTimelineUIContextMenu: - def test_is_shown_on_right_click( - self, marker_tlui, tluis, user_actions, tilia_state - ): + def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): with patch.object(MarkerTimelineUIContextMenu, "exec") as mock: click_timeline_ui(marker_tlui, 50, button="right") mock.assert_called_once() - def test_has_no_height_set_action( - self, marker_tlui, tluis, user_actions, tilia_state - ): + def test_has_no_height_set_action(self, marker_tlui, tluis, tilia_state): context_menu = marker_tlui.CONTEXT_MENU_CLASS(marker_tlui) - assert ( - get_qaction(TiliaAction.TIMELINE_HEIGHT_SET) not in context_menu.actions() - ) + assert get_qaction("timeline.set_height") not in context_menu.actions() - def test_has_no_move_down_action_when_last(self, tluis, user_actions): + def test_has_no_move_down_action_when_last(self, tluis): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") + commands.execute("timelines.add.marker") context_menu = tluis[1].CONTEXT_MENU_CLASS(tluis[1]) @@ -353,10 +344,10 @@ def test_has_no_move_down_action_when_last(self, tluis, user_actions): assert "Move up" in action_names assert "Move down" not in action_names - def test_has_no_move_up_action_when_first(self, tluis, user_actions): + def test_has_no_move_up_action_when_first(self, tluis): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") + commands.execute("timelines.add.marker") context_menu = tluis[0].CONTEXT_MENU_CLASS(tluis[0]) @@ -364,31 +355,29 @@ def test_has_no_move_up_action_when_first(self, tluis, user_actions): assert "Move up" not in action_names assert "Move down" in action_names - @pytest.mark.xfail( - reason="Waiting for refactor of timeline clear and delete actions." - ) - def test_has_the_right_actions(self, marker_tlui, tluis, user_actions, tilia_state): + def test_has_the_right_actions(self, marker_tlui, tluis, tilia_state): context_menu = marker_tlui.CONTEXT_MENU_CLASS(marker_tlui) - expected = (TiliaAction.TIMELINE_DELETE, TiliaAction.TIMELINE_CLEAR) + # As each context menu creates its own QActions, + # we can't get them with commands.get_action(). + # Instead, we see if their "text" property is as expected. + expected = ("Delete", "Clear") - for action in expected: - assert get_qaction(action) in context_menu.actions() + for name in expected: + assert name in [a.text() for a in context_menu.actions()] class TestInspect: - def test_open_inspect_menu(self, marker_tlui, tluis, user_actions, qtui): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_open_inspect_menu(self, marker_tlui, tluis, qtui): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) press_key("Enter") assert qtui.is_window_open(WindowKind.INSPECT) - def test_close_inspect_menu_with_enter( - self, marker_tlui, tluis, user_actions, qtui - ): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_close_inspect_menu_with_enter(self, marker_tlui, tluis, qtui): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) press_key("Enter") @@ -396,10 +385,8 @@ def test_close_inspect_menu_with_enter( assert not qtui.is_window_open(WindowKind.INSPECT) - def test_close_inspect_menu_with_escape( - self, marker_tlui, tluis, user_actions, qtui - ): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_close_inspect_menu_with_escape(self, marker_tlui, tluis, qtui): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) press_key("Enter") @@ -407,8 +394,8 @@ def test_close_inspect_menu_with_escape( assert not qtui.is_window_open(WindowKind.INSPECT) - def test_set_label(self, qtui, marker_tlui, tluis, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_set_label(self, qtui, marker_tlui, tluis): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -417,8 +404,8 @@ def test_set_label(self, qtui, marker_tlui, tluis, user_actions): type_string("hello tilia") assert marker_tlui[0].get_data("label") == "hello tilia" - def test_set_label_to_empty_string(self, marker_tlui, tluis, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_set_label_to_empty_string(self, marker_tlui, tluis): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -430,8 +417,8 @@ def test_set_label_to_empty_string(self, marker_tlui, tluis, user_actions): press_key("Backspace") assert marker_tlui[0].get_data("label") == "" - def test_set_comments(self, marker_tlui, tluis, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_set_comments(self, marker_tlui, tluis): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -441,8 +428,8 @@ def test_set_comments(self, marker_tlui, tluis, user_actions): type_string("some comments") assert marker_tlui[0].get_data("comments") == "some comments" - def test_set_comments_to_empty_string(self, marker_tlui, tluis, user_actions): - user_actions.trigger(TiliaAction.MARKER_ADD) + def test_set_comments_to_empty_string(self, marker_tlui, tluis): + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -459,11 +446,11 @@ def test_set_comments_to_empty_string(self, marker_tlui, tluis, user_actions): assert marker_tlui[0].get_data("comments") == "" def test_set_attribute_with_multiple_selected( - self, marker_tlui, tluis, user_actions, tilia_state + self, marker_tlui, tluis, tilia_state ): - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") tilia_state.current_time = 10 - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) click_marker_ui(marker_tlui[1], modifier="ctrl") @@ -478,23 +465,23 @@ def test_set_attribute_with_multiple_selected( class TestSetTimelineName: - def test_set(self, user_actions, tluis): + def test_set(self, tluis): with Serve(Get.FROM_USER_STRING, (True, "initial name")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") with undoable(): with patch.object(QInputDialog, "getText", return_value=("new name", True)): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", tluis[0]) assert tluis[0].get_data("name") == "new name" - def test_set_to_empty_string(self, tluis, user_actions): + def test_set_to_empty_string(self, tluis): with Serve(Get.FROM_USER_STRING, (True, "initial name")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") with undoable(): with patch.object(QInputDialog, "getText", return_value=("", True)): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", tluis[0]) assert tluis[0].get_data("name") == "" @@ -505,18 +492,18 @@ def test_is_created_when_timeline_is_created(self, tluis, qtui, marker_tlui): def test_right_actions_are_shown(self, tluis, qtui, marker_tlui): expected_actions = [ - TiliaAction.MARKER_ADD, + "timeline.marker.add", ] toolbar = get_toolbars_of_class(qtui, MarkerTimelineToolbar)[0] - for action in expected_actions: - assert get_qaction(action) in toolbar.actions() + for command in expected_actions: + assert get_qaction(command) in toolbar.actions() class TestMoveInTimelineOrder: - def test_move_up(self, tluis, user_actions): + def test_move_up(self, tluis): for name in ["1", "2", "3"]: with Serve(Get.FROM_USER_STRING, (True, name)): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") context_menu = tluis[1].CONTEXT_MENU_CLASS(tluis[1]) action = get_action(context_menu, "Move up") @@ -529,10 +516,10 @@ def test_move_up(self, tluis, user_actions): "3", ] - def test_move_down(self, tluis, user_actions): + def test_move_down(self, tluis): for name in ["1", "2", "3"]: with Serve(Get.FROM_USER_STRING, (True, name)): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") context_menu = tluis[1].CONTEXT_MENU_CLASS(tluis[1]) action = get_action(context_menu, "Move down") @@ -546,10 +533,8 @@ def test_move_down(self, tluis, user_actions): ] -def test_timeline_menu_has_right_actions( - tluis, qtui, marker_tlui, tilia_state, user_actions -): - expected_actions = [TiliaAction.IMPORT_CSV_MARKER_TIMELINE] +def test_timeline_menu_has_right_actions(tluis, qtui, marker_tlui, tilia_state): + expected_actions = ["timelines.import.marker"] menu = get_main_window_menu(qtui, "Timelines") marker_submenu = get_submenu(menu, "Marker") assert marker_submenu @@ -558,24 +543,19 @@ def test_timeline_menu_has_right_actions( assert get_qaction(a) in marker_submenu.actions() -@pytest.mark.xfail(reason="Waiting for refactor of timeline clear actions.") -def test_clear(tluis, qtui, marker_tlui, tilia_state, user_actions): - # TODO - # needs refactoring of timeline clear actions - # we want to be able to do post(Post.TIMELINE_CLEAR, marker_tlui) +def test_clear(tluis, qtui, marker_tlui, tilia_state): for i in range(10): tilia_state.current_time = i - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") + + with Serve(Get.FROM_USER_YES_OR_NO, True): + commands.execute("timeline.clear", marker_tlui) - post(Post.TIMELINE_DELETE, marker_tlui) assert len(marker_tlui) == 0 -@pytest.mark.xfail(reason="Waiting for refactor of timeline delete actions.") -def test_delete(tluis, qtui, marker_tlui, tilia_state, user_actions): - # TODO - # needs refactoring of timeline delete actions - # we want to be able to do post(Post.TIMELINE_DELETE, marker_tlui) - user_actions.trigger(TiliaAction.TIMELINE_DELETE, marker_tlui) +def test_delete(tluis, qtui, marker_tlui, tilia_state): + with Serve(Get.FROM_USER_YES_OR_NO, True): + commands.execute("timeline.delete", marker_tlui) assert tluis.is_empty diff --git a/tests/ui/timelines/score/test_score_timeline_ui.py b/tests/ui/timelines/score/test_score_timeline_ui.py index deb474d52..9351c7517 100644 --- a/tests/ui/timelines/score/test_score_timeline_ui.py +++ b/tests/ui/timelines/score/test_score_timeline_ui.py @@ -10,12 +10,12 @@ from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.score.components import Clef from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.actions import TiliaAction +from tilia.ui import commands -def test_create(tluis, user_actions): +def test_create(tluis): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_SCORE_TIMELINE) + commands.execute("timelines.add.score") assert len(tluis) == 1 @@ -51,8 +51,8 @@ def test_create_key_signature(score_tlui, fifths): assert score_tlui[0] -def _check_attrs(tmp_path, user_actions, items_per_attr): - @reloadable(tmp_path / "file.tla", user_actions) +def _check_attrs(tmp_path, items_per_attr): + @reloadable(tmp_path / "file.tla") def check_attrs() -> None: score = get( Get.TIMELINE_UI_BY_ATTR, "TIMELINE_KIND", TimelineKind.SCORE_TIMELINE @@ -77,7 +77,7 @@ def check_attrs() -> None: return check_attrs -def test_attribute_positions(qtui, score_tl, beat_tl, tmp_path, user_actions): +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) @@ -86,12 +86,10 @@ def test_attribute_positions(qtui, score_tl, beat_tl, tmp_path, user_actions): notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) - _check_attrs(tmp_path, user_actions, items_per_attr=3) + _check_attrs(tmp_path, items_per_attr=3) -def test_attribute_positions_without_measure_zero( - qtui, score_tl, beat_tl, tmp_path, user_actions -): +def test_attribute_positions_without_measure_zero(qtui, score_tl, beat_tl, tmp_path): beat_tl.beat_pattern = [1] for i in range(1, 3): beat_tl.create_beat(i) @@ -101,7 +99,7 @@ def test_attribute_positions_without_measure_zero( with patch_yes_or_no_dialog(False): notes_from_musicXML(score_tl, beat_tl, EXAMPLE_MULTISTAFF_MUSICXML_PATH) - _check_attrs(tmp_path, user_actions, items_per_attr=3) + _check_attrs(tmp_path, items_per_attr=3) def test_correct_clef_to_staff(qtui, score_tl, beat_tl): @@ -121,9 +119,7 @@ def test_correct_clef_to_staff(qtui, score_tl, beat_tl): assert "bass" in staff_no_to_clef[2] -def test_missing_staff_deletes_timeline( - qtui, tls, tilia_errors, tmp_path, user_actions -): +def test_missing_staff_deletes_timeline(qtui, tls, tilia_errors, tmp_path): file_data = get_blank_file_data() file_data["timelines"] = { 0: { @@ -169,15 +165,13 @@ def test_missing_staff_deletes_timeline( tmp_file.write_text(json.dumps(file_data), encoding="utf-8") with patch_file_dialog(True, [tmp_file]): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") tilia_errors.assert_in_error_title(SCORE_STAFF_ID_ERROR.title) assert tls.get_timeline_by_attr("KIND", TimelineKind.SCORE_TIMELINE) is None -def test_duplicate_staff_deletes_timeline( - qtui, tls, tilia_errors, tmp_path, user_actions -): +def test_duplicate_staff_deletes_timeline(qtui, tls, tilia_errors, tmp_path): file_data = get_blank_file_data() file_data["timelines"] = { 0: { @@ -201,13 +195,13 @@ def test_duplicate_staff_deletes_timeline( tmp_file.write_text(json.dumps(file_data), encoding="utf-8") with patch_file_dialog(True, [tmp_file]): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") tilia_errors.assert_in_error_title(SCORE_STAFF_ID_ERROR.title) assert tls.get_timeline_by_attr("KIND", TimelineKind.SCORE_TIMELINE) is None -def test_symbol_staff_collision(qtui, tmp_path, user_actions): +def test_symbol_staff_collision(qtui, tmp_path): file_data_with_symbols = get_blank_file_data() file_data_with_symbols["timelines"] = { 0: { @@ -242,7 +236,7 @@ def test_symbol_staff_collision(qtui, tmp_path, user_actions): ) with (patch_file_dialog(True, [tmp_file_with_symbols])): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") score = get(Get.TIMELINE_UI_BY_ATTR, "TIMELINE_KIND", TimelineKind.SCORE_TIMELINE) clef = score.timeline.get_component_by_attr("KIND", ComponentKind.CLEF) @@ -281,7 +275,7 @@ def test_symbol_staff_collision(qtui, tmp_path, user_actions): patch_file_dialog(True, [tmp_file_sans_symbols]), patch_yes_or_no_dialog(False), # do not save changes ): - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.open") score = get(Get.TIMELINE_UI_BY_ATTR, "TIMELINE_KIND", TimelineKind.SCORE_TIMELINE) staff = score.timeline.get_component_by_attr("KIND", ComponentKind.STAFF) diff --git a/tests/ui/timelines/slider/test_slider_timeline_ui.py b/tests/ui/timelines/slider/test_slider_timeline_ui.py index f957dc76e..7c803706a 100644 --- a/tests/ui/timelines/slider/test_slider_timeline_ui.py +++ b/tests/ui/timelines/slider/test_slider_timeline_ui.py @@ -1,14 +1,13 @@ -from tilia.requests import post, Post -from tilia.ui.actions import TiliaAction +from tilia.ui import commands -def test_undo_redo(slider_tlui, marker_tlui, user_actions): +def test_undo_redo(slider_tlui, marker_tlui): # using marker tl to trigger an actions that can be undone - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert len(marker_tlui) == 0 - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert len(marker_tlui) == 1 diff --git a/tests/ui/timelines/test_timeline_ui.py b/tests/ui/timelines/test_timeline_ui.py index 772e024be..9c0235d7d 100644 --- a/tests/ui/timelines/test_timeline_ui.py +++ b/tests/ui/timelines/test_timeline_ui.py @@ -9,7 +9,7 @@ from tilia.requests import Post, post, Get from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.actions import TiliaAction +from tilia.ui import commands def get_time_shifted_args(args: dict[str, int], d_time) -> dict[str, int]: @@ -239,41 +239,41 @@ def test_clicking_left_arrow_does_nothing_if_first_element_is_selected( class TestSetTimelineName: - def test_set(self, tls, tluis, user_actions): + def test_set(self, tls, tluis): tls.create_timeline(TimelineKind.MARKER_TIMELINE, name="change me") with Serve(Get.FROM_USER_STRING, (True, "this")): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", tluis[0]) assert tls[0].get_data("name") == "this" assert tluis[0].displayed_name == "this" - def test_set_undo(self, tls, tluis, user_actions): + def test_set_undo(self, tls, tluis): with Serve(Get.FROM_USER_STRING, (True, "pure")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") with Serve(Get.FROM_USER_STRING, (True, "tainted")): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", tluis[0]) - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") assert tls[0].get_data("name") == "pure" assert tluis[0].displayed_name == "pure" - def test_set_redo(self, tls, tluis, user_actions): + def test_set_redo(self, tls, tluis): with Serve(Get.FROM_USER_STRING, (True, "pure")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") with Serve(Get.FROM_USER_STRING, (True, "tainted")): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", tluis[0]) - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.undo") + commands.execute("edit.redo") assert tls[0].get_data("name") == "tainted" assert tluis[0].displayed_name == "tainted" - def test_set_empty_string(self, tls, tluis, user_actions): + def test_set_empty_string(self, tls, tluis): tls.create_timeline(TimelineKind.MARKER_TIMELINE, name="change me") with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + commands.execute("timeline.set_name", tluis[0]) assert tls[0].get_data("name") == "" assert tluis[0].displayed_name == "" diff --git a/tests/ui/timelines/test_timelineui_collection.py b/tests/ui/timelines/test_timelineui_collection.py index d65d9e161..9896ed1f2 100644 --- a/tests/ui/timelines/test_timelineui_collection.py +++ b/tests/ui/timelines/test_timelineui_collection.py @@ -1,11 +1,11 @@ +import functools from unittest.mock import patch import pytest from tests.constants import EXAMPLE_MEDIA_PATH, EXAMPLE_MEDIA_DURATION from tests.ui.timelines.interact import click_timeline_ui, drag_mouse_in_timeline_view -from tests.mock import Serve -from tests.utils import get_method_patch_target +from tests.mock import Serve, patch_yes_or_no_dialog from tilia.file.common import are_tilia_data_equal from tilia.media.player.base import MediaTimeChangeReason from tilia.requests import Post, post, get, Get @@ -14,54 +14,49 @@ TimelineKind as TlKind, TimelineKind, ) -from tilia.ui import actions -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.enums import ScrollType -from tilia.ui.timelines.base.request_handlers import TimelineRequestHandler -from tilia.ui.timelines.base.timeline import TimelineUI -from tilia.ui.timelines.collection.request_handler import TimelineUIsRequestHandler -from tilia.ui.timelines.marker.request_handlers import MarkerUIRequestHandler +from tilia.ui.timelines.collection.collection import TimelineSelector from tilia.ui.coords import time_x_converter from tilia.ui.dialogs.add_timeline_without_media import AddTimelineWithoutMedia +from tilia.ui.timelines.marker import MarkerTimelineUI ADD_TIMELINE_ACTIONS = [ - TiliaAction.TIMELINES_ADD_HIERARCHY_TIMELINE, - TiliaAction.TIMELINES_ADD_BEAT_TIMELINE, - TiliaAction.TIMELINES_ADD_HARMONY_TIMELINE, - TiliaAction.TIMELINES_ADD_MARKER_TIMELINE, - TiliaAction.TIMELINES_ADD_AUDIOWAVE_TIMELINE, + "timelines.add.hierarchy", + "timelines.add.beat", + "timelines.add.harmony", + "timelines.add.marker", + "timelines.add.audiowave", ] class TestTimelineUICreation: - @pytest.mark.parametrize("action", ADD_TIMELINE_ACTIONS) - def test_create(self, action, tluis, user_actions): + @pytest.mark.parametrize("command", ADD_TIMELINE_ACTIONS) + def test_create(self, command, tluis): with ( Serve(Get.FROM_USER_BEAT_PATTERN, (True, [1])), Serve(Get.FROM_USER_STRING, (True, "")), ): - user_actions.trigger(action) + commands.execute(command) assert len(tluis) == 1 - def test_create_multiple(self, tilia_state, tluis, user_actions): + def test_create_multiple(self, tilia_state, tluis): create_actions = [ - TiliaAction.TIMELINES_ADD_HARMONY_TIMELINE, - TiliaAction.TIMELINES_ADD_MARKER_TIMELINE, - TiliaAction.TIMELINES_ADD_BEAT_TIMELINE, - TiliaAction.TIMELINES_ADD_HIERARCHY_TIMELINE, - TiliaAction.TIMELINES_ADD_AUDIOWAVE_TIMELINE, + "timelines.add.harmony", + "timelines.add.marker", + "timelines.add.beat", + "timelines.add.hierarchy", + "timelines.add.audiowave", ] with ( Serve(Get.FROM_USER_BEAT_PATTERN, (True, [1])), Serve(Get.FROM_USER_STRING, (True, "")), ): - for action in create_actions: - user_actions.trigger(action) + for command in create_actions: + commands.execute(command) assert len(tluis) == len(create_actions) - def test_with_no_media_loaded_set_media_duration( - self, tluis, tilia_state, user_actions - ): + def test_with_no_media_loaded_set_media_duration(self, tluis, tilia_state): tilia_state.duration = 0 with Serve( Get.FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA, @@ -69,13 +64,11 @@ def test_with_no_media_loaded_set_media_duration( ): with Serve(Get.FROM_USER_FLOAT, (True, 10)): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") assert tilia_state.duration == 10 assert len(tluis) == 1 - def test_with_no_media_loaded_load_media( - self, tluis, tilia_state, user_actions, resources - ): + def test_with_no_media_loaded_load_media(self, tluis, tilia_state, resources): tilia_state.duration = 0 with Serve( Get.FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA, @@ -83,47 +76,43 @@ def test_with_no_media_loaded_load_media( ): with Serve(Get.FROM_USER_MEDIA_PATH, (True, EXAMPLE_MEDIA_PATH)): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") assert tilia_state.duration == EXAMPLE_MEDIA_DURATION assert len(tluis) == 1 - def test_with_no_media_loaded_cancel_set_media_duration( - self, tluis, tilia_state, user_actions - ): + def test_with_no_media_loaded_cancel_set_media_duration(self, tluis, tilia_state): tilia_state.duration = 0 with Serve( Get.FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA, (True, AddTimelineWithoutMedia.Result.SET_DURATION), ): with Serve(Get.FROM_USER_FLOAT, (False, 10)): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") assert tilia_state.duration == 0 assert len(tluis) == 0 - def test_with_no_media_loaded_cancelload_media( - self, tluis, tilia_state, user_actions, resources - ): + def test_with_no_media_loaded_cancelload_media(self, tluis, tilia_state, resources): tilia_state.duration = 0 with Serve( Get.FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA, (True, AddTimelineWithoutMedia.Result.LOAD_MEDIA), ): with Serve(Get.FROM_USER_MEDIA_PATH, (False, EXAMPLE_MEDIA_PATH)): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") assert tilia_state.duration == 0 assert len(tluis) == 0 - @pytest.mark.parametrize("action", ADD_TIMELINE_ACTIONS) - def test_user_cancels_creation(self, action, tilia_state, tluis, user_actions): + @pytest.mark.parametrize("command", ADD_TIMELINE_ACTIONS) + def test_user_cancels_creation(self, command, tilia_state, tluis): with Serve(Get.FROM_USER_STRING, (False, "")): - user_actions.trigger(action) + commands.execute(command) assert tluis.is_empty - def test_delete(self, tls, tluis, user_actions): + def test_delete(self, tls, tluis): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") - tls.delete_timeline(tls[0]) # this should be an user action + tls.delete_timeline(tls[0]) # this should be a command assert tls.is_empty def test_update_select_order(self, tls, tluis): @@ -249,7 +238,7 @@ def test_set_timeline_height_updates_playback_line_height(tls, tluis): def test_zooming_updates_playback_line_position(tls, tluis): tls.create_timeline(TimelineKind.MARKER_TIMELINE) post(Post.PLAYER_SEEK, 50) - post(Post.VIEW_ZOOM_IN) + commands.execute("view.zoom.in") assert tluis[0].scene.playback_line.line().x1() == pytest.approx( time_x_converter.get_x_by_time(50) ) @@ -277,14 +266,14 @@ def test_playback_line_follows_slider_drag_if_media_is_playing( @pytest.mark.parametrize( "tlui,request_to_serve, add_request", [ - ("marker", None, TiliaAction.MARKER_ADD), + ("marker", None, "timeline.marker.add"), ( "harmony", ( Get.FROM_USER_MODE_PARAMS, (True, {"step": 0, "accidental": 0, "type": "major"}), ), - TiliaAction.MODE_ADD, + "timeline.harmony.add_mode", ), ( "harmony", @@ -292,9 +281,9 @@ def test_playback_line_follows_slider_drag_if_media_is_playing( Get.FROM_USER_HARMONY_PARAMS, (True, {"step": 0, "accidental": 0, "quality": "major"}), ), - TiliaAction.HARMONY_ADD, + "timeline.harmony.add_harmony", ), - ("beat", None, TiliaAction.BEAT_ADD), + ("beat", None, "timeline.beat.add"), ], indirect=["tlui"], ) @@ -305,16 +294,15 @@ def test_add_component_while_media_is_playing_and_slider_is_being_dragged( add_request, slider_tlui, tilia_state, - user_actions, ): y = slider_tlui.trough.pos().y() click_timeline_ui(slider_tlui, 0, y=y) drag_mouse_in_timeline_view(time_x_converter.get_x_by_time(50), y) if request_to_serve: with Serve(*request_to_serve): - user_actions.trigger(add_request) + commands.execute(add_request) else: - user_actions.trigger(add_request) + commands.execute(add_request) assert tlui[0].get_data("time") == pytest.approx(50) @@ -361,7 +349,7 @@ def test_loop_hierarchy_delete_all_cancels(self): post(Post.PLAYER_TOGGLE_LOOP, True) assert get(Get.LOOP_TIME) == (10, 50) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert get(Get.LOOP_TIME) == (0, 0) def test_loop_hierarchy_neighbouring_passes(self): @@ -389,7 +377,7 @@ def test_loop_hierarchy_delete_end_continues(self): self.tlui.deselect_all_elements() self.tlui.select_element(self.tlui[0]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert get(Get.LOOP_TIME) == (50, 100) def test_loop_hierarchy_delete_middle_cancels(self): @@ -402,7 +390,7 @@ def test_loop_hierarchy_delete_middle_cancels(self): self.tlui.deselect_all_elements() self.tlui.select_element(self.tlui[1]) - actions.trigger(TiliaAction.TIMELINE_ELEMENT_DELETE) + commands.execute("timeline.component.delete") assert get(Get.LOOP_TIME) == (0, 0) def test_loop_hierarchy_merge_split(self): @@ -412,12 +400,12 @@ def test_loop_hierarchy_merge_split(self): assert get(Get.LOOP_TIME) == (10, 20) with Serve(Get.MEDIA_CURRENT_TIME, 15): - actions.trigger(TiliaAction.HIERARCHY_SPLIT) + commands.execute("timeline.hierarchy.split") assert get(Get.LOOP_TIME) == (10, 20) self.tlui.deselect_all_elements() self.tlui.select_all_elements() - actions.trigger(TiliaAction.HIERARCHY_MERGE) + commands.execute("timeline.hierarchy.merge") assert get(Get.LOOP_TIME) == (10, 20) def test_loop_undo_manager_cancels(self): @@ -426,66 +414,82 @@ def test_loop_undo_manager_cancels(self): post(Post.PLAYER_TOGGLE_LOOP, True) assert get(Get.LOOP_TIME) == (10, 20) - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert get(Get.LOOP_TIME) == (0, 0) -class TestRequests: - def test_timeline_element_request_fails( - self, tilia, qtui, user_actions, marker_tlui, tilia_errors - ): - healthy_state = tilia.get_app_state() - original_marker_add_func = MarkerUIRequestHandler.on_add - - def on_add_patch(*args, **kwargs): - # It's important that we let the operation happen - # so a marker is actually created before the exception is raised. - # In this way, there is actually a change in a app state - # that will need to be reverted. - original_marker_add_func(*args, **kwargs) - raise Exception - - with patch( - get_method_patch_target(MarkerUIRequestHandler.on_add), - side_effect=on_add_patch, - ): - user_actions.trigger(TiliaAction.MARKER_ADD) +class TestClearAllTimelines: + def test_none(self, tilia, tluis): + commands.execute("timelines.clear_all") + assert tluis.is_empty - assert are_tilia_data_equal(tilia.get_app_state(), healthy_state) + def test_non_clearable_timeline(self, tilia, tls, tluis): + tls.create_timeline(TimelineKind.SLIDER_TIMELINE) + commands.execute("timelines.clear_all") - def test_timeline_uis_request_fails(self, tilia, qtui, user_actions, tilia_errors): - healthy_state = tilia.get_app_state() - original_timeline_add_func = TimelineUIsRequestHandler.on_timeline_add + def test_timelines_are_empty(self, tilia, tls, tluis): + commands.execute("timelines.add.marker", name="") + commands.execute("timelines.add.harmony", name="") + commands.execute("timelines.clear_all") - def on_timeline_add_patch(*args, **kwargs): - # see comment in previous test - original_timeline_add_func(*args, **kwargs) - raise Exception + def test_not_clearable_and_empty_timeline(self, tilia, tls, tluis): + tls.create_timeline(TimelineKind.SLIDER_TIMELINE) + commands.execute("timelines.add.marker", name="") + commands.execute("timelines.clear_all") - with patch( - get_method_patch_target(TimelineUIsRequestHandler.on_timeline_add), - side_effect=on_timeline_add_patch, - ): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + def test_user_cancels(self, tilia, tluis): + commands.execute("timelines.add.marker", name="") + commands.execute("timeline.marker.add") + assert not tluis[0].is_empty + with patch_yes_or_no_dialog(False): + commands.execute("timelines.clear_all") - assert are_tilia_data_equal(tilia.get_app_state(), healthy_state) + assert not tluis[0].is_empty - def test_timeline_ui_request_fails( - self, tilia, qtui, user_actions, tilia_errors, marker_tlui - ): - healthy_state = tilia.get_app_state() - original_timeline_add_func = TimelineRequestHandler.on_timeline_data_set + def test_one(self, tilia, tluis): + commands.execute("timelines.add.marker", name="") + commands.execute("timeline.marker.add") + assert not tluis[0].is_empty + with patch_yes_or_no_dialog(True): + commands.execute("timelines.clear_all") - def on_timeline_data_set_patch(*args, **kwargs): - # see comment in previous test - original_timeline_add_func(*args, **kwargs) - raise Exception + assert tluis[0].is_empty - with patch( - TimelineUI.__module__ + ".TimelineRequestHandler.on_timeline_data_set", - side_effect=on_timeline_data_set_patch, - ): - with Serve(Get.FROM_USER_STRING, ("new name", True)): - user_actions.trigger(TiliaAction.TIMELINE_NAME_SET) + def test_multiple(self, tilia, tluis): + commands.execute("timelines.add.marker", name="") + commands.execute("timeline.marker.add") + + commands.execute("timelines.add.marker", name="") + commands.execute("timeline.marker.add") + + with patch_yes_or_no_dialog(True): + commands.execute("timelines.clear_all") + + assert all(tl.is_empty for tl in tluis[0]) + + +def test_timeline_command_fails(tilia, qtui, tluis, marker_tlui, tilia_errors): + healthy_state = tilia.get_app_state() + + def add_and_fail(): + # It's important that we let the operation happen + # so a marker is actually created before the exception is raised. + # In this way, there is actually a change in a app state + # that will need to be reverted. + marker_tlui.on_add() + raise Exception + + MarkerTimelineUI.add_and_fail = add_and_fail + + callback = functools.partial( + tluis.on_timeline_command, + TimelineKind.MARKER_TIMELINE, + "add_and_fail", + TimelineSelector.ALL, + ) + # register the add_and_fail as a callback for the timeline.marker.add command + commands.register("timeline.marker.add", callback) + + commands.execute("timeline.marker.add") - assert are_tilia_data_equal(tilia.get_app_state(), healthy_state) + assert are_tilia_data_equal(tilia.get_app_state(), healthy_state) diff --git a/tests/ui/windows/test_manage_timelines.py b/tests/ui/windows/test_manage_timelines.py index 59f402241..923a09107 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -4,7 +4,7 @@ from tilia.requests import Get, get from tilia.timelines.base.timeline import Timeline from tilia.timelines.collection.collection import Timelines -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.windows.manage_timelines import ManageTimelines @@ -35,11 +35,11 @@ def manage_timelines(qtui): class TestChangeTimelineOrder: @pytest.fixture(autouse=True) - def setup_timelines(self, tls, user_actions): + def setup_timelines(self, tls): with Serve(Get.FROM_USER_STRING, (True, "")): - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) - user_actions.trigger(TiliaAction.TIMELINES_ADD_MARKER_TIMELINE) + commands.execute("timelines.add.marker") + commands.execute("timelines.add.marker") + commands.execute("timelines.add.marker") return list(tls) def test_increase_ordinal(self, tls, manage_timelines, setup_timelines): @@ -50,31 +50,27 @@ def test_increase_ordinal(self, tls, manage_timelines, setup_timelines): assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) - def test_increase_ordinal_undo( - self, tls, user_actions, manage_timelines, setup_timelines - ): + def test_increase_ordinal_undo(self, tls, manage_timelines, setup_timelines): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(1) manage_timelines.up_button.click() - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) - def test_increase_ordinal_redo( - self, tls, user_actions, manage_timelines, setup_timelines - ): + def test_increase_ordinal_redo(self, tls, manage_timelines, setup_timelines): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(1) manage_timelines.up_button.click() - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.undo") + commands.execute("edit.redo") assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) def test_increase_ordinal_with_first_selected_does_nothing( - self, tls, user_actions, manage_timelines, setup_timelines + self, tls, manage_timelines, setup_timelines ): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(0) @@ -82,40 +78,34 @@ def test_increase_ordinal_with_first_selected_does_nothing( assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) - def test_decrease_ordinal( - self, tls, manage_timelines, user_actions, setup_timelines - ): + def test_decrease_ordinal(self, tls, manage_timelines, setup_timelines): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(0) manage_timelines.down_button.click() assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) - def test_decrease_ordinal_undo( - self, tls, user_actions, manage_timelines, setup_timelines - ): + def test_decrease_ordinal_undo(self, tls, manage_timelines, setup_timelines): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(0) manage_timelines.down_button.click() - user_actions.trigger(TiliaAction.EDIT_UNDO) + commands.execute("edit.undo") assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) - def test_decrease_ordinal_redo( - self, tls, user_actions, manage_timelines, setup_timelines - ): + def test_decrease_ordinal_redo(self, tls, manage_timelines, setup_timelines): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(0) manage_timelines.down_button.click() - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + commands.execute("edit.undo") + commands.execute("edit.redo") assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) def test_decrease_ordinal_with_last_selected_does_nothing( - self, tls, manage_timelines, user_actions, setup_timelines + self, tls, manage_timelines, setup_timelines ): tl0, tl1, tl2 = setup_timelines manage_timelines.list_widget.setCurrentRow(2) diff --git a/tests/utils.py b/tests/utils.py index c8d93522c..3106f69c3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,9 +5,9 @@ from PyQt6.QtWidgets import QMenu -from tilia.requests import get, Get, Post, post -from tilia.ui.actions import TiliaAction +from tilia.requests import get, Get from tests.mock import patch_file_dialog +from tilia.ui import commands def get_blank_file_data(): @@ -81,29 +81,29 @@ def get_method_patch_target(method: Callable) -> str: def undoable(): """ Asserts whether state is handled correctly when undoing/redoing. - Use this as a context manager around a call of user_actions.trigger. + Use this as a context manager around a call of commands.execute. E.g. ``` with undoable(): - user_actions.trigger(TiliaAction.MARKER_ADD) + commands.execute("timeline.marker.add") ``` """ state_before = get(Get.APP_STATE) yield state_after = get(Get.APP_STATE) - post(Post.EDIT_UNDO) + commands.execute("edit.undo") assert get(Get.APP_STATE) == state_before - post(Post.EDIT_REDO) + commands.execute("edit.redo") assert get(Get.APP_STATE) == state_after -def reloadable(save_path, user_actions): +def reloadable(save_path): """ Ensures the file loads similarly after saving and loading. Use this as a decorator for a function that checks for the correct values. E.g. (See tests.ui.timelines.score.test_score_timeline_ui.test_attribute_positions) ``` - @reloadable(save_path, user_actions) + @reloadable(save_path) def check_values(): ... ``` """ @@ -112,8 +112,8 @@ def check_and_reload(checks): checks() with patch_file_dialog(True, [save_path, save_path]): - user_actions.trigger(TiliaAction.FILE_SAVE) - user_actions.trigger(TiliaAction.FILE_OPEN) + commands.execute("file.save") + commands.execute("file.open") checks() diff --git a/tilia/app.py b/tilia/app.py index 085d97be8..ef3557717 100644 --- a/tilia/app.py +++ b/tilia/app.py @@ -1,4 +1,6 @@ from __future__ import annotations + +import functools import itertools import json import os @@ -13,6 +15,7 @@ from tilia.exceptions import NoReplyToRequest from tilia.file.tilia_file import TiliaFile from tilia.media.loader import load_media +from tilia.ui import commands from tilia.utils import get_tilia_class_string from tilia.requests import get, post, serve, listen, Get, Post from tilia.timelines.collection.collection import Timelines @@ -47,6 +50,7 @@ def __init__( self._setup_timelines() self.file_manager.file.timelines_hash = self.get_timelines_state()[1] self._setup_requests() + self._setup_commands() self.old_file_path = None self.cur_file_path = None @@ -56,18 +60,13 @@ def __str__(self): def _setup_requests(self): LISTENS = { (Post.APP_CLEAR, self.on_clear), - (Post.APP_CLOSE, self.on_close), (Post.APP_FILE_LOAD, self.on_file_load), (Post.APP_MEDIA_LOAD, self.load_media), (Post.APP_STATE_RESTORE, self.on_restore_state), (Post.APP_STATE_RECOVER, self.recover_to_state), (Post.APP_SETUP_FILE, self.setup_file), (Post.APP_STATE_RECORD, self.on_record_state), - (Post.FILE_EXPORT, self.on_export), (Post.PLAYER_DURATION_AVAILABLE, self.set_file_media_duration), - # Listening on tilia.dirs would need to be top-level. - # That sounds like a bad idea, so we're listening here. - (Post.AUTOSAVES_FOLDER_OPEN, tilia.dirs.open_autosaves_dir), } SERVES = { @@ -87,6 +86,38 @@ def _setup_requests(self): def _setup_timelines(self): self.timelines = Timelines(self) + def _setup_commands(self): + commands.register("file.open", self.on_open, text="&Open...", shortcut="Ctrl+O") + commands.register( + "tilia.close", + self.on_close, + text="Close TiLiA", + ) + commands.register( + "edit.undo", self.undo_manager.undo, text="&Undo", shortcut="Ctrl+Z" + ) + commands.register( + "edit.redo", self.undo_manager.redo, text="&Redo", shortcut="Ctrl+Shift+Z" + ) + + commands.register( + "open_autosaves_folder", + tilia.dirs.open_autosaves_dir, + "Open autosa&ves folder...", + ), + + commands.register( + "file.export.img", + functools.partial(self.on_export, export_type="img"), + text="&Image", + ) + + commands.register( + "file.export.json", + functools.partial(self.on_export, export_type="json"), + text="&JSON", + ) + def set_file_media_duration( self, duration: float, @@ -110,7 +141,7 @@ def on_open(self, path: Path | str | None = None) -> None: return if should_save: - post(Post.FILE_SAVE) + commands.execute("file.save") if not path: success, path = get(Get.FROM_USER_TILIA_FILE_PATH) diff --git a/tilia/errors.py b/tilia/errors.py index 9ef63c16a..852dd61e2 100644 --- a/tilia/errors.py +++ b/tilia/errors.py @@ -102,10 +102,11 @@ class Error(NamedTuple): LOAD_FILE_ERROR = Error("Load file error", "File '{}' could not be loaded. \n{}") UNDO_FAILED = Error( "Undo failed", - "Undo failed. TiLiA encountered an error while undoing the last action.\n{}", + "Undo failed. TiLiA encountered an error while undoing the last command.\n{}", ) COMMAND_FAILED = Error( - "Command failed", "Something went wrong when executing the last command. \n{}" + "Command failed", + "Something went wrong when executing the last command.\nCommand={}\n\n{}", ) SCORE_SVG_CREATE_ERROR = Error("Error creating SVG", "{}") INVALID_MEASURE_FRACTION = Error( diff --git a/tilia/exceptions.py b/tilia/exceptions.py index 21b3c711b..fdb997eed 100644 --- a/tilia/exceptions.py +++ b/tilia/exceptions.py @@ -9,10 +9,6 @@ class TiliaExit(TiliaException): pass -class UserCancelledDialog(TiliaException): - pass - - class InvalidComponentKindError(TiliaException): pass diff --git a/tilia/file/file_manager.py b/tilia/file/file_manager.py index cca19e601..3a0132202 100644 --- a/tilia/file/file_manager.py +++ b/tilia/file/file_manager.py @@ -10,6 +10,7 @@ from tilia.file.media_metadata import MediaMetadata import tilia.errors from tilia.settings import settings +from tilia.ui import commands def open_tla(file_path: str | Path) -> tuple[bool, TiliaFile | None, Path | None]: @@ -50,16 +51,14 @@ class FileManager: def __init__(self): self._setup_requests() + self._setup_commands() self._setup_file() def _setup_requests(self): LISTENS = { (Post.PLAYER_URL_CHANGED, self.on_player_url_changed), (Post.FILE_MEDIA_DURATION_CHANGED, self.on_player_duration_changed), - (Post.FILE_SAVE, self.on_save_request), - (Post.FILE_SAVE_AS, self.on_save_as_request), (Post.REQUEST_SAVE_TO_PATH, self.on_save_to_path_request), - (Post.REQUEST_FILE_NEW, self.on_request_new_file), ( Post.REQUEST_IMPORT_MEDIA_METADATA_FROM_PATH, self.on_import_media_metadata_request, @@ -84,6 +83,29 @@ def _setup_requests(self): for request, callback in SERVES: serve(self, request, callback) + def _setup_commands(self): + + commands.register( + "file.new", + self.on_request_new_file, + "&New...", + "Ctrl+N", + ) + + commands.register( + "file.save", + self.on_save_request, + "&Save", + "Ctrl+S", + ) + + commands.register( + "file.save_as", + self.on_save_as_request, + "Save &As...", + "Ctrl+Shift+S", + ) + def _setup_file(self): self.file = TiliaFile() diff --git a/tilia/media/player/base.py b/tilia/media/player/base.py index 722ca9bc2..374a926c8 100644 --- a/tilia/media/player/base.py +++ b/tilia/media/player/base.py @@ -9,6 +9,7 @@ import tilia.errors from tilia.media import exporter +from tilia.ui import commands from tilia.utils import get_tilia_class_string from tilia.requests import ( listen, @@ -37,6 +38,7 @@ def __init__(self): super().__init__() self._setup_requests() + self._setup_commands() self.is_media_loaded = False self.duration = 0.0 self.playback_start = 0.0 @@ -53,10 +55,12 @@ def __init__(self): def __str__(self): return get_tilia_class_string(self) + def _setup_commands(self): + commands.register("media.stop", self.stop, text="Stop", icon="stop15") + def _setup_requests(self): LISTENS = { (Post.PLAYER_TOGGLE_PLAY_PAUSE, self.toggle_play), - (Post.PLAYER_STOP, self.stop), (Post.PLAYER_VOLUME_CHANGE, self.on_volume_change), (Post.PLAYER_VOLUME_MUTE, self.on_volume_mute), (Post.PLAYER_PLAYBACK_RATE_TRY, self.on_playback_rate_try), @@ -145,17 +149,14 @@ def toggle_play(self, toggle_is_playing: bool): self._engine_play() self.is_playing = True self.start_play_loop() - post(Post.PLAYER_UNPAUSED) else: self._engine_pause() self.stop_play_loop() self.is_playing = False - post(Post.PLAYER_PAUSED) def stop(self): """Stops music playback and resets slider position""" - post(Post.PLAYER_STOPPING) if not self.is_playing and self.current_time == 0.0: return diff --git a/tilia/parsers/__init__.py b/tilia/parsers/__init__.py index 1985efc9c..f8efecfe6 100644 --- a/tilia/parsers/__init__.py +++ b/tilia/parsers/__init__.py @@ -1,19 +1,20 @@ from typing import Literal from tilia.timelines.timeline_kinds import TimelineKind as TlKind -from .csv.marker import import_by_measure as marker_by_measure -from .csv.marker import import_by_time as marker_by_time -from .csv.hierarchy import import_by_measure as hierarchy_by_measure -from .csv.hierarchy import import_by_time as hierarchy_by_time -from .csv.beat import beats_from_csv -from .csv.harmony import import_by_measure as harmony_by_measure -from .csv.harmony import import_by_time as harmony_by_time -from .csv.pdf import import_by_measure as pdf_by_measure -from .csv.pdf import import_by_time as pdf_by_time -from .score.musicxml import notes_from_musicXML as score_from_musicxml def get_import_function(tl_kind: TlKind, by=Literal["time", "measure"]): + from .csv.marker import import_by_measure as marker_by_measure + from .csv.marker import import_by_time as marker_by_time + from .csv.hierarchy import import_by_measure as hierarchy_by_measure + from .csv.hierarchy import import_by_time as hierarchy_by_time + from .csv.beat import beats_from_csv + from .csv.harmony import import_by_measure as harmony_by_measure + from .csv.harmony import import_by_time as harmony_by_time + from .csv.pdf import import_by_measure as pdf_by_measure + from .csv.pdf import import_by_time as pdf_by_time + from .score.musicxml import notes_from_musicXML as score_from_musicxml + if tl_kind == TlKind.BEAT_TIMELINE: return beats_from_csv elif tl_kind == TlKind.SCORE_TIMELINE: diff --git a/tilia/requests/get.py b/tilia/requests/get.py index 15fef1e70..b47e8d428 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -9,7 +9,6 @@ class Get(Enum): APP_STATE = auto() TIMELINE_ELEMENTS_SELECTED = auto() CLIPBOARD_CONTENTS = auto() - CONTEXT_MENU_TIMELINE_UIS_TO_PERMUTE = auto() CONTEXT_MENU_TIMELINE_UI = auto() FIRST_TIMELINE_UI_IN_SELECT_ORDER = auto() FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA = auto() @@ -65,8 +64,6 @@ class Get(Enum): TIMELINE_WIDTH = auto() VERIFIED_PATH = auto() WINDOW_GEOMETRY = auto() - WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_CURRENT = auto() - WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_TO_PERMUTE = auto() WINDOW_STATE = auto() diff --git a/tilia/requests/post.py b/tilia/requests/post.py index 2b4bf7b73..9c9a0fb5f 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -6,8 +6,6 @@ class Post(Enum): - PDF_MARKER_ADD = auto() - AUTOSAVES_FOLDER_OPEN = auto() UI_EXIT = auto() APP_CLEAR = auto() APP_FILE_LOAD = auto() @@ -16,76 +14,29 @@ class Post(Enum): APP_STATE_RECORD = auto() APP_STATE_RECOVER = auto() APP_STATE_RESTORE = auto() - BEAT_ADD = auto() - BEAT_DISTRIBUTE = auto() - BEAT_RESET_MEASURE_NUMBER = auto() - BEAT_SET_AMOUNT_IN_MEASURE = auto() - BEAT_SET_MEASURE_NUMBER = auto() + APP_STATE_UNDO_OR_REDO_DONE = auto() BEAT_TIMELINE_COMPONENTS_DESERIALIZED = auto() - BEAT_TIMELINE_FILL = auto() BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE = auto() - CANVAS_RIGHT_CLICK = auto() DEBUG = auto() DISPLAY_ERROR = auto() - EDIT_REDO = auto() - EDIT_UNDO = auto() ELEMENT_DRAG_END = auto() ELEMENT_DRAG_START = auto() - EXPLORER_AUDIO_INFO_FROM_SEARCH_RESULT = auto() - EXPLORER_DISPLAY_SEARCH_RESULTS = auto() - EXPLORER_LOAD_MEDIA = auto() - EXPLORER_PLAY = auto() - EXPLORER_SEARCH = auto() - FILE_EXPORT = auto() FILE_MEDIA_DURATION_CHANGED = auto() - FILE_OPEN = auto() - FILE_SAVE = auto() - FILE_SAVE_AS = auto() - FOCUS_TIMELINES = auto() - HARMONY_ADD = auto() - HARMONY_DISPLAY_AS_CHORD_SYMBOL = auto() - HARMONY_DISPLAY_AS_ROMAN_NUMERAL = auto() - HARMONY_TIMELINE_HIDE_KEYS = auto() - HARMONY_TIMELINE_SHOW_KEYS = auto() HARMONY_TIMELINE_COMPONENTS_DESERIALIZED = auto() - HIERARCHY_ADD_POST_END = auto() - HIERARCHY_ADD_PRE_START = auto() - HIERARCHY_COLOR_RESET = auto() - HIERARCHY_COLOR_SET = auto() - HIERARCHY_CREATE_CHILD = auto() - HIERARCHY_DECREASE_LEVEL = auto() HIERARCHY_DESELECTED = auto() - HIERARCHY_GENEALOGY_CHANGED = auto() - HIERARCHY_GROUP = auto() - HIERARCHY_INCREASE_LEVEL = auto() - HIERARCHY_LEVEL_CHANGED = auto() - HIERARCHY_MERGE = auto() HIERARCHY_MERGE_SPLIT_DONE = auto() - HIERARCHY_PASTE = auto() HIERARCHY_SELECTED = auto() - HIERARCHY_SPLIT = auto() IMPORT_CSV = auto() IMPORT_MUSICXML = auto() INSPECTABLE_ELEMENT_DESELECTED = auto() INSPECTABLE_ELEMENT_SELECTED = auto() INSPECTOR_FIELD_EDITED = auto() - KEY_PRESS_DELETE = auto() - KEY_PRESS_DOWN = auto() - KEY_PRESS_ENTER = auto() - KEY_PRESS_LEFT = auto() - KEY_PRESS_RIGHT = auto() - KEY_PRESS_UP = auto() LEFT_BUTTON_CLICK = auto() LOOP_IGNORE_COMPONENT = auto() - MARKER_ADD = auto() MEDIA_METADATA_FIELD_ADD = auto() MEDIA_METADATA_FIELD_SET = auto() - MERGE_RANGE_BUTTON = auto() METADATA_UPDATE_FIELDS = auto() - MODE_ADD = auto() - MODE_DISPLAY_AS_ROMAN_NUMERAL = auto() PLAYBACK_AREA_SET_WIDTH = auto() - PLAYER_AVAILABLE = auto() PLAYER_CANCEL_LOOP = auto() PLAYER_CHANGE_TO_AUDIO_PLAYER = auto() PLAYER_CHANGE_TO_VIDEO_PLAYER = auto() @@ -94,25 +45,20 @@ class Post(Enum): PLAYER_DURATION_AVAILABLE = auto() PLAYER_EXPORT_AUDIO = auto() PLAYER_MEDIA_UNLOADED = auto() - PLAYER_PAUSED = auto() PLAYER_PLAYBACK_RATE_TRY = auto() PLAYER_REQUEST_TO_LOAD_MEDIA = auto() PLAYER_REQUEST_TO_UNLOAD_MEDIA = auto() PLAYER_SEEK = auto() PLAYER_SEEK_IF_NOT_PLAYING = auto() - PLAYER_STOP = auto() PLAYER_STOPPED = auto() - PLAYER_STOPPING = auto() PLAYER_TOGGLE_LOOP = auto() PLAYER_TOGGLE_PLAY_PAUSE = auto() PLAYER_UI_UPDATE = auto() - PLAYER_UNPAUSED = auto() PLAYER_UPDATE_CONTROLS = auto() PLAYER_URL_CHANGED = auto() PLAYER_VOLUME_CHANGE = auto() PLAYER_VOLUME_MUTE = auto() REQUEST_CLEAR_UI = auto() - REQUEST_FILE_NEW = auto() REQUEST_IMPORT_MEDIA_METADATA_FROM_PATH = auto() REQUEST_SAVE_TO_PATH = auto() SCORE_TIMELINE_CLEAR_DONE = auto() @@ -124,10 +70,8 @@ class Post(Enum): SLIDER_DRAG_END = auto() SLIDER_DRAG_START = auto() TIMELINES_AUTO_SCROLL_UPDATE = auto() - TIMELINES_CLEAR = auto() TIMELINES_CROP_DONE = auto() TIMELINE_ADD = auto() - TIMELINE_CLEAR_FROM_MANAGE_TIMELINES = auto() TIMELINE_COMPONENT_CREATED = auto() TIMELINE_COMPONENT_DELETED = auto() TIMELINE_COMPONENT_DESELECTED = auto() @@ -137,33 +81,14 @@ class Post(Enum): TIMELINE_CREATE_DONE = auto() TIMELINE_DELETE_FROM_CLI = auto() TIMELINE_DELETE_DONE = auto() - TIMELINE_DELETE_FROM_CONTEXT_MENU = auto() - TIMELINE_DELETE_FROM_MANAGE_TIMELINES = auto() - TIMELINE_ELEMENT_COLOR_RESET = auto() - TIMELINE_ELEMENT_COLOR_SET = auto() - TIMELINE_ELEMENT_COPY = auto() TIMELINE_ELEMENT_COPY_DONE = auto() - TIMELINE_ELEMENT_DELETE = auto() - TIMELINE_ELEMENT_EXPORT_AUDIO = auto() - TIMELINE_ELEMENT_INSPECT = auto() - TIMELINE_ELEMENT_PASTE = auto() TIMELINE_ELEMENT_PASTE_ALL = auto() - TIMELINE_ELEMENT_PASTE_COMPLETE = auto() - TIMELINE_HEIGHT_SET = auto() - TIMELINE_IS_VISIBLE_SET_FROM_MANAGE_TIMELINES = auto() TIMELINE_KEY_PRESS_DOWN = auto() TIMELINE_KEY_PRESS_LEFT = auto() TIMELINE_KEY_PRESS_RIGHT = auto() TIMELINE_KEY_PRESS_UP = auto() TIMELINE_KIND_INSTANCED = auto() TIMELINE_KIND_NOT_INSTANCED = auto() - TIMELINE_NAME_SET = auto() - TIMELINE_ORDINAL_DECREASE_FROM_MANAGE_TIMELINES = auto() - TIMELINE_ORDINAL_INCREASE_FROM_MANAGE_TIMELINES = auto() - TIMELINE_ORDINAL_DECREASE_FROM_CONTEXT_MENU = auto() - TIMELINE_ORDINAL_INCREASE_FROM_CONTEXT_MENU = auto() - TIMELINE_ORDINAL_SET = auto() - TIMELINE_PREPARING_TO_DRAG = auto() TIMELINE_SET_DATA_DONE = auto() TIMELINE_VIEW_DOUBLE_LEFT_CLICK = auto() TIMELINE_VIEW_LEFT_BUTTON_DRAG = auto() @@ -171,13 +96,10 @@ class Post(Enum): TIMELINE_VIEW_LEFT_CLICK = auto() TIMELINE_VIEW_RIGHT_CLICK = auto() TIMELINE_WIDTH_SET_DONE = auto() - APP_CLOSE = auto() UI_MEDIA_LOAD = auto() - UI_MEDIA_LOAD_LOCAL = auto() - UI_MEDIA_LOAD_YOUTUBE = auto() UNDO_MANAGER_SET_IS_RECORDING = auto() - VIEW_ZOOM_IN = auto() VIEW_ZOOM_OUT = auto() + VIEW_ZOOM_IN = auto() WEBSITE_HELP_OPEN = auto() WINDOW_OPEN = auto() WINDOW_OPEN_DONE = auto() diff --git a/tilia/timelines/audiowave/timeline.py b/tilia/timelines/audiowave/timeline.py index 3aa48a841..9cdc284cf 100644 --- a/tilia/timelines/audiowave/timeline.py +++ b/tilia/timelines/audiowave/timeline.py @@ -20,7 +20,11 @@ def __init__(self, timeline: AudioWaveTimeline): class AudioWaveTimeline(Timeline): KIND = TimelineKind.AUDIOWAVE_TIMELINE COMPONENT_MANAGER_CLASS = AudioWaveTLComponentManager - FLAGS = [TimelineFlag.NOT_CLEARABLE, TimelineFlag.NOT_EXPORTABLE] + FLAGS = [ + TimelineFlag.NOT_CLEARABLE, + TimelineFlag.NOT_EXPORTABLE, + TimelineFlag.COMPONENTS_NOT_EDITABLE, + ] @property def default_height(self): diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index 9216041b0..a954058b9 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -15,7 +15,12 @@ from tilia.timelines.base.component.pointlike import scale_pointlike, crop_pointlike from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind -from tilia.timelines.base.timeline import Timeline, TimelineComponentManager, TC +from tilia.timelines.base.timeline import ( + Timeline, + TimelineComponentManager, + TC, + TimelineFlag, +) from tilia.timelines.beat.components import Beat @@ -184,6 +189,7 @@ class BeatTimeline(Timeline): KIND = TimelineKind.BEAT_TIMELINE COMPONENT_MANAGER_CLASS = BeatTLComponentManager + FLAGS = [TimelineFlag.COMPONENTS_COPYABLE, TimelineFlag.COMPONENTS_IMPORTABLE] def __init__( self, diff --git a/tilia/timelines/harmony/timeline.py b/tilia/timelines/harmony/timeline.py index 77db7aae2..3bc9efb45 100644 --- a/tilia/timelines/harmony/timeline.py +++ b/tilia/timelines/harmony/timeline.py @@ -15,7 +15,12 @@ from tilia.timelines.harmony.validators import validate_level_count from tilia.timelines.timeline_kinds import TimelineKind from tilia.timelines.base.component import TimelineComponent -from tilia.timelines.base.timeline import Timeline, TimelineComponentManager, TC +from tilia.timelines.base.timeline import ( + Timeline, + TimelineComponentManager, + TC, + TimelineFlag, +) class HarmonyTLComponentManager(TimelineComponentManager): @@ -143,6 +148,7 @@ class HarmonyTimeline(Timeline): ] NOT_EXPORTABLE_ATTRS = ["level_count", "level_height"] COMPONENT_MANAGER_CLASS = HarmonyTLComponentManager + FLAGS = [TimelineFlag.COMPONENTS_COPYABLE, TimelineFlag.COMPONENTS_IMPORTABLE] def __init__( self, diff --git a/tilia/timelines/pdf/timeline.py b/tilia/timelines/pdf/timeline.py index d60853b31..a0013a044 100644 --- a/tilia/timelines/pdf/timeline.py +++ b/tilia/timelines/pdf/timeline.py @@ -12,7 +12,11 @@ from tilia.timelines.pdf.components import PdfMarker from tilia.timelines.timeline_kinds import TimelineKind from tilia.timelines.base.component import TimelineComponent -from tilia.timelines.base.timeline import Timeline, TimelineComponentManager +from tilia.timelines.base.timeline import ( + Timeline, + TimelineComponentManager, + TimelineFlag, +) class PdfTLComponentManager(TimelineComponentManager): @@ -29,6 +33,7 @@ class PdfTimeline(Timeline): KIND = TimelineKind.PDF_TIMELINE SERIALIZABLE = ["height", "is_visible", "name", "ordinal", "path"] COMPONENT_MANAGER_CLASS = PdfTLComponentManager + FLAGS = [TimelineFlag.COMPONENTS_COPYABLE, TimelineFlag.COMPONENTS_IMPORTABLE] def __init__(self, path: str, name: str = "", height: int | None = None, **kwargs): super().__init__( diff --git a/tilia/timelines/slider/timeline.py b/tilia/timelines/slider/timeline.py index 83d386e88..03f97caf9 100644 --- a/tilia/timelines/slider/timeline.py +++ b/tilia/timelines/slider/timeline.py @@ -13,6 +13,7 @@ class SliderTimeline(Timeline): SERIALIZABLE = ["is_visible", "ordinal", "height"] KIND = TimelineKind.SLIDER_TIMELINE FLAGS = [ + TimelineFlag.COMPONENTS_NOT_EDITABLE, TimelineFlag.NOT_CLEARABLE, TimelineFlag.NOT_DELETABLE, TimelineFlag.NOT_EXPORTABLE, diff --git a/tilia/timelines/timeline_kinds.py b/tilia/timelines/timeline_kinds.py index 546c67d26..7ef76fc6c 100644 --- a/tilia/timelines/timeline_kinds.py +++ b/tilia/timelines/timeline_kinds.py @@ -14,16 +14,6 @@ class TimelineKind(Enum): AUDIOWAVE_TIMELINE = "AUDIOWAVE_TIMELINE" -COLORED_COMPONENTS = [ - TimelineKind.MARKER_TIMELINE, - TimelineKind.HIERARCHY_TIMELINE, - TimelineKind.SCORE_TIMELINE, -] - -NOT_SLIDER = [kind for kind in TimelineKind if kind != TimelineKind.SLIDER_TIMELINE] -ALL = list(TimelineKind) - - def get_timeline_kind_from_string(string): string = string.upper() if not string.endswith("_TIMELINE"): @@ -32,7 +22,27 @@ def get_timeline_kind_from_string(string): return TimelineKind(string) +def get_timeline_name(kind: TimelineKind) -> str: + return kind.name.replace("_TIMELINE", "").lower() + + +def get_timeline_frontend_name(kind: TimelineKind) -> str: + name = get_timeline_name(kind) + if kind == TimelineKind.PDF_TIMELINE: + name = name.upper() + + return name + + def get_timeline_class_from_kind(kind: TimelineKind) -> type[Timeline]: classes = Timeline.subclasses() kind_to_class = {c.KIND: c for c in classes} return kind_to_class[kind] + + +IMPORTABLE = [ + kind + for kind in TimelineKind + if kind not in [TimelineKind.SLIDER_TIMELINE, TimelineKind.AUDIOWAVE_TIMELINE] +] +ALL = list(TimelineKind) diff --git a/tilia/ui/actions.py b/tilia/ui/actions.py deleted file mode 100644 index 8926d4b62..000000000 --- a/tilia/ui/actions.py +++ /dev/null @@ -1,398 +0,0 @@ -from dataclasses import dataclass -from enum import Enum, auto -from typing import Any, Optional - -from tilia.dirs import IMG_DIR -from tilia.requests.post import post, Post - -from PyQt6.QtWidgets import QMainWindow -from PyQt6.QtGui import QAction, QKeySequence, QIcon - -from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.windows import WindowKind - - -class TiliaAction(Enum): - PDF_MARKER_ADD = auto() - AUTOSAVES_FOLDER_OPEN = auto() - APP_CLOSE = auto() - MODE_ADD = auto() - HARMONY_TIMELINE_HIDE_KEYS = auto() - HARMONY_TIMELINE_SHOW_KEYS = auto() - HARMONY_DISPLAY_AS_ROMAN_NUMERAL = auto() - HARMONY_DISPLAY_AS_CHORD_SYMBOL = auto() - TIMELINE_ELEMENT_EDIT = auto() - TIMELINES_ADD_HARMONY_TIMELINE = auto() - HARMONY_ADD = auto() - MEDIA_LOAD_YOUTUBE = auto() - HIERARCHY_ADD_POST_END = auto() - HIERARCHY_ADD_PRE_START = auto() - ABOUT_WINDOW_OPEN = auto() - BEAT_ADD = auto() - BEAT_DISTRIBUTE = auto() - BEAT_RESET_MEASURE_NUMBER = auto() - BEAT_SET_AMOUNT_IN_MEASURE = auto() - BEAT_SET_MEASURE_NUMBER = auto() - BEAT_TIMELINE_FILL = auto() - EDIT_REDO = auto() - EDIT_UNDO = auto() - FILE_EXPORT_JSON = auto() - FILE_EXPORT_IMG = auto() - FILE_NEW = auto() - FILE_OPEN = auto() - FILE_SAVE = auto() - FILE_SAVE_AS = auto() - HIERARCHY_CREATE_CHILD = auto() - HIERARCHY_DECREASE_LEVEL = auto() - HIERARCHY_GROUP = auto() - HIERARCHY_INCREASE_LEVEL = auto() - HIERARCHY_MERGE = auto() - IMPORT_MUSICXML = auto() - IMPORT_CSV_PDF_TIMELINE = auto() - IMPORT_CSV_HARMONY_TIMELINE = auto() - IMPORT_CSV_HIERARCHY_TIMELINE = auto() - IMPORT_CSV_MARKER_TIMELINE = auto() - IMPORT_CSV_BEAT_TIMELINE = auto() - TIMELINE_ELEMENT_PASTE_COMPLETE = auto() - HIERARCHY_SPLIT = auto() - MARKER_ADD = auto() - MEDIA_LOAD_LOCAL = auto() - MEDIA_STOP = auto() - METADATA_WINDOW_OPEN = auto() - SCORE_ANNOTATION_ADD = auto() - SCORE_ANNOTATION_DELETE = auto() - SCORE_ANNOTATION_EDIT = auto() - SCORE_ANNOTATION_FONT_DEC = auto() - SCORE_ANNOTATION_FONT_INC = auto() - SETTINGS_WINDOW_OPEN = auto() - TIMELINES_CLEAR = auto() - TIMELINES_ADD_BEAT_TIMELINE = auto() - TIMELINES_ADD_HIERARCHY_TIMELINE = auto() - TIMELINES_ADD_MARKER_TIMELINE = auto() - TIMELINES_ADD_PDF_TIMELINE = auto() - TIMELINES_ADD_AUDIOWAVE_TIMELINE = auto() - TIMELINES_ADD_SCORE_TIMELINE = auto() - TIMELINE_ELEMENT_COLOR_SET = auto() - TIMELINE_ELEMENT_COLOR_RESET = auto() - TIMELINE_ELEMENT_COPY = auto() - TIMELINE_ELEMENT_DELETE = auto() - TIMELINE_ELEMENT_INSPECT = auto() - TIMELINE_ELEMENT_PASTE = auto() - TIMELINE_ELEMENT_EXPORT_AUDIO = auto() - TIMELINE_HEIGHT_SET = auto() - TIMELINE_NAME_SET = auto() - VIEW_ZOOM_IN = auto() - VIEW_ZOOM_OUT = auto() - WEBSITE_HELP_OPEN = auto() - WINDOW_MANAGE_TIMELINES_OPEN = auto() - - -@dataclass -class ActionParams: - request: Optional[Post] - text: str - icon: str - shortcut: str - args: Optional[Any] = None - kwargs: Optional[dict[str, Any]] = None - - -def setup_actions(parent: QMainWindow): - for action in TiliaAction: - qaction = setup_action(action, parent) - _taction_to_qaction[action] = qaction - - -def setup_action(action: TiliaAction, parent: QMainWindow): - params = taction_to_params[action] - qaction = QAction(parent) - set_text(qaction, params.text) - set_request(qaction, params.request, params.args, params.kwargs) - set_action_icon(qaction, params.icon) - qaction.setIconVisibleInMenu(False) - set_shortcut(qaction, params.shortcut) - set_tooltip(qaction, params.text, params.shortcut) - return qaction - - -def get_img_path(basename: str): - return IMG_DIR / f"{basename}.png" - - -def set_request( - action: QAction, - request: Post | None, - args: Optional[Any], - kwargs: Optional[dict[str, Any]], -): - if not request: - return - args = args or [] - kwargs = kwargs or {} - callback = _get_request_callback(request, args, kwargs) - action.triggered.connect(callback) - - -def _get_request_callback(request: Post, args: tuple[Any], kwargs: dict[str, Any]): - if args or kwargs: - - def callback(): - post(request, *args, **kwargs) - - else: - - def callback(): - post(request) - - return callback - - -def set_action_icon(action: QAction, icon: str | None): - if icon: - action.setIcon(QIcon(str(get_img_path(icon)))) - - -def set_shortcut(action: QAction, shortcut: str | None): - if shortcut: - action.setShortcut(QKeySequence(shortcut)) - - -def set_text(action: QAction, text: str): - action.setText(text) - - -def set_tooltip(action: QAction, text: str, shortcut: str): - action.setToolTip(f"{text} ({shortcut})" if shortcut else text) - - -taction_to_params = { - TiliaAction.APP_CLOSE: ActionParams(Post.APP_CLOSE, "Close tilia", "Close", ""), - TiliaAction.BEAT_ADD: ActionParams( - Post.BEAT_ADD, "Add beat at current position", "beat_add", "b" - ), - TiliaAction.BEAT_DISTRIBUTE: ActionParams( - Post.BEAT_DISTRIBUTE, "Distribute", "beat_distribute", "" - ), - TiliaAction.BEAT_SET_MEASURE_NUMBER: ActionParams( - Post.BEAT_SET_MEASURE_NUMBER, "Set measure number", "beat_set_number", "" - ), - TiliaAction.BEAT_RESET_MEASURE_NUMBER: ActionParams( - Post.BEAT_RESET_MEASURE_NUMBER, "Reset measure number", "beat_reset_number", "" - ), - TiliaAction.BEAT_SET_AMOUNT_IN_MEASURE: ActionParams( - Post.BEAT_SET_AMOUNT_IN_MEASURE, "Set beat amount in measure", "", "" - ), - TiliaAction.BEAT_TIMELINE_FILL: ActionParams( - Post.BEAT_TIMELINE_FILL, "Fill timeline with beats", "", "" - ), - TiliaAction.MARKER_ADD: ActionParams( - Post.MARKER_ADD, "Add marker at current position", "add_marker30", "m" - ), - TiliaAction.TIMELINE_ELEMENT_COLOR_SET: ActionParams( - Post.TIMELINE_ELEMENT_COLOR_SET, "Change color", "", "" - ), - TiliaAction.TIMELINE_ELEMENT_COLOR_RESET: ActionParams( - Post.TIMELINE_ELEMENT_COLOR_RESET, "Reset color", "", "" - ), - TiliaAction.HIERARCHY_SPLIT: ActionParams( - Post.HIERARCHY_SPLIT, "Split at current position", "split30", "s" - ), - TiliaAction.HIERARCHY_MERGE: ActionParams( - Post.HIERARCHY_MERGE, "Merge", "merge30", "e" - ), - TiliaAction.HIERARCHY_GROUP: ActionParams( - Post.HIERARCHY_GROUP, "Group", "group30", "g" - ), - TiliaAction.HIERARCHY_INCREASE_LEVEL: ActionParams( - Post.HIERARCHY_INCREASE_LEVEL, "Move up a level", "lvlup30", "Ctrl+Up" - ), - TiliaAction.HIERARCHY_DECREASE_LEVEL: ActionParams( - Post.HIERARCHY_DECREASE_LEVEL, "Move down a level", "lvldwn30", "Ctrl+Down" - ), - TiliaAction.HIERARCHY_CREATE_CHILD: ActionParams( - Post.HIERARCHY_CREATE_CHILD, "Create child", "below30", "" - ), - TiliaAction.HIERARCHY_ADD_PRE_START: ActionParams( - Post.HIERARCHY_ADD_PRE_START, "Add pre-start", "", "" - ), - TiliaAction.HIERARCHY_ADD_POST_END: ActionParams( - Post.HIERARCHY_ADD_POST_END, "Add post-end", "", "" - ), - TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE: ActionParams( - Post.TIMELINE_ELEMENT_PASTE_COMPLETE, - "Pas&te complete", - "paste_with_data30", - "Ctrl+Shift+V", - ), - TiliaAction.HARMONY_ADD: ActionParams( - Post.HARMONY_ADD, "Add harmony", "harmony_add", "h" - ), - TiliaAction.MODE_ADD: ActionParams(Post.MODE_ADD, "Add mode", "mode_add", ""), - TiliaAction.HARMONY_DISPLAY_AS_ROMAN_NUMERAL: ActionParams( - Post.HARMONY_DISPLAY_AS_ROMAN_NUMERAL, - "Display as roman numeral", - "harmony_display_roman", - "", - ), - TiliaAction.HARMONY_DISPLAY_AS_CHORD_SYMBOL: ActionParams( - Post.HARMONY_DISPLAY_AS_CHORD_SYMBOL, - "Display as chord symbol", - "harmony_display_chord", - "", - ), - TiliaAction.HARMONY_TIMELINE_SHOW_KEYS: ActionParams( - Post.HARMONY_TIMELINE_SHOW_KEYS, "Show keys", "", "" - ), - TiliaAction.HARMONY_TIMELINE_HIDE_KEYS: ActionParams( - Post.HARMONY_TIMELINE_HIDE_KEYS, "Hide keys", "", "" - ), - TiliaAction.FILE_NEW: ActionParams(Post.REQUEST_FILE_NEW, "&New...", "", "Ctrl+N"), - TiliaAction.FILE_OPEN: ActionParams(Post.FILE_OPEN, "&Open...", "", "Ctrl+O"), - TiliaAction.FILE_SAVE: ActionParams(Post.FILE_SAVE, "&Save", "", "Ctrl+S"), - TiliaAction.FILE_SAVE_AS: ActionParams( - Post.FILE_SAVE_AS, "Save &As...", "", "Ctrl+Shift+S" - ), - TiliaAction.FILE_EXPORT_JSON: ActionParams( - Post.FILE_EXPORT, "&JSON", "", "", (None, "json") - ), - TiliaAction.FILE_EXPORT_IMG: ActionParams( - Post.FILE_EXPORT, "&Image", "", "", (None, "img") - ), - TiliaAction.MEDIA_LOAD_LOCAL: ActionParams( - Post.UI_MEDIA_LOAD_LOCAL, "&Local...", "", "Ctrl+Shift+L" - ), - TiliaAction.MEDIA_LOAD_YOUTUBE: ActionParams( - Post.UI_MEDIA_LOAD_YOUTUBE, "&YouTube...", "", "" - ), - TiliaAction.METADATA_WINDOW_OPEN: ActionParams( - Post.WINDOW_OPEN, "Edit &Metadata...", "", "", (WindowKind.MEDIA_METADATA,) - ), - TiliaAction.SETTINGS_WINDOW_OPEN: ActionParams( - Post.WINDOW_OPEN, "&Settings...", "", "", (WindowKind.SETTINGS,) - ), - TiliaAction.AUTOSAVES_FOLDER_OPEN: ActionParams( - Post.AUTOSAVES_FOLDER_OPEN, "Open autosa&ves folder...", "", "" - ), - TiliaAction.EDIT_REDO: ActionParams(Post.EDIT_REDO, "&Redo", "", "Ctrl+Shift+Z"), - TiliaAction.EDIT_UNDO: ActionParams(Post.EDIT_UNDO, "&Undo", "", "Ctrl+Z"), - TiliaAction.WINDOW_MANAGE_TIMELINES_OPEN: ActionParams( - Post.WINDOW_OPEN, "&Manage...", "", "", (WindowKind.MANAGE_TIMELINES,) - ), - TiliaAction.TIMELINES_ADD_HIERARCHY_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "&Hierarchy", "", "", (TimelineKind.HIERARCHY_TIMELINE,) - ), - TiliaAction.TIMELINES_ADD_MARKER_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "&Marker", "", "", (TimelineKind.MARKER_TIMELINE,) - ), - TiliaAction.TIMELINES_ADD_BEAT_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "&Beat", "", "", (TimelineKind.BEAT_TIMELINE,) - ), - TiliaAction.TIMELINES_ADD_HARMONY_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "Ha&rmony", "", "", (TimelineKind.HARMONY_TIMELINE,) - ), - TiliaAction.TIMELINES_ADD_PDF_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "&PDF", "", "", (TimelineKind.PDF_TIMELINE,) - ), - TiliaAction.TIMELINES_ADD_AUDIOWAVE_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "&AudioWave", "", "", (TimelineKind.AUDIOWAVE_TIMELINE,) - ), - TiliaAction.TIMELINES_ADD_SCORE_TIMELINE: ActionParams( - Post.TIMELINE_ADD, "&Score", "", "", (TimelineKind.SCORE_TIMELINE,) - ), - TiliaAction.TIMELINES_CLEAR: ActionParams( - Post.TIMELINES_CLEAR, "Clear all", "", "" - ), - TiliaAction.TIMELINE_ELEMENT_INSPECT: ActionParams( - Post.TIMELINE_ELEMENT_INSPECT, "Inspect", "", "" - ), - TiliaAction.TIMELINE_ELEMENT_EDIT: ActionParams( - Post.TIMELINE_ELEMENT_INSPECT, "&Edit", "", "" - ), - TiliaAction.TIMELINE_ELEMENT_COPY: ActionParams( - Post.TIMELINE_ELEMENT_COPY, "&Copy", "", "Ctrl+C" - ), - TiliaAction.TIMELINE_ELEMENT_PASTE: ActionParams( - Post.TIMELINE_ELEMENT_PASTE, "&Paste", "", "Ctrl+V" - ), - TiliaAction.TIMELINE_ELEMENT_EXPORT_AUDIO: ActionParams( - Post.TIMELINE_ELEMENT_EXPORT_AUDIO, "Export to audio", "", "" - ), - TiliaAction.TIMELINE_ELEMENT_DELETE: ActionParams( - Post.TIMELINE_ELEMENT_DELETE, "Delete", "", "Delete" - ), - TiliaAction.TIMELINE_NAME_SET: ActionParams( - Post.TIMELINE_NAME_SET, "Change name", "", "" - ), - TiliaAction.TIMELINE_HEIGHT_SET: ActionParams( - Post.TIMELINE_HEIGHT_SET, "Change height", "", "" - ), - TiliaAction.VIEW_ZOOM_IN: ActionParams(Post.VIEW_ZOOM_IN, "Zoom &In", "", "Ctrl++"), - TiliaAction.VIEW_ZOOM_OUT: ActionParams( - Post.VIEW_ZOOM_OUT, "Zoom &Out", "", "Ctrl+-" - ), - TiliaAction.ABOUT_WINDOW_OPEN: ActionParams( - Post.WINDOW_OPEN, "&About...", "", "", (WindowKind.ABOUT,) - ), - TiliaAction.MEDIA_STOP: ActionParams(Post.PLAYER_STOP, "Stop", "stop15", ""), - TiliaAction.WEBSITE_HELP_OPEN: ActionParams( - Post.WEBSITE_HELP_OPEN, "&Help...", "", "" - ), - TiliaAction.PDF_MARKER_ADD: ActionParams( - Post.PDF_MARKER_ADD, "Add PDF marker", "pdf_add", "p" - ), - TiliaAction.IMPORT_CSV_HARMONY_TIMELINE: ActionParams( - Post.IMPORT_CSV, - "&Import from CSV file", - "", - "", - (TimelineKind.HARMONY_TIMELINE,), - ), - TiliaAction.IMPORT_CSV_PDF_TIMELINE: ActionParams( - Post.IMPORT_CSV, "&Import from CSV file", "", "", (TimelineKind.PDF_TIMELINE,) - ), - TiliaAction.IMPORT_CSV_HIERARCHY_TIMELINE: ActionParams( - Post.IMPORT_CSV, - "&Import from CSV file", - "", - "", - (TimelineKind.HIERARCHY_TIMELINE,), - ), - TiliaAction.IMPORT_CSV_MARKER_TIMELINE: ActionParams( - Post.IMPORT_CSV, - "&Import from CSV file", - "", - "", - (TimelineKind.MARKER_TIMELINE,), - ), - TiliaAction.IMPORT_CSV_BEAT_TIMELINE: ActionParams( - Post.IMPORT_CSV, "&Import from CSV file", "", "", (TimelineKind.BEAT_TIMELINE,) - ), - TiliaAction.IMPORT_MUSICXML: ActionParams( - Post.IMPORT_MUSICXML, "&Import from musicxml file", "", "" - ), - TiliaAction.SCORE_ANNOTATION_ADD: ActionParams( - None, "Add Annotation (Return)", "annotation_add", "" - ), - TiliaAction.SCORE_ANNOTATION_DELETE: ActionParams( - None, "Delete Annotation (Delete)", "annotation_delete", "" - ), - TiliaAction.SCORE_ANNOTATION_EDIT: ActionParams( - None, "Edit Annotation", "annotation_edit", "Shift+Return" - ), - TiliaAction.SCORE_ANNOTATION_FONT_DEC: ActionParams( - None, "Decrease Annotation Font", "annotation_font_dec", "Shift+Down" - ), - TiliaAction.SCORE_ANNOTATION_FONT_INC: ActionParams( - None, "Increase Annotation Font", "annotation_font_inc", "Shift+Up" - ), -} - -_taction_to_qaction: dict[TiliaAction, QAction] = {} # will be populated on startup - - -def get_qaction(tilia_action: TiliaAction): - return _taction_to_qaction[tilia_action] - - -def trigger(tilia_action: TiliaAction): - _taction_to_qaction[tilia_action].trigger() diff --git a/tilia/ui/cli/export.py b/tilia/ui/cli/export.py index 63d04cbcc..3182626e0 100644 --- a/tilia/ui/cli/export.py +++ b/tilia/ui/cli/export.py @@ -1,6 +1,6 @@ from pathlib import Path -from tilia.requests import post, Post +from tilia.ui import commands from tilia.ui.cli import io @@ -22,4 +22,4 @@ def export(namespace): if not io.ask_yes_or_no(f"File {path} already exists. Overwrite?"): return - post(Post.FILE_EXPORT, str(path.resolve())) + commands.execute("file.export.json", str(path.resolve())) diff --git a/tilia/ui/cli/open.py b/tilia/ui/cli/open.py index 5c82e8c85..1e5b4a22d 100644 --- a/tilia/ui/cli/open.py +++ b/tilia/ui/cli/open.py @@ -1,6 +1,6 @@ from pathlib import Path -from tilia.requests import post, Post +from tilia.ui import commands from tilia.ui.path import ensure_tla_extension @@ -16,4 +16,4 @@ def open(namespace): path = Path(namespace.path) path = ensure_tla_extension(path) - post(Post.FILE_OPEN, str(path.resolve())) + commands.execute("file.open", str(path.resolve())) diff --git a/tilia/ui/commands.py b/tilia/ui/commands.py new file mode 100644 index 000000000..12ee6ba07 --- /dev/null +++ b/tilia/ui/commands.py @@ -0,0 +1,134 @@ +""" +This module provides a way to register and execute reusable commands. +Commands are the primary way to handle user interactions that modify the application state. + +Note that commands should only be used for operations that could be initiated by the user. +For internal communication between components, use `tilia.requests.post()` and `tilia.requests.get()` instead. + +Naming conventions for commands: +- Use snake_case. +- Use nested categories and separate with dots (e.g., 'timeline.component.copy'). +- Group related commands under same category (e.g., 'file.open', 'file.save'). + +Examples: +- File commands: 'file.open', 'file.save', 'file.export.img' +- Timeline commands: 'timeline.delete', 'timeline.component.copy' +- View commands: 'view.zoom.in', 'view.zoom.out' + +Usage: + # Register a new command + commands.register( + 'example.command', + callback_function, + text='Menu Text', # Optional: for display in Qt interface + shortcut='Ctrl+E', # Optional: keyboard shortcut + icon='example_icon' # Optional: icon name in IMG_DIR (without extension) + ) + + # Execute a command + commands.execute('example.command', arg1, arg2, kwarg1=value1) +""" + +import inspect +import os +import traceback +from typing import Callable + +import tilia.errors +from tilia.dirs import IMG_DIR + +from PyQt6.QtWidgets import QMainWindow, QWidget +from PyQt6.QtGui import QAction, QKeySequence, QIcon + + +def get_img_path(basename: str): + return IMG_DIR / f"{basename}.png" + + +def register( + name: str, + callback: Callable, + text: str = "", + shortcut: str = "", + icon: str = "", + parent: QMainWindow | QWidget | None = None, +): + """ + Register a command with name to a callback. + Registered commands can be executed anywhere using commands.execute(name). + + Also creates a QAction with the given text, shortcut and icon. + The action can be retrieved with commands.get_qaction(name) and used in the Qt interface. + """ + action = QAction(parent) + + action.setText(text) + action.setToolTip(f"{text} ({shortcut})" if shortcut else text) + + if shortcut: + action.setShortcut(QKeySequence(shortcut)) + + if icon: + action.setIcon(QIcon(str(get_img_path(icon)))) + action.setIconVisibleInMenu(False) + + if callback: + # Qt sometimes activates signals with additional parameters, + # so we need to make sure that we call the callback without them. + # This also means that we can't pass arguments to the actions, + # for that, we should use commands.execute(). + action.triggered.connect(lambda *_: execute(name)) + + _name_to_callback[name] = callback + _name_to_action[name] = action + + +def get_qaction(name): + try: + return _name_to_action[name] + except KeyError: + raise ValueError(f"Unknown command: {name}") + + +def execute(command_name: str, *args, **kwargs): + """ + Executes commands previously registered with commands.register(name) by calling the registered callback. + If in development environment, prints errors to console, else displays them as error message with tilia.errors. + """ + if os.environ.get("ENVIRONMENT") != "prod": + return _execute_dev(command_name, *args, **kwargs) + else: + return _execute_prod(command_name, *args, **kwargs) + + +def _execute_dev(command_name: str, *args, **kwargs): + if command_name not in _name_to_callback: + for key in _name_to_callback: + print(key) + raise ValueError(f"Unregistered command: {command_name}.") + + try: + return _name_to_callback[command_name](*args, **kwargs) + except Exception as e: + callback = _name_to_callback[command_name] + sig = inspect.signature(callback) + raise Exception( + f"Error executing command '{command_name}'. \n" + f"Callback: {callback.__module__}.{callback.__name__}{sig}\n" + f"Called with args: {args}, kwargs: {kwargs}" + ) from e + + +def _execute_prod(command_name: str, *args, **kwargs): + """Returns False if an error was raised during execution.""" + try: + return _name_to_callback[command_name](*args, **kwargs) + except Exception: + tilia.errors.display( + tilia.errors.COMMAND_FAILED, command_name, traceback.format_exc() + ) + return False + + +_name_to_action = {} +_name_to_callback = {} diff --git a/tilia/ui/enums.py b/tilia/ui/enums.py index 95613e43e..c9265e190 100644 --- a/tilia/ui/enums.py +++ b/tilia/ui/enums.py @@ -1,16 +1,6 @@ from enum import auto, Enum -class PasteCardinality(Enum): - MULTIPLE = auto() - SINGLE = auto() - - -class PasteDestination(Enum): - ELEMENTS = auto() - TIMELINE = auto() - - class WindowState(Enum): OPENED = auto() CLOSED = auto() diff --git a/tilia/ui/menus.py b/tilia/ui/menus.py index 19b34a3b2..784d94cf0 100644 --- a/tilia/ui/menus.py +++ b/tilia/ui/menus.py @@ -6,19 +6,21 @@ from PyQt6.QtWidgets import QMenu from PyQt6.QtGui import QAction -from tilia.ui.actions import TiliaAction, get_qaction +from tilia.timelines.timeline_kinds import get_timeline_name, TimelineKind +from tilia.ui import commands +from tilia.ui.commands import get_qaction from tilia.settings import settings from tilia.requests.post import post, Post, listen from tilia.ui.enums import WindowState class MenuItemKind(Enum): + COMMAND = auto() SEPARATOR = auto() - ACTION = auto() SUBMENU = auto() -TiliaMenuItem: TypeAlias = None | TiliaAction | type[QMenu] +TiliaMenuItem: TypeAlias = None | type[QMenu] class TiliaMenu(QMenu): @@ -51,8 +53,8 @@ def add_submenu(self, cls: type[TiliaMenu]): self.class_to_submenu[cls] = submenu self.addMenu(submenu) - def add_action(self, t_action: TiliaAction): - self.addAction(get_qaction(t_action)) + def add_action(self, name: str): + self.addAction(get_qaction(name)) def get_submenu(self, cls: type[TiliaMenu]): return self.class_to_submenu[cls] @@ -61,8 +63,8 @@ def get_submenu(self, cls: type[TiliaMenu]): class LoadMediaMenu(TiliaMenu): menu_title = "&Load media" items = [ - (MenuItemKind.ACTION, TiliaAction.MEDIA_LOAD_LOCAL), - (MenuItemKind.ACTION, TiliaAction.MEDIA_LOAD_YOUTUBE), + (MenuItemKind.COMMAND, "media.load.local"), + (MenuItemKind.COMMAND, "media.load.youtube"), ] @@ -80,7 +82,7 @@ def add_items(self): def _get_action(self, file): qaction = QAction(str(file), self) - qaction.triggered.connect(lambda _: post(Post.FILE_OPEN, file)) + qaction.triggered.connect(lambda _: commands.execute("file.open", file)) return qaction def update_items(self): @@ -91,94 +93,94 @@ def update_items(self): class ExportMenu(TiliaMenu): menu_title = "&Export..." items = [ - (MenuItemKind.ACTION, TiliaAction.FILE_EXPORT_JSON), - (MenuItemKind.ACTION, TiliaAction.FILE_EXPORT_IMG), + (MenuItemKind.COMMAND, "file.export.json"), + (MenuItemKind.COMMAND, "file.export.img"), ] class FileMenu(TiliaMenu): menu_title = "&File" items = [ - (MenuItemKind.ACTION, TiliaAction.FILE_NEW), - (MenuItemKind.ACTION, TiliaAction.FILE_OPEN), + (MenuItemKind.COMMAND, "file.new"), + (MenuItemKind.COMMAND, "file.open"), (MenuItemKind.SUBMENU, RecentFilesMenu), - (MenuItemKind.ACTION, TiliaAction.FILE_SAVE), - (MenuItemKind.ACTION, TiliaAction.FILE_SAVE_AS), + (MenuItemKind.COMMAND, "file.save"), + (MenuItemKind.COMMAND, "file.save_as"), (MenuItemKind.SUBMENU, ExportMenu), (MenuItemKind.SEPARATOR, None), (MenuItemKind.SUBMENU, LoadMediaMenu), - (MenuItemKind.ACTION, TiliaAction.METADATA_WINDOW_OPEN), + (MenuItemKind.COMMAND, "window.open.metadata"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.AUTOSAVES_FOLDER_OPEN), + (MenuItemKind.COMMAND, "open_autosaves_folder"), ] class EditMenu(TiliaMenu): menu_title = "&Edit" items = [ - (MenuItemKind.ACTION, TiliaAction.EDIT_UNDO), - (MenuItemKind.ACTION, TiliaAction.EDIT_REDO), + (MenuItemKind.COMMAND, "edit.undo"), + (MenuItemKind.COMMAND, "edit.redo"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), + (MenuItemKind.COMMAND, "timeline.component.paste_complete"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.SETTINGS_WINDOW_OPEN), + (MenuItemKind.COMMAND, "window.open.settings"), ] class AddTimelinesMenu(TiliaMenu): menu_title = "&Add" - items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_AUDIOWAVE_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_BEAT_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_HARMONY_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_HIERARCHY_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_MARKER_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_PDF_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_ADD_SCORE_TIMELINE), - ] + + def __init__(self): + commands = [ + f"timelines.add.{get_timeline_name(kind)}" + for kind in TimelineKind + if kind != TimelineKind.SLIDER_TIMELINE + ] + self.items = [(MenuItemKind.COMMAND, command) for command in commands] + super().__init__() class HierarchyMenu(TiliaMenu): menu_title = "&Hierarchy" - items = [(MenuItemKind.ACTION, TiliaAction.IMPORT_CSV_HIERARCHY_TIMELINE)] + items = [(MenuItemKind.COMMAND, "timelines.import.hierarchy")] class MarkerMenu(TiliaMenu): menu_title = "&Marker" - items = [(MenuItemKind.ACTION, TiliaAction.IMPORT_CSV_MARKER_TIMELINE)] + items = [(MenuItemKind.COMMAND, "timelines.import.marker")] class BeatMenu(TiliaMenu): menu_title = "&Beat" items = [ - (MenuItemKind.ACTION, TiliaAction.IMPORT_CSV_BEAT_TIMELINE), - (MenuItemKind.ACTION, TiliaAction.BEAT_TIMELINE_FILL), + (MenuItemKind.COMMAND, "timelines.import.beat"), + (MenuItemKind.COMMAND, "timeline.beat.fill"), ] class HarmonyMenu(TiliaMenu): menu_title = "Ha&rmony" - items = [(MenuItemKind.ACTION, TiliaAction.IMPORT_CSV_HARMONY_TIMELINE)] + items = [(MenuItemKind.COMMAND, "timelines.import.harmony")] class PdfMenu(TiliaMenu): menu_title = "&PDF" - items = [(MenuItemKind.ACTION, TiliaAction.IMPORT_CSV_PDF_TIMELINE)] + items = [(MenuItemKind.COMMAND, "timelines.import.pdf")] class ScoreMenu(TiliaMenu): menu_title = "&Score" - items = [(MenuItemKind.ACTION, TiliaAction.IMPORT_MUSICXML)] + items = [(MenuItemKind.COMMAND, "timelines.import.score")] class TimelinesMenu(TiliaMenu): menu_title = "&Timelines" items = [ (MenuItemKind.SUBMENU, AddTimelinesMenu), - (MenuItemKind.ACTION, TiliaAction.WINDOW_MANAGE_TIMELINES_OPEN), - (MenuItemKind.ACTION, TiliaAction.TIMELINES_CLEAR), + (MenuItemKind.COMMAND, "window.open.manage_timelines"), + (MenuItemKind.COMMAND, "timelines.clear_all"), (MenuItemKind.SUBMENU, HierarchyMenu), (MenuItemKind.SUBMENU, MarkerMenu), (MenuItemKind.SUBMENU, BeatMenu), @@ -198,8 +200,8 @@ def __init__(self): listen(self, Post.WINDOW_UPDATE_STATE, self.update_items) def add_default_items(self): - self.addAction(get_qaction(TiliaAction.VIEW_ZOOM_IN)) - self.addAction(get_qaction(TiliaAction.VIEW_ZOOM_OUT)) + self.addAction(get_qaction("view.zoom.in")) + self.addAction(get_qaction("view.zoom.out")) def update_items(self, window_id: int, window_state: WindowState, window_title=""): if not self.windows: @@ -237,6 +239,6 @@ def _get_action(self, window_id): class HelpMenu(TiliaMenu): menu_title = "&Help" items = [ - (MenuItemKind.ACTION, TiliaAction.ABOUT_WINDOW_OPEN), - (MenuItemKind.ACTION, TiliaAction.WEBSITE_HELP_OPEN), + (MenuItemKind.COMMAND, "window.open.about"), + (MenuItemKind.COMMAND, "open_website_help"), ] diff --git a/tilia/ui/player.py b/tilia/ui/player.py index 8a1505ae7..912d6f622 100644 --- a/tilia/ui/player.py +++ b/tilia/ui/player.py @@ -8,8 +8,7 @@ from PyQt6.QtCore import Qt from tilia.dirs import IMG_DIR -from tilia.ui import actions -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.format import format_media_time from tilia.requests import Post, post, listen, stop_listening_to_all, get, Get @@ -173,7 +172,7 @@ def on_play_toggle(checked: bool) -> None: self.addAction(self.play_toggle_action) def add_stop_button(self): - self.stop_action = actions.get_qaction(TiliaAction.MEDIA_STOP) + self.stop_action = commands.get_qaction("media.stop") self.tooltipped_widgets[self.stop_action] = "Stop" self.addAction(self.stop_action) diff --git a/tilia/ui/qtui.py b/tilia/ui/qtui.py index 8698022ef..906d0d876 100644 --- a/tilia/ui/qtui.py +++ b/tilia/ui/qtui.py @@ -1,7 +1,7 @@ from __future__ import annotations +import functools import re -from functools import partial from pathlib import Path from typing import Optional @@ -29,8 +29,7 @@ import tilia.parsers.csv.beat import tilia.parsers.csv.marker import tilia.parsers.score.musicxml -from . import actions -from .actions import TiliaAction +from tilia.ui import commands from .dialog_manager import DialogManager from .dialogs.basic import display_error from .dialogs.crash import CrashDialog @@ -48,7 +47,6 @@ ) from .options_toolbar import OptionsToolbar from .player import PlayerToolbar -from .ui_import import on_import_to_timeline from .windows.manage_timelines import ManageTimelines from .windows.metadata import MediaMetadataWindow from .windows.about import About @@ -87,38 +85,35 @@ def keyPressEvent(self, event: Optional[QtGui.QKeyEvent]) -> None: key_comb_to_taction = [ ( QKeyCombination(Qt.KeyboardModifier.ControlModifier, Qt.Key.Key_C), - TiliaAction.TIMELINE_ELEMENT_COPY, + "timeline.component.copy", ), ( QKeyCombination(Qt.KeyboardModifier.ControlModifier, Qt.Key.Key_V), - TiliaAction.TIMELINE_ELEMENT_PASTE, + "timeline.component.paste", ), ( QKeyCombination(Qt.KeyboardModifier.NoModifier, Qt.Key.Key_Delete), - TiliaAction.TIMELINE_ELEMENT_DELETE, + "timeline.component.delete", ), ( QKeyCombination(Qt.KeyboardModifier.NoModifier, Qt.Key.Key_Return), - TiliaAction.TIMELINE_ELEMENT_INSPECT, + "timeline.element.inspect", ), ( QKeyCombination(Qt.KeyboardModifier.NoModifier, Qt.Key.Key_Enter), - TiliaAction.TIMELINE_ELEMENT_INSPECT, + "timeline.element.inspect", ), ] for comb, taction in key_comb_to_taction: if event.keyCombination() == comb: - actions.get_qaction(taction).trigger() + commands.get_qaction(taction).trigger() super().keyPressEvent(event) def closeEvent(self, event): - actions.trigger(TiliaAction.APP_CLOSE) + commands.execute("tilia.close") event.ignore() - def on_close(self): - super().closeEvent(None) - def on_export(self, save_path: str): widget: QGraphicsScene = self.centralWidget().scene() success, result = ResizeRect.new_size( @@ -130,7 +125,7 @@ def on_export(self, save_path: str): if result != widget.sceneRect().width(): margins = 2 * get(Get.LEFT_MARGIN_X) zoom_level = (result - margins) / (widget.sceneRect().width() - margins) - post(Post.VIEW_ZOOM_IN, zoom_level) + commands.execute("view.zoom.in", zoom_level) else: zoom_level = 1.0 @@ -142,7 +137,7 @@ def on_export(self, save_path: str): del image if zoom_level != 1.0: - post(Post.VIEW_ZOOM_OUT, zoom_level) + commands.execute("view.zoom.out", zoom_level) class QtUI: @@ -153,7 +148,7 @@ def __init__(self, q_application: QApplication, mw: TiliaMainWindow): self._setup_fonts() self._setup_sizes() self._setup_requests() - self._setup_actions() + self._setup_commands() self._setup_widgets() self._setup_dialog_manager() self._setup_menus() @@ -176,21 +171,12 @@ def _setup_requests(self): LISTENS = { (Post.APP_FILE_LOAD, self.on_file_load), (Post.PLAYBACK_AREA_SET_WIDTH, self.on_timeline_set_width), - (Post.UI_MEDIA_LOAD_LOCAL, self.on_media_load_local), - (Post.UI_MEDIA_LOAD_YOUTUBE, self.on_media_load_youtube), - (Post.TIMELINE_ELEMENT_INSPECT, self.on_timeline_element_inspect), - (Post.WEBSITE_HELP_OPEN, self.on_website_help_open), (Post.WINDOW_OPEN, self.on_window_open), (Post.WINDOW_CLOSE, self.on_window_close), (Post.WINDOW_CLOSE_DONE, self.on_window_close_done), (Post.REQUEST_CLEAR_UI, self.on_clear_ui), (Post.TIMELINE_KIND_INSTANCED, self.on_timeline_kind_change), (Post.TIMELINE_KIND_NOT_INSTANCED, self.on_timeline_kind_change), - (Post.IMPORT_CSV, self.on_import_to_timeline), - ( - Post.IMPORT_MUSICXML, - partial(self.on_import_to_timeline, TlKind.SCORE_TIMELINE), - ), (Post.DISPLAY_ERROR, display_error), (Post.UI_EXIT, self.exit), } @@ -215,6 +201,40 @@ def _setup_requests(self): for request, callback in SERVES: serve(self, request, callback) + def _setup_commands(self): + window_commands = [ + ("window.open.metadata", WindowKind.MEDIA_METADATA, "Metadata"), + ("window.open.settings", WindowKind.SETTINGS, "Settings"), + ("window.open.manage_timelines", WindowKind.MANAGE_TIMELINES, "Manage"), + ("window.open.about", WindowKind.ABOUT, "About"), + ] + + for command, kind, text in window_commands: + commands.register( + command, functools.partial(self.on_window_open, kind), text + ) + + commands.register( + "media.load.local", + self.on_media_load_local, + text="&Local...", + shortcut="Ctrl+Shift+L", + ) + + commands.register( + "media.load.youtube", + self.on_media_load_youtube, + text="&YouTube...", + ) + + commands.register( + "timeline.element.inspect", + self.on_timeline_element_inspect, + text="Inspect", + ) + + commands.register("open_website_help", self.on_open_website_help, "&Help...") + def _setup_main_window(self, mw: TiliaMainWindow): self.main_window = mw @@ -317,9 +337,6 @@ def _setup_widgets(self): self.main_window.addToolBar(self.player_toolbar) self.main_window.addToolBar(self.options_toolbar) - def _setup_actions(self): - actions.setup_actions(self.main_window) - def on_window_open(self, kind: WindowKind): """Open a window of 'kind', if there is no window of that kind open. Otherwise, focus window of that kind.""" @@ -404,23 +421,10 @@ def on_clear_ui(self): window.close() self.main_window.setFocus() - def on_website_help_open(self): + @staticmethod + def on_open_website_help(): QDesktopServices.openUrl(QUrl(f"{constants.WEBSITE_URL}/help")) - def on_import_to_timeline(self, tl_kind: TlKind): - prev_state = get(Get.APP_STATE) - status, errors = on_import_to_timeline(self.timeline_uis, tl_kind) - - if status == "failure": - post(Post.APP_STATE_RESTORE, prev_state) - if errors: - tilia.errors.display(tilia.errors.CSV_IMPORT_FAILED, "\n".join(errors)) - elif status == "success" and errors: - tilia.errors.display( - tilia.errors.CSV_IMPORT_SUCCESS_ERRORS, "\n".join(errors) - ) - post(Post.APP_STATE_RECORD, "Import from csv file") - @staticmethod def show_crash_dialog(exception_info): dialog = CrashDialog(exception_info) diff --git a/tilia/ui/request_handler.py b/tilia/ui/request_handler.py deleted file mode 100644 index 7f1e7acee..000000000 --- a/tilia/ui/request_handler.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from typing import Callable - -from tilia.requests import Post - - -class RequestHandler: - def __init__(self, request_to_callback: dict[Post, Callable]): - base_request_to_callback = {} - self.request_to_callback = base_request_to_callback | request_to_callback - - def on_request(self, request, *args, **kwargs): - return self.request_to_callback[request](*args, **kwargs) diff --git a/tilia/ui/timelines/audiowave/request_handlers.py b/tilia/ui/timelines/audiowave/request_handlers.py deleted file mode 100644 index 47b75d3b4..000000000 --- a/tilia/ui/timelines/audiowave/request_handlers.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from tilia.requests import Post -from tilia.ui.timelines.base.request_handlers import ElementRequestHandler - -if TYPE_CHECKING: - from tilia.ui.timelines.audiowave import AudioWaveTimelineUI - - -class AudioWaveUIRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui: AudioWaveTimelineUI): - super().__init__( - timeline_ui, - { - Post.TIMELINE_ELEMENT_DELETE: self.on_delete, - Post.TIMELINE_ELEMENT_COPY: self.on_copy, - Post.TIMELINE_ELEMENT_PASTE: self.on_paste, - }, - ) - - def on_delete(self, *_, **__): - return False - - def on_copy(self, *_, **__): - return False - - def on_paste(self, *_, **__): - return False diff --git a/tilia/ui/timelines/audiowave/timeline.py b/tilia/ui/timelines/audiowave/timeline.py index 09196fd93..111c01e7d 100644 --- a/tilia/ui/timelines/audiowave/timeline.py +++ b/tilia/ui/timelines/audiowave/timeline.py @@ -4,10 +4,8 @@ from tilia.requests import Post, Get, get, listen from tilia.timelines.timeline_kinds import TimelineKind from tilia.ui.timelines.base.timeline import TimelineUI -from tilia.ui.timelines.collection.requests.enums import ElementSelector from tilia.ui.timelines.audiowave.element import AmplitudeBarUI -from tilia.ui.timelines.audiowave.request_handlers import AudioWaveUIRequestHandler from ...format import format_media_time @@ -37,13 +35,6 @@ def on_settings_updated(self, updated_settings): ) self.timeline.refresh() - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return AudioWaveUIRequestHandler(self).on_request( - request, selector, *args, *kwargs - ) - def on_side_arrow_press(self, side: Side): if not self.has_selected_elements: return diff --git a/tilia/ui/timelines/base/context_menus.py b/tilia/ui/timelines/base/context_menus.py index 22ede7cc7..03f97cd8a 100644 --- a/tilia/ui/timelines/base/context_menus.py +++ b/tilia/ui/timelines/base/context_menus.py @@ -1,55 +1,39 @@ from PyQt6.QtGui import QAction -from tilia.ui.actions import TiliaAction + +from tilia.ui import commands from tilia.ui.menus import MenuItemKind, TiliaMenu -from tilia.requests import get, Get, serve, post, Post +from tilia.requests import get, Get class TimelineUIContextMenu(TiliaMenu): title = "Timeline" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_NAME_SET), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_HEIGHT_SET), + (MenuItemKind.COMMAND, "timeline.set_name"), + (MenuItemKind.COMMAND, "timeline.set_height"), ] def __init__(self, timeline_ui): super().__init__() self.timeline_ui = timeline_ui - self.timeline_uis_to_permute = None - self._setup_requests() self._add_timeline_actions() - def _setup_requests(self): - SERVES = { - ( - Get.CONTEXT_MENU_TIMELINE_UIS_TO_PERMUTE, - self.get_timeline_uis_to_permute, - ), - (Get.CONTEXT_MENU_TIMELINE_UI, self.get_timeline_ui_for_selector), - } - - for request, callback in SERVES: - serve(self, request, callback) - def _add_timeline_actions(self): self.addSeparator() self.check_move_up() self.check_move_down() - self.add_delete_timeline() - - def get_timeline_uis_to_permute(self): - return self.timeline_uis_to_permute + self.add_default_actions() def get_timeline_ui_for_selector(self): return [self.timeline_ui] def check_move_up(self): def on_move_up(): - self.timeline_uis_to_permute = ( + commands.execute( + "timelines.permute_ordinal", self.timeline_ui, indices_to_timelines[current_index - 1], ) - post(Post.TIMELINE_ORDINAL_INCREASE_FROM_CONTEXT_MENU) current_index = self.timeline_ui.get_data("ordinal") indices_to_timelines = { @@ -62,11 +46,11 @@ def on_move_up(): def check_move_down(self): def on_move_down(): - self.timeline_uis_to_permute = ( + commands.execute( + "timelines.permute_ordinal", self.timeline_ui, indices_to_timelines[current_index + 1], ) - post(Post.TIMELINE_ORDINAL_DECREASE_FROM_CONTEXT_MENU) current_index = self.timeline_ui.get_data("ordinal") indices_to_timelines = { @@ -77,13 +61,25 @@ def on_move_down(): move_down.triggered.connect(on_move_down) self.addAction(move_down) - def add_delete_timeline(self): + def add_default_actions(self): + # I wasn't able to make this work with functools.partial, + # so I'm defining these functions. + def on_delete_timeline(): + commands.execute("timeline.delete", self.timeline_ui) + + def on_clear_timeline(): + commands.execute("timeline.clear", self.timeline_ui) + + self.addSeparator() + delete_timeline = QAction("Delete", self) - delete_timeline.triggered.connect( - lambda: post(Post.TIMELINE_DELETE_FROM_CONTEXT_MENU) - ) + delete_timeline.triggered.connect(on_delete_timeline) self.addAction(delete_timeline) + clear_timeline = QAction("Clear", self) + clear_timeline.triggered.connect(on_clear_timeline) + self.addAction(clear_timeline) + class TimelineUIElementContextMenu(TiliaMenu): title = "TimelineElement" diff --git a/tilia/ui/timelines/base/request_handlers.py b/tilia/ui/timelines/base/request_handlers.py deleted file mode 100644 index 5e323cef8..000000000 --- a/tilia/ui/timelines/base/request_handlers.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import functools -from typing import Callable, Iterator - -from tilia.requests import Post, get, Get, post -from tilia.ui.enums import PasteCardinality -from tilia.ui.timelines.base.element import TimelineUIElement -from tilia.ui.request_handler import RequestHandler -from tilia.ui.timelines.copy_paste import get_copy_data_from_element - - -class TimelineRequestHandler(RequestHandler): - def __init__(self, timeline_ui, request_to_callback: dict[Post, Callable]): - base_request_to_callback = { - Post.TIMELINE_DELETE_FROM_MANAGE_TIMELINES: self.on_timeline_delete, - Post.TIMELINE_CLEAR_FROM_MANAGE_TIMELINES: self.on_timeline_clear, - Post.TIMELINE_DELETE_FROM_CLI: functools.partial( - self.on_timeline_delete, True - ), - Post.TIMELINE_NAME_SET: functools.partial( - self.on_timeline_data_set, "name" - ), - Post.TIMELINE_HEIGHT_SET: functools.partial( - self.on_timeline_data_set, "height" - ), - Post.TIMELINE_IS_VISIBLE_SET_FROM_MANAGE_TIMELINES: functools.partial( - self.on_timeline_data_set, "is_visible" - ), - Post.TIMELINE_ORDINAL_INCREASE_FROM_MANAGE_TIMELINES: self.on_timeline_ordinal_permute_from_manage_timelines, - Post.TIMELINE_ORDINAL_DECREASE_FROM_MANAGE_TIMELINES: self.on_timeline_ordinal_permute_from_manage_timelines, - Post.TIMELINE_DELETE_FROM_CONTEXT_MENU: self.on_timeline_delete, - Post.TIMELINE_ORDINAL_DECREASE_FROM_CONTEXT_MENU: self.on_timeline_ordinal_permute_from_context_menu, - Post.TIMELINE_ORDINAL_INCREASE_FROM_CONTEXT_MENU: self.on_timeline_ordinal_permute_from_context_menu, - } - super().__init__( - request_to_callback=request_to_callback | base_request_to_callback - ) - self.timeline_ui = timeline_ui - - @property - def timeline(self): - return get(Get.TIMELINE, self.timeline_ui.id) - - def on_timeline_data_set(self, attr, value, **_): - return get(Get.TIMELINE_COLLECTION).set_timeline_data( - self.timeline_ui.id, attr, value - ) - - def on_timeline_ordinal_permute_from_manage_timelines(self, id_to_ordinal): - return self.on_timeline_data_set("ordinal", id_to_ordinal[self.timeline_ui.id]) - - def on_timeline_ordinal_permute_from_context_menu(self, id_to_ordinal): - return self.on_timeline_data_set("ordinal", id_to_ordinal[self.timeline_ui.id]) - - def on_timeline_delete(self, confirmed): - if not confirmed: - return False - get(Get.TIMELINE_COLLECTION).delete_timeline(self.timeline_ui.timeline) - return True - - def on_timeline_clear(self, confirmed): - if not confirmed: - return False - get(Get.TIMELINE_COLLECTION).clear_timeline(self.timeline_ui.timeline) - return True - - -class ElementRequestHandler(RequestHandler): - def __init__(self, timeline_ui, request_to_callback: dict[Post, Callable]): - base_request_to_callback = {} - super().__init__( - request_to_callback=request_to_callback | base_request_to_callback - ) - self.timeline_ui = timeline_ui - - def on_request(self, request, selector, *args, **kwargs): - return self.request_to_callback[request]( - self.timeline_ui.get_elements_by_selector(selector), *args, **kwargs - ) - - @property - def timeline(self): - return get(Get.TIMELINE, self.timeline_ui.id) - - @staticmethod - def elements_to_components(elements: Iterator[TimelineUIElement]): - return [e.tl_component for e in elements] - - def on_copy(self, elements: list[TimelineUIElement]): - component_data = [ - get_copy_data_from_element(e, e.DEFAULT_COPY_ATTRIBUTES) for e in elements - ] - - if not component_data: - return False - - post( - Post.TIMELINE_ELEMENT_COPY_DONE, - {"components": component_data, "timeline_kind": self.timeline.KIND}, - ) - return True - - def on_paste(self, *_, **__): - clipboard_contents = get(Get.CLIPBOARD_CONTENTS) - components = clipboard_contents["components"] - cardinality = ( - PasteCardinality.MULTIPLE - if len(clipboard_contents["components"]) > 1 - else PasteCardinality.SINGLE - ) - if self.timeline_ui.has_selected_elements: - if cardinality == PasteCardinality.SINGLE and hasattr( - self.timeline_ui, "paste_single_into_selected_elements" - ): - self.timeline_ui.paste_single_into_selected_elements(components) - elif cardinality == PasteCardinality.MULTIPLE and hasattr( - self.timeline_ui, "paste_multiple_into_selected_elements" - ): - self.timeline_ui.paste_multiple_into_selected_elements(components) - else: - if cardinality == PasteCardinality.SINGLE and hasattr( - self.timeline_ui, "paste_single_into_timeline" - ): - self.timeline_ui.paste_single_into_timeline(components) - elif cardinality == PasteCardinality.MULTIPLE and hasattr( - self.timeline_ui, "paste_multiple_into_timeline" - ): - self.timeline_ui.paste_multiple_into_timeline(components) - - return True diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index 316a5791b..7a41ed273 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -9,6 +9,7 @@ TYPE_CHECKING, TypeVar, Optional, + Callable, ) from PyQt6.QtCore import Qt, QPoint @@ -26,19 +27,39 @@ from tilia.ui.timelines.copy_paste import ( CopyAttributes, get_copy_data_from_elements, + get_copy_data_from_element, ) -from .request_handlers import TimelineRequestHandler -from ..collection.requests.enums import ElementSelector +from tilia.ui import commands from ..view import TimelineView from ...coords import time_x_converter from ...windows import WindowKind if TYPE_CHECKING: - from tilia.ui.timelines.collection.collection import TimelineUIs + from tilia.ui.timelines.collection.collection import TimelineUIs, TimelineSelector T = TypeVar("T", bound="TimelineUIElement") +def with_elements(func: Callable) -> Callable: + """Decorator to handle element selection for timeline commands. + + If no elements are provided, uses the timeline's selected elements. + If no elements are selected, returns False. + """ + + @functools.wraps(func) + def wrapper( + self, elements: list[TimelineUIElement] | None = None, *args: Any, **kwargs: Any + ) -> Any: + if not elements: + if not self.selected_elements: + return False + elements = self.selected_elements + return func(self, elements, *args, **kwargs) + + return wrapper + + class TimelineUI(ABC): TIMELINE_KIND = None TOOLBAR_CLASS = None @@ -139,6 +160,38 @@ def playback_line(self): def displayed_name(self): return self.scene.text.toPlainText() + @classmethod + def register_timeline_command( + cls, + collection: TimelineUIs, + name: str, + callback: Callable, + selector: TimelineSelector, + *args, + **kwargs, + ): + """ + Register a command named "timeline.{timeline_kind}.{name}" for this timeline kind + with TimelineUIs.on_timeline_command() as a wrapper for the callback. + """ + kind_shortname = cls.TIMELINE_KIND.name.lower().replace("_timeline", "") + + commands.register( + f"timeline.{kind_shortname}.{name}", + functools.partial( + collection.on_timeline_command, cls.TIMELINE_KIND, callback, selector + ), + *args, + **kwargs, + ) + + @classmethod + def register_commands(cls, collection: TimelineUIs): + """ + Override to register commands for this timeline kind. + This will be called by TimelineUIs.register_commands() on launch. + """ + def get_data(self, attr: str): return self.timeline.get_data(attr) @@ -197,15 +250,6 @@ def get_element(self, id: int) -> T: def get_elements_by_attr(self, attr: str, value: Any) -> list[T]: return [el for el in self.elements if getattr(el, attr) == value] - def get_elements_by_selector(self, selector: ElementSelector): - selector_to_elements = { - ElementSelector.ALL: self.elements.copy(), - ElementSelector.SELECTED: self.selected_elements.copy(), - ElementSelector.NONE: None, - } - - return selector_to_elements[selector] - @staticmethod def set_elements_attr(elements: list[T], attr: str, value: Any): for elm in elements: @@ -220,9 +264,6 @@ def get_component_ui(self, component: TimelineComponent): def _setup_collection_requests(self): self.request_to_callback = {} - def on_timeline_request(self, request, *args, **kwargs): - return TimelineRequestHandler(self, {}).on_request(request, *args, **kwargs) - def on_timeline_component_created( self, kind: ComponentKind, id: int, get_data, set_data ): @@ -529,3 +570,44 @@ def __str__(self): def update_element_order(self, element: T): self.element_manager.update_element_order(element) + + @staticmethod + def elements_to_components( + elements: list[TimelineUIElement], + ) -> list[TimelineComponent]: + return [e.tl_component for e in elements] + + @with_elements + def on_delete_component(self, elements: list[TimelineUIElement]) -> bool: + self.timeline.delete_components(self.elements_to_components(elements)) + return True + + @with_elements + def on_copy_element(self, elements: list[TimelineUIElement]) -> bool: + component_data = [ + get_copy_data_from_element(e, e.DEFAULT_COPY_ATTRIBUTES) for e in elements + ] + + if not component_data: + return False + + post( + Post.TIMELINE_ELEMENT_COPY_DONE, + {"components": component_data, "timeline_kind": self.timeline.KIND}, + ) + return True + + def on_paste_element(self, clipboard_contents: dict): + components = clipboard_contents["components"] + cardinality = "multiple" if len(components) > 1 else "single" + + if self.has_selected_elements: + method_name = f"paste_{cardinality}_into_selected_elements" + else: + method_name = f"paste_{cardinality}_into_timeline" + + if hasattr(self, method_name): + getattr(self, method_name)(components) + return True + else: + return False diff --git a/tilia/ui/timelines/beat/context_menu.py b/tilia/ui/timelines/beat/context_menu.py index cafff742b..ec6ca6a9a 100644 --- a/tilia/ui/timelines/beat/context_menu.py +++ b/tilia/ui/timelines/beat/context_menu.py @@ -1,4 +1,3 @@ -from tilia.ui.actions import TiliaAction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import ( TimelineUIElementContextMenu, @@ -9,20 +8,20 @@ class BeatContextMenu(TimelineUIElementContextMenu): name = "Beat" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_INSPECT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.BEAT_SET_MEASURE_NUMBER), - (MenuItemKind.ACTION, TiliaAction.BEAT_RESET_MEASURE_NUMBER), - (MenuItemKind.ACTION, TiliaAction.BEAT_DISTRIBUTE), - (MenuItemKind.ACTION, TiliaAction.BEAT_SET_AMOUNT_IN_MEASURE), + (MenuItemKind.COMMAND, "timeline.beat.set_measure_number"), + (MenuItemKind.COMMAND, "timeline.beat.reset_measure_number"), + (MenuItemKind.COMMAND, "timeline.beat.distribute"), + (MenuItemKind.COMMAND, "timeline.beat.set_amount_in_measure"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_DELETE), + (MenuItemKind.COMMAND, "timeline.component.delete"), ] class BeatTimelineUIContextMenu(TimelineUIContextMenu): name = "Beat timeline" - items = [(MenuItemKind, TiliaAction.TIMELINE_NAME_SET)] + items = [(MenuItemKind, "timeline.set_name")] diff --git a/tilia/ui/timelines/beat/request_handlers.py b/tilia/ui/timelines/beat/request_handlers.py deleted file mode 100644 index 4e077eabc..000000000 --- a/tilia/ui/timelines/beat/request_handlers.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from tilia.requests import Post, get, Get -from tilia.timelines.component_kinds import ComponentKind -from tilia.ui.timelines.base.request_handlers import ElementRequestHandler - - -if TYPE_CHECKING: - from tilia.ui.timelines.beat import BeatTimelineUI - - -class BeatUIRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui: BeatTimelineUI): - super().__init__( - timeline_ui, - { - Post.BEAT_ADD: self.on_add, - Post.BEAT_SET_MEASURE_NUMBER: self.on_set_measure_number, - Post.BEAT_RESET_MEASURE_NUMBER: self.on_reset_measure_number, - Post.BEAT_DISTRIBUTE: self.on_distribute, - Post.BEAT_SET_AMOUNT_IN_MEASURE: self.on_set_amount_in_measure, - Post.TIMELINE_ELEMENT_DELETE: self.on_delete, - Post.TIMELINE_ELEMENT_COPY: self.on_copy, - Post.TIMELINE_ELEMENT_PASTE: self.on_paste, - }, - ) - - def on_add(self, *_, **__): - self.timeline_ui: BeatTimelineUI - component, _ = self.timeline.create_component( - ComponentKind.BEAT, get(Get.SELECTED_TIME) - ) - self.timeline.recalculate_measures() - return False if component is None else True - - def on_delete(self, elements, *_, **__): - self.timeline.delete_components( - [self.timeline.get_component(e.id) for e in elements] - ) - self.timeline.recalculate_measures() - return True - - def _get_measure_indices(self, elements): - measure_indices = set() - for e in elements: - beat_index = self.timeline.get_beat_index(self.timeline.get_component(e.id)) - measure_index, _ = self.timeline.get_measure_index(beat_index) - measure_indices.add(measure_index) - - return sorted(list(measure_indices)) - - def on_set_measure_number(self, elements): - accepted, number = get( - Get.FROM_USER_INT, - "Change measure number", - "Insert measure number", - min=0, - ) - if not accepted: - return - for i in reversed(self._get_measure_indices(elements)): - self.timeline.set_measure_number(i, number) - return True - - def on_reset_measure_number(self, elements): - for i in reversed(self._get_measure_indices(elements)): - self.timeline.reset_measure_number(i) - return True - - def on_distribute(self, elements): - for i in self._get_measure_indices(elements): - self.timeline.distribute_beats(i) - return True - - def on_set_amount_in_measure(self, elements): - accepted, amount = get( - Get.FROM_USER_INT, - "Change beats in measure", - "Insert amount of beats in measure", - min=1, - ) - if not accepted: - return - for i in reversed(self._get_measure_indices(elements)): - self.timeline.set_beat_amount_in_measure(i, amount) - return True diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 08a873072..9be25d8bb 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -1,19 +1,26 @@ -from __future__ import annotations import copy from tilia.requests import get, Get, Post, listen from tilia.enums import Side from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.timelines.base.timeline import TimelineUI +from tilia.ui import commands +from tilia.ui.strings import ( + BEAT_TIMELINE_FILL_TITLE, + BEAT_TIMELINE_DELETE_EXISTING_BEATS_PROMPT, +) +from tilia.ui.timelines.base.timeline import TimelineUI, with_elements from tilia.ui.timelines.beat.context_menu import BeatTimelineUIContextMenu from tilia.ui.timelines.beat.element import BeatUI -from tilia.ui.timelines.beat.request_handlers import BeatUIRequestHandler from tilia.ui.timelines.beat.toolbar import BeatTimelineToolbar -from tilia.ui.timelines.collection.requests.enums import ElementSelector from tilia.ui.timelines.copy_paste import ( get_copy_data_from_element, ) +from tilia.ui.timelines.collection.collection import ( + TimelineUIs, + TimelineSelector, + command_callback, +) class BeatTimelineUI(TimelineUI): @@ -38,6 +45,144 @@ def __init__(self, *args, **kwargs): self.on_settings_updated, ) + @classmethod + def register_commands(cls, collection: TimelineUIs): + commands.register( + "timeline.beat.fill", cls.on_beat_timeline_fill, "&Fill with beats" + ) + + cls.register_timeline_command( + collection, + "add", + cls.on_add, + TimelineSelector.FIRST, + text="Add beat at current position", + shortcut="b", + icon="beat_add", + ) + + cls.register_timeline_command( + collection, + "set_measure_number", + cls.on_set_measure_number, + TimelineSelector.SELECTED, + text="Set measure number", + icon="beat_set_number", + ) + + cls.register_timeline_command( + collection, + "reset_measure_number", + cls.on_reset_measure_number, + TimelineSelector.SELECTED, + text="Reset measure number", + icon="beat_reset_number", + ) + + cls.register_timeline_command( + collection, + "distribute", + cls.on_distribute, + TimelineSelector.SELECTED, + text="Distribute", + icon="beat_distribute", + ) + + cls.register_timeline_command( + collection, + "set_amount_in_measure", + cls.on_set_amount_in_measure, + TimelineSelector.SELECTED, + text="Set amount in measure", + ) + + def _get_measure_indices(self, elements: list[BeatUI]): + measure_indices = set() + for e in elements: + beat_index = self.timeline.get_beat_index(self.timeline.get_component(e.id)) + measure_index, _ = self.timeline.get_measure_index(beat_index) + measure_indices.add(measure_index) + + 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( + Get.FROM_USER_INT, + "Change measure number", + "Insert measure number", + min=0, + ) + if not accepted: + return False + + for i in reversed(self._get_measure_indices(elements)): + self.timeline.set_measure_number(i, number) + return True + + @with_elements + def on_reset_measure_number(self, elements: list[BeatUI] | None = None): + for i in reversed(self._get_measure_indices(elements)): + self.timeline.reset_measure_number(i) + return True + + @with_elements + def on_distribute(self, elements: list[BeatUI] | None = None): + for i in self._get_measure_indices(elements): + self.timeline.distribute_beats(i) + return True + + @with_elements + def on_set_amount_in_measure(self, elements: list[BeatUI] | None = None): + accepted, amount = get( + Get.FROM_USER_INT, + "Change beats in measure", + "Insert amount of beats in measure", + min=1, + ) + if not accepted: + return False + for i in reversed(self._get_measure_indices(elements)): + self.timeline.set_beat_amount_in_measure(i, amount) + return True + + def on_add(self, *_, **__): + self.timeline_ui: BeatTimelineUI + component, _ = self.timeline.create_component( + ComponentKind.BEAT, get(Get.SELECTED_TIME) + ) + self.timeline.recalculate_measures() + return False if component is None else True + + @staticmethod + @command_callback + def on_beat_timeline_fill(): + accepted, result = get(Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD) + if not accepted: + return False + + timeline, method, value = result + + if not timeline.is_empty: + confirmed = get( + Get.FROM_USER_YES_OR_NO, + BEAT_TIMELINE_FILL_TITLE, + BEAT_TIMELINE_DELETE_EXISTING_BEATS_PROMPT, + ) + if not confirmed: + return False + timeline.clear() + + timeline.fill_with_beats(method, value) + + return True + def on_timeline_components_deserialized(self): for beat_ui in self: beat_ui.update_label() @@ -52,11 +197,6 @@ def on_settings_updated(self, updated_settings): for beat_ui in self: beat_ui.update_label() - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return BeatUIRequestHandler(self).on_request(request, selector, *args, **kwargs) - def _deselect_all_but_last(self): if len(self.selected_elements) > 1: for element in self.selected_elements[:-1]: diff --git a/tilia/ui/timelines/beat/toolbar.py b/tilia/ui/timelines/beat/toolbar.py index 037df119e..918def7c0 100644 --- a/tilia/ui/timelines/beat/toolbar.py +++ b/tilia/ui/timelines/beat/toolbar.py @@ -1,13 +1,12 @@ from __future__ import annotations -from tilia.ui.actions import TiliaAction from tilia.ui.timelines.toolbar import TimelineToolbar class BeatTimelineToolbar(TimelineToolbar): - ACTIONS = [ - TiliaAction.BEAT_ADD, - TiliaAction.BEAT_DISTRIBUTE, - TiliaAction.BEAT_SET_MEASURE_NUMBER, - TiliaAction.BEAT_RESET_MEASURE_NUMBER, + COMMANDS = [ + "timeline.beat.add", + "timeline.beat.distribute", + "timeline.beat.set_measure_number", + "timeline.beat.reset_measure_number", ] diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index dafcc916d..5f6e50801 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -2,6 +2,7 @@ import functools import traceback +from enum import Enum, auto from typing import Any, Optional, Callable, cast from PyQt6.QtCore import Qt, QPoint @@ -14,45 +15,47 @@ import tilia import tilia.errors -import tilia.ui.timelines.collection.request_handler -import tilia.ui.timelines.collection.requests.timeline_uis -import tilia.ui.timelines.collection.requests.args -import tilia.ui.timelines.collection.requests.enums -from tilia.ui import actions +from tilia.ui.timelines.collection.import_ import _on_import_to_timeline +from tilia.ui import commands from tilia.settings import settings from tilia.media.player.base import MediaTimeChangeReason -from tilia.timelines import timeline_kinds from tilia.timelines.component_kinds import ComponentKind from .scene import TimelineUIsScene -from .validators import validate -from tilia.exceptions import UserCancelledDialog from tilia.log import logger from tilia.requests import get, Get, serve from tilia.requests import listen, Post, post -from tilia.timelines.base.timeline import Timeline -from tilia.timelines.timeline_kinds import TimelineKind as TlKind, TimelineKind +from tilia.timelines.base.timeline import Timeline, TimelineFlag +from tilia.timelines.timeline_kinds import ( + TimelineKind as TlKind, + TimelineKind, + get_timeline_name, +) from tilia.ui.coords import time_x_converter from tilia.ui.dialogs.choose import ChooseDialog from tilia.ui.enums import ScrollType from tilia.ui.player import PlayerToolbarElement from tilia.ui.smooth_scroll import setup_smooth, smooth from tilia.ui.timelines.base.element_manager import ElementManager -from tilia.ui.timelines.base.timeline import TimelineUI +from tilia.ui.timelines.base.timeline import TimelineUI, with_elements from tilia.ui.timelines.scene import TimelineScene from tilia.ui.timelines.toolbar import TimelineToolbar from tilia.ui.timelines.view import TimelineView -from tilia.ui.timelines.collection.requests.timeline import ( - TimelineSelector, - TlRequestSelector, -) -from tilia.ui.timelines.collection.requests.element import TlElmRequestSelector from .view import TimelineUIsView from ..base.element import TimelineUIElement -from ..beat import BeatTimelineUI -from ..hierarchy import HierarchyTimelineUI from ..selection_box import SelectionBoxQt from ..slider.timeline import SliderTimelineUI -from ...actions import TiliaAction +from ...dialogs.add_timeline_without_media import AddTimelineWithoutMedia + + +def command_callback(func, *args, **kwargs): + @functools.wraps(func) + def wrapper(*args, **kwargs): + success = func(*args, **kwargs) + + if success: + post(Post.APP_STATE_RECORD, f"timelines command: {func.__name__}") + + return wrapper class TimelineUIs: @@ -65,7 +68,9 @@ def __init__( ): self.main_window = main_window - self.kind_to_toolbar = {kind: None for kind in timeline_kinds.NOT_SLIDER} + self.kind_to_toolbar = { + kind: None for kind in TimelineKind if kind != TlKind.SLIDER_TIMELINE + } self._timeline_uis = set() self._select_order = [] @@ -74,6 +79,7 @@ def __init__( self._setup_widgets(main_window) self._setup_requests() + self._setup_commands() self._setup_selection_box() self._setup_drag_tracking_vars() @@ -118,6 +124,274 @@ def _setup_widgets(self, main_window: QMainWindow): self.view.setScene(self.scene) main_window.setCentralWidget(self.view) + def _setup_commands(self): + for cls in TimelineUI.subclasses(): + kind = cls.TIMELINE_KIND + name = get_timeline_name(kind) + if kind != TlKind.SLIDER_TIMELINE: + if kind == TlKind.HARMONY_TIMELINE: + text = name[0].upper() + "&" + name[1:] + elif kind == TlKind.PDF_TIMELINE: + text = "&" + name.upper() + else: + text = "&" + name.capitalize() + + commands.register( + f"timelines.add.{name}", + functools.partial(self.on_timeline_add, kind), + text, + ) + + if kind in Timeline.get_kinds_by_flag(TimelineFlag.COMPONENTS_IMPORTABLE): + if kind == TimelineKind.SCORE_TIMELINE: + text = "&Import from MusicXML" + else: + text = "&Import from CSV file" + + commands.register( + f"timelines.import.{name}", + functools.partial(self.on_import_to_timeline, kind), + text, + ) + + if hasattr(cls, "register_commands"): + cls.register_commands(self) + + # Commands for individual timelines + commands.register( + "timelines.permute_ordinal", + self.on_timeline_ordinal_permute, + ) + + commands.register("timeline.delete", self.on_timeline_delete, "Delete") + + commands.register("timeline.clear", self.on_timeline_clear, "Clear") + + commands.register( + "timeline.set_name", + self.on_timeline_set_name, + "Set name", + ) + + commands.register( + "timeline.set_height", + self.on_timeline_set_height, + "Set height", + ) + + commands.register( + "timeline.set_is_visible", + self.on_timeline_set_is_visible, + ) + + commands.register( + "timeline.component.copy", + self.on_timeline_copy_element, + text="&Copy", + shortcut="Ctrl+C", + ) + + commands.register( + "timeline.component.paste", + self.on_timeline_paste_element, + text="&Paste", + shortcut="Ctrl+V", + ) + + commands.register( + "timeline.component.paste_complete", + functools.partial(self.on_timeline_paste_element, type="complete"), + text="Pas&te complete", + shortcut="Ctrl+Shift+V", + icon="paste_with_data30", + ) + + commands.register( + "timeline.component.delete", + self.on_timeline_delete_element, + text="Delete", + ) + + commands.register( + "timeline.component.set_color", + self.on_timeline_set_component_color, + "Set color", + "", + "", + ) + # + commands.register( + "timeline.component.reset_color", + self.on_timeline_reset_component_color, + "Reset color", + "", + "", + ) + + # Commands for all timelines + commands.register("timelines.clear_all", self.on_timelines_clear, "Clear all") + + # Commands for timeline view + commands.register( + "view.zoom.in", + functools.partial(self.on_zoom, "in"), + "Zoom &In", + "Ctrl++", + ) + + commands.register( + "view.zoom.out", + functools.partial(self.on_zoom, "out"), + "Zoom &Out", + "Ctrl+-", + ) + + def on_timeline_command( + self, + kind: TimelineKind | list[TimelineKind], + callback: Callable | str, + selector: TimelineSelector, + *args, + **kwargs, + ): + """ + To be used as a wrapper for a timeline command. + Displays errors to the user and attempts to recover from them. + Records state so the user can undo/redo the command. + """ + + if isinstance(kind, TimelineKind): + kind = [kind] + + # Passing method names is allowed to enable calling methods overriden by TimelineUI subclasses, + # as we wouldn't be able to pass a different callback for each subclass. + method_name = callback if isinstance(callback, str) else None + + timeline_uis = self.get_timelines_uis_for_request(kind, selector) + state_backup = get(Get.APP_STATE) + result = [] + + try: + for tlui in timeline_uis: + if method_name: + # Use type(tlui) to get an unbound method, since we will pass tlui explicitly. + callback = getattr(type(tlui), method_name) + result.append(callback(tlui, *args, **kwargs)) + except Exception: + tilia.errors.display( + tilia.errors.COMMAND_FAILED, + f"Timeline command callback '{callback.__name__}'", + traceback.format_exc(), + ) + post(Post.APP_STATE_RECOVER, state_backup) + return + + success = self.validate_command_return_value( + method_name or callback.__name__, result + ) + + if success: + post( + Post.APP_STATE_RECORD, + f"timeline command: {method_name or callback.__name__}", + ) + + @command_callback + def on_timelines_clear(self): + timeline_collection = get(Get.TIMELINE_COLLECTION) + + if timeline_collection.is_empty: + # There are no timelines. + return False + + clear_needed = False + for tl in timeline_collection: + if (TimelineFlag.NOT_CLEARABLE not in tl.FLAGS) and not tl.is_empty: + # At least one clearable, non-empty timeline is required + # for clearing to make sense. + clear_needed = True + break + + if not clear_needed: + return False + + confirmed = get( + Get.FROM_USER_YES_OR_NO, + "Clear timelines", + "Are you sure you want to clear ALL timelines? This can be undone later.", + ) + + if confirmed: + get(Get.TIMELINE_COLLECTION).clear_timelines() + return True + return False + + def on_timeline_add(self, kind: TimelineKind, name: str | None = None): + def _get_media_is_loaded(): + if get(Get.MEDIA_DURATION) == 0: + return False + return True + + def _get_timeline_name(): + accepted, name = get( + Get.FROM_USER_STRING, + title="New timeline", + prompt="Choose name for new timeline", + ) + + return accepted, name + + if not _get_media_is_loaded() and not self._handle_media_not_loaded(): + return False + + if name is None: + accepted, name = _get_timeline_name() + if not accepted: + return False + + kwargs = dict() + cls = self.get_timeline_ui_class(kind) + if hasattr(cls, "get_additional_args_for_creation"): + success, additional_args = cls.get_additional_args_for_creation() + if not success: + return False + kwargs |= additional_args + + get(Get.TIMELINE_COLLECTION).create_timeline( + kind=kind, components=None, name=name, **kwargs + ) + + post(Post.APP_STATE_RECORD, f"timelines command: timeline add {name}") + + @staticmethod + def _handle_media_not_loaded() -> bool: + accept, action_to_take = get(Get.FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA) + if not accept: + return False + + if action_to_take == AddTimelineWithoutMedia.Result.SET_DURATION: + success, duration = get( + Get.FROM_USER_FLOAT, + "Set duration", + "Insert duration (s)", + value=60, + min=1, + ) + if not success: + return False + post(Post.PLAYER_DURATION_AVAILABLE, duration) + elif action_to_take == AddTimelineWithoutMedia.Result.LOAD_MEDIA: + success, path = get(Get.FROM_USER_MEDIA_PATH) + if not success: + return False + post(Post.APP_MEDIA_LOAD, path) + else: + raise ValueError( + f"Unknown action to take '{action_to_take}' for timeline without media." + ) + + return True + def _setup_requests(self): LISTENS = { (Post.TIMELINE_CREATE_DONE, self.on_timeline_created), @@ -180,12 +454,16 @@ def _setup_requests(self): (Post.LOOP_IGNORE_COMPONENT, self.on_loop_ignore_delete), (Post.PLAYER_CANCEL_LOOP, self.on_loop_cancel), (Post.PLAYER_TOGGLE_LOOP, self.on_loop_toggle), - (Post.EDIT_REDO, self.loop_cancel), - (Post.EDIT_UNDO, self.loop_cancel), + (Post.APP_STATE_UNDO_OR_REDO_DONE, self.on_loop_cancel), ( Post.BEAT_TIMELINE_COMPONENTS_DESERIALIZED, self.on_beat_timeline_components_deserialized, ), + (Post.IMPORT_CSV, self.on_import_to_timeline), + ( + Post.IMPORT_MUSICXML, + functools.partial(self.on_import_to_timeline, TlKind.SCORE_TIMELINE), + ), } SERVES = { @@ -209,31 +487,6 @@ def _setup_requests(self): for request, callback in SERVES: serve(self, request, callback) - for request in tilia.ui.timelines.collection.requests.timeline_uis.requests: - listen( - self, request, functools.partial(self.on_timeline_ui_request, request) - ) - - for ( - request, - selector, - ) in tilia.ui.timelines.collection.requests.element.request_to_scope.items(): - listen( - self, - request, - functools.partial(self.on_timeline_element_request, request, selector), - ) - - for ( - request, - selector, - ) in tilia.ui.timelines.collection.requests.timeline.request_to_scope.items(): - listen( - self, - request, - functools.partial(self.on_timeline_request, request, selector), - ) - def create_timeline_ui(self, kind: TlKind, id: int) -> TimelineUI: timeline_class = self.get_timeline_ui_class(kind) w = get(Get.TIMELINE_WIDTH) @@ -633,14 +886,14 @@ def on_arrow_press(self, arrow: str): tlui.on_vertical_arrow_press(arrow) def on_beat_timeline_measure_number_change_done(self, id: int, start_index: int): + from tilia.ui.timelines.beat import BeatTimelineUI + timeline_ui = cast(BeatTimelineUI, self.get_timeline_ui(id)) timeline_ui.on_measure_number_change_done(start_index) @staticmethod def on_hierarchy_selected(): - actions.get_qaction(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE).setVisible( - True - ) + commands.get_qaction("timeline.component.paste_complete").setVisible(True) def on_hierarchy_deselected(self): selected_hierarchies = [] @@ -648,9 +901,7 @@ def on_hierarchy_deselected(self): if tlui.TIMELINE_KIND == TlKind.HIERARCHY_TIMELINE: selected_hierarchies += tlui.selected_elements if not selected_hierarchies: - actions.get_qaction(TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE).setVisible( - False - ) + commands.get_qaction("timeline.component.paste_complete").setVisible(False) def on_hierarchy_merge_split(self, new_units: list, old_units: list): if ( @@ -666,6 +917,8 @@ def on_harmony_timeline_components_deserialized(self, id): self.get_timeline_ui(id).on_timeline_components_deserialized() # noqa def on_beat_timeline_components_deserialized(self, id: int): + from tilia.ui.timelines.beat import BeatTimelineUI + timeline_ui = cast(BeatTimelineUI, self.get_timeline_ui(id)) timeline_ui.on_timeline_components_deserialized() @@ -814,39 +1067,8 @@ def update_loop_elements_ui(self, is_looping: bool) -> None: if not is_looping: self.loop_elements.clear() - def pre_process_timeline_request( - self, - request: Post, - kinds: list[TlKind], - selector: tilia.ui.timelines.collection.requests.timeline.TimelineSelector, - ): - timeline_uis = self.get_timelines_uis_for_request(kinds, selector) - - try: - ( - args, - kwargs, - ) = tilia.ui.timelines.collection.requests.args.get_args_for_request( - request, timeline_uis - ) - except UserCancelledDialog: - return None, None, None, False - - if not validate(request, timeline_uis, *args, **kwargs): - return None, None, None, False - - return timeline_uis, args, kwargs, True - - @staticmethod - def pre_process_timeline_uis_request(request, *args, **kwargs): - args, kwargs = tilia.ui.timelines.collection.requests.args.get_args_for_request( - request, [], *args, **kwargs - ) - - return args, kwargs, True - @staticmethod - def validate_request_return_value(request: Post, success: Any): + def validate_command_return_value(request: Post, success: Any): if isinstance(success, list) and all(isinstance(s, bool) for s in success): valid = True else: @@ -854,117 +1076,11 @@ def validate_request_return_value(request: Post, success: Any): if not valid: logger.error( - f"Request {request} returned an invalid value: {success}.\n" + f"Command {request} returned an invalid value: {success}.\n" f"Return value should be a bool or a list of bools." ) - def on_timeline_element_request( - self, - request: Post, - selector: TlElmRequestSelector, - *args: tuple[Any], - **kwargs: dict[str, Any], - ) -> None: - ( - timeline_uis, - more_args, - more_kwargs, - success, - ) = self.pre_process_timeline_request( - request, selector.tl_kind, selector.timeline - ) - - if not success: - return - - args += more_args - kwargs |= more_kwargs - - state_backup = get(Get.APP_STATE) - result = [] - try: - for tlui in timeline_uis: - result.append( - tlui.on_timeline_element_request( - request, selector.element, *args, **kwargs - ) - ) - except Exception: - post(Post.APP_STATE_RECOVER, state_backup) - tilia.errors.display(tilia.errors.COMMAND_FAILED, traceback.format_exc()) - return - - self.validate_request_return_value(request, result) - - if any(result): - post(Post.APP_STATE_RECORD, f"timeline element request: {request.name}") - - def on_timeline_ui_request( - self, request: Post, *args: tuple[Any], **kwargs: dict[str, Any] - ): - more_args, more_kwargs, success = self.pre_process_timeline_uis_request( - request, *args, **kwargs - ) - args += more_args - kwargs |= more_kwargs - - if not success: - return - - state_backup = get(Get.APP_STATE) - try: - success = ( - tilia.ui.timelines.collection.request_handler.TimelineUIsRequestHandler( - self - ).on_request(request, *args, **kwargs) - ) - except Exception: - post(Post.APP_STATE_RECOVER, state_backup) - tilia.errors.display(tilia.errors.COMMAND_FAILED, traceback.format_exc()) - return - - self.validate_request_return_value(request, success) - - if success: - post(Post.APP_STATE_RECORD, f"timeline element request: {request.name}") - - def on_timeline_request( - self, - request: Post, - selector: TlRequestSelector, - *args: tuple[Any], - **kwargs: dict[str, Any], - ): - ( - timeline_uis, - more_args, - more_kwargs, - success, - ) = self.pre_process_timeline_request( - request, - selector.tl_kind, - selector.timeline, - ) - args += more_args - kwargs |= more_kwargs - - if not success: - return - - state_backup = get(Get.APP_STATE) - result = [] - try: - for tlui in timeline_uis: - result.append(tlui.on_timeline_request(request, *args, **kwargs)) - except Exception: - post(Post.APP_STATE_RECOVER, state_backup) - tilia.errors.display(tilia.errors.COMMAND_FAILED, traceback.format_exc()) - return - - self.validate_request_return_value(request, result) - - if request: - post(Post.APP_STATE_RECORD, f"timeline request: {request.name}") + return valid def get_timelines_uis_for_request( self, kinds: list[TlKind], selector: TimelineSelector @@ -995,28 +1111,12 @@ def filter_for_pasting(_) -> list[TimelineUI]: else: return filter_if_first_on_select_order(timeline_uis) - def filter_if_from_manage_timelines_to_permute(_): - return get(Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_TO_PERMUTE) - - def filter_if_from_manage_timelines_current(_): - return [get(Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_CURRENT)] - - def filter_if_from_context_menu(_): - return get(Get.CONTEXT_MENU_TIMELINE_UI) - - def filter_if_from_context_menu_to_permute(_): - return get(Get.CONTEXT_MENU_TIMELINE_UIS_TO_PERMUTE) - selector_to_func = { TimelineSelector.ALL: lambda x: x, TimelineSelector.FIRST: filter_if_first_on_select_order, TimelineSelector.SELECTED: filter_if_has_selected_elements, TimelineSelector.PASTE: filter_for_pasting, - TimelineSelector.FROM_MANAGE_TIMELINES_TO_PERMUTE: filter_if_from_manage_timelines_to_permute, - TimelineSelector.FROM_MANAGE_TIMELINES_CURRENT: filter_if_from_manage_timelines_current, TimelineSelector.ANY: filter_if_first_on_select_order, - TimelineSelector.FROM_CONTEXT_MENU: filter_if_from_context_menu, - TimelineSelector.FROM_CONTEXT_MENU_TO_PERMUTE: filter_if_from_context_menu_to_permute, } try: @@ -1024,7 +1124,186 @@ def filter_if_from_context_menu_to_permute(_): except KeyError: raise NotImplementedError(f"Can't select with {selector=}") - def on_zoom(self, is_zoom_in: bool, zoom_factor: float = ZOOM_FACTOR): + @command_callback + def on_timeline_ordinal_permute(self, tlui1: TimelineUI, tlui2: TimelineUI): + id_to_ordinal = { + tlui1.id: tlui2.get_data("ordinal"), + tlui2.id: tlui1.get_data("ordinal"), + } + for id, ordinal in id_to_ordinal.items(): + get(Get.TIMELINE_COLLECTION).set_timeline_data( + id, attr="ordinal", value=ordinal + ) + + return True + + @staticmethod + @command_callback + def on_timeline_delete(timeline_ui: TimelineUI): + confirmed = get( + Get.FROM_USER_YES_OR_NO, + "Delete timeline", + "Are you sure you want to delete the selected timeline? This can be undone later.", + ) + + if not confirmed: + return False + + get(Get.TIMELINE_COLLECTION).delete_timeline(timeline_ui.timeline) + + return True + + @staticmethod + @command_callback + def on_timeline_clear(timeline_ui: TimelineUI): + if timeline_ui.is_empty: + return False + + confirmed = get( + Get.FROM_USER_YES_OR_NO, + "Clear timeline", + "Are you sure you want to clear the selected timeline? This can be undone later.", + ) + if not confirmed: + return False + get(Get.TIMELINE_COLLECTION).clear_timeline(timeline_ui.timeline) + return True + + @staticmethod + def on_timeline_data_set(id: int, attr: str, value: Any) -> bool: + return get(Get.TIMELINE_COLLECTION).set_timeline_data(id, attr, value) + + @command_callback + def on_timeline_set_is_visible( + self, timeline_ui: TimelineUI, is_visible: bool + ) -> bool: + return self.on_timeline_data_set(timeline_ui.id, "is_visible", is_visible) + + @command_callback + def on_timeline_set_name(self, timeline_ui: TimelineUI, name: str | None = None): + if not name: + accepted, name = get( + Get.FROM_USER_STRING, + "Change timeline name", + "Choose new name", + text=get(Get.TIMELINE, timeline_ui.id).name, + ) + if not accepted: + return False + + return self.on_timeline_data_set(timeline_ui.id, "name", name) + + @command_callback + def on_timeline_set_height( + self, timeline_ui: TimelineUI, height: int | None = None + ): + if not height: + accepted, height = get( + Get.FROM_USER_INT, + "Change timeline height", + "Insert new timeline height", + value=timeline_ui.get_data("height"), + min=10, + ) + if not accepted: + return False + + return self.on_timeline_data_set(timeline_ui.id, "height", height) + + def on_timeline_set_component_color(self) -> None: + success, color = get(Get.FROM_USER_COLOR) + if not success or not color.isValid(): + return + + @with_elements + def set_components_color( + tlui: TimelineUI, elements: list[TimelineUIElement] + ) -> bool: + tlui.set_elements_attr(elements, "color", color.name()) + # This is a command callback, so we need to return a bool indicating success. + return True + + self.on_timeline_command( + Timeline.get_kinds_by_flag(TimelineFlag.COMPONENTS_COLORED), + set_components_color, + TimelineSelector.SELECTED, + ) + + def on_timeline_reset_component_color(self): + @with_elements + def reset_components_color( + tlui: TimelineUI, elements: list[TimelineUIElement] + ) -> bool: + tlui.set_elements_attr(elements, "color", None) + # This is a command callback, so we need to return a bool indicating success. + return True + + self.on_timeline_command( + Timeline.get_kinds_by_flag(TimelineFlag.COMPONENTS_COLORED), + reset_components_color, + TimelineSelector.SELECTED, + ) + + def on_timeline_delete_element(self): + def components_are_deletable(flags: list[TimelineFlag]): + return ( + TimelineFlag.COMPONENTS_NOT_DELETABLE not in flags + and TimelineFlag.COMPONENTS_NOT_EDITABLE not in flags + ) + + kinds = [ + cls.KIND + for cls in Timeline.__subclasses__() + if components_are_deletable(cls.FLAGS) + ] + + self.on_timeline_command( + kinds, + "on_delete_component", + TimelineSelector.SELECTED, + ) + + def on_timeline_copy_element(self): + kinds = Timeline.get_kinds_by_flag(TimelineFlag.COMPONENTS_COPYABLE) + timeline_uis = self.get_timelines_uis_for_request( + kinds, TimelineSelector.SELECTED + ) + + if len(timeline_uis) == 0: + # Can't copy: there are no selected elements. + return + elif len(timeline_uis) > 1: + tilia.errors.display( + tilia.errors.COMPONENTS_COPY_ERROR, + "Cannot copy components from more than one timeline.", + ) + return + + timeline_ui = timeline_uis[0] + timeline_ui.on_copy_element(timeline_ui.selected_elements) + + def on_timeline_paste_element(self, type: str = ""): + if type == "complete": + from tilia.ui.timelines.hierarchy import HierarchyTimelineUI + + self.on_timeline_command( + TlKind.HIERARCHY_TIMELINE, + HierarchyTimelineUI.on_paste_element_complete, + TimelineSelector.PASTE, + get(Get.CLIPBOARD_CONTENTS), + ) + else: + self.on_timeline_command( + Timeline.get_kinds_by_flag(TimelineFlag.COMPONENTS_COPYABLE), + TimelineUI.on_paste_element, + TimelineSelector.PASTE, + get(Get.CLIPBOARD_CONTENTS), + ) + + def on_zoom(self, direction: str, zoom_factor: float = ZOOM_FACTOR): + if direction not in ["in", "out"]: + return + prev_smooth_scroll = settings.get("general", "prioritise_performance") if not prev_smooth_scroll: settings.set("general", "prioritise_performance", True) @@ -1033,7 +1312,7 @@ def on_zoom(self, is_zoom_in: bool, zoom_factor: float = ZOOM_FACTOR): post( Post.PLAYBACK_AREA_SET_WIDTH, get(Get.PLAYBACK_AREA_WIDTH) - * (zoom_factor if is_zoom_in else 1 / zoom_factor), + * (zoom_factor if direction == "in" else 1 / zoom_factor), ) self.center_on_time(self.selected_time) self.view.setUpdatesEnabled(True) @@ -1041,6 +1320,24 @@ def on_zoom(self, is_zoom_in: bool, zoom_factor: float = ZOOM_FACTOR): if not prev_smooth_scroll: settings.set("general", "prioritise_performance", False) + def on_import_to_timeline(self, tl_kind: TlKind): + # Refactor later: merge this with _on_import_to_timeline() + prev_state = get(Get.APP_STATE) + status, errors = _on_import_to_timeline(self, tl_kind) + + if status == "failure": + post(Post.APP_STATE_RESTORE, prev_state) + if errors: + tilia.errors.display(tilia.errors.CSV_IMPORT_FAILED, "\n".join(errors)) + elif status == "success" and errors: + tilia.errors.display( + tilia.errors.CSV_IMPORT_SUCCESS_ERRORS, "\n".join(errors) + ) + post(Post.APP_STATE_RECORD, "Import from csv file") + + # Return for testing purposes. + return status, errors + def _auto_scroll(self, media_time_change_reason, time) -> None: if any( [ @@ -1225,3 +1522,12 @@ def on_timeline_created(self, kind: TlKind, id: int): def on_timeline_deleted(self, id: int): self.delete_timeline_ui(self.get_timeline_ui(id)) + + +class TimelineSelector(Enum): + ANY = auto() + EXPLICIT = auto() + SELECTED = auto() + ALL = auto() + FIRST = auto() + PASTE = auto() diff --git a/tilia/ui/ui_import.py b/tilia/ui/timelines/collection/import_.py similarity index 96% rename from tilia/ui/ui_import.py rename to tilia/ui/timelines/collection/import_.py index 6e0af693d..ca7ac2541 100644 --- a/tilia/ui/ui_import.py +++ b/tilia/ui/timelines/collection/import_.py @@ -1,4 +1,6 @@ -from typing import Literal +from __future__ import annotations + +from typing import Literal, TYPE_CHECKING import tilia.errors import tilia.parsers @@ -6,11 +8,13 @@ from tilia.timelines.timeline_kinds import TimelineKind as TlKind from tilia.ui.dialogs.by_time_or_by_measure import ByTimeOrByMeasure from tilia.ui.strings import UTF8_DECODE_FAILED -from tilia.ui.timelines.collection.collection import TimelineUIs from tilia.parsers import get_import_function +if TYPE_CHECKING: + from tilia.ui.timelines.collection.collection import TimelineUIs + -def on_import_to_timeline( +def _on_import_to_timeline( timeline_uis: TimelineUIs, tlkind: TlKind ) -> tuple[Literal["success", "failure", "cancelled"], list[str]]: if not _validate_timeline_kind_on_import(timeline_uis, tlkind): diff --git a/tilia/ui/timelines/collection/request_handler.py b/tilia/ui/timelines/collection/request_handler.py deleted file mode 100644 index 45d1fcd2a..000000000 --- a/tilia/ui/timelines/collection/request_handler.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -from tilia.requests import Post, get, Get, post -from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.dialogs.add_timeline_without_media import AddTimelineWithoutMedia -from tilia.ui.request_handler import RequestHandler -from tilia.ui.strings import ( - BEAT_TIMELINE_FILL_TITLE, - BEAT_TIMELINE_DELETE_EXISTING_BEATS_PROMPT, -) - - -def _get_media_is_loaded(): - if get(Get.MEDIA_DURATION) == 0: - return False - return True - - -def _get_timeline_name(): - accepted, name = get( - Get.FROM_USER_STRING, - title="New timeline", - prompt="Choose name for new timeline", - ) - - return accepted, name - - -class TimelineUIsRequestHandler(RequestHandler): - def __init__(self, timeline_uis): - super().__init__( - request_to_callback={ - Post.TIMELINE_ADD: self.on_timeline_add, - Post.TIMELINES_CLEAR: self.on_timelines_clear, - Post.BEAT_TIMELINE_FILL: self.on_beat_timeline_fill, - } - ) - self.timeline_uis = timeline_uis - self.timelines = get(Get.TIMELINE_COLLECTION) - - @staticmethod - def _handle_media_not_loaded() -> bool: - accept, action_to_take = get(Get.FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA) - if not accept: - return False - - if action_to_take == AddTimelineWithoutMedia.Result.SET_DURATION: - success, duration = get( - Get.FROM_USER_FLOAT, - "Set duration", - "Insert duration (s)", - value=60, - min=1, - ) - if not success: - return False - post(Post.PLAYER_DURATION_AVAILABLE, duration) - elif action_to_take == AddTimelineWithoutMedia.Result.LOAD_MEDIA: - success, path = get(Get.FROM_USER_MEDIA_PATH) - if not success: - return False - post(Post.APP_MEDIA_LOAD, path) - else: - raise ValueError( - f"Unknown action to take '{action_to_take}' for timeline without media." - ) - - return True - - def on_timeline_add(self, kind: TimelineKind): - if not _get_media_is_loaded() and not self._handle_media_not_loaded(): - return False - success, name = _get_timeline_name() - kwargs = dict() - if not success: - return False - cls = self.timeline_uis.get_timeline_ui_class(kind) - if hasattr(cls, "get_additional_args_for_creation"): - success, additional_args = cls.get_additional_args_for_creation() - if not success: - return False - kwargs |= additional_args - - self.timelines.create_timeline(kind=kind, components=None, name=name, **kwargs) - - return True - - def on_timelines_clear(self, confirmed): - if confirmed: - self.timelines.clear_timelines() - return True - return False - - @staticmethod - def on_beat_timeline_fill(): - accepted, result = get(Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD) - if not accepted: - return False - - timeline, method, value = result - - if not timeline.is_empty: - confirmed = get( - Get.FROM_USER_YES_OR_NO, - BEAT_TIMELINE_FILL_TITLE, - BEAT_TIMELINE_DELETE_EXISTING_BEATS_PROMPT, - ) - if not confirmed: - return False - timeline.clear() - - timeline.fill_with_beats(method, value) - - return True diff --git a/tilia/ui/timelines/collection/requests/__init__.py b/tilia/ui/timelines/collection/requests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tilia/ui/timelines/collection/requests/args.py b/tilia/ui/timelines/collection/requests/args.py deleted file mode 100644 index 3f4b18992..000000000 --- a/tilia/ui/timelines/collection/requests/args.py +++ /dev/null @@ -1,135 +0,0 @@ -import sys - -from tilia.exceptions import UserCancelledDialog -from tilia.requests import get, Get, Post -from tilia.ui.timelines.base.timeline import TimelineUI - - -def _get_args_for_timeline_element_color_set(_): - success, color = get(Get.FROM_USER_COLOR) - if not success or not color.isValid(): - raise UserCancelledDialog - return (color,), {} - - -def _get_args_for_timeline_name_set(timeline_uis): - timeline_ui = timeline_uis[0] - accepted, name = get( - Get.FROM_USER_STRING, - "Change timeline name", - "Choose new name", - text=get(Get.TIMELINE, timeline_ui.id).name, - ) - if not accepted: - raise UserCancelledDialog - return (name,), {} - - -def _get_args_for_timeline_height_set(timeline_uis): - timeline_ui = timeline_uis[0] - accepted, height = get( - Get.FROM_USER_INT, - "Change timeline height", - "Insert new timeline height", - value=timeline_ui.get_data("height"), - min=10, - ) - if not accepted: - raise UserCancelledDialog - return (height,), {} - - -def _get_args_for_timeline_ordinal_increase_from_context_menu(timelines): - return _get_args_for_timeline_ordinal_permute_from_context_menu(timelines, 1) - - -def _get_args_for_timeline_ordinal_decrease_from_context_menu(timelines): - return _get_args_for_timeline_ordinal_permute_from_context_menu(timelines, -1) - - -def _get_args_for_timeline_ordinal_permute_from_context_menu(*_): - tlui1, tlui2 = get(Get.CONTEXT_MENU_TIMELINE_UIS_TO_PERMUTE) - return ( - {tlui1.id: tlui2.get_data("ordinal"), tlui2.id: tlui1.get_data("ordinal")}, - ), {} - - -def _get_args_for_timeline_is_visible_set_from_manage_timelines(_): - is_visible = not ( - get(Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_CURRENT).get_data("is_visible") - ) - return (is_visible,), {} - - -def _get_args_for_timeline_ordinal_increase_from_manage_timelines(timelines): - return _get_args_for_timeline_ordinal_permute_from_manage_timelines(timelines, 1) - - -def _get_args_for_timeline_ordinal_decrease_from_manage_timelines(timelines): - return _get_args_for_timeline_ordinal_permute_from_manage_timelines(timelines, -1) - - -def _get_args_for_timeline_ordinal_permute_from_manage_timelines(*_): - tlui1, tlui2 = get(Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_TO_PERMUTE) - return ( - {tlui1.id: tlui2.get_data("ordinal"), tlui2.id: tlui1.get_data("ordinal")}, - ), {} - - -def _confirm_delete_timeline(): - return get( - Get.FROM_USER_YES_OR_NO, - "Delete timeline", - "Are you sure you want to delete the selected timeline? This can be undone later.", - ) - - -def _get_args_for_timeline_delete_from_manage_timelines(_): - confirmed = _confirm_delete_timeline() - return (confirmed,), {} - - -def _get_args_for_timeline_delete_from_context_menu(_): - confirmed = _confirm_delete_timeline() - return (confirmed,), {} - - -def _get_args_for_timeline_clear_from_manage_timelines(_): - confirmed = get( - Get.FROM_USER_YES_OR_NO, - "Clear timeline", - "Are you sure you want to clear the selected timeline? This can be undone later.", - ) - return (confirmed,), {} - - -def _get_args_for_timelines_clear(_): - confirmed = get( - Get.FROM_USER_YES_OR_NO, - "Clear timelines", - "Are you sure you want to clear ALL timelines? This can be undone later.", - ) - return (confirmed,), {} - - -def _get_args_for_hierarchy_add_pre_start(_): - accept, number = get(Get.FROM_USER_FLOAT, "Add pre-start", "Pre-start length") - if not accept: - raise UserCancelledDialog - return (number,), {} - - -def _get_args_for_hierarchy_add_post_end(_): - accept, number = get(Get.FROM_USER_FLOAT, "Add post-end", "Post-end length") - if not accept: - raise UserCancelledDialog - return (number,), {} - - -def get_args_for_request(request: Post, timeline_uis: list[TimelineUI], *_, **__): - try: - return getattr(sys.modules[__name__], "_get_args_for_" + request.name.lower())( - timeline_uis - ) - except AttributeError: - return tuple(), {} diff --git a/tilia/ui/timelines/collection/requests/element.py b/tilia/ui/timelines/collection/requests/element.py index 93a4ffe0e..e69de29bb 100644 --- a/tilia/ui/timelines/collection/requests/element.py +++ b/tilia/ui/timelines/collection/requests/element.py @@ -1,120 +0,0 @@ -from dataclasses import dataclass - -from tilia.requests import Post -from tilia.timelines.timeline_kinds import ( - TimelineKind as TlKind, - NOT_SLIDER, - COLORED_COMPONENTS, -) -from tilia.ui.timelines.collection.requests.enums import ( - TimelineSelector, - ElementSelector, -) - - -@dataclass -class TlElmRequestSelector: - tl_kind: list[TlKind] - timeline: TimelineSelector - element: ElementSelector - - -request_to_scope: dict[Post, TlElmRequestSelector] = { - Post.TIMELINE_ELEMENT_COLOR_SET: TlElmRequestSelector( - COLORED_COMPONENTS, - TimelineSelector.SELECTED, - ElementSelector.SELECTED, - ), - Post.TIMELINE_ELEMENT_COLOR_RESET: TlElmRequestSelector( - COLORED_COMPONENTS, - TimelineSelector.SELECTED, - ElementSelector.SELECTED, - ), - Post.TIMELINE_ELEMENT_DELETE: TlElmRequestSelector( - NOT_SLIDER, - TimelineSelector.SELECTED, - ElementSelector.SELECTED, - ), - Post.TIMELINE_ELEMENT_COPY: TlElmRequestSelector( - NOT_SLIDER, - TimelineSelector.SELECTED, - ElementSelector.SELECTED, - ), - Post.TIMELINE_ELEMENT_PASTE: TlElmRequestSelector( - NOT_SLIDER, - TimelineSelector.PASTE, - ElementSelector.SELECTED, - ), - Post.TIMELINE_ELEMENT_EXPORT_AUDIO: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.MARKER_ADD: TlElmRequestSelector( - [TlKind.MARKER_TIMELINE], TimelineSelector.FIRST, ElementSelector.NONE - ), - Post.TIMELINE_NAME_SET: TlElmRequestSelector( - NOT_SLIDER, TimelineSelector.FIRST, ElementSelector.NONE - ), - Post.BEAT_ADD: TlElmRequestSelector( - [TlKind.BEAT_TIMELINE], TimelineSelector.FIRST, ElementSelector.NONE - ), - Post.BEAT_SET_MEASURE_NUMBER: TlElmRequestSelector( - [TlKind.BEAT_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.BEAT_RESET_MEASURE_NUMBER: TlElmRequestSelector( - [TlKind.BEAT_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.BEAT_DISTRIBUTE: TlElmRequestSelector( - [TlKind.BEAT_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.BEAT_SET_AMOUNT_IN_MEASURE: TlElmRequestSelector( - [TlKind.BEAT_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_INCREASE_LEVEL: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_DECREASE_LEVEL: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_GROUP: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_COLOR_SET: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_COLOR_RESET: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_MERGE: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_CREATE_CHILD: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_SPLIT: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.FIRST, ElementSelector.ALL - ), - Post.TIMELINE_ELEMENT_PASTE_COMPLETE: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_ADD_PRE_START: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HIERARCHY_ADD_POST_END: TlElmRequestSelector( - [TlKind.HIERARCHY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HARMONY_ADD: TlElmRequestSelector( - [TlKind.HARMONY_TIMELINE], TimelineSelector.FIRST, ElementSelector.NONE - ), - Post.HARMONY_DISPLAY_AS_ROMAN_NUMERAL: TlElmRequestSelector( - [TlKind.HARMONY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.HARMONY_DISPLAY_AS_CHORD_SYMBOL: TlElmRequestSelector( - [TlKind.HARMONY_TIMELINE], TimelineSelector.SELECTED, ElementSelector.SELECTED - ), - Post.MODE_ADD: TlElmRequestSelector( - [TlKind.HARMONY_TIMELINE], TimelineSelector.FIRST, ElementSelector.NONE - ), - Post.PDF_MARKER_ADD: TlElmRequestSelector( - [TlKind.PDF_TIMELINE], TimelineSelector.FIRST, ElementSelector.NONE - ), -} diff --git a/tilia/ui/timelines/collection/requests/enums.py b/tilia/ui/timelines/collection/requests/enums.py deleted file mode 100644 index b7d9b3cf8..000000000 --- a/tilia/ui/timelines/collection/requests/enums.py +++ /dev/null @@ -1,21 +0,0 @@ -from enum import Enum, auto - - -class TimelineSelector(Enum): - ANY = auto() - FROM_CLI = auto() - FROM_MANAGE_TIMELINES_CURRENT = auto() - FROM_MANAGE_TIMELINES_TO_PERMUTE = auto() - FROM_CONTEXT_MENU = auto() - FROM_CONTEXT_MENU_TO_PERMUTE = auto() - EXPLICIT = auto() - SELECTED = auto() - ALL = auto() - FIRST = auto() - PASTE = auto() - - -class ElementSelector(Enum): - SELECTED = auto() - ALL = auto() - NONE = auto() diff --git a/tilia/ui/timelines/collection/requests/timeline.py b/tilia/ui/timelines/collection/requests/timeline.py deleted file mode 100644 index 36e26b836..000000000 --- a/tilia/ui/timelines/collection/requests/timeline.py +++ /dev/null @@ -1,48 +0,0 @@ -from dataclasses import dataclass - -from tilia.requests import Post -from tilia.timelines.timeline_kinds import TimelineKind as TlKind, NOT_SLIDER, ALL -from tilia.ui.timelines.collection.requests.enums import TimelineSelector - - -@dataclass -class TlRequestSelector: - tl_kind: list[TlKind] - timeline: TimelineSelector - - -request_to_scope: dict[Post, TlRequestSelector] = { - Post.TIMELINE_DELETE_FROM_MANAGE_TIMELINES: TlRequestSelector( - ALL, TimelineSelector.FROM_MANAGE_TIMELINES_CURRENT - ), - Post.TIMELINE_DELETE_FROM_CLI: TlRequestSelector(ALL, TimelineSelector.FROM_CLI), - Post.TIMELINE_CLEAR_FROM_MANAGE_TIMELINES: TlRequestSelector( - ALL, TimelineSelector.FROM_MANAGE_TIMELINES_CURRENT - ), - Post.TIMELINE_NAME_SET: TlRequestSelector(NOT_SLIDER, TimelineSelector.FIRST), - Post.TIMELINE_HEIGHT_SET: TlRequestSelector(NOT_SLIDER, TimelineSelector.FIRST), - Post.TIMELINE_ORDINAL_INCREASE_FROM_MANAGE_TIMELINES: TlRequestSelector( - ALL, TimelineSelector.FROM_MANAGE_TIMELINES_TO_PERMUTE - ), - Post.TIMELINE_ORDINAL_DECREASE_FROM_MANAGE_TIMELINES: TlRequestSelector( - ALL, TimelineSelector.FROM_MANAGE_TIMELINES_TO_PERMUTE - ), - Post.TIMELINE_IS_VISIBLE_SET_FROM_MANAGE_TIMELINES: TlRequestSelector( - ALL, TimelineSelector.FROM_MANAGE_TIMELINES_CURRENT - ), - Post.HARMONY_TIMELINE_SHOW_KEYS: TlRequestSelector( - [TlKind.HARMONY_TIMELINE], TimelineSelector.FIRST - ), - Post.HARMONY_TIMELINE_HIDE_KEYS: TlRequestSelector( - [TlKind.HARMONY_TIMELINE], TimelineSelector.FIRST - ), - Post.TIMELINE_DELETE_FROM_CONTEXT_MENU: TlRequestSelector( - ALL, TimelineSelector.FROM_CONTEXT_MENU - ), - Post.TIMELINE_ORDINAL_DECREASE_FROM_CONTEXT_MENU: TlRequestSelector( - ALL, TimelineSelector.FROM_CONTEXT_MENU_TO_PERMUTE - ), - Post.TIMELINE_ORDINAL_INCREASE_FROM_CONTEXT_MENU: TlRequestSelector( - ALL, TimelineSelector.FROM_CONTEXT_MENU_TO_PERMUTE - ), -} diff --git a/tilia/ui/timelines/collection/requests/timeline_uis.py b/tilia/ui/timelines/collection/requests/timeline_uis.py deleted file mode 100644 index 4fab0335d..000000000 --- a/tilia/ui/timelines/collection/requests/timeline_uis.py +++ /dev/null @@ -1,3 +0,0 @@ -from tilia.requests import Post - -requests = [Post.TIMELINES_CLEAR, Post.TIMELINE_ADD, Post.BEAT_TIMELINE_FILL] diff --git a/tilia/ui/timelines/collection/validators.py b/tilia/ui/timelines/collection/validators.py deleted file mode 100644 index ae7e85556..000000000 --- a/tilia/ui/timelines/collection/validators.py +++ /dev/null @@ -1,29 +0,0 @@ -from tilia.requests import Post, Get, get -import tilia.errors - - -def has_validator(request: Post): - return request in request_to_validator - - -def validate_timeline_element_copy(timeline_uis, *_, **__): - if len(timeline_uis) == 0: - # Can't copy: there are no selected elements. - return False - elif len(get(Get.TIMELINE_UIS_BY_ATTR, "has_selected_elements", True)) > 1: - tilia.errors.display( - tilia.errors.COMPONENTS_COPY_ERROR, - "Cannot copy components from more than one timeline.", - ) - return False - return True - - -def validate(request, timeline_uis, *args, **kwargs): - if not has_validator(request): - return True - - return request_to_validator[request](timeline_uis, *args, **kwargs) - - -request_to_validator = {Post.TIMELINE_ELEMENT_COPY: validate_timeline_element_copy} diff --git a/tilia/ui/timelines/collection/view.py b/tilia/ui/timelines/collection/view.py index 45f15aaff..d0d4d9de4 100644 --- a/tilia/ui/timelines/collection/view.py +++ b/tilia/ui/timelines/collection/view.py @@ -2,7 +2,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QGraphicsView, QAbstractSlider -from tilia.requests import post, Post +from tilia.ui import commands from tilia.ui.smooth_scroll import setup_smooth, smooth @@ -52,9 +52,9 @@ def wheelEvent(self, event) -> None: if Qt.KeyboardModifier.ControlModifier in event.modifiers(): if dy > 0: - post(Post.VIEW_ZOOM_IN) + commands.execute("view.zoom.in") else: - post(Post.VIEW_ZOOM_OUT) + commands.execute("view.zoom.out") return else: diff --git a/tilia/ui/timelines/harmony/context_menu.py b/tilia/ui/timelines/harmony/context_menu.py index d7cfad6b9..57e39a0e4 100644 --- a/tilia/ui/timelines/harmony/context_menu.py +++ b/tilia/ui/timelines/harmony/context_menu.py @@ -1,5 +1,4 @@ -from tilia.ui import actions -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import ( TimelineUIContextMenu, @@ -10,42 +9,42 @@ class ModeContextMenu(TimelineUIElementContextMenu): name = "Mode" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_EDIT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_DELETE), + (MenuItemKind.COMMAND, "timeline.component.delete"), ] class HarmonyContextMenu(TimelineUIElementContextMenu): name = "Harmony" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_EDIT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.HARMONY_DISPLAY_AS_ROMAN_NUMERAL), - (MenuItemKind.ACTION, TiliaAction.HARMONY_DISPLAY_AS_CHORD_SYMBOL), + (MenuItemKind.COMMAND, "timeline.harmony.component.display_as_roman"), + (MenuItemKind.COMMAND, "timeline.harmony.component.display_as_chord"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_DELETE), + (MenuItemKind.COMMAND, "timeline.component.delete"), ] class HarmonyTimelineUIContextMenu(TimelineUIContextMenu): name = "Harmony timeline" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_NAME_SET), + (MenuItemKind.COMMAND, "timeline.set_name"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.HARMONY_TIMELINE_SHOW_KEYS), - (MenuItemKind.ACTION, TiliaAction.HARMONY_TIMELINE_HIDE_KEYS), + (MenuItemKind.COMMAND, "timeline.harmony.show_keys"), + (MenuItemKind.COMMAND, "timeline.harmony.hide_keys"), ] def __init__(self, timeline_ui): - hide_keys_action = actions.get_qaction(TiliaAction.HARMONY_TIMELINE_HIDE_KEYS) - show_keys_action = actions.get_qaction(TiliaAction.HARMONY_TIMELINE_SHOW_KEYS) + hide_keys_action = commands.get_qaction("timeline.harmony.hide_keys") + show_keys_action = commands.get_qaction("timeline.harmony.show_keys") if timeline_ui.get_data("visible_level_count") == 1: hide_keys_action.setVisible(False) show_keys_action.setVisible(True) diff --git a/tilia/ui/timelines/harmony/request_handlers.py b/tilia/ui/timelines/harmony/request_handlers.py deleted file mode 100644 index e513ef0b0..000000000 --- a/tilia/ui/timelines/harmony/request_handlers.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import tilia.errors -from tilia.requests import Post, get, Get -from tilia.timelines.component_kinds import ComponentKind -from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.timelines.base.request_handlers import ( - ElementRequestHandler, - TimelineRequestHandler, -) -from tilia.ui.timelines.copy_paste import get_copy_data_from_element -from tilia.ui.timelines.harmony import HarmonyUI, ModeUI - - -class HarmonyUIRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui): - super().__init__( - timeline_ui, - { - Post.HARMONY_ADD: self.on_harmony_add, - Post.HARMONY_DISPLAY_AS_ROMAN_NUMERAL: self.on_display_as_roman_numeral, - Post.HARMONY_DISPLAY_AS_CHORD_SYMBOL: self.on_display_as_chord_symbol, - Post.MODE_ADD: self.on_mode_add, - Post.TIMELINE_ELEMENT_DELETE: self.on_element_delete, - Post.TIMELINE_ELEMENT_COPY: self.on_copy, - Post.TIMELINE_ELEMENT_PASTE: self.on_paste, - }, - ) - - @staticmethod - def filter_harmonies(elements: list[HarmonyUI | ModeUI]): - return filter(lambda e: isinstance(e, HarmonyUI), elements) - - def on_mode_add(self, *_, **__): - time = get(Get.SELECTED_TIME) - valid, reason = self.timeline.component_manager._validate_component_creation( - ComponentKind.MODE, time - ) - - if not valid: - tilia.errors.display(tilia.errors.ADD_MODE_FAILED, reason) - return False - - confirmed, kwargs = get(Get.FROM_USER_MODE_PARAMS) - if not confirmed: - return False - mode, reason = self.timeline.create_component( - ComponentKind.MODE, time, **kwargs - ) - if not mode: - tilia.errors.display(tilia.errors.ADD_MODE_FAILED, reason) - return False - self.timeline_ui.on_mode_add_done() - return True - - def on_harmony_add(self, *_, **__): - time = get(Get.SELECTED_TIME) - valid, reason = self.timeline.component_manager._validate_component_creation( - ComponentKind.HARMONY, time - ) - - if not valid: - tilia.errors.display(tilia.errors.ADD_HARMONY_FAILED, reason) - return False - - confirmed, kwargs = get(Get.FROM_USER_HARMONY_PARAMS) - if not confirmed: - return False - - harmony, reason = self.timeline.create_component( - ComponentKind.HARMONY, time, **kwargs - ) - - if not harmony: - tilia.errors.display(tilia.errors.ADD_HARMONY_FAILED, reason) - return False - return True - - def on_element_delete(self, elements, *_, **__): - - if any((elm for elm in elements if elm.get_data("KIND") == ComponentKind.MODE)): - needs_recalculation = True - else: - needs_recalculation = False - - self.timeline.delete_components(self.elements_to_components(elements)) - - if needs_recalculation: - self.timeline_ui.on_mode_delete_done() - - return True - - def on_display_as_chord_symbol(self, elements, *_, **__): - elements = self.filter_harmonies(elements) - self.timeline_ui.set_elements_attr(elements, "display_mode", "chord") - return True - - def on_display_as_roman_numeral(self, elements, *_, **__): - elements = self.filter_harmonies(elements) - self.timeline_ui.set_elements_attr(elements, "display_mode", "roman") - return True - - @staticmethod - def _get_copy_data_from_element(element: HarmonyUI | ModeUI): - return { - "components": get_copy_data_from_element( - element, element.DEFAULT_COPY_ATTRIBUTES - ), - "timeline_kind": TimelineKind.HARMONY_TIMELINE, - } - - -class HarmonyTimelineUIRequestHandler(TimelineRequestHandler): - def __init__(self, timeline_ui): - super().__init__( - timeline_ui, - { - Post.HARMONY_TIMELINE_SHOW_KEYS: self.on_show_keys, - Post.HARMONY_TIMELINE_HIDE_KEYS: self.on_hide_keys, - }, - ) - - def on_show_keys(self): - get(Get.TIMELINE_COLLECTION).set_timeline_data( - self.timeline_ui.id, - "visible_level_count", - 2, - ) - return True - - def on_hide_keys(self): - get(Get.TIMELINE_COLLECTION).set_timeline_data( - self.timeline_ui.id, - "visible_level_count", - 1, - ) - return True diff --git a/tilia/ui/timelines/harmony/timeline.py b/tilia/ui/timelines/harmony/timeline.py index 41e93abec..1e40cdce4 100644 --- a/tilia/ui/timelines/harmony/timeline.py +++ b/tilia/ui/timelines/harmony/timeline.py @@ -1,26 +1,24 @@ -from __future__ import annotations - import music21 +from tilia import errors +from tilia.timelines.component_kinds import ComponentKind from . import level_label from tilia.requests import Get, get from tilia.timelines.timeline_kinds import TimelineKind from tilia.ui.timelines.base.element import TimelineUIElement from tilia.ui.timelines.base.timeline import ( TimelineUI, + with_elements, ) -from tilia.ui.timelines.collection.requests.enums import ElementSelector from tilia.ui.timelines.harmony.context_menu import HarmonyTimelineUIContextMenu from tilia.ui.timelines.harmony import HarmonyUI, ModeUI -from tilia.ui.timelines.harmony.request_handlers import ( - HarmonyUIRequestHandler, - HarmonyTimelineUIRequestHandler, -) from tilia.ui.timelines.harmony.toolbar import HarmonyTimelineToolbar from tilia.ui.timelines.copy_paste import ( paste_into_element, + get_copy_data_from_element, ) +from tilia.ui.timelines.collection.collection import TimelineUIs, TimelineSelector class HarmonyTimelineUI(TimelineUI): @@ -38,6 +36,40 @@ def __init__(self, *args, **kwargs): ] self._setup_level_labels() + @classmethod + def register_commands(cls, collection: TimelineUIs): + args = [ + ("add_harmony", "Add harmony", "h", "harmony_add", TimelineSelector.FIRST), + ( + "component.display_as_chord", + "Display as chord symbol", + "", + "harmony_display_chord", + TimelineSelector.SELECTED, + ), + ( + "component.display_as_roman", + "Display as roman numeral", + "", + "harmony_display_roman", + TimelineSelector.SELECTED, + ), + ("hide_keys", "Hide keys", "", "", TimelineSelector.FIRST), + ("show_keys", "Show keys", "", "", TimelineSelector.FIRST), + ("add_mode", "Add mode", "", "mode_add", TimelineSelector.FIRST), + ] + + for name, text, shortcut, icon, selector in args: + cls.register_timeline_command( + collection, + name, + getattr(cls, f"on_{name.replace('.', '_')}"), + selector, + text=text, + shortcut=shortcut, + icon=icon, + ) + def _setup_level_labels(self): self.harmony_level_label = level_label.LevelLabel( get(Get.LEFT_MARGIN_X) - 5, self.get_y(1) - 5, "Harmonies" @@ -59,17 +91,19 @@ def harmonies(self): lambda elm: isinstance(elm, HarmonyUI) ) - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return HarmonyUIRequestHandler(self).on_request( - request, selector, *args, **kwargs - ) + @with_elements + def on_delete_component(self, elements: list[TimelineUIElement], *_, **__): + if any((elm for elm in elements if elm.get_data("KIND") == ComponentKind.MODE)): + needs_recalculation = True + else: + needs_recalculation = False - def on_timeline_request(self, request, *args, **kwargs): - return HarmonyTimelineUIRequestHandler(self).on_request( - request, *args, **kwargs - ) + success = self.timeline.delete_components(self.elements_to_components(elements)) + + if needs_recalculation: + self.on_mode_delete_done() + + return success def on_mode_add_done(self): self.update_harmony_labels() @@ -189,3 +223,85 @@ def create_pasted_components( target_time + (harmony_time - reference_time), **harmony_data["by_component_value"], ) + + def on_add_mode(self): + time = get(Get.SELECTED_TIME) + valid, reason = self.timeline.component_manager._validate_component_creation( + ComponentKind.MODE, time + ) + + if not valid: + errors.display(errors.ADD_MODE_FAILED, reason) + return False + + confirmed, kwargs = get(Get.FROM_USER_MODE_PARAMS) + if not confirmed: + return False + mode, reason = self.timeline.create_component( + ComponentKind.MODE, time, **kwargs + ) + if not mode: + errors.display(errors.ADD_MODE_FAILED, reason) + return False + self.on_mode_add_done() + return True + + def on_add_harmony(self): + time = get(Get.SELECTED_TIME) + valid, reason = self.timeline.component_manager._validate_component_creation( + ComponentKind.HARMONY, time + ) + + if not valid: + errors.display(errors.ADD_HARMONY_FAILED, reason) + return False + + confirmed, kwargs = get(Get.FROM_USER_HARMONY_PARAMS) + if not confirmed: + return False + + harmony, reason = self.timeline.create_component( + ComponentKind.HARMONY, time, **kwargs + ) + + if not harmony: + errors.display(errors.ADD_HARMONY_FAILED, reason) + return False + return True + + @with_elements + def on_component_display_as_chord(self, elements: list[ModeUI | HarmonyUI]): + harmonies = [elm for elm in elements if isinstance(elm, HarmonyUI)] + self.set_elements_attr(harmonies, "display_mode", "chord") + return True + + @with_elements + def on_component_display_as_roman(self, elements: list[ModeUI | HarmonyUI]): + harmonies = [elm for elm in elements if isinstance(elm, HarmonyUI)] + self.set_elements_attr(harmonies, "display_mode", "roman") + return True + + @staticmethod + def _get_copy_data_from_element(element: HarmonyUI | ModeUI): + return { + "components": get_copy_data_from_element( + element, element.DEFAULT_COPY_ATTRIBUTES + ), + "timeline_kind": TimelineKind.HARMONY_TIMELINE, + } + + def on_show_keys(self): + get(Get.TIMELINE_COLLECTION).set_timeline_data( + self.id, + "visible_level_count", + 2, + ) + return True + + def on_hide_keys(self): + get(Get.TIMELINE_COLLECTION).set_timeline_data( + self.id, + "visible_level_count", + 1, + ) + return True diff --git a/tilia/ui/timelines/harmony/toolbar.py b/tilia/ui/timelines/harmony/toolbar.py index 8b3e2a7a4..54ac5f697 100644 --- a/tilia/ui/timelines/harmony/toolbar.py +++ b/tilia/ui/timelines/harmony/toolbar.py @@ -1,13 +1,12 @@ from __future__ import annotations -from tilia.ui.actions import TiliaAction from tilia.ui.timelines.toolbar import TimelineToolbar class HarmonyTimelineToolbar(TimelineToolbar): - ACTIONS = [ - TiliaAction.HARMONY_ADD, - TiliaAction.MODE_ADD, - TiliaAction.HARMONY_DISPLAY_AS_ROMAN_NUMERAL, - TiliaAction.HARMONY_DISPLAY_AS_CHORD_SYMBOL, + COMMANDS = [ + "timeline.harmony.add_harmony", + "timeline.harmony.add_mode", + "timeline.harmony.component.display_as_roman", + "timeline.harmony.component.display_as_chord", ] diff --git a/tilia/ui/timelines/hierarchy/context_menu.py b/tilia/ui/timelines/hierarchy/context_menu.py index 9ef590a1c..6b634579b 100644 --- a/tilia/ui/timelines/hierarchy/context_menu.py +++ b/tilia/ui/timelines/hierarchy/context_menu.py @@ -1,24 +1,23 @@ from __future__ import annotations -from tilia.ui.actions import TiliaAction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import TimelineUIElementContextMenu DEFAULT_ITEMS = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_INSPECT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.HIERARCHY_INCREASE_LEVEL), - (MenuItemKind.ACTION, TiliaAction.HIERARCHY_DECREASE_LEVEL), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COLOR_SET), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COLOR_RESET), + (MenuItemKind.COMMAND, "timeline.hierarchy.increase_level"), + (MenuItemKind.COMMAND, "timeline.hierarchy.decrease_level"), + (MenuItemKind.COMMAND, "timeline.component.set_color"), + (MenuItemKind.COMMAND, "timeline.component.reset_color"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE_COMPLETE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), + (MenuItemKind.COMMAND, "timeline.component.paste_complete"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_EXPORT_AUDIO), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_DELETE), + (MenuItemKind.COMMAND, "timeline.hierarchy.export_audio"), + (MenuItemKind.COMMAND, "timeline.component.delete"), ] @@ -29,12 +28,12 @@ def __init__(self, element): self.items = DEFAULT_ITEMS.copy() if not element.has_pre_start: self.items.insert( - 6, (MenuItemKind.ACTION, TiliaAction.HIERARCHY_ADD_PRE_START) + 6, (MenuItemKind.COMMAND, "timeline.hierarchy.add_pre_start") ) if not element.has_post_end: self.items.insert( - 6, (MenuItemKind.ACTION, TiliaAction.HIERARCHY_ADD_POST_END) + 6, (MenuItemKind.COMMAND, "timeline.hierarchy.add_post_end") ) super().__init__(element) diff --git a/tilia/ui/timelines/hierarchy/drag.py b/tilia/ui/timelines/hierarchy/drag.py index 2d5474f2e..a00445689 100644 --- a/tilia/ui/timelines/hierarchy/drag.py +++ b/tilia/ui/timelines/hierarchy/drag.py @@ -4,11 +4,11 @@ from tilia.requests import get, Get, post, Post from tilia.ui.coords import time_x_converter from tilia.ui.timelines.drag import DragManager +from tilia.ui.timelines.hierarchy import HierarchyUI from tilia.ui.timelines.hierarchy.handles import ( HierarchyBodyHandle, HierarchyFrameHandle, ) -from tilia.ui.timelines.hierarchy.extremity import Extremity MIN_DRAG_GAP = 4 DRAG_PROXIMITY_LIMIT = 4 @@ -33,9 +33,9 @@ def start_drag( def get_after_each_drag_func(hierarchy_ui, extremity): - if extremity in [Extremity.START, Extremity.END]: + if extremity in [HierarchyUI.Extremity.START, HierarchyUI.Extremity.END]: return functools.partial(after_each_body_handle_drag, hierarchy_ui, extremity) - elif extremity in [Extremity.PRE_START, Extremity.POST_END]: + elif extremity in [HierarchyUI.Extremity.PRE_START, HierarchyUI.Extremity.POST_END]: extremity_x = extremity.value + "_x" uis_that_share_handle = hierarchy_ui.timeline_ui.get_elements_by_attr( extremity_x, getattr(hierarchy_ui, extremity_x) @@ -48,9 +48,9 @@ def get_after_each_drag_func(hierarchy_ui, extremity): def get_drag_limits(hierarchy_ui, extremity): - if extremity in [Extremity.START, Extremity.END]: + if extremity in [HierarchyUI.Extremity.START, HierarchyUI.Extremity.END]: return get_body_handle_drag_limits(hierarchy_ui, extremity) - elif extremity in [Extremity.PRE_START, Extremity.POST_END]: + elif extremity in [HierarchyUI.Extremity.PRE_START, HierarchyUI.Extremity.POST_END]: return get_frame_handles_drag_limits(hierarchy_ui, extremity) else: raise ValueError("Unrecognized extremity") @@ -58,7 +58,7 @@ def get_drag_limits(hierarchy_ui, extremity): def get_body_handle_drag_limits( hierarchy_ui, - extremity: Extremity, + extremity: HierarchyUI.Extremity, ) -> tuple[int, int]: handle_x = hierarchy_ui.extremity_to_x( extremity, hierarchy_ui.start_x, hierarchy_ui.end_x @@ -81,9 +81,9 @@ def get_body_handle_drag_limits( def get_frame_handles_drag_limits(hierarchy_ui, extremity): - if extremity == Extremity.PRE_START: + if extremity == HierarchyUI.Extremity.PRE_START: return get(Get.LEFT_MARGIN_X), hierarchy_ui.start_x - elif extremity == Extremity.POST_END: + elif extremity == HierarchyUI.Extremity.POST_END: return hierarchy_ui.end_x, get(Get.RIGHT_MARGIN_X) @@ -100,8 +100,8 @@ def after_each_body_handle_drag(hierarchy_ui, extremity, x: int) -> None: def after_each_frame_handle_drag(extremity, uis, x: int): body_extremity = { - Extremity.PRE_START: Extremity.START, - Extremity.POST_END: Extremity.END, + HierarchyUI.Extremity.PRE_START: HierarchyUI.Extremity.START, + HierarchyUI.Extremity.POST_END: HierarchyUI.Extremity.END, }[extremity] if math.isclose( time := time_x_converter.get_time_by_x(x), diff --git a/tilia/ui/timelines/hierarchy/element.py b/tilia/ui/timelines/hierarchy/element.py index 975e24a82..52c8be8f1 100644 --- a/tilia/ui/timelines/hierarchy/element.py +++ b/tilia/ui/timelines/hierarchy/element.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import Enum from typing import Literal from PyQt6.QtCore import Qt, QRectF, QPointF @@ -12,8 +13,6 @@ from tilia.dirs import IMG_DIR from .context_menu import HierarchyContextMenu -from .drag import start_drag -from .extremity import Extremity from .handles import HierarchyBodyHandle, HierarchyFrameHandle from ..cursors import CursorMixIn from ...color import get_tinted_color, get_untinted_color @@ -93,6 +92,12 @@ class HierarchyUI(TimelineUIElement): CONTEXT_MENU_CLASS = HierarchyContextMenu FONT_METRICS = QFontMetrics(QFont("Arial", 10)) + class Extremity(Enum): + START = "start" + END = "end" + PRE_START = "pre_start" + POST_END = "post_end" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -266,23 +271,23 @@ def update_start(self): def update_pre_start(self): self.update_frame_handle_position( - Extremity.PRE_START, + HierarchyUI.Extremity.PRE_START, self.get_data("level"), self.timeline_ui.get_data("height"), self.start_x, ) if self.is_selected(): - self.update_frame_handle_visibility(Extremity.PRE_START) + self.update_frame_handle_visibility(HierarchyUI.Extremity.PRE_START) def update_post_end(self): self.update_frame_handle_position( - Extremity.POST_END, + HierarchyUI.Extremity.POST_END, self.get_data("level"), self.timeline_ui.get_data("height"), self.end_x, ) if self.is_selected(): - self.update_frame_handle_visibility(Extremity.POST_END) + self.update_frame_handle_visibility(HierarchyUI.Extremity.POST_END) def update_position(self): start_x = self.start_x @@ -320,7 +325,7 @@ def update_label_position(self, level, height, start_x, end_x): ) def update_body_handles_position(self, height, start_x, end_x): - for extremity in [Extremity.START, Extremity.END]: + for extremity in [HierarchyUI.Extremity.START, HierarchyUI.Extremity.END]: self.extremity_to_handle(extremity).set_position( self.extremity_to_x(extremity, start_x, end_x), self.HANDLE_WIDTH, @@ -330,15 +335,21 @@ def update_body_handles_position(self, height, start_x, end_x): ) def update_frame_handles_position(self, level, height, start_x, end_x): - self.update_frame_handle_position(Extremity.PRE_START, level, height, start_x) - self.update_frame_handle_position(Extremity.POST_END, level, height, end_x) + self.update_frame_handle_position( + HierarchyUI.Extremity.PRE_START, level, height, start_x + ) + self.update_frame_handle_position( + HierarchyUI.Extremity.POST_END, level, height, end_x + ) - def update_frame_handle_position(self, extremity: Extremity, level, height, body_x): - if extremity == Extremity.PRE_START: + def update_frame_handle_position( + self, extremity: HierarchyUI.Extremity, level, height, body_x + ): + if extremity == HierarchyUI.Extremity.PRE_START: self.pre_start_handle.set_position( body_x, self.pre_start_x, self.frame_handle_y(level, height) ) - elif extremity == Extremity.POST_END: + elif extremity == HierarchyUI.Extremity.POST_END: self.post_end_handle.set_position( body_x, self.post_end_x, self.frame_handle_y(level, height) ) @@ -346,13 +357,16 @@ def update_frame_handle_position(self, extremity: Extremity, level, height, body raise ValueError("Unrecognized extremity") def update_frame_handles_visibility(self): - self.update_frame_handle_visibility(Extremity.PRE_START) - self.update_frame_handle_visibility(Extremity.POST_END) + self.update_frame_handle_visibility(HierarchyUI.Extremity.PRE_START) + self.update_frame_handle_visibility(HierarchyUI.Extremity.POST_END) - def update_frame_handle_visibility(self, extremity: Extremity): + def update_frame_handle_visibility(self, extremity: HierarchyUI.Extremity): handle, exists = { - Extremity.PRE_START: (self.pre_start_handle, self.has_pre_start), - Extremity.POST_END: (self.post_end_handle, self.has_post_end), + HierarchyUI.Extremity.PRE_START: ( + self.pre_start_handle, + self.has_pre_start, + ), + HierarchyUI.Extremity.POST_END: (self.post_end_handle, self.has_post_end), }[extremity] if not handle.isVisible() and exists: @@ -400,12 +414,12 @@ def _setup_body_handles(self): self.start_handle = self.timeline_ui.get_handle_by_x(self.start_x) if not self.start_handle: - self.start_handle = self._setup_handle(Extremity.START) + self.start_handle = self._setup_handle(HierarchyUI.Extremity.START) self.scene.addItem(self.start_handle) self.end_handle = self.timeline_ui.get_handle_by_x(self.end_x) if not self.end_handle: - self.end_handle = self._setup_handle(Extremity.END) + self.end_handle = self._setup_handle(HierarchyUI.Extremity.END) self.scene.addItem(self.end_handle) def _setup_frame_handles(self): @@ -419,26 +433,28 @@ def _setup_frame_handles(self): self.scene.addItem(self.post_end_handle) def extremity_to_handle( - self, extremity: Extremity + self, extremity: HierarchyUI.Extremity ) -> HierarchyBodyHandle | HierarchyFrameHandle: try: return { - Extremity.START: self.start_handle, - Extremity.END: self.end_handle, - Extremity.PRE_START: self.pre_start_handle, - Extremity.POST_END: self.post_end_handle, + HierarchyUI.Extremity.START: self.start_handle, + HierarchyUI.Extremity.END: self.end_handle, + HierarchyUI.Extremity.PRE_START: self.pre_start_handle, + HierarchyUI.Extremity.POST_END: self.post_end_handle, }[extremity] except KeyError: raise ValueError("Unrecognized extremity") @staticmethod def frame_to_body_extremity( - extremity: Literal[Extremity.PRE_START, Extremity.POST_END] + extremity: Literal[ + HierarchyUI.Extremity.PRE_START, HierarchyUI.Extremity.POST_END + ], ): try: return { - Extremity.PRE_START: Extremity.START, - Extremity.POST_END: Extremity.END, + HierarchyUI.Extremity.PRE_START: HierarchyUI.Extremity.START, + HierarchyUI.Extremity.POST_END: HierarchyUI.Extremity.END, }[extremity] except KeyError: raise ValueError("Unrecognized extremity") @@ -446,24 +462,24 @@ def frame_to_body_extremity( def handle_to_extremity(self, handle: HierarchyBodyHandle | HierarchyFrameHandle): try: return { - self.start_handle: Extremity.START, - self.end_handle: Extremity.END, - self.pre_start_handle: Extremity.PRE_START, - self.post_end_handle: Extremity.POST_END, + self.start_handle: HierarchyUI.Extremity.START, + self.end_handle: HierarchyUI.Extremity.END, + self.pre_start_handle: HierarchyUI.Extremity.PRE_START, + self.post_end_handle: HierarchyUI.Extremity.POST_END, }[handle] except KeyError: raise ValueError(f"{handle} if not a handle of {self}") @staticmethod - def extremity_to_x(extremity: Extremity, start_x, end_x): - if extremity == Extremity.START: + def extremity_to_x(extremity: HierarchyUI.Extremity, start_x, end_x): + if extremity == HierarchyUI.Extremity.START: return start_x - elif extremity == Extremity.END: + elif extremity == HierarchyUI.Extremity.END: return end_x else: raise ValueError("Unrecognized extremity") - def _setup_handle(self, extremity: Extremity): + def _setup_handle(self, extremity: HierarchyUI.Extremity): return HierarchyBodyHandle( self.extremity_to_x(extremity, self.start_x, self.end_x), self.HANDLE_WIDTH, @@ -488,6 +504,9 @@ def left_click_triggers(self): def on_left_click( self, item: HierarchyBodyHandle | HierarchyFrameHandle.VLine ) -> None: + # Avoid circular import + from .drag import start_drag + start_drag(self, item) def double_left_click_triggers(self): @@ -506,8 +525,8 @@ def on_select(self) -> None: self.body.on_select() if not self.selected_ascendants(): - self.update_frame_handle_visibility(Extremity.PRE_START) - self.update_frame_handle_visibility(Extremity.POST_END) + self.update_frame_handle_visibility(HierarchyUI.Extremity.PRE_START) + self.update_frame_handle_visibility(HierarchyUI.Extremity.POST_END) if selected_descendants := self.selected_descendants(): for ui in selected_descendants: diff --git a/tilia/ui/timelines/hierarchy/extremity.py b/tilia/ui/timelines/hierarchy/extremity.py deleted file mode 100644 index b1f848c1c..000000000 --- a/tilia/ui/timelines/hierarchy/extremity.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -from enum import Enum - - -class Extremity(Enum): - START = "start" - END = "end" - PRE_START = "pre_start" - POST_END = "post_end" diff --git a/tilia/ui/timelines/hierarchy/request_handlers.py b/tilia/ui/timelines/hierarchy/request_handlers.py deleted file mode 100644 index d3a2dc1c8..000000000 --- a/tilia/ui/timelines/hierarchy/request_handlers.py +++ /dev/null @@ -1,178 +0,0 @@ -import tilia.ui.strings -from tilia.requests import Post, get, Get, post -from tilia.settings import settings -from tilia.ui.timelines.base.request_handlers import ElementRequestHandler -from tilia.ui.timelines.hierarchy.copy_paste import ( - _display_copy_error, - _validate_copy_cardinality, - _validate_paste_complete_cardinality, - _validate_paste_complete_level, - _display_paste_complete_error, -) -from tilia.ui.timelines.hierarchy.extremity import Extremity - - -class HierarchyUIRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui): - super().__init__( - timeline_ui, - { - Post.HIERARCHY_INCREASE_LEVEL: self.on_increase_level, - Post.HIERARCHY_DECREASE_LEVEL: self.on_decrease_level, - Post.HIERARCHY_GROUP: self.on_group, - Post.HIERARCHY_SPLIT: self.on_split, - Post.HIERARCHY_MERGE: self.on_merge, - Post.HIERARCHY_CREATE_CHILD: self.on_create_child, - Post.HIERARCHY_ADD_PRE_START: self.on_add_pre_start, - Post.HIERARCHY_ADD_POST_END: self.on_add_post_end, - Post.TIMELINE_ELEMENT_PASTE_COMPLETE: self.on_paste_complete, - Post.TIMELINE_ELEMENT_DELETE: self.on_delete, - Post.TIMELINE_ELEMENT_COLOR_SET: self.on_color_set, - Post.TIMELINE_ELEMENT_COLOR_RESET: self.on_color_reset, - Post.TIMELINE_ELEMENT_EXPORT_AUDIO: self.on_export_audio, - Post.TIMELINE_ELEMENT_COPY: self.on_copy, - Post.TIMELINE_ELEMENT_PASTE: self.on_paste, - }, - ) - - def on_increase_level(self, elements, *_, **__): - min_margin = 10 - success = self.timeline.alter_levels( - self.elements_to_components(reversed(elements)), 1 - ) - if success: - max_height = self.timeline_ui.get_max_hierarchy_height() - if max_height > self.timeline_ui.get_data("height") + min_margin: - get(Get.TIMELINE_COLLECTION).set_timeline_data( - self.timeline_ui.id, "height", max_height + min_margin - ) - - return success - - def on_decrease_level(self, elements, *_, **__): - return self.timeline.alter_levels(self.elements_to_components(elements), -1) - - def on_group(self, elements, *_, **__): - return self.timeline.group(self.elements_to_components(elements)) - - def on_split(self, *_, **__): - return self.timeline.split(get(Get.SELECTED_TIME)) - - def on_merge(self, elements, *_, **__): - return self.timeline.merge(self.elements_to_components(elements)) - - def on_create_child(self, elements, *_, **__): - def _should_prompt_create_level_below() -> bool: - return settings.get("hierarchy_timeline", "prompt_create_level_below") - - def _prompt_create_level_below() -> bool: - return get( - Get.FROM_USER_YES_OR_NO, - tilia.ui.strings.PROMPT_CREATE_LEVEL_BELOW_TITLE, - tilia.ui.strings.PROMPT_CREATE_LEVEL_BELOW_MESSAGE, - ) - - if any([e.get_data("level") == 1 for e in elements]): - if not _should_prompt_create_level_below() or _prompt_create_level_below(): - self.on_increase_level(self.timeline_ui.elements, *_, **__) - else: - return False - - return self.timeline.create_children(self.elements_to_components(elements)) - - def on_add_pre_start(self, elements, value, *_, **__): - self.on_add_frame(elements, value, Extremity.PRE_START) - return True - - def on_add_post_end(self, elements, value, *_, **__): - self.on_add_frame(elements, value, Extremity.POST_END) - return True - - def on_add_frame(self, elements, value, extremity): - from tilia.ui.timelines.hierarchy.element import HierarchyUI - - elements_to_set = [] - x_attr = extremity.value + "_x" - for elm in elements: - elements_to_set += self.timeline_ui.get_elements_by_attr( - x_attr, getattr(elm, x_attr) - ) - - time_offset = value if extremity == Extremity.PRE_START else value * -1 - time = ( - elements_to_set[0].get_data( - HierarchyUI.frame_to_body_extremity(extremity).value - ) - - time_offset - ) - self.timeline_ui.set_elements_attr(elements_to_set, extremity.value, time) - - def on_delete(self, elements, *_, **__): - self.timeline.delete_components(self.elements_to_components(elements)) - return True - - def on_color_set(self, elements, value, **_): - self.timeline_ui.set_elements_attr(elements, "color", value.name()) - return True - - def on_color_reset(self, elements, *_, **__): - self.timeline_ui.set_elements_attr( - elements, - "color", - None, - ) - return True - - @staticmethod - def on_export_audio(elements, *_, **__): - for elm in elements: - post( - Post.PLAYER_EXPORT_AUDIO, - segment_name=elm.full_name, - start_time=elm.get_data("start"), - end_time=elm.get_data("end"), - ) - return False # this action shouldn't be recorded in undo manager - - def on_copy(self, elements): - success, reason = _validate_copy_cardinality(elements) - if not success: - _display_copy_error(reason) - - component_data = [ - self.timeline_ui.get_copy_data_from_hierarchy_ui(e) for e in elements - ] - - if not component_data: - return False - - post( - Post.TIMELINE_ELEMENT_COPY_DONE, - {"components": component_data, "timeline_kind": self.timeline.KIND}, - ) - - return True - - def on_paste_complete(self, *_, **__) -> bool: - copied_components = get(Get.CLIPBOARD_CONTENTS)["components"] - if not copied_components or not self.timeline_ui.has_selected_elements: - return False - - success, reason = _validate_paste_complete_cardinality(copied_components) - if not success: - _display_paste_complete_error(reason) - return False - - data = copied_components[0] - for element in self.timeline_ui.selected_elements: - success, reason = _validate_paste_complete_level(element, data) - if not success: - _display_paste_complete_error(reason) - return False - - while children := element.get_data("children"): - self.timeline.delete_components(children) - - self.timeline_ui.paste_with_children_into_element(data, element) - - return True diff --git a/tilia/ui/timelines/hierarchy/timeline.py b/tilia/ui/timelines/hierarchy/timeline.py index 2fe0063a0..7a9210b88 100644 --- a/tilia/ui/timelines/hierarchy/timeline.py +++ b/tilia/ui/timelines/hierarchy/timeline.py @@ -1,22 +1,29 @@ -from __future__ import annotations - -from tilia.requests import get, Get, Post, listen +from tilia.requests import get, Get, Post, listen, post from tilia.ui.timelines.base.timeline import ( TimelineUI, + with_elements, ) -from tilia.ui.timelines.collection.requests.enums import ElementSelector from tilia.ui.timelines.hierarchy import HierarchyTimelineToolbar, HierarchyUI from tilia.ui.timelines.copy_paste import get_copy_data_from_element import tilia.ui.timelines.copy_paste from tilia.ui.timelines.copy_paste import paste_into_element from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind +from tilia.ui.timelines.hierarchy.copy_paste import ( + _validate_copy_cardinality, + _display_copy_error, + _validate_paste_complete_cardinality, + _display_paste_complete_error, + _validate_paste_complete_level, +) from tilia.ui.timelines.hierarchy.handles import HierarchyBodyHandle from tilia.ui.timelines.hierarchy.key_press_manager import ( HierarchyTimelineUIKeyPressManager, ) -from tilia.ui.timelines.hierarchy.request_handlers import HierarchyUIRequestHandler from tilia.undo_manager import PauseUndoManager +from tilia.settings import settings +import tilia.ui.strings +from tilia.ui.timelines.collection.collection import TimelineUIs, TimelineSelector class HierarchyTimelineUI(TimelineUI): @@ -34,6 +41,59 @@ def __init__(self, *args, **kwargs): self.on_settings_updated, ) + @classmethod + def register_commands(cls, collection: TimelineUIs): + args = [ + ("add_post_end", "Add post-end", "", ""), + ("add_pre_start", "Add pre-start", "", ""), + ( + "create_child", + "Create child", + "", + "below30", + ), + ( + "decrease_level", + "Move down a level", + "Ctrl+Down", + "lvldwn30", + ), + ("group", "Group", "g", "group30"), + ( + "increase_level", + "Move up a level", + "Ctrl+Up", + "lvlup30", + ), + ("merge", "Merge", "e", "merge30"), + ( + "split", + "Split at current position", + "s", + "split30", + ), + ( + "export_audio", + "Export to audio", + "", + "", + ), + ] + + for name, text, shortcut, icon in args: + selector = ( + TimelineSelector.SELECTED if name != "split" else TimelineSelector.FIRST + ) + cls.register_timeline_command( + collection, + name, + getattr(cls, "on_" + name), + selector, + text=text, + shortcut=shortcut, + icon=icon, + ) + def on_settings_updated(self, updated_settings): if "hierarchy_timeline" in updated_settings: get(Get.TIMELINE_COLLECTION).set_timeline_data( @@ -43,13 +103,6 @@ def on_settings_updated(self, updated_settings): hierarchy_ui.update_position() hierarchy_ui.update_color() - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return HierarchyUIRequestHandler(self).on_request( - request, selector, *args, **kwargs - ) - def get_handle_by_x(self, x: float): def starts_or_ends_at_time(ui: HierarchyUI) -> bool: return ui.start_x == x or ui.end_x == x @@ -228,3 +281,150 @@ def get_max_hierarchy_height(self): return HierarchyUI.base_height() + ( HierarchyUI.x_increment_per_lvl() * max_level ) + + @with_elements + def on_copy_element(self, elements: list[HierarchyUI]) -> bool: + success, reason = _validate_copy_cardinality(elements) + if not success: + _display_copy_error(reason) + + component_data = [self.get_copy_data_from_hierarchy_ui(e) for e in elements] + + if not component_data: + return False + + post( + Post.TIMELINE_ELEMENT_COPY_DONE, + {"components": component_data, "timeline_kind": self.timeline.KIND}, + ) + + return True + + def on_paste_element_complete(self, clipboard_contents: dict) -> bool: + copied_components = clipboard_contents["components"] + if not copied_components or not self.has_selected_elements: + return False + + success, reason = _validate_paste_complete_cardinality(copied_components) + if not success: + _display_paste_complete_error(reason) + return False + + data = copied_components[0] + for element in self.selected_elements: + success, reason = _validate_paste_complete_level(element, data) + if not success: + _display_paste_complete_error(reason) + return False + + while children := element.get_data("children"): + self.timeline.delete_components(children) + + self.paste_with_children_into_element(data, element) + + return True + + @with_elements + def on_increase_level(self, elements: list[HierarchyUI]) -> bool: + min_margin = 10 + success = self.timeline.alter_levels( + self.elements_to_components(list(reversed(elements))), 1 + ) + if success: + max_height = self.get_max_hierarchy_height() + if max_height > self.get_data("height") + min_margin: + get(Get.TIMELINE_COLLECTION).set_timeline_data( + self.id, "height", max_height + min_margin + ) + + return success + + @with_elements + def on_decrease_level(self, elements: list[HierarchyUI]): + return self.timeline.alter_levels(self.elements_to_components(elements), -1) + + @with_elements + def on_group(self, elements: list[HierarchyUI]): + return self.timeline.group(self.elements_to_components(elements)) + + def on_split(self, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) + return self.timeline.split(time) + + @with_elements + def on_merge(self, elements: list[HierarchyUI]): + return self.timeline.merge(self.elements_to_components(elements)) + + @with_elements + def on_create_child(self, elements: list[HierarchyUI]): + def _should_prompt_create_level_below() -> bool: + return settings.get("hierarchy_timeline", "prompt_create_level_below") + + def _prompt_create_level_below() -> bool: + return get( + Get.FROM_USER_YES_OR_NO, + tilia.ui.strings.PROMPT_CREATE_LEVEL_BELOW_TITLE, + tilia.ui.strings.PROMPT_CREATE_LEVEL_BELOW_MESSAGE, + ) + + if any([e.get_data("level") == 1 for e in elements]): + if not _should_prompt_create_level_below() or _prompt_create_level_below(): + self.on_increase_level(self.elements) + else: + return False + + return self.timeline.create_children(self.elements_to_components(elements)) + + @with_elements + def on_add_pre_start(self, elements: list[HierarchyUI]): + accept, value = get(Get.FROM_USER_FLOAT, "Add pre-start", "Pre-start length") + if not accept: + return False + + self._on_add_frame(elements, value, HierarchyUI.Extremity.PRE_START) + return True + + @with_elements + def on_add_post_end(self, elements: list[HierarchyUI]): + accept, value = get(Get.FROM_USER_FLOAT, "Add post-end", "Post-end length") + if not accept: + return False + + self._on_add_frame(elements, value, HierarchyUI.Extremity.POST_END) + return True + + def _on_add_frame( + self, + elements: list[HierarchyUI], + value: float, + extremity: HierarchyUI.Extremity, + ): + from tilia.ui.timelines.hierarchy.element import HierarchyUI + + elements_to_set = [] + x_attr = extremity.value + "_x" + for elm in elements: + elements_to_set += self.get_elements_by_attr(x_attr, getattr(elm, x_attr)) + + time_offset = ( + value if extremity == HierarchyUI.Extremity.PRE_START else value * -1 + ) + time = ( + elements_to_set[0].get_data( + HierarchyUI.frame_to_body_extremity(extremity).value + ) + - time_offset + ) + self.set_elements_attr(elements_to_set, extremity.value, time) + + @with_elements + def on_export_audio(self, elements: list[HierarchyUI]) -> bool: + for elm in elements: + post( + Post.PLAYER_EXPORT_AUDIO, + segment_name=elm.full_name, + start_time=elm.get_data("start"), + end_time=elm.get_data("end"), + ) + return False diff --git a/tilia/ui/timelines/hierarchy/toolbar.py b/tilia/ui/timelines/hierarchy/toolbar.py index d22ee50c1..c6cb7fcff 100644 --- a/tilia/ui/timelines/hierarchy/toolbar.py +++ b/tilia/ui/timelines/hierarchy/toolbar.py @@ -1,13 +1,12 @@ -from tilia.ui.actions import TiliaAction from tilia.ui.timelines.toolbar import TimelineToolbar class HierarchyTimelineToolbar(TimelineToolbar): - ACTIONS = [ - TiliaAction.HIERARCHY_SPLIT, - TiliaAction.HIERARCHY_MERGE, - TiliaAction.HIERARCHY_GROUP, - TiliaAction.HIERARCHY_INCREASE_LEVEL, - TiliaAction.HIERARCHY_DECREASE_LEVEL, - TiliaAction.HIERARCHY_CREATE_CHILD, + COMMANDS = [ + "timeline.hierarchy.split", + "timeline.hierarchy.merge", + "timeline.hierarchy.group", + "timeline.hierarchy.increase_level", + "timeline.hierarchy.decrease_level", + "timeline.hierarchy.create_child", ] diff --git a/tilia/ui/timelines/marker/context_menu.py b/tilia/ui/timelines/marker/context_menu.py index 7d60cc99b..42cb163c9 100644 --- a/tilia/ui/timelines/marker/context_menu.py +++ b/tilia/ui/timelines/marker/context_menu.py @@ -1,4 +1,3 @@ -from tilia.ui.actions import TiliaAction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import ( TimelineUIElementContextMenu, @@ -9,18 +8,18 @@ class MarkerContextMenu(TimelineUIElementContextMenu): name = "Marker" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_INSPECT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COLOR_SET), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COLOR_RESET), + (MenuItemKind.COMMAND, "timeline.component.set_color"), + (MenuItemKind.COMMAND, "timeline.component.reset_color"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_DELETE), + (MenuItemKind.COMMAND, "timeline.component.delete"), ] class MarkerTimelineUIContextMenu(TimelineUIContextMenu): name = "Marker timeline" - items = [(MenuItemKind.ACTION, TiliaAction.TIMELINE_NAME_SET)] + items = [(MenuItemKind.COMMAND, "timeline.set_name")] diff --git a/tilia/ui/timelines/marker/request_handlers.py b/tilia/ui/timelines/marker/request_handlers.py deleted file mode 100644 index b46b0cd06..000000000 --- a/tilia/ui/timelines/marker/request_handlers.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from tilia.requests import Post, get, Get -from tilia.timelines.component_kinds import ComponentKind -from tilia.ui.timelines.base.request_handlers import ElementRequestHandler - -if TYPE_CHECKING: - from tilia.ui.timelines.marker import MarkerTimelineUI - - -class MarkerUIRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui: MarkerTimelineUI): - - super().__init__( - timeline_ui, - { - Post.MARKER_ADD: self.on_add, - Post.TIMELINE_ELEMENT_DELETE: self.on_delete, - Post.TIMELINE_ELEMENT_COLOR_SET: self.on_color_set, - Post.TIMELINE_ELEMENT_COLOR_RESET: self.on_color_reset, - Post.TIMELINE_ELEMENT_COPY: self.on_copy, - Post.TIMELINE_ELEMENT_PASTE: self.on_paste, - }, - ) - - def on_color_set(self, elements, value, **_): - self.timeline_ui.set_elements_attr(elements, "color", value.name()) - return True - - def on_color_reset(self, elements, *_, **__): - self.timeline_ui.set_elements_attr(elements, "color", None) - return True - - def on_add(self, *_, **__): - component, _ = self.timeline.create_component( - ComponentKind.MARKER, get(Get.SELECTED_TIME) - ) - return False if component is None else True - - def on_delete(self, elements, *_, **__): - self.timeline.delete_components(self.elements_to_components(elements)) - return True diff --git a/tilia/ui/timelines/marker/timeline.py b/tilia/ui/timelines/marker/timeline.py index b0ac9de5f..a6711ea92 100644 --- a/tilia/ui/timelines/marker/timeline.py +++ b/tilia/ui/timelines/marker/timeline.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +from typing import TYPE_CHECKING from tilia.timelines.component_kinds import ComponentKind from tilia.requests import Get, get, listen, Post @@ -10,16 +11,18 @@ from tilia.ui.timelines.base.timeline import ( TimelineUI, ) -from tilia.ui.timelines.collection.requests.enums import ElementSelector +from tilia.ui.timelines.collection.collection import TimelineSelector from tilia.ui.timelines.marker.context_menu import MarkerTimelineUIContextMenu from tilia.ui.timelines.marker.element import MarkerUI -from tilia.ui.timelines.marker.request_handlers import MarkerUIRequestHandler from tilia.ui.timelines.marker.toolbar import MarkerTimelineToolbar from tilia.ui.timelines.copy_paste import ( paste_into_element, ) +if TYPE_CHECKING: + from tilia.ui.timelines.base.timeline import TimelineUIs + class MarkerTimelineUI(TimelineUI): TOOLBAR_CLASS = MarkerTimelineToolbar @@ -46,12 +49,23 @@ def on_settings_updated(self, updated_settings): marker_ui.update_time() marker_ui.update_color() - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return MarkerUIRequestHandler(self).on_request( - request, selector, *args, **kwargs + @classmethod + def register_commands(cls, collection: TimelineUIs): + cls.register_timeline_command( + collection, + "add", + cls.on_add, + TimelineSelector.FIRST, + text="Add marker at current position", + shortcut="m", + icon="add_marker30", + ) + + def on_add(self): + component, failure_reason = self.timeline.create_component( + ComponentKind.MARKER, get(Get.SELECTED_TIME) ) + return bool(component) def _deselect_all_but_last(self): if len(self.selected_elements) > 1: diff --git a/tilia/ui/timelines/marker/toolbar.py b/tilia/ui/timelines/marker/toolbar.py index aa633c0e8..e190223f6 100644 --- a/tilia/ui/timelines/marker/toolbar.py +++ b/tilia/ui/timelines/marker/toolbar.py @@ -1,8 +1,7 @@ from __future__ import annotations -from tilia.ui.actions import TiliaAction from tilia.ui.timelines.toolbar import TimelineToolbar class MarkerTimelineToolbar(TimelineToolbar): - ACTIONS = [TiliaAction.MARKER_ADD] + COMMANDS = ["timeline.marker.add"] diff --git a/tilia/ui/timelines/pdf/context_menu.py b/tilia/ui/timelines/pdf/context_menu.py index b258d7d08..0cd26e2d6 100644 --- a/tilia/ui/timelines/pdf/context_menu.py +++ b/tilia/ui/timelines/pdf/context_menu.py @@ -1,4 +1,3 @@ -from tilia.ui.actions import TiliaAction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import ( TimelineUIElementContextMenu, @@ -9,15 +8,15 @@ class PdfMarkerContextMenu(TimelineUIElementContextMenu): name = "PDF Marker" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_INSPECT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COPY), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_PASTE), + (MenuItemKind.COMMAND, "timeline.component.copy"), + (MenuItemKind.COMMAND, "timeline.component.paste"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_DELETE), + (MenuItemKind.COMMAND, "timeline.component.delete"), ] class PdfTimelineUIContextMenu(TimelineUIContextMenu): name = "PDF timeline" - items = [(MenuItemKind, TiliaAction.TIMELINE_NAME_SET)] + items = [(MenuItemKind, "timeline.set_name")] diff --git a/tilia/ui/timelines/pdf/request_handlers.py b/tilia/ui/timelines/pdf/request_handlers.py deleted file mode 100644 index 6b02e5410..000000000 --- a/tilia/ui/timelines/pdf/request_handlers.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import tilia.errors -from tilia.requests import Post, get, Get -from tilia.timelines.component_kinds import ComponentKind -from tilia.ui.timelines.base.request_handlers import ElementRequestHandler - -if TYPE_CHECKING: - from tilia.ui.timelines.pdf import PdfTimelineUI - - -class PdfMarkerUIRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui: PdfTimelineUI): - - super().__init__( - timeline_ui, - { - Post.PDF_MARKER_ADD: self.on_add, - Post.TIMELINE_ELEMENT_DELETE: self.on_delete, - Post.TIMELINE_ELEMENT_COPY: self.on_copy, - Post.TIMELINE_ELEMENT_PASTE: self.on_paste, - }, - ) - - def on_add(self, *_, **__): - time = get(Get.SELECTED_TIME) - - page_number = min( - self.timeline.get_previous_page_number(time) + 1, self.timeline.page_total - ) - - pdf_marker, reason = self.timeline.create_component( - ComponentKind.PDF_MARKER, get(Get.SELECTED_TIME), page_number - ) - if not pdf_marker: - tilia.errors.display(tilia.errors.ADD_PDF_MARKER_FAILED, reason) - return False - return True - - def on_delete(self, elements, *_, **__): - self.timeline.delete_components(self.elements_to_components(elements)) - return True diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index b6ec0c04f..6ca1576cb 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -17,19 +17,17 @@ from tilia.ui.timelines.base.timeline import ( TimelineUI, ) -from tilia.ui.timelines.collection.requests.enums import ElementSelector from tilia.ui.timelines.copy_paste import ( paste_into_element, ) from tilia.ui.timelines.pdf.context_menu import PdfTimelineUIContextMenu from tilia.ui.timelines.pdf.element import PdfMarkerUI -from tilia.ui.timelines.pdf.request_handlers import PdfMarkerUIRequestHandler from tilia.ui.timelines.pdf.toolbar import PdfTimelineToolbar from tilia.ui.windows.view_window import ViewWindow +from tilia.ui.timelines.collection.collection import TimelineUIs, TimelineSelector if TYPE_CHECKING: - from tilia.ui.timelines.collection.collection import TimelineUIs from tilia.ui.timelines.base.element_manager import ElementManager from tilia.ui.timelines.scene import TimelineScene from tilia.ui.timelines.view import TimelineView @@ -106,12 +104,32 @@ def page_total(self): return 99999999 return self.timeline.get_data("page_total") - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return PdfMarkerUIRequestHandler(self).on_request( - request, selector, *args, **kwargs + @classmethod + def register_commands(cls, collection: TimelineUIs): + cls.register_timeline_command( + collection, + "add", + cls.on_add, + TimelineSelector.FIRST, + text="Add PDF marker", + shortcut="p", + icon="pdf_add", + ) + + def on_add(self): + time = get(Get.SELECTED_TIME) + + page_number = min( + self.timeline.get_previous_page_number(time) + 1, self.timeline.page_total + ) + + pdf_marker, reason = self.timeline.create_component( + ComponentKind.PDF_MARKER, get(Get.SELECTED_TIME), page_number ) + if not pdf_marker: + tilia.errors.display(tilia.errors.ADD_PDF_MARKER_FAILED, reason) + return False + return True def on_timeline_component_created( self, kind: ComponentKind, id: int, get_data, set_data diff --git a/tilia/ui/timelines/pdf/toolbar.py b/tilia/ui/timelines/pdf/toolbar.py index aa6f8b5f7..5b913e2d8 100644 --- a/tilia/ui/timelines/pdf/toolbar.py +++ b/tilia/ui/timelines/pdf/toolbar.py @@ -1,8 +1,7 @@ from __future__ import annotations -from tilia.ui.actions import TiliaAction from tilia.ui.timelines.toolbar import TimelineToolbar class PdfTimelineToolbar(TimelineToolbar): - ACTIONS = [TiliaAction.PDF_MARKER_ADD] + COMMANDS = ["timeline.pdf.add"] diff --git a/tilia/ui/timelines/score/context_menu.py b/tilia/ui/timelines/score/context_menu.py index 4e95c6934..d96f619d8 100644 --- a/tilia/ui/timelines/score/context_menu.py +++ b/tilia/ui/timelines/score/context_menu.py @@ -1,4 +1,3 @@ -from tilia.ui.actions import TiliaAction from tilia.ui.menus import MenuItemKind from tilia.ui.timelines.base.context_menus import TimelineUIElementContextMenu @@ -6,13 +5,13 @@ class NoteContextMenu(TimelineUIElementContextMenu): name = "Note" items = [ - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_INSPECT), + (MenuItemKind.COMMAND, "timeline.element.inspect"), (MenuItemKind.SEPARATOR, None), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COLOR_SET), - (MenuItemKind.ACTION, TiliaAction.TIMELINE_ELEMENT_COLOR_RESET), + (MenuItemKind.COMMAND, "timeline.component.set_color"), + (MenuItemKind.COMMAND, "timeline.component.reset_color"), ] class ScoreTimelineUIContextMenu(TimelineUIElementContextMenu): name = "Beat timeline" - items = [(MenuItemKind, TiliaAction.TIMELINE_NAME_SET)] + items = [(MenuItemKind, "timeline.set_name")] diff --git a/tilia/ui/timelines/score/request_handlers.py b/tilia/ui/timelines/score/request_handlers.py deleted file mode 100644 index 1022ec83f..000000000 --- a/tilia/ui/timelines/score/request_handlers.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from tilia.requests import Post -from tilia.ui.timelines.base.request_handlers import ElementRequestHandler - -if TYPE_CHECKING: - from tilia.ui.timelines.score.timeline import ScoreTimelineUI - - -class ScoreTimelineUIElementRequestHandler(ElementRequestHandler): - def __init__(self, timeline_ui: ScoreTimelineUI): - - super().__init__( - timeline_ui, - { - Post.TIMELINE_ELEMENT_DELETE: self.on_delete, - Post.TIMELINE_ELEMENT_COLOR_SET: self.on_color_set, - Post.TIMELINE_ELEMENT_COLOR_RESET: self.on_color_reset, - }, - ) - - def on_color_set(self, elements, value, **_): - self.timeline_ui.set_elements_attr(elements, "color", value.name()) - return True - - def on_color_reset(self, elements, *_, **__): - self.timeline_ui.set_elements_attr(elements, "color", None) - return True - - def on_delete(self, *_, **__): - return False - - def on_copy(self, *_, **__): - return False - - def on_paste(self, *_, **__): - return False diff --git a/tilia/ui/timelines/score/timeline.py b/tilia/ui/timelines/score/timeline.py index c3ab18c1b..bf0151e5a 100644 --- a/tilia/ui/timelines/score/timeline.py +++ b/tilia/ui/timelines/score/timeline.py @@ -19,7 +19,6 @@ from tilia.ui.coords import time_x_converter from tilia.ui.smooth_scroll import setup_smooth, smooth from tilia.ui.timelines.base.timeline import TimelineUI -from tilia.ui.timelines.collection.requests.enums import ElementSelector from tilia.ui.timelines.cursors import CursorMixIn from tilia.ui.timelines.drag import DragManager from tilia.ui.timelines.score.context_menu import ScoreTimelineUIContextMenu @@ -34,9 +33,6 @@ from tilia.ui.timelines.score.element.with_collision import ( TimelineUIElementWithCollision, ) -from tilia.ui.timelines.score.request_handlers import ( - ScoreTimelineUIElementRequestHandler, -) from tilia.ui.timelines.score.toolbar import ScoreTimelineToolbar from tilia.ui.windows.svg_viewer import SvgViewer @@ -113,13 +109,6 @@ def on_settings_updated(self, updated_settings): if "score_timeline" in updated_settings: self.measure_tracker.update_color() - def on_timeline_element_request( - self, request, selector: ElementSelector, *args, **kwargs - ): - return ScoreTimelineUIElementRequestHandler(self).on_request( - request, selector, *args, **kwargs - ) - def update_name(self): name = self.get_data("name") self.scene.set_text(name) diff --git a/tilia/ui/timelines/score/toolbar.py b/tilia/ui/timelines/score/toolbar.py index ea2a2d5f1..1e4d4e0f6 100644 --- a/tilia/ui/timelines/score/toolbar.py +++ b/tilia/ui/timelines/score/toolbar.py @@ -4,4 +4,4 @@ class ScoreTimelineToolbar(TimelineToolbar): - ACTIONS = [] + COMMANDS = [] diff --git a/tilia/ui/timelines/toolbar.py b/tilia/ui/timelines/toolbar.py index 59d924f2c..00232bb87 100644 --- a/tilia/ui/timelines/toolbar.py +++ b/tilia/ui/timelines/toolbar.py @@ -1,11 +1,11 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QToolBar -from tilia.ui import actions +from tilia.ui import commands class TimelineToolbar(QToolBar): - ACTIONS = [] + COMMANDS = [] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -13,5 +13,5 @@ def __init__(self, *args, **kwargs): self.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu) self.visible = False self._visible_timelines_count = 0 - for action in self.ACTIONS: - self.addAction(actions.get_qaction(action)) + for command in self.COMMANDS: + self.addAction(commands.get_qaction(command)) diff --git a/tilia/ui/windows/manage_timelines.py b/tilia/ui/windows/manage_timelines.py index e9aa1a32e..d56fb7d25 100644 --- a/tilia/ui/windows/manage_timelines.py +++ b/tilia/ui/windows/manage_timelines.py @@ -19,12 +19,12 @@ Get, get, listen, - serve, stop_listening_to_all, stop_serving_all, ) from tilia.timelines.base.timeline import TimelineFlag from tilia.timelines.timeline_kinds import TimelineKind +from tilia.ui import commands from tilia.ui.timelines.base.timeline import TimelineUI from tilia.ui.windows import WindowKind @@ -35,7 +35,6 @@ def __init__(self): self.setWindowTitle("Manage Timelines") self._setup_widgets() self._setup_checkbox() - self._setup_requests() self.show() post(Post.WINDOW_OPEN_DONE, WindowKind.MANAGE_TIMELINES) @@ -74,21 +73,6 @@ def _setup_widgets(self): layout.addLayout(right_layout) - def _setup_requests(self): - SERVES = { - ( - Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_TO_PERMUTE, - self.get_timeline_uis_to_permute, - ), - ( - Get.WINDOW_MANAGE_TIMELINES_TIMELINE_UIS_CURRENT, - self.get_current_timeline_ui, - ), - } - - for request, callback in SERVES: - serve(self, request, callback) - def _setup_checkbox(self): self.on_list_current_item_changed(self.list_widget.currentItem()) @@ -150,7 +134,6 @@ def __init__(self): self._setup_items() self.setCurrentRow(0) - self.timeline_uis_to_permute = None self._setup_requests() def _setup_requests(self): @@ -204,9 +187,9 @@ def on_up_button(self): index = self.selectedIndexes()[0].row() previous = self.item(index - 1) if previous: - self.timeline_uis_to_permute = (selected.timeline_ui, previous.timeline_ui) - post(Post.TIMELINE_ORDINAL_DECREASE_FROM_MANAGE_TIMELINES) - self.timeline_uis_to_permute = None + commands.execute( + "timelines.permute_ordinal", selected.timeline_ui, previous.timeline_ui + ) def on_down_button(self): if not self.selectedIndexes(): @@ -215,14 +198,16 @@ def on_down_button(self): index = self.selectedIndexes()[0].row() next_item = self.item(index + 1) if next_item: - self.timeline_uis_to_permute = (selected.timeline_ui, next_item.timeline_ui) - post(Post.TIMELINE_ORDINAL_INCREASE_FROM_MANAGE_TIMELINES) - self.timeline_uis_to_permute = None + commands.execute( + "timelines.permute_ordinal", selected.timeline_ui, next_item.timeline_ui + ) - @staticmethod - def on_delete_button(): - post(Post.TIMELINE_DELETE_FROM_MANAGE_TIMELINES) + @property + def selected_timeline_ui(self): + return self.selectedItems()[0].timeline_ui - @staticmethod - def on_clear_button(): - post(Post.TIMELINE_CLEAR_FROM_MANAGE_TIMELINES) + def on_delete_button(self): + commands.execute("timeline.delete", self.selected_timeline_ui) + + def on_clear_button(self): + commands.execute("timeline.clear", self.selected_timeline_ui) diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index d00b573b6..6b702afbe 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -26,7 +26,8 @@ ) from PyQt6.QtSvg import QSvgRenderer -from tilia.ui.actions import TiliaAction, get_qaction +from tilia.ui import commands +from tilia.ui.commands import get_qaction from tilia.ui.smooth_scroll import smooth, setup_smooth from tilia.ui.windows.view_window import ViewDockWidget @@ -98,23 +99,50 @@ def __setup_score_viewer(self) -> None: self._update_scroll_margins() def _get_toolbar(self) -> QVBoxLayout: - def get_button(qaction, callback): - button = QToolButton() - qaction.triggered.connect(callback) - button.setDefaultAction(qaction) - return button - - actions = [ - (TiliaAction.SCORE_ANNOTATION_ADD, self.annotation_add), - (TiliaAction.SCORE_ANNOTATION_EDIT, self.annotation_edit), - (TiliaAction.SCORE_ANNOTATION_DELETE, self.annotation_delete), - (TiliaAction.SCORE_ANNOTATION_FONT_INC, self.annotation_font_inc), - (TiliaAction.SCORE_ANNOTATION_FONT_DEC, self.annotation_font_dec), + command_data = [ + ( + "timeline.score.add", + self.annotation_add, + "Add Annotation (Return)", + "", + "annotation_add", + ), + ( + "timeline.score.delete", + self.annotation_delete, + "Delete Annotation (Delete)", + "", + "annotation_delete", + ), + ( + "timeline.score.edit", + self.annotation_edit, + "Edit Annotation", + "Shift+Return", + "annotation_edit", + ), + ( + "timeline.score.font_dec", + self.annotation_font_dec, + "Decrease Annotation Font", + "Shift+Down", + "annotation_font_dec", + ), + ( + "timeline.score.font_inc", + self.annotation_font_inc, + "Increase Annotation Font", + "Shift+Up", + "annotation_font_inc", + ), ] v_toolbar = QVBoxLayout() - for taction, callback in actions: - button = get_button(get_qaction(taction), callback) + + for name, callback, text, shortcut, icon in command_data: + commands.register(name, callback, text, shortcut, icon) + button = QToolButton() + button.setDefaultAction(get_qaction(name)) v_toolbar.addWidget(button) return v_toolbar diff --git a/tilia/undo_manager.py b/tilia/undo_manager.py index 212d0aa0c..248e4cc81 100644 --- a/tilia/undo_manager.py +++ b/tilia/undo_manager.py @@ -16,14 +16,7 @@ def __str__(self): return get_tilia_class_string(self) def _setup_requests(self): - LISTENS = { - (Post.EDIT_UNDO, self.undo), - (Post.EDIT_REDO, self.redo), - (Post.UNDO_MANAGER_SET_IS_RECORDING, self.set_is_recording), - } - - for post_, callback in LISTENS: - listen(self, post_, callback) + listen(self, Post.UNDO_MANAGER_SET_IS_RECORDING, self.set_is_recording) @property def is_cleared(self): @@ -72,6 +65,8 @@ def undo(self): self.current_state_index -= 1 + post(Post.APP_STATE_UNDO_OR_REDO_DONE) + def redo(self): if self.current_state_index == -1: return From 63abd6b78e75772534832a9b23494fb33405de7f Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 12 Nov 2025 16:32:41 +0100 Subject: [PATCH 15/47] fix: detection of empty timelines Also add tests for clear commands --- .../hierarchy/test_hierarchy_timeline_ui.py | 30 +++++++++++++++++++ tilia/timelines/hierarchy/timeline.py | 19 ++++++++++++ tilia/timelines/slider/timeline.py | 4 +++ tilia/ui/timelines/base/timeline.py | 2 +- tilia/ui/timelines/collection/import_.py | 2 +- 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py index 71094c91b..653a16715 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py @@ -625,3 +625,33 @@ def test_prompt_create_level_below_is_false(self, tlui): commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 2 + + +class TestClear: + def test_initial_hierarchy_doesnt_trigger_confirmation(self, tlui, tilia_state): + tlui.create_hierarchy(0, tilia_state.duration, 1) + + commands.execute("timeline.clear", tlui) + + assert tlui.is_empty + + def test_initial_hierarchy_with_edited_label_triggers_confirmation( + self, tlui, tilia_state + ): + tlui.create_hierarchy(0, tilia_state.duration, 1, label="I WAS EDITED") + + with patch_yes_or_no_dialog(False): + commands.execute("timeline.clear", tlui) + + # we must test explictly for len, to ensure the component was not deleted + assert len(tlui) == 1 + + def test_not_empty(self, tlui): + tlui.create_hierarchy(0, 1, 1) + tlui.create_hierarchy(1, 2, 1) + tlui.create_hierarchy(2, 3, 1) + + with patch_yes_or_no_dialog(True): + commands.execute("timeline.clear", tlui) + + assert tlui.is_empty diff --git a/tilia/timelines/hierarchy/timeline.py b/tilia/timelines/hierarchy/timeline.py index aff745558..660337ab4 100644 --- a/tilia/timelines/hierarchy/timeline.py +++ b/tilia/timelines/hierarchy/timeline.py @@ -428,6 +428,25 @@ class HierarchyTimeline(Timeline): def default_height(self): return settings.get("hierarchy_timeline", "default_height") + @property + def is_empty(self): + if len(self) == 0: + return True + if len(self) == 1: + # Initial hierarchy shouldn't make the timeline not empty + hrc = self[0] + return all( + [ + hrc.start == 0, + hrc.end == get(Get.MEDIA_DURATION), + not hrc.label, + not hrc.comments, + not hrc.color, + hrc.level == 1, + ] + ) + return False + def setup_blank_timeline(self): """Create unit of level 1 encompassing whole timeline""" self.create_component( diff --git a/tilia/timelines/slider/timeline.py b/tilia/timelines/slider/timeline.py index 03f97caf9..25035be2c 100644 --- a/tilia/timelines/slider/timeline.py +++ b/tilia/timelines/slider/timeline.py @@ -28,6 +28,10 @@ def default_height(self): def components(self): return [] + @property + def is_empty(self): + return True + def _validate_delete_components(self, component: TimelineComponent): """Nothing to do. Must impement abstract method.""" diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index 7a41ed273..cd6fd6e50 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -126,7 +126,7 @@ def ensure_subclasses_are_available(cls): @property def is_empty(self): - return len(self) == 0 + return self.timeline.is_empty @property def timeline(self): diff --git a/tilia/ui/timelines/collection/import_.py b/tilia/ui/timelines/collection/import_.py index ca7ac2541..d2ebfd081 100644 --- a/tilia/ui/timelines/collection/import_.py +++ b/tilia/ui/timelines/collection/import_.py @@ -34,7 +34,7 @@ def _on_import_to_timeline( return "cancelled", ["User cancelled when choosing timeline."] timeline = get(Get.TIMELINE, timeline_ui.id) - if timeline.components and not _confirm_timeline_overwrite_on_import(): + if not timeline.is_empty and not _confirm_timeline_overwrite_on_import(): return "cancelled", ["User rejected components overwrite."] if tlkind == TlKind.SCORE_TIMELINE: From d33d58191be1a19e345e1f28ab2c6cba9b0d2a77 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 12 Nov 2025 16:38:29 +0100 Subject: [PATCH 16/47] refactor: take "time" parameters to on_add method of point-like TimelineUI subclasses --- tilia/ui/timelines/beat/timeline.py | 9 +++++---- tilia/ui/timelines/harmony/timeline.py | 12 ++++++++---- tilia/ui/timelines/marker/timeline.py | 6 ++++-- tilia/ui/timelines/pdf/timeline.py | 5 +++-- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index 9be25d8bb..dc73e216e 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -152,11 +152,12 @@ def on_set_amount_in_measure(self, elements: list[BeatUI] | None = None): self.timeline.set_beat_amount_in_measure(i, amount) return True - def on_add(self, *_, **__): + 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, get(Get.SELECTED_TIME) - ) + component, _ = self.timeline.create_component(ComponentKind.BEAT, time) self.timeline.recalculate_measures() return False if component is None else True diff --git a/tilia/ui/timelines/harmony/timeline.py b/tilia/ui/timelines/harmony/timeline.py index 1e40cdce4..ce94f47fc 100644 --- a/tilia/ui/timelines/harmony/timeline.py +++ b/tilia/ui/timelines/harmony/timeline.py @@ -224,8 +224,10 @@ def create_pasted_components( **harmony_data["by_component_value"], ) - def on_add_mode(self): - time = get(Get.SELECTED_TIME) + def on_add_mode(self, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) + valid, reason = self.timeline.component_manager._validate_component_creation( ComponentKind.MODE, time ) @@ -246,8 +248,10 @@ def on_add_mode(self): self.on_mode_add_done() return True - def on_add_harmony(self): - time = get(Get.SELECTED_TIME) + def on_add_harmony(self, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) + valid, reason = self.timeline.component_manager._validate_component_creation( ComponentKind.HARMONY, time ) diff --git a/tilia/ui/timelines/marker/timeline.py b/tilia/ui/timelines/marker/timeline.py index a6711ea92..06dc66026 100644 --- a/tilia/ui/timelines/marker/timeline.py +++ b/tilia/ui/timelines/marker/timeline.py @@ -61,9 +61,11 @@ def register_commands(cls, collection: TimelineUIs): icon="add_marker30", ) - def on_add(self): + def on_add(self, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) component, failure_reason = self.timeline.create_component( - ComponentKind.MARKER, get(Get.SELECTED_TIME) + ComponentKind.MARKER, time ) return bool(component) diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index 6ca1576cb..cfe26eca3 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -116,8 +116,9 @@ def register_commands(cls, collection: TimelineUIs): icon="pdf_add", ) - def on_add(self): - time = get(Get.SELECTED_TIME) + def on_add(self, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) page_number = min( self.timeline.get_previous_page_number(time) + 1, self.timeline.page_total From 1a31ad5be7e6440ac2609654e81680dd70481cab Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 25 Nov 2025 18:55:04 +0100 Subject: [PATCH 17/47] test: fix errors on test_dirs.py --- tests/test_dirs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dirs.py b/tests/test_dirs.py index 02dd73459..4805fd7f2 100644 --- a/tests/test_dirs.py +++ b/tests/test_dirs.py @@ -37,7 +37,7 @@ def makedirs_mock_raise_permissionerror_if_sitedirs(dir_path: str) -> None: if dir_path == dirs._SITE_DATA_DIR: raise PermissionError else: - Path(dir_path).mkdir() + Path(dir_path).mkdir(exist_ok=True) @patch("tilia.dirs._USER_DATA_DIR", Path("user_dir")) From ae05c63f8dec0213242f1a3d3c2a9107e59e1f86 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 26 Nov 2025 16:52:37 +0100 Subject: [PATCH 18/47] fix: duplicated error message Also improve message wording. --- tilia/ui/timelines/collection/import_.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tilia/ui/timelines/collection/import_.py b/tilia/ui/timelines/collection/import_.py index d2ebfd081..9f4972c68 100644 --- a/tilia/ui/timelines/collection/import_.py +++ b/tilia/ui/timelines/collection/import_.py @@ -41,7 +41,9 @@ def _on_import_to_timeline( time_or_measure = "measure" beat_tlui = _get_beat_timeline_ui_for_import_from_csv(timeline_uis) if not beat_tlui: - return "failure", ["No beat timeline found for importing score timeline."] + return "failure", [ + "A beat timeline is required to import a score timeline." + ] beat_tl = get(Get.TIMELINE, beat_tlui.id) success, path = get( @@ -123,10 +125,6 @@ def _get_beat_timeline_ui_for_import_from_csv(timeline_uis: TimelineUIs): "TIMELINE_KIND", TlKind.BEAT_TIMELINE ) if not beat_tls: - tilia.errors.display( - tilia.errors.CSV_IMPORT_FAILED, - "No beat timelines found. Must have a beat timeline if importing by measure.", - ) return elif len(beat_tls) == 1: return beat_tls[0] From 7e64d1f108643d6df3d8f6e062b6921a1a9193ff Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 26 Nov 2025 16:54:39 +0100 Subject: [PATCH 19/47] fix: error message wording --- tilia/ui/timelines/collection/import_.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilia/ui/timelines/collection/import_.py b/tilia/ui/timelines/collection/import_.py index 9f4972c68..2891d07a0 100644 --- a/tilia/ui/timelines/collection/import_.py +++ b/tilia/ui/timelines/collection/import_.py @@ -65,7 +65,7 @@ def _on_import_to_timeline( if time_or_measure == "measure": beat_tlui = _get_beat_timeline_ui_for_import_from_csv(timeline_uis) if not beat_tlui: - return "failure", ["No beat timeline found for importing by measure."] + return "failure", ["A beat timeline is required to import by measure."] beat_tl = get(Get.TIMELINE, beat_tlui.id) else: From 45dd1dadace511835b260b1f694094e5e95d83b4 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 26 Nov 2025 17:21:44 +0100 Subject: [PATCH 20/47] docs: add debug tip --- tests/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/utils.py b/tests/utils.py index 3106f69c3..3d2d88cc7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -95,6 +95,10 @@ def undoable(): assert get(Get.APP_STATE) == state_before commands.execute("edit.redo") assert get(Get.APP_STATE) == state_after + # Debug tip: use the deepdiff library to compare states + # import deepdiff + # from pprint import pprint + # pprint(deepdiff.DeepDiff(get(Get.APP_STATE), state_before)) def reloadable(save_path): From 0fae6ecadf74d9c0276684d368462291d3dcfe68 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 26 Nov 2025 17:24:58 +0100 Subject: [PATCH 21/47] fix: undo/redo when importing --- tests/parsers/csv/test_markers_from_csv.py | 53 +++++++++++---------- tilia/ui/timelines/collection/collection.py | 13 ++--- tilia/ui/timelines/collection/import_.py | 26 +++++----- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/tests/parsers/csv/test_markers_from_csv.py b/tests/parsers/csv/test_markers_from_csv.py index 7d6d2f9e4..ad7e860c0 100644 --- a/tests/parsers/csv/test_markers_from_csv.py +++ b/tests/parsers/csv/test_markers_from_csv.py @@ -5,14 +5,16 @@ from PyQt6.QtWidgets import QFileDialog -from tests.parsers.csv.common import assert_in_errors +from tests.utils import undoable +from tilia.requests import Post, post from tilia.ui import commands from tilia.ui.format import format_media_time def patch_import(by: Literal["time", "measure"], tl, data) -> tuple[str, list[str]]: - status = "" - errors = [] + # Tests are using low-level functions to create beats + # so we need to trigger an app state record manually. + post(Post.APP_STATE_RECORD, "test state") with ( patch( @@ -22,9 +24,10 @@ def patch_import(by: Literal["time", "measure"], tl, data) -> tuple[str, list[st patch.object(QFileDialog, "exec", return_value=True), patch.object(QFileDialog, "selectedFiles", return_value=[Path()]), patch("builtins.open", mock_open(read_data=data)), + undoable(), ): - status, errors = commands.execute("timelines.import.marker") - return status, errors + success = commands.execute("timelines.import.marker") + return success def test_markers_by_measure_from_csv(beat_tlui, marker_tlui): @@ -110,39 +113,41 @@ def test_markers_by_time_from_csv_fails_if_no_time_column(marker_tlui): assert marker_tlui.is_empty -def test_markers_by_time_from_csv_outputs_error_if_bad_time_value(marker_tlui): +def test_markers_by_time_from_csv_outputs_error_if_bad_time_value( + marker_tlui, tilia_errors +): data = "time\nnonsense" - status, errors = patch_import("time", marker_tlui.timeline, data) + success = patch_import("time", marker_tlui.timeline, data) - assert status == "success" - assert_in_errors("nonsense", errors) + assert success is True + tilia_errors.assert_in_error_message("nonsense") def test_markers_by_time_from_csv_outputs_error_if_time_out_of_bound( - marker_tlui, tilia_state + marker_tlui, tilia_state, tilia_errors ): tilia_state.duration = 100 data = "time\n999" - status, errors = patch_import("time", marker_tlui.timeline, data) + success = patch_import("time", marker_tlui.timeline, data) - assert status == "success" - assert format_media_time(999) in errors[0] + assert success is True + tilia_errors.assert_in_error_message(format_media_time(999)) def test_markers_by_measure_from_csv_outputs_error_if_bad_measure_value( - marker_tlui, beat_tlui + marker_tlui, beat_tlui, tilia_errors ): data = "measure\nnonsense" - status, errors = patch_import("measure", marker_tlui.timeline, data) + success = patch_import("measure", marker_tlui.timeline, data) - assert status == "success" - assert_in_errors("nonsense", errors) + assert success is True + tilia_errors.assert_in_error_message("nonsense") def test_markers_by_measure_from_csv_outputs_error_if_bad_fraction_value( - marker_tlui, beat_tlui + marker_tlui, beat_tlui, tilia_errors ): beat_tl = beat_tlui.timeline marker_tl = marker_tlui.timeline @@ -151,21 +156,21 @@ def test_markers_by_measure_from_csv_outputs_error_if_bad_fraction_value( beat_tlui.create_beat(time=2) data = "measure,fraction\n1,nonsense" - status, errors = patch_import("measure", marker_tl, data) + success = patch_import("measure", marker_tl, data) - assert status == "success" - assert_in_errors("nonsense", errors) + assert success is True + tilia_errors.assert_in_error_message("nonsense") assert marker_tl[0].time == 1 def test_component_creation_fail_reason_gets_into_errors( - marker_tl, beat_tlui, tilia_state + marker_tl, beat_tlui, tilia_state, tilia_errors ): tilia_state.duration = 100 data = "time\n101" - status, errors = patch_import("time", marker_tl, data) + patch_import("time", marker_tl, data) - assert_in_errors(format_media_time(101), errors) + tilia_errors.assert_in_error_message(format_media_time(101)) diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index 5f6e50801..a5193c878 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -55,6 +55,8 @@ def wrapper(*args, **kwargs): if success: post(Post.APP_STATE_RECORD, f"timelines command: {func.__name__}") + return success + return wrapper @@ -1320,23 +1322,22 @@ def on_zoom(self, direction: str, zoom_factor: float = ZOOM_FACTOR): if not prev_smooth_scroll: settings.set("general", "prioritise_performance", False) + @command_callback def on_import_to_timeline(self, tl_kind: TlKind): # Refactor later: merge this with _on_import_to_timeline() prev_state = get(Get.APP_STATE) - status, errors = _on_import_to_timeline(self, tl_kind) + success, errors = _on_import_to_timeline(self, tl_kind) - if status == "failure": + if not success: post(Post.APP_STATE_RESTORE, prev_state) if errors: tilia.errors.display(tilia.errors.CSV_IMPORT_FAILED, "\n".join(errors)) - elif status == "success" and errors: + elif success and errors: tilia.errors.display( tilia.errors.CSV_IMPORT_SUCCESS_ERRORS, "\n".join(errors) ) - post(Post.APP_STATE_RECORD, "Import from csv file") - # Return for testing purposes. - return status, errors + return success def _auto_scroll(self, media_time_change_reason, time) -> None: if any( diff --git a/tilia/ui/timelines/collection/import_.py b/tilia/ui/timelines/collection/import_.py index 2891d07a0..06c1089b2 100644 --- a/tilia/ui/timelines/collection/import_.py +++ b/tilia/ui/timelines/collection/import_.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, TYPE_CHECKING +from typing import TYPE_CHECKING import tilia.errors import tilia.parsers @@ -16,9 +16,9 @@ def _on_import_to_timeline( timeline_uis: TimelineUIs, tlkind: TlKind -) -> tuple[Literal["success", "failure", "cancelled"], list[str]]: +) -> tuple[bool, list[str]]: if not _validate_timeline_kind_on_import(timeline_uis, tlkind): - return "failure", [f"No timeline of kind {tlkind} found."] + return False, [f"No timeline of kind {tlkind} found."] tls_of_kind = timeline_uis.get_timeline_uis_by_attr("TIMELINE_KIND", tlkind) if len(tls_of_kind) == 1: @@ -31,19 +31,17 @@ def _on_import_to_timeline( ) if not timeline_ui: - return "cancelled", ["User cancelled when choosing timeline."] + return False, ["User cancelled when choosing timeline."] timeline = get(Get.TIMELINE, timeline_ui.id) if not timeline.is_empty and not _confirm_timeline_overwrite_on_import(): - return "cancelled", ["User rejected components overwrite."] + return False, ["User rejected components overwrite."] if tlkind == TlKind.SCORE_TIMELINE: time_or_measure = "measure" beat_tlui = _get_beat_timeline_ui_for_import_from_csv(timeline_uis) if not beat_tlui: - return "failure", [ - "A beat timeline is required to import a score timeline." - ] + return False, ["A beat timeline is required to import a score timeline."] beat_tl = get(Get.TIMELINE, beat_tlui.id) success, path = get( @@ -58,14 +56,12 @@ def _on_import_to_timeline( else: success, time_or_measure = _get_by_time_or_by_measure_from_user() if not success: - return "cancelled", [ - "User cancelled when choosing by time or by measure." - ] + return False, ["User cancelled when choosing by time or by measure."] if time_or_measure == "measure": beat_tlui = _get_beat_timeline_ui_for_import_from_csv(timeline_uis) if not beat_tlui: - return "failure", ["A beat timeline is required to import by measure."] + return False, ["A beat timeline is required to import by measure."] beat_tl = get(Get.TIMELINE, beat_tlui.id) else: @@ -76,7 +72,7 @@ def _on_import_to_timeline( ) if not success: - return "cancelled", ["User cancelled when choosing file to import."] + return False, ["User cancelled when choosing file to import."] timeline.clear() @@ -92,9 +88,9 @@ def _on_import_to_timeline( success, errors = func(*args) except UnicodeDecodeError: file_type = "musicXML" if tlkind == TlKind.SCORE_TIMELINE else "CSV" - return "failure", [UTF8_DECODE_FAILED.format(path, file_type)] + return False, [UTF8_DECODE_FAILED.format(path, file_type)] - return ("success" if success else "failure"), errors + return success, errors def _get_by_time_or_by_measure_from_user(): From cfa4ab07b036950831842b86b232c62f4237a512 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Wed, 26 Nov 2025 17:44:09 +0100 Subject: [PATCH 22/47] refactor: remove dead code --- tilia/timelines/timeline_kinds.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tilia/timelines/timeline_kinds.py b/tilia/timelines/timeline_kinds.py index 7ef76fc6c..f19952c80 100644 --- a/tilia/timelines/timeline_kinds.py +++ b/tilia/timelines/timeline_kinds.py @@ -38,11 +38,3 @@ def get_timeline_class_from_kind(kind: TimelineKind) -> type[Timeline]: classes = Timeline.subclasses() kind_to_class = {c.KIND: c for c in classes} return kind_to_class[kind] - - -IMPORTABLE = [ - kind - for kind in TimelineKind - if kind not in [TimelineKind.SLIDER_TIMELINE, TimelineKind.AUDIOWAVE_TIMELINE] -] -ALL = list(TimelineKind) From 7074f76022cbf543578b9f48f041df1c15738b88 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 3 Feb 2026 17:11:42 +0100 Subject: [PATCH 23/47] fix: toggle timeline visibility --- tests/ui/windows/test_manage_timelines.py | 28 +++++++++++++++++++++++ tilia/ui/windows/manage_timelines.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/ui/windows/test_manage_timelines.py b/tests/ui/windows/test_manage_timelines.py index 923a09107..96219b85f 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -33,6 +33,34 @@ def manage_timelines(qtui): mt.close() +class TestChangeTimelineVisibility: + def set_is_visible(self, manage_timelines, row: int, is_visible: bool): + manage_timelines.list_widget.setCurrentRow(row) + manage_timelines.checkbox.setChecked(is_visible) + + def test_hide(self, marker_tl, manage_timelines): + marker_tl.set_data("is_visible", True) + self.set_is_visible(manage_timelines, 0, False) + assert marker_tl.get_data("is_visible") is False + + def test_show(self, marker_tl, manage_timelines): + marker_tl.set_data("is_visible", False) + self.set_is_visible(manage_timelines, 0, True) + assert marker_tl.get_data("is_visible") is True + + def test_hide_then_show(self, marker_tl, manage_timelines): + marker_tl.set_data("is_visible", True) + self.set_is_visible(manage_timelines, 0, False) + self.set_is_visible(manage_timelines, 0, True) + assert marker_tl.get_data("is_visible") is True + + def test_show_then_hide(self, marker_tl, manage_timelines): + marker_tl.set_data("is_visible", False) + self.set_is_visible(manage_timelines, 0, True) + self.set_is_visible(manage_timelines, 0, False) + assert marker_tl.get_data("is_visible") is False + + class TestChangeTimelineOrder: @pytest.fixture(autouse=True) def setup_timelines(self, tls): diff --git a/tilia/ui/windows/manage_timelines.py b/tilia/ui/windows/manage_timelines.py index d56fb7d25..3ff7cc467 100644 --- a/tilia/ui/windows/manage_timelines.py +++ b/tilia/ui/windows/manage_timelines.py @@ -96,7 +96,7 @@ def on_checkbox_state_changed(self, state): return timeline_ui = item.timeline_ui if timeline_ui.get_data("is_visible") != bool(state): - post(Post.TIMELINE_IS_VISIBLE_SET_FROM_MANAGE_TIMELINES) + commands.execute("timeline.set_is_visible", timeline_ui, bool(state)) def get_timeline_uis_to_permute(self): return self.list_widget.timeline_uis_to_permute From 817a0aded63ec66e3f4b48680bc24b5b68a8c892 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 5 Feb 2026 09:08:35 +0100 Subject: [PATCH 24/47] dev: clearer "Unregistered command" error output --- tilia/ui/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tilia/ui/commands.py b/tilia/ui/commands.py index 12ee6ba07..a8ab21c84 100644 --- a/tilia/ui/commands.py +++ b/tilia/ui/commands.py @@ -105,7 +105,7 @@ def _execute_dev(command_name: str, *args, **kwargs): if command_name not in _name_to_callback: for key in _name_to_callback: print(key) - raise ValueError(f"Unregistered command: {command_name}.") + raise ValueError(f"Unregistered command: {command_name}") try: return _name_to_callback[command_name](*args, **kwargs) From b13eb27db86fbf41d887d6a322d96d83cd6a2056 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 5 Feb 2026 09:50:40 +0100 Subject: [PATCH 25/47] fix: preserve timeline ids when opening files and undoing/redoing --- tilia/timelines/base/timeline.py | 5 +++-- tilia/timelines/collection/collection.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index a70ff65cd..fcc2c17c7 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -72,10 +72,11 @@ def __init__( name: str = "", height: int = 0, is_visible: bool = True, - ordinal: int = None, + ordinal: int | None = None, + id: int | None = None, **kwargs, # ignores components_hash ): - self.id = get(Get.ID) + self.id = id if id is not None else get(Get.ID) self.name = name self.is_visible = is_visible diff --git a/tilia/timelines/collection/collection.py b/tilia/timelines/collection/collection.py index 6d25c5f58..04917a5ed 100644 --- a/tilia/timelines/collection/collection.py +++ b/tilia/timelines/collection/collection.py @@ -211,11 +211,11 @@ def serialize_timelines(self): def deserialize_timelines(self, data: dict) -> None: data_copy = copy.deepcopy(data) # so pop does not modify original data - for tl_data in data_copy.values(): + for id, tl_data in data_copy.items(): if "display_position" in tl_data: tl_data["ordinal"] = tl_data.pop("display_position") + 1 kind = TimelineKind(tl_data.pop("kind")) - self.create_timeline(kind, **tl_data) + self.create_timeline(kind, id=id, **tl_data) def restore_state(self, timeline_states: dict[str, dict]) -> None: id_to_timelines = {tl.id: tl for tl in self} @@ -233,7 +233,7 @@ def restore_state(self, timeline_states: dict[str, dict]) -> None: for id in list(set(timeline_states) - set(shared_tl_ids)): params = timeline_states[id].copy() kind = TimelineKind(params.pop("kind")) - self.create_timeline(kind, **params) + self.create_timeline(kind, id=id, **params) def _restore_timeline_state(self, timeline: Timeline, state: dict[str, dict]): if ( From cae09c6c34713853ea51d7f9f773e4e88a79d817 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 5 Feb 2026 09:51:57 +0100 Subject: [PATCH 26/47] fix: do not crash on race condition for creation of dirs.SITE_DATA_DIR --- tests/test_dirs.py | 4 +++- tilia/dirs.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_dirs.py b/tests/test_dirs.py index 4805fd7f2..33f5b688c 100644 --- a/tests/test_dirs.py +++ b/tests/test_dirs.py @@ -33,7 +33,9 @@ def test_create_data_dir_site(): Path.rmdir(Path("site_dir")) -def makedirs_mock_raise_permissionerror_if_sitedirs(dir_path: str) -> None: +def makedirs_mock_raise_permissionerror_if_sitedirs( + dir_path: str, exist_ok: bool = True +) -> None: if dir_path == dirs._SITE_DATA_DIR: raise PermissionError else: diff --git a/tilia/dirs.py b/tilia/dirs.py index 496fb9699..f2f2650fe 100644 --- a/tilia/dirs.py +++ b/tilia/dirs.py @@ -55,10 +55,10 @@ def setup_dirs() -> None: def create_data_dir() -> Path: try: - os.makedirs(_SITE_DATA_DIR) + os.makedirs(_SITE_DATA_DIR, exist_ok=True) _data_path = _SITE_DATA_DIR except PermissionError: - os.makedirs(_USER_DATA_DIR) + os.makedirs(_USER_DATA_DIR, exist_ok=True) _data_path = _USER_DATA_DIR return _data_path From 0b99b59da3b8728cd84bbfc18dc5f4d86453457f Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 5 Feb 2026 09:53:22 +0100 Subject: [PATCH 27/47] test: use deepdiff to print mismatching app states --- tests/utils.py | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 3d2d88cc7..b7b762e84 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,8 @@ +import importlib.util import json from contextlib import contextmanager from pathlib import Path +from pprint import pformat from typing import Callable from PyQt6.QtWidgets import QMenu @@ -92,13 +94,33 @@ def undoable(): yield state_after = get(Get.APP_STATE) commands.execute("edit.undo") - assert get(Get.APP_STATE) == state_before + + try: + assert get(Get.APP_STATE) == state_before + except AssertionError as e: + if importlib.util.find_spec("deepdiff") is None: + state_diff = ( + "Consider installing `deepdiff` library for debugging app states." + ) + else: + import deepdiff + + state_diff = pformat(deepdiff.DeepDiff(get(Get.APP_STATE), state_before)) + raise AssertionError("Undoing did not preserve state.\n" + state_diff) from e + commands.execute("edit.redo") - assert get(Get.APP_STATE) == state_after - # Debug tip: use the deepdiff library to compare states - # import deepdiff - # from pprint import pprint - # pprint(deepdiff.DeepDiff(get(Get.APP_STATE), state_before)) + try: + assert get(Get.APP_STATE) == state_after + except AssertionError as e: + if importlib.util.find_spec("deepdiff") is None: + state_diff = ( + "Consider installing `deepdiff` library for debugging app states." + ) + else: + import deepdiff + + state_diff = pformat(deepdiff.DeepDiff(get(Get.APP_STATE), state_after)) + raise AssertionError("Redoing did not preserve state.\n" + state_diff) from e def reloadable(save_path): From 6eb1f2af54ef8edc00ad2b8806641581510ef865 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 5 Feb 2026 09:58:54 +0100 Subject: [PATCH 28/47] fix: crash when using timeline context menus --- .../marker/test_marker_timeline_ui.py | 95 ++++++++++++++++--- tests/utils.py | 13 ++- tilia/ui/commands.py | 14 ++- tilia/ui/timelines/base/context_menus.py | 21 ++-- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/tests/ui/timelines/marker/test_marker_timeline_ui.py b/tests/ui/timelines/marker/test_marker_timeline_ui.py index 368c18e84..e3439d228 100644 --- a/tests/ui/timelines/marker/test_marker_timeline_ui.py +++ b/tests/ui/timelines/marker/test_marker_timeline_ui.py @@ -4,7 +4,7 @@ from PyQt6.QtGui import QColor from PyQt6.QtWidgets import QColorDialog, QInputDialog -from tests.mock import Serve +from tests.mock import Serve, patch_ask_for_string_dialog, patch_yes_or_no_dialog from tests.ui.test_qtui import get_toolbars_of_class from tests.ui.timelines.interact import ( click_timeline_ui, @@ -13,7 +13,13 @@ type_string, ) from tests.ui.timelines.marker.interact import click_marker_ui, get_marker_ui_center -from tests.utils import undoable, get_action, get_submenu, get_main_window_menu +from tests.utils import ( + undoable, + get_command_action, + get_submenu, + get_main_window_menu, + get_command_names, +) from tilia.requests import Post, Get, post from tilia.ui import commands from tilia.ui.commands import get_qaction @@ -322,6 +328,10 @@ def test_has_the_right_options(self, marker_tlui, tluis, tilia_state): class TestTimelineUIContextMenu: + @staticmethod + def get_context_menu(tluis, tl_index=0): + return tluis[tl_index].CONTEXT_MENU_CLASS(tluis[tl_index]) + def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): with patch.object(MarkerTimelineUIContextMenu, "exec") as mock: click_timeline_ui(marker_tlui, 50, button="right") @@ -329,7 +339,7 @@ def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): mock.assert_called_once() def test_has_no_height_set_action(self, marker_tlui, tluis, tilia_state): - context_menu = marker_tlui.CONTEXT_MENU_CLASS(marker_tlui) + context_menu = self.get_context_menu(tluis) assert get_qaction("timeline.set_height") not in context_menu.actions() @@ -338,25 +348,25 @@ def test_has_no_move_down_action_when_last(self, tluis): commands.execute("timelines.add.marker") commands.execute("timelines.add.marker") - context_menu = tluis[1].CONTEXT_MENU_CLASS(tluis[1]) + context_menu = self.get_context_menu(tluis, 1) - action_names = [a.text().replace("&", "") for a in context_menu.actions()] - assert "Move up" in action_names - assert "Move down" not in action_names + action_commands = get_command_names(context_menu) + assert "timeline.move_up" in action_commands + assert "timeline.move_down" not in action_commands def test_has_no_move_up_action_when_first(self, tluis): with Serve(Get.FROM_USER_STRING, (True, "")): commands.execute("timelines.add.marker") commands.execute("timelines.add.marker") - context_menu = tluis[0].CONTEXT_MENU_CLASS(tluis[0]) + context_menu = self.get_context_menu(tluis) - action_names = [a.text().replace("&", "") for a in context_menu.actions()] - assert "Move up" not in action_names - assert "Move down" in action_names + action_commands = get_command_names(context_menu) + assert "timeline.move_up" not in action_commands + assert "timeline.move_down" in action_commands def test_has_the_right_actions(self, marker_tlui, tluis, tilia_state): - context_menu = marker_tlui.CONTEXT_MENU_CLASS(marker_tlui) + context_menu = self.get_context_menu(tluis) # As each context menu creates its own QActions, # we can't get them with commands.get_action(). @@ -366,6 +376,62 @@ def test_has_the_right_actions(self, marker_tlui, tluis, tilia_state): for name in expected: assert name in [a.text() for a in context_menu.actions()] + def test_set_name_via_context_menu(self, marker_tlui, tluis): + context_menu = self.get_context_menu(tluis) + marker_tlui.get_data("name") + set_name_action = get_command_action(context_menu, "timeline.set_name") + + with patch_ask_for_string_dialog(True, "new name"): + with undoable(): + set_name_action.trigger() + assert marker_tlui.get_data("name") == "new name" + + def test_move_up_via_context_menu(self, tluis): + commands.execute("timelines.add.marker", name="") + commands.execute("timelines.add.marker", name="Move me up") + + context_menu = self.get_context_menu(tluis, 1) + move_up_action = get_command_action(context_menu, "timeline.move_up") + with undoable(): + move_up_action.trigger() + + assert tluis[0].get_data("name") == "Move me up" + + def test_move_down_via_context_menu(self, tluis): + commands.execute("timelines.add.marker", name="Move me down") + commands.execute("timelines.add.marker", name="") + + context_menu = self.get_context_menu(tluis) + move_down_action = get_command_action(context_menu, "timeline.move_down") + + with undoable(): + move_down_action.trigger() + + assert tluis[1].get_data("name") == "Move me down" + + def test_delete_via_context_menu(self, marker_tlui, tluis): + context_menu = self.get_context_menu(tluis) + delete_action = get_command_action(context_menu, "timeline.delete") + with patch_yes_or_no_dialog(True): + with undoable(): + delete_action.trigger() + + assert tluis.is_empty + + def test_clear_via_context_menu(self, marker_tlui, tluis): + commands.execute("timeline.marker.add") + commands.execute("timeline.marker.add", time=1) + commands.execute("timeline.marker.add", time=2) + # post(Post.APP_STATE_RECORD, "test setup") + + context_menu = self.get_context_menu(tluis) + delete_action = get_command_action(context_menu, "timeline.clear") + with patch_yes_or_no_dialog(True): + with undoable(): + delete_action.trigger() + + assert marker_tlui.is_empty + class TestInspect: def test_open_inspect_menu(self, marker_tlui, tluis, qtui): @@ -506,8 +572,7 @@ def test_move_up(self, tluis): commands.execute("timelines.add.marker") context_menu = tluis[1].CONTEXT_MENU_CLASS(tluis[1]) - action = get_action(context_menu, "Move up") - assert action + action = get_command_action(context_menu, "timeline.move_up") with undoable(): action.trigger() assert [tlui.get_data("name") for tlui in tluis.get_timeline_uis()] == [ @@ -522,7 +587,7 @@ def test_move_down(self, tluis): commands.execute("timelines.add.marker") context_menu = tluis[1].CONTEXT_MENU_CLASS(tluis[1]) - action = get_action(context_menu, "Move down") + action = get_command_action(context_menu, "timeline.move_down") assert action with undoable(): action.trigger() diff --git a/tests/utils.py b/tests/utils.py index b7b762e84..5cc9e83a7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,6 +10,7 @@ from tilia.requests import get, Get from tests.mock import patch_file_dialog from tilia.ui import commands +from tilia.ui.commands import CommandQAction def get_blank_file_data(): @@ -146,13 +147,21 @@ def check_and_reload(checks): return check_and_reload -def get_action(menu, name): +def get_command_action(menu: QMenu, command_name: str) -> CommandQAction | None: for action in menu.actions(): - if action.text().replace("&", "") == name: + if isinstance(action, CommandQAction) and action.command_name == command_name: return action return None +def get_command_names(menu: QMenu) -> list[str]: + command_names = [] + for action in menu.actions(): + if isinstance(action, CommandQAction): + command_names.append(action.command_name) + return command_names + + def get_submenu(menu, name): for action in menu.actions(): if action.text().replace("&", "") == name: diff --git a/tilia/ui/commands.py b/tilia/ui/commands.py index a8ab21c84..700778664 100644 --- a/tilia/ui/commands.py +++ b/tilia/ui/commands.py @@ -41,6 +41,18 @@ from PyQt6.QtGui import QAction, QKeySequence, QIcon +class CommandQAction(QAction): + """ + Wrapper around QAction adding a `command_name` property. + The property can be used to retrieve actions from menu by command name, + which is very useful for testing. + """ + + def __init__(self, command_name: str, parent: QMainWindow | QWidget | None): + super().__init__(parent) + self.command_name = command_name + + def get_img_path(basename: str): return IMG_DIR / f"{basename}.png" @@ -60,7 +72,7 @@ def register( Also creates a QAction with the given text, shortcut and icon. The action can be retrieved with commands.get_qaction(name) and used in the Qt interface. """ - action = QAction(parent) + action = CommandQAction(name, parent) action.setText(text) action.setToolTip(f"{text} ({shortcut})" if shortcut else text) diff --git a/tilia/ui/timelines/base/context_menus.py b/tilia/ui/timelines/base/context_menus.py index 03f97cd8a..8f49352de 100644 --- a/tilia/ui/timelines/base/context_menus.py +++ b/tilia/ui/timelines/base/context_menus.py @@ -1,6 +1,5 @@ -from PyQt6.QtGui import QAction - from tilia.ui import commands +from tilia.ui.commands import get_qaction, CommandQAction from tilia.ui.menus import MenuItemKind, TiliaMenu from tilia.requests import get, Get @@ -27,6 +26,12 @@ def _add_timeline_actions(self): def get_timeline_ui_for_selector(self): return [self.timeline_ui] + def add_action(self, name: str): + action = get_qaction(name) + action.triggered.disconnect() + action.triggered.connect(lambda: commands.execute(name, self.timeline_ui)) + self.addAction(action) + def check_move_up(self): def on_move_up(): commands.execute( @@ -40,7 +45,8 @@ def on_move_up(): tlui.get_data("ordinal"): tlui for tlui in get(Get.TIMELINE_UIS) } if indices_to_timelines.get(current_index - 1, False): - move_up = QAction("Move up", self) + move_up = CommandQAction("timeline.move_up", self) + move_up.setText("Move up") move_up.triggered.connect(on_move_up) self.addAction(move_up) @@ -57,7 +63,8 @@ def on_move_down(): tlui.get_data("ordinal"): tlui for tlui in get(Get.TIMELINE_UIS) } if indices_to_timelines.get(current_index + 1, False): - move_down = QAction("Move down", self) + move_down = CommandQAction("timeline.move_down", self) + move_down.setText("Move down") move_down.triggered.connect(on_move_down) self.addAction(move_down) @@ -72,11 +79,13 @@ def on_clear_timeline(): self.addSeparator() - delete_timeline = QAction("Delete", self) + delete_timeline = CommandQAction("timeline.delete", self) + delete_timeline.setText("Delete") delete_timeline.triggered.connect(on_delete_timeline) self.addAction(delete_timeline) - clear_timeline = QAction("Clear", self) + clear_timeline = CommandQAction("timeline.clear", self) + clear_timeline.setText("Clear") clear_timeline.triggered.connect(on_clear_timeline) self.addAction(clear_timeline) From b90e4a07c31bf7eeae96b15ac726c12450b562b1 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Thu, 5 Feb 2026 10:08:59 +0100 Subject: [PATCH 29/47] fix: crash when deleting beat timeline after score timeline has been created We should find a more robust solution for this. For instance, we should warn the user that the score won't scroll automatically if the beat timeline is deleted; and we shouldn't rely on dictionaries if we care about order. --- tilia/ui/windows/svg_viewer.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index 6b702afbe..f08f01f16 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -685,7 +685,17 @@ def wheelEvent(self, event): def paintEvent(self, event) -> None: super().paintEvent(event) if self._viewport_updated(): - start_ts, end_ts = self.get_times(self.current_viewport_x).values() + times = self.get_times(self.current_viewport_x).values() + if not times: + # Beat timeline has been deleted? + # This means the svg viewer won't scroll? + return + else: + # This relies on the order dictionary entries, + # which is undefined. + # We should use another data structure if we care about order. + start_ts, end_ts = list(times) + current_time = get(Get.SELECTED_TIME) start_time = start_ts[ s_idx - 1 if (s_idx := bisect(start_ts, current_time)) != 0 else s_idx From 1c5a8add60d1b583af8c6306047e74b757f05d6b Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 10 Feb 2026 12:01:21 +0100 Subject: [PATCH 30/47] fix: "//" in YouTube URL being replaced with "/" when saving --- tests/test_app.py | 28 +++++++++++++++++++++++++++- tilia/file/common.py | 5 ++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 521ec5315..4f3a6edfb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -8,7 +8,13 @@ import tests.utils from tests.constants import EXAMPLE_MEDIA_PATH, EXAMPLE_MEDIA_DURATION -from tests.mock import Serve, PatchPost, patch_file_dialog, patch_yes_or_no_dialog +from tests.mock import ( + Serve, + PatchPost, + patch_file_dialog, + patch_yes_or_no_dialog, + patch_ask_for_string_dialog, +) from tilia.media.player import YouTubePlayer, QtAudioPlayer from tilia.settings import settings @@ -642,3 +648,23 @@ def test_moving_files(self, tla, media, tilia, qtui, tmp_path): commands.execute("file.open") assert tilia.player.media_path == str(new_media) + + +class TestSave: + def test_youtube_url_is_preserved(self, tilia_state, qtui, tmp_path): + url = "https://www.youtube.com/watch?v=wBfVsucRe1w" + with patch_ask_for_string_dialog(True, url): + commands.execute("media.load.youtube") + + assert get(Get.MEDIA_PATH) == url + + save_path = tmp_path / "test.tla" + with patch_file_dialog(True, [str(save_path.resolve())]): + commands.execute("file.save_as") + + with open(save_path) as f: + contents = json.load(f) + + assert contents["media_path"] == url + + # TODO: add tests for saving paths as Posix paths diff --git a/tilia/file/common.py b/tilia/file/common.py index 7f03908ff..f2f599784 100644 --- a/tilia/file/common.py +++ b/tilia/file/common.py @@ -3,7 +3,9 @@ import json import os from pathlib import Path +import re +import tilia.constants from tilia.file.tilia_file import TiliaFile JSON_CONFIG = {"indent": 2} @@ -28,7 +30,8 @@ def are_tilia_data_equal(data1: dict, data2: dict) -> bool: def write_tilia_file_to_disk(file: TiliaFile, path: str | Path): file.file_path = Path(file.file_path).as_posix() - file.media_path = Path(file.media_path).as_posix() + if not re.match(tilia.constants.YOUTUBE_URL_REGEX, file.media_path): + file.media_path = Path(file.media_path).as_posix() with open(path, "w", encoding="utf-8") as f: json.dump(file.__dict__, f, **JSON_CONFIG) From 6d9a8a2a92b2a74656cd90d9fa69115195817899 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Tue, 17 Feb 2026 17:33:16 +0100 Subject: [PATCH 31/47] refactor: use patch_yes_or_no instead of Serve --- tests/timelines/test_timeline_collection.py | 12 ++++++------ tests/ui/timelines/beat/test_beat_timeline_ui.py | 6 +++--- .../hierarchy/test_hierarchy_timeline_ui.py | 8 ++++---- tests/ui/timelines/marker/test_marker_timeline_ui.py | 4 ++-- tests/ui/timelines/score/test_musicxml.py | 9 ++++----- tests/ui/windows/test_media_metadata_window.py | 10 +++++----- 6 files changed, 24 insertions(+), 25 deletions(-) diff --git a/tests/timelines/test_timeline_collection.py b/tests/timelines/test_timeline_collection.py index 2c3ba3ee5..8fdbe9d26 100644 --- a/tests/timelines/test_timeline_collection.py +++ b/tests/timelines/test_timeline_collection.py @@ -1,6 +1,6 @@ import pytest -from tests.mock import PatchPost, Serve, ServeSequence +from tests.mock import PatchPost, ServeSequence, patch_yes_or_no_dialog, Serve from tilia.requests import Post, Get from tilia.timelines.timeline_kinds import TimelineKind @@ -19,23 +19,23 @@ def test_create(self, kind, tls): class TestTimelines: def tests_scales_timelines_when_media_duration_changes( - self, marker_tl, tilia_state + self, marker_tl, tilia_state, tluis # needed for patch_yes_or_no ): marker_tl.create_marker(10) - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): tilia_state.duration = 200 assert marker_tl[0].get_data("time") == 20 def tests_does_not_scale_timelines_when_media_duration_changes_if_user_refuses( - self, marker_tl, tilia_state + self, marker_tl, tilia_state, tluis # needed for patch_yes_or_no ): marker_tl.create_marker(10) - with Serve(Get.FROM_USER_YES_OR_NO, False): + with patch_yes_or_no_dialog(False): tilia_state.duration = 200 assert marker_tl[0].get_data("time") == 10 def test_scale_timeline_when_media_duration_changes_if_user_refuses_crop( - self, marker_tlui, tilia_state + self, marker_tlui, tilia_state, tluis # needed for patch_yes_or_no ): marker_tlui.create_marker(90) with ServeSequence(Get.FROM_USER_YES_OR_NO, [False, False]): diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index d2b377ade..38fc76a5b 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -2,7 +2,7 @@ import pytest -from tests.mock import Serve +from tests.mock import Serve, patch_yes_or_no_dialog from tests.utils import undoable from tilia.requests import Post, post from tilia.enums import Side @@ -554,7 +554,7 @@ def test_accept_delete_existing_beats(self, beat_tlui): response = (True, (beat_tlui.timeline, BeatTimeline.FillMethod.BY_AMOUNT, 100)) with Serve(Get.FROM_USER_BEAT_TIMELINE_FILL_METHOD, response): - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): with undoable(): commands.execute("timeline.beat.fill") @@ -564,7 +564,7 @@ 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 Serve(Get.FROM_USER_YES_OR_NO, False): + with patch_yes_or_no_dialog(False): commands.execute("timeline.beat.fill") assert len(beat_tlui) == 1 diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py index 653a16715..ca11860e8 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py @@ -564,7 +564,7 @@ def test_at_lowest_level_user_declines_new_level(self, tlui): tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", True) - with Serve(Get.FROM_USER_YES_OR_NO, False): + with patch_yes_or_no_dialog(False): commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 1 @@ -577,7 +577,7 @@ def test_single_hierarchy(self, tlui): tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", True) - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 2 @@ -591,7 +591,7 @@ def test_with_parent(self, tlui): tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", True) - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 3 @@ -607,7 +607,7 @@ def test_with_siblings(self, tlui): tlui.select_element(tlui[0]) settings.set("hierarchy_timeline", "prompt_create_level_below", True) - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 4 diff --git a/tests/ui/timelines/marker/test_marker_timeline_ui.py b/tests/ui/timelines/marker/test_marker_timeline_ui.py index e3439d228..17faa1b15 100644 --- a/tests/ui/timelines/marker/test_marker_timeline_ui.py +++ b/tests/ui/timelines/marker/test_marker_timeline_ui.py @@ -613,14 +613,14 @@ def test_clear(tluis, qtui, marker_tlui, tilia_state): tilia_state.current_time = i commands.execute("timeline.marker.add") - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): commands.execute("timeline.clear", marker_tlui) assert len(marker_tlui) == 0 def test_delete(tluis, qtui, marker_tlui, tilia_state): - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): commands.execute("timeline.delete", marker_tlui) assert tluis.is_empty diff --git a/tests/ui/timelines/score/test_musicxml.py b/tests/ui/timelines/score/test_musicxml.py index 653c92028..1fefb500f 100644 --- a/tests/ui/timelines/score/test_musicxml.py +++ b/tests/ui/timelines/score/test_musicxml.py @@ -1,7 +1,6 @@ -from tests.mock import Serve +from tests.mock import patch_yes_or_no_dialog from tests.constants import EXAMPLE_MUSICXML_PATH from tilia.parsers.score.musicxml import notes_from_musicXML -from tilia.requests import Get from tilia.timelines.component_kinds import ComponentKind @@ -17,7 +16,7 @@ def setup_valid_beats(self, beat_tl): def test_user_accpets(self, qtui, score_tlui, beat_tl, tmp_path, tilia_state): self.setup_valid_beats(beat_tl) - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): notes_from_musicXML(score_tlui.timeline, beat_tl, EXAMPLE_MUSICXML_PATH) clefs = score_tlui.timeline.get_components_by_attr("KIND", ComponentKind.CLEF) @@ -39,7 +38,7 @@ def test_user_accepts_but_no_space_for_measure( ): beat_tl.fill_with_beats(beat_tl.FillMethod.BY_AMOUNT, 10) - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): notes_from_musicXML(score_tlui.timeline, beat_tl, EXAMPLE_MUSICXML_PATH) assert len(score_tlui) == 0 @@ -52,7 +51,7 @@ def test_user_accepts_but_less_than_two_measure_in_beat_timeline( beat_tl.create_beat(6) beat_tl.recalculate_measures() - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): notes_from_musicXML(score_tlui.timeline, beat_tl, EXAMPLE_MUSICXML_PATH) assert len(score_tlui) == 0 diff --git a/tests/ui/windows/test_media_metadata_window.py b/tests/ui/windows/test_media_metadata_window.py index 6c70f22de..41ac97fa6 100644 --- a/tests/ui/windows/test_media_metadata_window.py +++ b/tests/ui/windows/test_media_metadata_window.py @@ -1,7 +1,7 @@ import pytest -from tests.mock import Serve -from tilia.requests import post, Post, Get +from tests.mock import patch_yes_or_no_dialog +from tilia.requests import post, Post from tilia.ui.windows import WindowKind @@ -10,7 +10,7 @@ def media_metadata_window(qtui): post(Post.WINDOW_OPEN, WindowKind.MEDIA_METADATA) window = qtui._windows[WindowKind.MEDIA_METADATA] yield window - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): window.close() @@ -34,7 +34,7 @@ def test_edit_field_value(qtui, media_metadata_window, tilia_state): def test_do_not_confirm_close(qtui, media_metadata_window, tilia_state): prev_title = tilia_state.metadata["title"] media_metadata_window.metadata["title"].setText("New Title") - with Serve(Get.FROM_USER_YES_OR_NO, False): + with patch_yes_or_no_dialog(False): media_metadata_window.close() assert qtui.is_window_open(WindowKind.MEDIA_METADATA) @@ -44,7 +44,7 @@ def test_do_not_confirm_close(qtui, media_metadata_window, tilia_state): def test_confirm_close_discards_changes(qtui, media_metadata_window, tilia_state): prev_title = tilia_state.metadata["title"] media_metadata_window.metadata["title"].setText("New Title") - with Serve(Get.FROM_USER_YES_OR_NO, True): + with patch_yes_or_no_dialog(True): media_metadata_window.close() assert not qtui.is_window_open(WindowKind.MEDIA_METADATA) From 1b6af297b17f3d460b4ff25f0e52a6dfdc57032c Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 27 Feb 2026 10:58:31 +0100 Subject: [PATCH 32/47] fix: crash when timeline folder doesn't have the right structure This crash was particularly annoying when we are working on a new timeline in a separate branch, because the timeline folder wont't get deleted by git when switching branches. --- tilia/timelines/base/timeline.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index fcc2c17c7..e09c9bd92 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -146,7 +146,10 @@ def ensure_subclasses_are_available(cls): if d.is_dir() and d.name not in ["base", "__pycache__", "collection"] ] for pkg in packages: - importlib.import_module(pkg) + try: + importlib.import_module(pkg) + except ModuleNotFoundError: + print(f"Could not find timeline class in {pkg}.") @classmethod def get_kinds_by_flag(cls, flag: TimelineFlag | list[TimelineFlag]): From 850020585f5dd77d17b6251d8756c193ecba1825 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 27 Feb 2026 11:00:55 +0100 Subject: [PATCH 33/47] test: fix failing tests on Linux and macOS Takeaway: we should use `QTest` to simulate interaction with the ui instead of calling widget methods attributes ourselves. --- tests/ui/windows/test_manage_timelines.py | 60 +++++++++++------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/tests/ui/windows/test_manage_timelines.py b/tests/ui/windows/test_manage_timelines.py index 96219b85f..33392222b 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -1,4 +1,6 @@ import pytest +from PyQt6.QtCore import Qt +from PyQt6.QtTest import QTest from tests.mock import Serve from tilia.requests import Get, get @@ -26,39 +28,33 @@ def assert_order_is_correct( assert_list_widget_order(window, expected) -@pytest.fixture -def manage_timelines(qtui): - mt = ManageTimelines() - yield mt - mt.close() - - class TestChangeTimelineVisibility: - def set_is_visible(self, manage_timelines, row: int, is_visible: bool): - manage_timelines.list_widget.setCurrentRow(row) - manage_timelines.checkbox.setChecked(is_visible) - - def test_hide(self, marker_tl, manage_timelines): - marker_tl.set_data("is_visible", True) - self.set_is_visible(manage_timelines, 0, False) - assert marker_tl.get_data("is_visible") is False - - def test_show(self, marker_tl, manage_timelines): - marker_tl.set_data("is_visible", False) - self.set_is_visible(manage_timelines, 0, True) - assert marker_tl.get_data("is_visible") is True - - def test_hide_then_show(self, marker_tl, manage_timelines): - marker_tl.set_data("is_visible", True) - self.set_is_visible(manage_timelines, 0, False) - self.set_is_visible(manage_timelines, 0, True) - assert marker_tl.get_data("is_visible") is True - - def test_show_then_hide(self, marker_tl, manage_timelines): - marker_tl.set_data("is_visible", False) - self.set_is_visible(manage_timelines, 0, True) - self.set_is_visible(manage_timelines, 0, False) - assert marker_tl.get_data("is_visible") is False + @staticmethod + def toggle_timeline_is_visible(row: int = 0): + """Toggles timeline visibility using the Manage Timelines window.""" + mt = ManageTimelines() + mt.list_widget.setCurrentRow(row) + QTest.mouseClick(mt.checkbox, Qt.MouseButton.LeftButton) + mt.close() + + def test_hide(self, marker_tlui): + commands.execute("timeline.set_is_visible", marker_tlui, True) + self.toggle_timeline_is_visible() + assert marker_tlui.get_data("is_visible") is False + + def test_show(self, marker_tlui): + commands.execute("timeline.set_is_visible", marker_tlui, False) + self.toggle_timeline_is_visible() + assert marker_tlui.get_data("is_visible") is True + + def test_toggle_visibility_multiple_times(self, marker_tlui): + commands.execute("timeline.set_is_visible", marker_tlui, True) + for i in range(10): + self.toggle_timeline_is_visible() + if i % 2 == 1: + assert marker_tlui.get_data("is_visible") is True + else: + assert marker_tlui.get_data("is_visible") is False class TestChangeTimelineOrder: From c28cf26dea7c93ecace50c1f791a756050ad38b6 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 27 Feb 2026 11:33:12 +0100 Subject: [PATCH 34/47] test: fix failing tests on Linux and macOS Takeaway: we should use `QTest` to simulate interaction with the ui instead of calling widget methods attributes ourselves. --- tests/ui/windows/test_manage_timelines.py | 93 ++++++++++++----------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/tests/ui/windows/test_manage_timelines.py b/tests/ui/windows/test_manage_timelines.py index 33392222b..f1e92439b 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -1,3 +1,5 @@ +from typing import Literal + import pytest from PyQt6.QtCore import Qt from PyQt6.QtTest import QTest @@ -10,22 +12,15 @@ from tilia.ui.windows.manage_timelines import ManageTimelines -def assert_timeline_order(tls: Timelines, expected: list[Timeline]): +def assert_order_is_correct(tls: Timelines, expected: list[Timeline]): + # assert timeline order for tl, expected in zip(sorted(tls), expected): assert tl == expected - -def assert_list_widget_order(window: ManageTimelines, expected: list[Timeline]): + # assert list widget order for i, tl in enumerate(expected): tlui = get(Get.TIMELINE_UI, tl.id) - assert window.list_widget.item(i).timeline_ui == tlui - - -def assert_order_is_correct( - tls: Timelines, window: ManageTimelines, expected: list[Timeline] -): - assert_timeline_order(tls, expected) - assert_list_widget_order(window, expected) + assert ManageTimelines().list_widget.item(i).timeline_ui == tlui class TestChangeTimelineVisibility: @@ -59,80 +54,90 @@ def test_toggle_visibility_multiple_times(self, marker_tlui): class TestChangeTimelineOrder: @pytest.fixture(autouse=True) - def setup_timelines(self, tls): + def setup_timelines(self, tluis, tls): with Serve(Get.FROM_USER_STRING, (True, "")): commands.execute("timelines.add.marker") commands.execute("timelines.add.marker") commands.execute("timelines.add.marker") return list(tls) - def test_increase_ordinal(self, tls, manage_timelines, setup_timelines): + @staticmethod + def click_set_ordinal_button(button: Literal["up", "down"], row: int): + """Toggles timeline visibility using the ManageTimelines window.""" + mt = ManageTimelines() + mt.list_widget.setCurrentRow(row) + if button == "up": + button = mt.up_button + elif button == "down": + button = mt.down_button + else: + assert False, "Invalid button value." + + QTest.mouseClick(button, Qt.MouseButton.LeftButton) + mt.close() + + def test_increase_ordinal(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(1) - manage_timelines.up_button.click() + self.click_set_ordinal_button("up", 1) - assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) + assert_order_is_correct(tls, [tl1, tl0, tl2]) - def test_increase_ordinal_undo(self, tls, manage_timelines, setup_timelines): + def test_increase_ordinal_undo(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(1) - manage_timelines.up_button.click() + self.click_set_ordinal_button("up", 1) commands.execute("edit.undo") - assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) + assert_order_is_correct(tls, [tl0, tl1, tl2]) - def test_increase_ordinal_redo(self, tls, manage_timelines, setup_timelines): + def test_increase_ordinal_redo(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(1) - manage_timelines.up_button.click() + self.click_set_ordinal_button("up", 1) commands.execute("edit.undo") commands.execute("edit.redo") - assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) + assert_order_is_correct(tls, [tl1, tl0, tl2]) def test_increase_ordinal_with_first_selected_does_nothing( - self, tls, manage_timelines, setup_timelines + self, tls, setup_timelines ): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(0) - manage_timelines.up_button.click() - assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) + self.click_set_ordinal_button("up", 0) - def test_decrease_ordinal(self, tls, manage_timelines, setup_timelines): + assert_order_is_correct(tls, [tl0, tl1, tl2]) + + def test_decrease_ordinal(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(0) - manage_timelines.down_button.click() - assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) + self.click_set_ordinal_button("down", 0) + + assert_order_is_correct(tls, [tl1, tl0, tl2]) - def test_decrease_ordinal_undo(self, tls, manage_timelines, setup_timelines): + def test_decrease_ordinal_undo(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(0) - manage_timelines.down_button.click() + self.click_set_ordinal_button("down", 0) commands.execute("edit.undo") - assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) + assert_order_is_correct(tls, [tl0, tl1, tl2]) - def test_decrease_ordinal_redo(self, tls, manage_timelines, setup_timelines): + def test_decrease_ordinal_redo(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(0) - manage_timelines.down_button.click() + self.click_set_ordinal_button("down", 0) commands.execute("edit.undo") commands.execute("edit.redo") - assert_order_is_correct(tls, manage_timelines, [tl1, tl0, tl2]) + assert_order_is_correct(tls, [tl1, tl0, tl2]) def test_decrease_ordinal_with_last_selected_does_nothing( - self, tls, manage_timelines, setup_timelines + self, tls, setup_timelines ): tl0, tl1, tl2 = setup_timelines - manage_timelines.list_widget.setCurrentRow(2) - manage_timelines.down_button.click() - assert_order_is_correct(tls, manage_timelines, [tl0, tl1, tl2]) + self.click_set_ordinal_button("down", 2) + + assert_order_is_correct(tls, [tl0, tl1, tl2]) From fb912be69a3086457563f163b51aa47c7bfb9f2d Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:44:11 +0000 Subject: [PATCH 35/47] refactor: remove unused posts --- tilia/media/player/base.py | 2 -- tilia/requests/post.py | 13 ------------- tilia/ui/player.py | 7 ------- tilia/ui/timelines/collection/collection.py | 8 -------- 4 files changed, 30 deletions(-) diff --git a/tilia/media/player/base.py b/tilia/media/player/base.py index 374a926c8..b3bf688b0 100644 --- a/tilia/media/player/base.py +++ b/tilia/media/player/base.py @@ -69,8 +69,6 @@ def _setup_requests(self): Post.PLAYER_SEEK_IF_NOT_PLAYING, functools.partial(self.on_seek, if_paused=True), ), - (Post.PLAYER_REQUEST_TO_UNLOAD_MEDIA, self.unload_media), - (Post.PLAYER_REQUEST_TO_LOAD_MEDIA, self.load_media), (Post.PLAYER_EXPORT_AUDIO, self.on_export_audio), (Post.PLAYER_CURRENT_LOOP_CHANGED, self.on_loop_changed), } diff --git a/tilia/requests/post.py b/tilia/requests/post.py index 9c9a0fb5f..d9e3b9eb9 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -17,7 +17,6 @@ class Post(Enum): APP_STATE_UNDO_OR_REDO_DONE = auto() BEAT_TIMELINE_COMPONENTS_DESERIALIZED = auto() BEAT_TIMELINE_MEASURE_NUMBER_CHANGE_DONE = auto() - DEBUG = auto() DISPLAY_ERROR = auto() ELEMENT_DRAG_END = auto() ELEMENT_DRAG_START = auto() @@ -31,23 +30,17 @@ class Post(Enum): INSPECTABLE_ELEMENT_DESELECTED = auto() INSPECTABLE_ELEMENT_SELECTED = auto() INSPECTOR_FIELD_EDITED = auto() - LEFT_BUTTON_CLICK = auto() LOOP_IGNORE_COMPONENT = auto() MEDIA_METADATA_FIELD_ADD = auto() MEDIA_METADATA_FIELD_SET = auto() METADATA_UPDATE_FIELDS = auto() PLAYBACK_AREA_SET_WIDTH = auto() PLAYER_CANCEL_LOOP = auto() - PLAYER_CHANGE_TO_AUDIO_PLAYER = auto() - PLAYER_CHANGE_TO_VIDEO_PLAYER = auto() PLAYER_CURRENT_LOOP_CHANGED = auto() PLAYER_CURRENT_TIME_CHANGED = auto() PLAYER_DURATION_AVAILABLE = auto() PLAYER_EXPORT_AUDIO = auto() - PLAYER_MEDIA_UNLOADED = auto() PLAYER_PLAYBACK_RATE_TRY = auto() - PLAYER_REQUEST_TO_LOAD_MEDIA = auto() - PLAYER_REQUEST_TO_UNLOAD_MEDIA = auto() PLAYER_SEEK = auto() PLAYER_SEEK_IF_NOT_PLAYING = auto() PLAYER_STOPPED = auto() @@ -71,7 +64,6 @@ class Post(Enum): SLIDER_DRAG_START = auto() TIMELINES_AUTO_SCROLL_UPDATE = auto() TIMELINES_CROP_DONE = auto() - TIMELINE_ADD = auto() TIMELINE_COMPONENT_CREATED = auto() TIMELINE_COMPONENT_DELETED = auto() TIMELINE_COMPONENT_DESELECTED = auto() @@ -82,7 +74,6 @@ class Post(Enum): TIMELINE_DELETE_FROM_CLI = auto() TIMELINE_DELETE_DONE = auto() TIMELINE_ELEMENT_COPY_DONE = auto() - TIMELINE_ELEMENT_PASTE_ALL = auto() TIMELINE_KEY_PRESS_DOWN = auto() TIMELINE_KEY_PRESS_LEFT = auto() TIMELINE_KEY_PRESS_RIGHT = auto() @@ -96,11 +87,7 @@ class Post(Enum): TIMELINE_VIEW_LEFT_CLICK = auto() TIMELINE_VIEW_RIGHT_CLICK = auto() TIMELINE_WIDTH_SET_DONE = auto() - UI_MEDIA_LOAD = auto() UNDO_MANAGER_SET_IS_RECORDING = auto() - VIEW_ZOOM_OUT = auto() - VIEW_ZOOM_IN = auto() - WEBSITE_HELP_OPEN = auto() WINDOW_OPEN = auto() WINDOW_OPEN_DONE = auto() WINDOW_CLOSE = auto() diff --git a/tilia/ui/player.py b/tilia/ui/player.py index 912d6f622..f8faa2e77 100644 --- a/tilia/ui/player.py +++ b/tilia/ui/player.py @@ -35,7 +35,6 @@ def _setup_requests(self): LISTENS = { (Post.PLAYER_CURRENT_TIME_CHANGED, self.on_player_current_time_changed), (Post.FILE_MEDIA_DURATION_CHANGED, self.on_media_duration_changed), - (Post.PLAYER_MEDIA_UNLOADED, self.on_media_unload), (Post.PLAYER_STOPPED, self.on_stop), (Post.PLAYER_UPDATE_CONTROLS, self.on_update_controls), (Post.PLAYER_UI_UPDATE, self.on_ui_update_silent), @@ -74,12 +73,6 @@ def on_media_duration_changed(self, duration: float): self.duration_string = format_media_time(duration) self.update_time_string() - def on_media_unload(self) -> None: - self.duration_string = format_media_time(0) - self.current_time_string = format_media_time(0) - self.update_time_string() - self.on_update_controls(PlayerStatus.NO_MEDIA) - def update_time_string(self): self.time_label.setText(f"{self.current_time_string}/{self.duration_string}") diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index a5193c878..75797a221 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -430,14 +430,6 @@ def _setup_requests(self): (Post.SLIDER_DRAG_END, lambda: self.set_is_dragging(False)), (Post.SLIDER_DRAG_START, lambda: self.set_is_dragging(True)), (Post.PLAYER_CURRENT_TIME_CHANGED, self.on_media_time_change), - ( - Post.VIEW_ZOOM_IN, - functools.partial(self.on_zoom, True), - ), - ( - Post.VIEW_ZOOM_OUT, - functools.partial(self.on_zoom, False), - ), (Post.SELECTION_BOX_SELECT_ITEM, self.on_selection_box_select_item), (Post.SELECTION_BOX_DESELECT_ITEM, self.on_selection_box_deselect_item), (Post.TIMELINE_WIDTH_SET_DONE, self.on_timeline_width_set_done), From 9db31428a75ab03c53032d6ab3e8ff8d8b88c065 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:45:15 +0000 Subject: [PATCH 36/47] refactor: actually call the post --- tilia/app.py | 2 +- tilia/requests/post.py | 2 +- tilia/ui/qtui.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tilia/app.py b/tilia/app.py index ef3557717..91121f1c4 100644 --- a/tilia/app.py +++ b/tilia/app.py @@ -60,7 +60,6 @@ def __str__(self): def _setup_requests(self): LISTENS = { (Post.APP_CLEAR, self.on_clear), - (Post.APP_FILE_LOAD, self.on_file_load), (Post.APP_MEDIA_LOAD, self.load_media), (Post.APP_STATE_RESTORE, self.on_restore_state), (Post.APP_STATE_RECOVER, self.recover_to_state), @@ -162,6 +161,7 @@ def on_open(self, path: Path | str | None = None) -> None: if not success: self.on_restore_state(prev_state) return + post(Post.APP_FILE_LOADED, file) self.file_manager.file = file self.update_recent_files() diff --git a/tilia/requests/post.py b/tilia/requests/post.py index d9e3b9eb9..85a85a1a0 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -8,7 +8,7 @@ class Post(Enum): UI_EXIT = auto() APP_CLEAR = auto() - APP_FILE_LOAD = auto() + APP_FILE_LOADED = auto() APP_MEDIA_LOAD = auto() APP_SETUP_FILE = auto() APP_STATE_RECORD = auto() diff --git a/tilia/ui/qtui.py b/tilia/ui/qtui.py index 906d0d876..a39b0047e 100644 --- a/tilia/ui/qtui.py +++ b/tilia/ui/qtui.py @@ -169,7 +169,7 @@ def _setup_sizes(self): def _setup_requests(self): LISTENS = { - (Post.APP_FILE_LOAD, self.on_file_load), + (Post.APP_FILE_LOADED, self.on_file_loaded), (Post.PLAYBACK_AREA_SET_WIDTH, self.on_timeline_set_width), (Post.WINDOW_OPEN, self.on_window_open), (Post.WINDOW_CLOSE, self.on_window_close), @@ -322,7 +322,7 @@ def get_window_geometry(self): def get_window_state(self): return self.main_window.saveState() - def on_file_load(self, file: TiliaFile) -> None: + def on_file_loaded(self, file: TiliaFile) -> None: geometry, state = settings.get_geometry_and_state_from_path(file.file_path) if geometry and state: self.main_window.restoreGeometry(geometry) From 710839702391fe86942d67964d5cde89fddd4b61 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:31:43 +0000 Subject: [PATCH 37/47] refactor: remove duplicate --- tilia/timelines/slider/timeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tilia/timelines/slider/timeline.py b/tilia/timelines/slider/timeline.py index 25035be2c..49fd61560 100644 --- a/tilia/timelines/slider/timeline.py +++ b/tilia/timelines/slider/timeline.py @@ -17,7 +17,6 @@ class SliderTimeline(Timeline): TimelineFlag.NOT_CLEARABLE, TimelineFlag.NOT_DELETABLE, TimelineFlag.NOT_EXPORTABLE, - TimelineFlag.NOT_CLEARABLE, ] @property From 946d599f11261c6ae50b686ae37fb88d7a62bf27 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:40:07 +0000 Subject: [PATCH 38/47] refactor: remove unused gets --- tilia/requests/get.py | 4 ---- tilia/timelines/collection/collection.py | 1 - tilia/ui/timelines/collection/collection.py | 1 - 3 files changed, 6 deletions(-) diff --git a/tilia/requests/get.py b/tilia/requests/get.py index b47e8d428..aae78bd41 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -9,7 +9,6 @@ class Get(Enum): APP_STATE = auto() TIMELINE_ELEMENTS_SELECTED = auto() CLIPBOARD_CONTENTS = auto() - CONTEXT_MENU_TIMELINE_UI = auto() FIRST_TIMELINE_UI_IN_SELECT_ORDER = auto() FROM_USER_ADD_TIMELINE_WITHOUT_MEDIA = auto() FROM_USER_BEAT_PATTERN = auto() @@ -51,14 +50,11 @@ class Get(Enum): SCORE_VIEWER = auto() TIMELINE = auto() TIMELINES = auto() - TIMELINES_BY_ATTR = auto() TIMELINE_BY_ATTR = auto() TIMELINE_COLLECTION = auto() - TIMELINE_ORDINAL = auto() TIMELINE_ORDINAL_FOR_NEW = auto() TIMELINE_UI = auto() TIMELINE_UIS = auto() - TIMELINE_UIS_BY_ATTR = auto() TIMELINE_UI_BY_ATTR = auto() TIMELINE_UI_ELEMENT = auto() TIMELINE_WIDTH = auto() diff --git a/tilia/timelines/collection/collection.py b/tilia/timelines/collection/collection.py index 04917a5ed..f61fd70b1 100644 --- a/tilia/timelines/collection/collection.py +++ b/tilia/timelines/collection/collection.py @@ -52,7 +52,6 @@ def _setup_requests(self): (Get.TIMELINE, self.get_timeline), (Get.TIMELINE_ORDINAL_FOR_NEW, self.serve_ordinal_for_new_timeline), (Get.TIMELINE_BY_ATTR, self.get_timeline_by_attr), - (Get.TIMELINES_BY_ATTR, self.get_timelines_by_attr), (Get.METRIC_POSITION, self.get_metric_position), } diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index 75797a221..a3c536bf3 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -463,7 +463,6 @@ def _setup_requests(self): SERVES = { (Get.TIMELINE_UI, self.get_timeline_ui), (Get.TIMELINE_UI_BY_ATTR, self.get_timeline_ui_by_attr), - (Get.TIMELINE_UIS_BY_ATTR, self.get_timeline_uis_by_attr), (Get.TIMELINE_UIS, self.get_timeline_uis), (Get.TIMELINE_UI_ELEMENT, self.get_timeline_ui_element), (Get.TIMELINE_ELEMENTS_SELECTED, self.get_timeline_elements_selected), From 9633a127e2b1bde5e3e1cf48c2e6cbcd19a56069 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:40:29 +0000 Subject: [PATCH 39/47] refactor: remove unused tl selectors --- tilia/ui/timelines/collection/collection.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index a3c536bf3..e5154030a 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -1109,7 +1109,6 @@ def filter_for_pasting(_) -> list[TimelineUI]: TimelineSelector.FIRST: filter_if_first_on_select_order, TimelineSelector.SELECTED: filter_if_has_selected_elements, TimelineSelector.PASTE: filter_for_pasting, - TimelineSelector.ANY: filter_if_first_on_select_order, } try: @@ -1517,8 +1516,6 @@ def on_timeline_deleted(self, id: int): class TimelineSelector(Enum): - ANY = auto() - EXPLICIT = auto() SELECTED = auto() ALL = auto() FIRST = auto() From 9bfb4c4e7a21e95fba13e398ef35336a2a0c00d3 Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:08:19 +0000 Subject: [PATCH 40/47] refactor: correct method name `on_side_arrow_press` and `on_horizontal_arrow_press` are duplicates, with the latter being defined in the parent tlui. Redefined in audiowave because elements don't have "time" attr. --- .../timelines/beat/test_beat_timeline_ui.py | 13 ++++++------ tilia/enums.py | 6 ------ tilia/ui/timelines/audiowave/timeline.py | 21 ++++++++++++------- tilia/ui/timelines/beat/timeline.py | 20 ------------------ tilia/ui/timelines/marker/timeline.py | 19 ----------------- tilia/ui/timelines/pdf/timeline.py | 19 ----------------- 6 files changed, 19 insertions(+), 79 deletions(-) delete mode 100644 tilia/enums.py diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 38fc76a5b..1f3f2f5fe 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -5,7 +5,6 @@ from tests.mock import Serve, patch_yes_or_no_dialog from tests.utils import undoable from tilia.requests import Post, post -from tilia.enums import Side from tilia.requests import Get from tilia.timelines.beat.timeline import BeatTimeline from tilia.settings import settings @@ -156,10 +155,10 @@ def test_on_right_arrow_press_one_element_selected(self, beat_tlui): beat1 = beat_tlui[1] beat_tlui.select_element(beat0) - beat_tlui.on_side_arrow_press(Side.RIGHT) + beat_tlui.on_horizontal_arrow_press("right") assert beat_tlui.selected_elements == [beat1] - beat_tlui.on_side_arrow_press(Side.RIGHT) + beat_tlui.on_horizontal_arrow_press("right") assert beat_tlui.selected_elements == [beat1] def test_on_left_arrow_press_one_element_selected(self, beat_tlui): @@ -170,10 +169,10 @@ def test_on_left_arrow_press_one_element_selected(self, beat_tlui): beat1 = beat_tlui[1] beat_tlui.select_element(beat1) - beat_tlui.on_side_arrow_press(Side.LEFT) + beat_tlui.on_horizontal_arrow_press("left") assert beat_tlui.selected_elements == [beat0] - beat_tlui.on_side_arrow_press(Side.LEFT) + beat_tlui.on_horizontal_arrow_press("left") assert beat_tlui.selected_elements == [beat0] def test_on_right_arrow_press_more_than_one_element_selected(self, beat_tlui): @@ -191,7 +190,7 @@ def test_on_right_arrow_press_more_than_one_element_selected(self, beat_tlui): beat_tlui.select_element(beat1) beat_tlui.select_element(beat2) - beat_tlui.on_side_arrow_press(Side.RIGHT) + beat_tlui.on_horizontal_arrow_press("right") assert beat_tlui.selected_elements == [beat3] def test_on_left_arrow_press_more_than_one_element_selected(self, beat_tlui): @@ -209,7 +208,7 @@ def test_on_left_arrow_press_more_than_one_element_selected(self, beat_tlui): beat_tlui.select_element(beat2) beat_tlui.select_element(beat3) - beat_tlui.on_side_arrow_press(Side.LEFT) + beat_tlui.on_horizontal_arrow_press("left") assert beat_tlui.selected_elements == [beat0] diff --git a/tilia/enums.py b/tilia/enums.py deleted file mode 100644 index a6d121c14..000000000 --- a/tilia/enums.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import annotations - - -class Side: - LEFT = "LEFT" - RIGHT = "RIGHT" diff --git a/tilia/ui/timelines/audiowave/timeline.py b/tilia/ui/timelines/audiowave/timeline.py index 111c01e7d..4a18c1f7d 100644 --- a/tilia/ui/timelines/audiowave/timeline.py +++ b/tilia/ui/timelines/audiowave/timeline.py @@ -1,6 +1,5 @@ from __future__ import annotations -from tilia.enums import Side from tilia.requests import Post, Get, get, listen from tilia.timelines.timeline_kinds import TimelineKind from tilia.ui.timelines.base.timeline import TimelineUI @@ -35,19 +34,25 @@ def on_settings_updated(self, updated_settings): ) self.timeline.refresh() - def on_side_arrow_press(self, side: Side): + def on_horizontal_arrow_press(self, arrow: str): if not self.has_selected_elements: return - self._deselect_all_but_last() + if arrow not in ["right", "left"]: + raise ValueError(f"Invalid arrow '{arrow}'.") + + if arrow == "right": + self._deselect_all_but_last() + else: + self._deselect_all_but_first() selected_element = self.element_manager.get_selected_elements()[0] - if side == side.RIGHT: - element_to_select = self.get_next_element(selected_element) - elif side == side.LEFT: - element_to_select = self.get_previous_element(selected_element) + if arrow == "right": + element_to_select = self.element_manager.get_next_element(selected_element) else: - raise ValueError(f"Invalid side '{side}'.") + element_to_select = self.element_manager.get_previous_element( + selected_element + ) if element_to_select: self.deselect_element(selected_element) diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index dc73e216e..cba78b600 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -1,7 +1,6 @@ import copy from tilia.requests import get, Get, Post, listen -from tilia.enums import Side from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind from tilia.ui import commands @@ -214,25 +213,6 @@ def should_display_measure_number(self, beat_ui): measure_index, _ = self.timeline.get_measure_index(beat_index) return self.timeline.should_display_measure_number(measure_index) - def on_side_arrow_press(self, side: Side): - if not self.has_selected_elements: - return - - if side == Side.RIGHT: - self._deselect_all_but_last() - selected_element = self.selected_elements[0] - element_to_select = self.get_next_element(selected_element) - elif side == Side.LEFT: - self._deselect_all_but_first() - selected_element = self.selected_elements[0] - element_to_select = self.get_previous_element(selected_element) - else: - raise ValueError(f"Invalid side '{side}'.") - - if element_to_select: - self.element_manager.deselect_element(selected_element) - self.select_element(element_to_select) - def on_measure_number_change_done(self, start_index: int): for beat_ui in self[start_index:]: beat_ui.update_label() diff --git a/tilia/ui/timelines/marker/timeline.py b/tilia/ui/timelines/marker/timeline.py index 06dc66026..6f6abc422 100644 --- a/tilia/ui/timelines/marker/timeline.py +++ b/tilia/ui/timelines/marker/timeline.py @@ -5,7 +5,6 @@ from tilia.timelines.component_kinds import ComponentKind from tilia.requests import Get, get, listen, Post -from tilia.enums import Side from tilia.timelines.timeline_kinds import TimelineKind from tilia.ui.timelines.base.element import TimelineUIElement from tilia.ui.timelines.base.timeline import ( @@ -74,24 +73,6 @@ def _deselect_all_but_last(self): for element in self.selected_elements[:-1]: self.element_manager.deselect_element(element) - def on_side_arrow_press(self, side: Side): - if not self.has_selected_elements: - return - - self._deselect_all_but_last() - - selected_element = self.element_manager.get_selected_elements()[0] - if side == Side.RIGHT: - element_to_select = self.get_next_element(selected_element) - elif side == Side.LEFT: - element_to_select = self.get_previous_element(selected_element) - else: - raise ValueError(f"Invalid side '{side}'.") - - if element_to_select: - self.deselect_element(selected_element) - self.select_element(element_to_select) - def validate_copy(self, elements: list[TimelineUIElement]) -> None: pass diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index cfe26eca3..896f24e9f 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -11,7 +11,6 @@ from tilia.media.player.base import MediaTimeChangeReason from tilia.timelines.component_kinds import ComponentKind from tilia.requests import Get, get, listen, Post -from tilia.enums import Side from tilia.timelines.timeline_kinds import TimelineKind from tilia.ui.timelines.base.element import TimelineUIElement from tilia.ui.timelines.base.timeline import ( @@ -147,24 +146,6 @@ def _deselect_all_but_last(self): for element in self.selected_elements[:-1]: self.element_manager.deselect_element(element) - def on_side_arrow_press(self, side: Side): - if not self.has_selected_elements: - return - - self._deselect_all_but_last() - - selected_element = self.element_manager.get_selected_elements()[0] - if side == Side.RIGHT: - element_to_select = self.get_next_element(selected_element) - elif side == Side.LEFT: - element_to_select = self.get_previous_element(selected_element) - else: - raise ValueError(f"Invalid side '{side}'.") - - if element_to_select: - self.deselect_element(selected_element) - self.select_element(element_to_select) - def validate_copy(self, elements: list[TimelineUIElement]) -> None: pass From 305f6d52128a7e85a6ca8d54acdc49ccbbc71ddf Mon Sep 17 00:00:00 2001 From: azfoo <45888544+azfoo@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:12:35 +0000 Subject: [PATCH 41/47] fix: restore missing inspector value --- tilia/ui/timelines/audiowave/timeline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tilia/ui/timelines/audiowave/timeline.py b/tilia/ui/timelines/audiowave/timeline.py index 4a18c1f7d..9287b5fc0 100644 --- a/tilia/ui/timelines/audiowave/timeline.py +++ b/tilia/ui/timelines/audiowave/timeline.py @@ -61,8 +61,11 @@ def on_horizontal_arrow_press(self, arrow: str): def get_inspector_dict(self): start_time = self.selected_elements[0].get_data("start") end_time = self.selected_elements[-1].get_data("end") + a_sum = sum([e.get_data("amplitude") for e in self.selected_elements]) + amplitude = f"{a_sum / len(self.selected_elements): .3f} (rms)" return { "Start / End": f"{format_media_time(start_time)} /" + f"{format_media_time(end_time)}", + "Amplitude": amplitude, } From 99c927fa7f197ae97e1fd078b41cf2507d46b1f1 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Fri, 6 Mar 2026 17:35:42 +0100 Subject: [PATCH 42/47] refactor: Post.MEDIA_TOGGLE_PLAY -> "media.toggle_play" command --- tests/player/test_player.py | 7 ++++--- tilia/media/player/base.py | 2 +- tilia/requests/post.py | 1 - tilia/ui/player.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/player/test_player.py b/tests/player/test_player.py index 1e16c61fd..f859b92c0 100644 --- a/tests/player/test_player.py +++ b/tests/player/test_player.py @@ -4,6 +4,7 @@ from tests.constants import EXAMPLE_MEDIA_PATH from tilia.requests import post, Post +from tilia.ui import commands @pytest.fixture @@ -33,11 +34,11 @@ def test_unload_media(self, tilia): def test_unload_media_after_playing(self, tilia): self._load_example() - post(Post.PLAYER_TOGGLE_PLAY_PAUSE, False) - post(Post.PLAYER_TOGGLE_PLAY_PAUSE, True) + commands.execute("media.toggle_play", False) + commands.execute("media.toggle_play", True) post(Post.APP_CLEAR) def test_unload_media_while_playing(self, tilia): self._load_example() - post(Post.PLAYER_TOGGLE_PLAY_PAUSE, False) + commands.execute("media.toggle_play", False) post(Post.APP_CLEAR) diff --git a/tilia/media/player/base.py b/tilia/media/player/base.py index b3bf688b0..02cb743a7 100644 --- a/tilia/media/player/base.py +++ b/tilia/media/player/base.py @@ -57,10 +57,10 @@ def __str__(self): def _setup_commands(self): commands.register("media.stop", self.stop, text="Stop", icon="stop15") + commands.register("media.toggle_play", self.toggle_play) def _setup_requests(self): LISTENS = { - (Post.PLAYER_TOGGLE_PLAY_PAUSE, self.toggle_play), (Post.PLAYER_VOLUME_CHANGE, self.on_volume_change), (Post.PLAYER_VOLUME_MUTE, self.on_volume_mute), (Post.PLAYER_PLAYBACK_RATE_TRY, self.on_playback_rate_try), diff --git a/tilia/requests/post.py b/tilia/requests/post.py index 85a85a1a0..d5c10123c 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -45,7 +45,6 @@ class Post(Enum): PLAYER_SEEK_IF_NOT_PLAYING = auto() PLAYER_STOPPED = auto() PLAYER_TOGGLE_LOOP = auto() - PLAYER_TOGGLE_PLAY_PAUSE = auto() PLAYER_UI_UPDATE = auto() PLAYER_UPDATE_CONTROLS = auto() PLAYER_URL_CHANGED = auto() diff --git a/tilia/ui/player.py b/tilia/ui/player.py index f8faa2e77..e11ef347f 100644 --- a/tilia/ui/player.py +++ b/tilia/ui/player.py @@ -140,7 +140,7 @@ def destroy(self): def add_play_toggle(self): def on_play_toggle(checked: bool) -> None: - post(Post.PLAYER_TOGGLE_PLAY_PAUSE, checked) + commands.execute("media.toggle_play", checked) play_toggle_icon = QIcon() play_toggle_icon.addPixmap( From a4b562617baf6144e1d4778d64d8226c5ea40887 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 7 Mar 2026 10:53:42 +0100 Subject: [PATCH 43/47] refactor: Post.MEDIA_SEEK -> "media.seek" command --- tests/ui/timelines/audiowave/test_audiowave_ui.py | 12 +++--------- tests/ui/timelines/hierarchy/test_hierarchy_ui.py | 10 +++------- tests/ui/timelines/marker/test_marker_ui.py | 7 +++---- tests/ui/timelines/test_timelineui_collection.py | 4 ++-- tilia/media/player/base.py | 11 +++-------- tilia/ui/timelines/audiowave/element.py | 3 ++- tilia/ui/timelines/base/timeline.py | 2 +- tilia/ui/timelines/beat/element.py | 3 ++- tilia/ui/timelines/harmony/elements/harmony.py | 3 ++- tilia/ui/timelines/harmony/elements/mode.py | 3 ++- tilia/ui/timelines/hierarchy/element.py | 3 ++- tilia/ui/timelines/marker/element.py | 3 ++- tilia/ui/timelines/pdf/element.py | 3 ++- tilia/ui/timelines/score/element/note/ui.py | 4 ++-- tilia/ui/timelines/slider/timeline.py | 5 +++-- tilia/ui/windows/svg_viewer.py | 4 ++-- 16 files changed, 36 insertions(+), 44 deletions(-) diff --git a/tests/ui/timelines/audiowave/test_audiowave_ui.py b/tests/ui/timelines/audiowave/test_audiowave_ui.py index 05de21657..bdae4d630 100644 --- a/tests/ui/timelines/audiowave/test_audiowave_ui.py +++ b/tests/ui/timelines/audiowave/test_audiowave_ui.py @@ -1,8 +1,5 @@ from unittest.mock import Mock -from tests.mock import PatchPost -from tilia.requests import Post - class TestAudioWaveUI: def test_create(self, audiowave_tlui): @@ -11,14 +8,11 @@ def test_create(self, audiowave_tlui): class TestDoubleClick: - def test_amplitudebar_seek(self, audiowave_tlui): + def test_amplitudebar_seek(self, audiowave_tlui, tilia_state): audiowave_tlui.create_amplitudebar(10, 15, 1) - with PatchPost( - "tilia.ui.timelines.audiowave.element", Post.PLAYER_SEEK - ) as mock: - audiowave_tlui[0].on_double_left_click(None) + audiowave_tlui[0].on_double_left_click(None) - mock.assert_called_with(Post.PLAYER_SEEK, 10) + assert tilia_state.current_time == 10 def test_does_not_trigger_drag(self, audiowave_tlui): audiowave_tlui.create_amplitudebar(0, 1, 1) diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_ui.py index 583451d36..6d3b0b1e4 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_ui.py @@ -2,7 +2,6 @@ import pytest -from tests.mock import PatchPost from tilia.requests import Post, post from tilia.ui.coords import time_x_converter from tilia.ui.timelines.hierarchy import HierarchyUI @@ -265,14 +264,11 @@ def test_display_as_deselected_with_no_descendant(self, tlui): class TestDoubleClick: - def test_posts_seek(self, tlui): + def test_posts_seek(self, tlui, tilia_state): tlui.create_hierarchy(10, 15, 1) - with PatchPost( - "tilia.ui.timelines.hierarchy.element", Post.PLAYER_SEEK - ) as mock: - tlui[0].on_double_left_click(None) + tlui[0].on_double_left_click(None) - mock.assert_called_with(Post.PLAYER_SEEK, 10) + assert tilia_state.current_time == 10 def test_does_not_trigger_drag(self, tlui): tlui.create_hierarchy(0, 1, 1) diff --git a/tests/ui/timelines/marker/test_marker_ui.py b/tests/ui/timelines/marker/test_marker_ui.py index ea649e284..1026e3206 100644 --- a/tests/ui/timelines/marker/test_marker_ui.py +++ b/tests/ui/timelines/marker/test_marker_ui.py @@ -89,12 +89,11 @@ def test_on_deselect(self, mrkui): class TestDoubleClick: - def test_posts_seek(self, marker_tlui): + def test_posts_seek(self, marker_tlui, tilia_state): marker_tlui.create_marker(10) - with PatchPost("tilia.ui.timelines.marker.element", Post.PLAYER_SEEK) as mock: - marker_tlui[0].on_double_left_click(None) + marker_tlui[0].on_double_left_click(None) - mock.assert_called_with(Post.PLAYER_SEEK, 10) + assert tilia_state.current_time == 10 def test_does_not_trigger_drag(self, marker_tlui): marker_tlui.create_marker(0) diff --git a/tests/ui/timelines/test_timelineui_collection.py b/tests/ui/timelines/test_timelineui_collection.py index 9896ed1f2..57195fe19 100644 --- a/tests/ui/timelines/test_timelineui_collection.py +++ b/tests/ui/timelines/test_timelineui_collection.py @@ -183,7 +183,7 @@ def test_continuous_is_triggered_when_playing(self, tluis): def test_is_not_triggered_when_seeking(self, scroll_type, tluis): self._set_auto_scroll(scroll_type) with patch.object(tluis, "center_on_time") as center_on_time_mock: - post(Post.PLAYER_SEEK, 50) + commands.execute("media.seek", 50) center_on_time_mock.assert_not_called() @pytest.mark.parametrize("scroll_type", [ScrollType.CONTINUOUS, ScrollType.BY_PAGE]) @@ -237,7 +237,7 @@ def test_set_timeline_height_updates_playback_line_height(tls, tluis): def test_zooming_updates_playback_line_position(tls, tluis): tls.create_timeline(TimelineKind.MARKER_TIMELINE) - post(Post.PLAYER_SEEK, 50) + commands.execute("media.seek", 50) commands.execute("view.zoom.in") assert tluis[0].scene.playback_line.line().x1() == pytest.approx( time_x_converter.get_x_by_time(50) diff --git a/tilia/media/player/base.py b/tilia/media/player/base.py index 02cb743a7..0cc53f353 100644 --- a/tilia/media/player/base.py +++ b/tilia/media/player/base.py @@ -1,6 +1,5 @@ from __future__ import annotations -import functools from abc import ABC, abstractmethod from enum import Enum, auto from pathlib import Path @@ -58,17 +57,13 @@ def __str__(self): def _setup_commands(self): commands.register("media.stop", self.stop, text="Stop", icon="stop15") commands.register("media.toggle_play", self.toggle_play) + commands.register("media.seek", self.on_seek) def _setup_requests(self): LISTENS = { (Post.PLAYER_VOLUME_CHANGE, self.on_volume_change), (Post.PLAYER_VOLUME_MUTE, self.on_volume_mute), (Post.PLAYER_PLAYBACK_RATE_TRY, self.on_playback_rate_try), - (Post.PLAYER_SEEK, self.on_seek), - ( - Post.PLAYER_SEEK_IF_NOT_PLAYING, - functools.partial(self.on_seek, if_paused=True), - ), (Post.PLAYER_EXPORT_AUDIO, self.on_export_audio), (Post.PLAYER_CURRENT_LOOP_CHANGED, self.on_loop_changed), } @@ -196,8 +191,8 @@ def on_volume_mute(self, is_muted: bool) -> None: def on_playback_rate_try(self, playback_rate: float) -> None: self._engine_try_playback_rate(playback_rate) - def on_seek(self, time: float, if_paused: bool = False) -> None: - if if_paused and self.is_playing: + def on_seek(self, time: float, if_playing: bool = True) -> None: + if not if_playing and self.is_playing: return if self.is_media_loaded: diff --git a/tilia/ui/timelines/audiowave/element.py b/tilia/ui/timelines/audiowave/element.py index 949158e6a..765bb2feb 100644 --- a/tilia/ui/timelines/audiowave/element.py +++ b/tilia/ui/timelines/audiowave/element.py @@ -12,6 +12,7 @@ from ...consts import TINT_FACTOR_ON_SELECTION from tilia.settings import settings from tilia.ui.timelines.base.element import TimelineUIElement +from tilia.ui import commands from ...windows.inspect import InspectRowKind @@ -70,7 +71,7 @@ def double_left_click_triggers(self): return [self.body] def on_double_left_click(self, _): - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def setup_drag(self): DragManager( diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index cd6fd6e50..cd0a971ee 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -366,7 +366,7 @@ def get_item_owner(self, item: QGraphicsItem) -> list[T]: @staticmethod def _seek_to_element(element: T) -> None: if hasattr(element, "seek_time"): - post(Post.PLAYER_SEEK_IF_NOT_PLAYING, element.seek_time) + commands.execute("media.seek", element.seek_time, if_playing=False) @staticmethod def _trigger_left_click_side_effects(element: T, item: QGraphicsItem) -> None: diff --git a/tilia/ui/timelines/beat/element.py b/tilia/ui/timelines/beat/element.py index 9a5e6b5ab..8f0be498f 100644 --- a/tilia/ui/timelines/beat/element.py +++ b/tilia/ui/timelines/beat/element.py @@ -10,6 +10,7 @@ from ..copy_paste import CopyAttributes from ..cursors import CursorMixIn from ..drag import DragManager +from tilia.ui import commands from ...format import format_media_time from ...coords import time_x_converter from tilia.ui.timelines.base.element import TimelineUIElement @@ -147,7 +148,7 @@ def on_double_left_click(self, _) -> None: if self.drag_manager: self.drag_manager.on_release() self.drag_manager = None - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) @property def right_click_triggers(self): diff --git a/tilia/ui/timelines/harmony/elements/harmony.py b/tilia/ui/timelines/harmony/elements/harmony.py index ec5bf195d..aebca98a9 100644 --- a/tilia/ui/timelines/harmony/elements/harmony.py +++ b/tilia/ui/timelines/harmony/elements/harmony.py @@ -8,6 +8,7 @@ from . import harmony_attrs from tilia.requests import get, Get, post, Post +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.drag import DragManager @@ -228,7 +229,7 @@ def on_double_left_click(self, _): if self.drag_manager: self.drag_manager.on_release() self.drag_manager = None - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def setup_drag(self): self.drag_manager = DragManager( diff --git a/tilia/ui/timelines/harmony/elements/mode.py b/tilia/ui/timelines/harmony/elements/mode.py index 05df9a375..12f479ce9 100644 --- a/tilia/ui/timelines/harmony/elements/mode.py +++ b/tilia/ui/timelines/harmony/elements/mode.py @@ -4,6 +4,7 @@ from PyQt6.QtWidgets import QGraphicsItem, QGraphicsTextItem from tilia.requests import get, Get, post, Post +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.drag import DragManager @@ -98,7 +99,7 @@ def on_double_left_click(self, _): if self.drag_manager: self.drag_manager.on_release() self.drag_manager = None - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def on_left_click(self, _) -> None: self.setup_drag() diff --git a/tilia/ui/timelines/hierarchy/element.py b/tilia/ui/timelines/hierarchy/element.py index 52c8be8f1..b136a4c21 100644 --- a/tilia/ui/timelines/hierarchy/element.py +++ b/tilia/ui/timelines/hierarchy/element.py @@ -12,6 +12,7 @@ ) from tilia.dirs import IMG_DIR +from tilia.ui import commands from .context_menu import HierarchyContextMenu from .handles import HierarchyBodyHandle, HierarchyFrameHandle from ..cursors import CursorMixIn @@ -516,7 +517,7 @@ def on_double_left_click(self, _) -> None: if self.drag_manager: self.drag_manager.on_release() self.drag_manager = None - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def right_click_triggers(self): return self.body, self.label, self.comments_icon diff --git a/tilia/ui/timelines/marker/element.py b/tilia/ui/timelines/marker/element.py index 6871c7856..2e97e1164 100644 --- a/tilia/ui/timelines/marker/element.py +++ b/tilia/ui/timelines/marker/element.py @@ -19,6 +19,7 @@ from ...coords import time_x_converter from tilia.settings import settings from tilia.ui.timelines.base.element import TimelineUIElement +from tilia.ui import commands from ...windows.inspect import InspectRowKind @@ -121,7 +122,7 @@ def on_double_left_click(self, _): if self.drag_manager: self.drag_manager.on_release() self.drag_manager = None - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def setup_drag(self): self.drag_manager = DragManager( diff --git a/tilia/ui/timelines/pdf/element.py b/tilia/ui/timelines/pdf/element.py index 0a1c8c739..4ad02e9af 100644 --- a/tilia/ui/timelines/pdf/element.py +++ b/tilia/ui/timelines/pdf/element.py @@ -24,6 +24,7 @@ from ...format import format_media_time from ...coords import time_x_converter from tilia.ui.timelines.base.element import TimelineUIElement +from tilia.ui import commands from ...windows.inspect import InspectRowKind @@ -102,7 +103,7 @@ def double_left_click_triggers(self): return [self.body, self.label] def on_double_left_click(self, _): - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def setup_drag(self): DragManager( diff --git a/tilia/ui/timelines/score/element/note/ui.py b/tilia/ui/timelines/score/element/note/ui.py index 3b9f624d6..a4b691214 100644 --- a/tilia/ui/timelines/score/element/note/ui.py +++ b/tilia/ui/timelines/score/element/note/ui.py @@ -6,8 +6,8 @@ ) from tilia.dirs import IMG_DIR -from tilia.requests import Post, post from tilia.timelines.score.components.note import pitch +from tilia.ui import commands from tilia.ui.timelines.score import attrs from tilia.ui.color import get_tinted_color from tilia.ui.format import format_media_time @@ -302,7 +302,7 @@ def double_left_click_triggers(self): return [self.body] def on_double_left_click(self, _): - post(Post.PLAYER_SEEK, self.seek_time) + commands.execute("media.seek", self.seek_time) def on_select(self) -> None: self.body.on_select() diff --git a/tilia/ui/timelines/slider/timeline.py b/tilia/ui/timelines/slider/timeline.py index 19ef75802..ee9e3520e 100644 --- a/tilia/ui/timelines/slider/timeline.py +++ b/tilia/ui/timelines/slider/timeline.py @@ -22,6 +22,7 @@ from tilia.ui.timelines.drag import DragManager from tilia.ui.timelines.view import TimelineView from tilia.ui.coords import time_x_converter +from tilia.ui import commands from ..cursors import CursorMixIn if TYPE_CHECKING: @@ -128,9 +129,9 @@ def on_left_click(self, item_id: int, double: bool, x: int, *_, **__) -> None: if item_id == self.line: time = time_x_converter.get_time_by_x(x) if double: - post(Post.PLAYER_SEEK, time) + commands.execute("media.seek", time) else: - post(Post.PLAYER_SEEK_IF_NOT_PLAYING, time) + commands.execute("media.seek", if_playing=False) elif item_id == self.trough: self.setup_drag() diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index f08f01f16..0f15d2736 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -738,9 +738,9 @@ def paint(self, painter, option, widget) -> None: def mouseDoubleClickEvent(self, event) -> None: t0, t1 = self.get_time(self.seek_x) if not t1 or abs(t0[1]) < abs(t1[1]): - post(Post.PLAYER_SEEK, t0[0]) + commands.execute("media.seek", t0[0]) else: - post(Post.PLAYER_SEEK, t1[0]) + commands.execute("media.seek", t1[0]) return super().mouseDoubleClickEvent(event) From ec9223d88e7cc9124db28320881966866b454ef5 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 7 Mar 2026 11:47:48 +0100 Subject: [PATCH 44/47] refactor: use "media.seek" command instead of tilia_state.current_time setter --- TESTING.md | 8 +-- tests/conftest.py | 6 +- tests/test_app.py | 18 ++--- tests/test_export.py | 7 +- tests/timelines/beat/test_beat_timeline.py | 16 ++--- tests/timelines/pdf/test_pdf_timeline.py | 51 ++++++-------- tests/ui/cli/test_load_media.py | 4 +- .../timelines/beat/test_beat_timeline_ui.py | 8 +-- .../harmony/test_harmony_timeline_ui.py | 6 +- tests/ui/timelines/harmony/test_harmony_ui.py | 8 +-- tests/ui/timelines/harmony/test_mode_ui.py | 8 +-- .../hierarchy/test_hierarchy_timeline_ui.py | 6 +- .../marker/test_marker_timeline_ui.py | 68 +++++++++---------- 13 files changed, 102 insertions(+), 112 deletions(-) diff --git a/TESTING.md b/TESTING.md index 8593df908..4990c0264 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,7 +1,7 @@ The test suite is written in pytest. Below are some things to keep in my mind when writing tests. For examples of good and thorough tests, see `tests\ui\timelines\test_marker_timeline_ui.py`. Older modules should be refactored at some point to follow the guidelines below. ## How to simulate interaction with the UI? - The `user_actions` fixture can be used to trigger actions on the UI. This is equivalent to pressing buttons on the UI. We should also check that the actions are available in the UI where we expect them. -- The `tilia_state` fixture can be used to make certain changes to state simulating user input (e.g. `tilia_state.current_time = 10`). +- The `tilia_state` fixture can be used to make certain changes to state simulating user input (e.g. `tilia_state.duration = 10`.) - The `press_key` and `type_string` functions can be used to simulate keyboard input. ### Modal dialogs @@ -24,7 +24,7 @@ Some known modal dialogs: An alternative to mocking modal dialogs would be appreciated. Experiments with mocking modal dialogs (to date) have not worked. ## How to simulate interaction with timelines? -We shouldn't use methods of the `Timeline` or the `TimelineUI` classes, but instead try to simulate user input. This makes for tests that are more resilient to changes in implementation. For instance, this: +We shouldn't use methods of the `Timeline` or the `TimelineUI` classes, but instead try to simulate user input or use commands. This makes for tests that are more resilient to changes in implementation. For instance, this: ```python def test_me(tlui, marker_tlui): tlui.create_marker(0) @@ -34,8 +34,8 @@ def test_me(tlui, marker_tlui): can be rewritten as: ```python -def test_me(marker_tlui, tilia_state): - tilia_state.current_time = 0 +def test_me(marker_tlui): + commands.execute("media.seek", 0) commands.execute("marker_add") assert not len(marker_tlui) == 1 ``` diff --git a/tests/conftest.py b/tests/conftest.py index 24ab48ce5..8e8139a4e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -90,7 +90,11 @@ def __init__(self, tilia: App, player): def reset(self): self.app.on_clear() self.duration = 100 - self.current_time = 0 + + # reset current time + self.player.current_time = 0 + post(Post.PLAYER_CURRENT_TIME_CHANGED, 0, MediaTimeChangeReason.PLAYBACK) + self.media_path = "" self._reset_undo_manager() post(Post.REQUEST_CLEAR_UI) diff --git a/tests/test_app.py b/tests/test_app.py index 4f3a6edfb..43064eebb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -259,7 +259,7 @@ def test_set_duration_twice_without_cropping( self, scale_timelines, scale_factor, tilia_state, marker_tlui ): marker_time = 50 - tilia_state.current_time = marker_time + commands.execute("media.seek", marker_time) commands.execute("timeline.marker.add") displacement_factor = 1 for factor, should_scale in zip(scale_factor, scale_timelines, strict=True): @@ -271,9 +271,9 @@ def test_set_duration_twice_without_cropping( assert marker_tlui[0].get_data("time") == marker_time * displacement_factor def test_scale_then_crop(self, marker_tl, tilia_state): - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.marker.add") tilia_state.set_duration(200, scale_timelines="yes") tilia_state.set_duration(50, scale_timelines="no") @@ -281,9 +281,9 @@ def test_scale_then_crop(self, marker_tl, tilia_state): assert marker_tl[0].get_data("time") == 20 def test_crop_then_scale(self, marker_tl, tilia_state): - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.marker.add") tilia_state.set_duration(40, scale_timelines="no") tilia_state.set_duration(80, scale_timelines="yes") @@ -291,9 +291,9 @@ def test_crop_then_scale(self, marker_tl, tilia_state): assert marker_tl[0].get_data("time") == 20 def test_crop_twice(self, marker_tlui, tilia_state): - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.marker.add") tilia_state.set_duration(80, scale_timelines="no") tilia_state.set_duration(40, scale_timelines="no") @@ -520,7 +520,7 @@ def test_open_then_save(self, tmp_path, tilia_errors): class TestUndoRedo: - def test_undo_fails(self, tilia, qtui, tluis, tilia_state, tilia_errors): + def test_undo_fails(self, tilia, qtui, tluis, tilia_errors): with Serve(Get.FROM_USER_STRING, (True, "test")): commands.execute("timelines.add.marker") @@ -533,7 +533,7 @@ def test_undo_fails(self, tilia, qtui, tluis, tilia_state, tilia_errors): # will try to restore the previous, faulty state # Note: this could be improved by providing a state that is actually # similar to a healthy state - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") commands.execute("edit.undo") diff --git a/tests/test_export.py b/tests/test_export.py index b95f6e8f3..454a0c9d0 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -68,12 +68,9 @@ def test_not_exportble_timelines_are_not_exported( assert len(data["timelines"]) == 1 - def test_export_timelines( - self, tilia, marker_tlui, harmony_tlui, tilia_state, tmp_path - ): + def test_export_timelines(self, tilia, marker_tlui, harmony_tlui, tmp_path): for i in range(5): - tilia_state.current_time = i - commands.execute("timeline.marker.add") + commands.execute("timeline.marker.add", time=i) harmony_tlui.create_harmony(0) harmony_tlui.create_mode(0) diff --git a/tests/timelines/beat/test_beat_timeline.py b/tests/timelines/beat/test_beat_timeline.py index 767fe92c9..2c4d55814 100644 --- a/tests/timelines/beat/test_beat_timeline.py +++ b/tests/timelines/beat/test_beat_timeline.py @@ -9,8 +9,8 @@ def test_create_beat_at_same_time_fails(self, beat_tlui): commands.execute("timeline.beat.add") assert len(beat_tlui) == 1 - def test_create_beat_at_negative_time_fails(self, beat_tlui, tilia_state): - tilia_state.current_time = -10 + def test_create_beat_at_negative_time_fails(self, beat_tlui): + commands.execute("media.seek", -10) commands.execute("timeline.beat.add") assert len(beat_tlui) == 0 @@ -18,22 +18,22 @@ def test_create_beat_at_time_bigger_than_media_duration_fails( self, beat_tlui, tilia_state ): tilia_state.duration = 100 - tilia_state.current_time = 101 + commands.execute("media.seek", 101) 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, tilia_state + self, beat_tlui ): beat_tlui.timeline.beat_pattern = [2] - tilia_state.current_time = 0 + commands.execute("media.seek", 0) commands.execute("timeline.beat.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.beat.add") - tilia_state.current_time = 20 + commands.execute("media.seek", 20) commands.execute("timeline.beat.add") - tilia_state.current_time = 5 + commands.execute("media.seek", 5) commands.execute("timeline.beat.add") assert beat_tlui[0].get_data("is_first_in_measure") is True diff --git a/tests/timelines/pdf/test_pdf_timeline.py b/tests/timelines/pdf/test_pdf_timeline.py index d1ee32d35..77b80ed38 100644 --- a/tests/timelines/pdf/test_pdf_timeline.py +++ b/tests/timelines/pdf/test_pdf_timeline.py @@ -14,10 +14,10 @@ def test_page_total_is_zero_with_invalid_pdf(self, pdf_tl): class TestPageNumber: - def test_marker_page_number_default_is_next_page(self, tilia_state, pdf_tl): + def test_marker_page_number_default_is_next_page(self, pdf_tl): pdf_tl.page_total = 2 commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.pdf.add") assert pdf_tl[1].get_data("page_number") == 2 @@ -26,51 +26,42 @@ def test_first_marker_page_number_is_one(self, pdf_tl): commands.execute("timeline.pdf.add") assert pdf_tl[0].get_data("page_number") == 1 - def test_correct_page_is_displayed(self, tilia_state, pdf_tlui, pdf_tl): + def test_correct_page_is_displayed(self, pdf_tlui, pdf_tl): pdf_tl.page_total = 2 commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.pdf.add") - tilia_state.current_time = 11 + commands.execute("media.seek", 11) assert pdf_tlui.current_page == 2 - def test_correct_page_is_displayed_when_marker_is_created( - self, tilia_state, pdf_tlui, pdf_tl - ): + def test_correct_page_is_displayed_when_marker_is_created(self, pdf_tlui, pdf_tl): pdf_tl.page_total = 2 commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.pdf.add") assert pdf_tlui.current_page == 2 - def test_correct_page_is_displayed_when_marker_is_deleted( - self, tilia_state, pdf_tl, pdf_tlui - ): - commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 - commands.execute("timeline.pdf.add") + def test_correct_page_is_displayed_when_marker_is_deleted(self, pdf_tl, pdf_tlui): + pdf_tl.page_total = 2 + commands.execute("timeline.pdf.add", time=0, page_number=1) + commands.execute("timeline.pdf.add", time=10, page_number=2) + commands.execute("media.seek", time=10) + assert pdf_tlui.current_page == 2 pdf_tl.delete_components([pdf_tl[1]]) assert pdf_tlui.current_page == 1 def test_correct_page_is_displayed_when_current_time_is_same_as_marker( - self, tilia_state, pdf_tlui, pdf_tl + self, pdf_tlui, pdf_tl ): pdf_tl.page_total = 2 - commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 - commands.execute("timeline.pdf.add") - tilia_state.current_time = 20 - commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 + commands.execute("timeline.pdf.add", time=0) + commands.execute("timeline.pdf.add", time=10) + commands.execute("timeline.pdf.add", time=20) + commands.execute("media.seek", 10) assert pdf_tlui.current_page == 2 - def test_page_number_is_limited_by_page_total(self, tilia_state, pdf_tl): + def test_page_number_for_new_marker_is_capped_at_page_total(self, pdf_tl, pdf_tlui): pdf_tl.page_total = 2 - commands.execute("timeline.pdf.add") - tilia_state.current_time = 10 - commands.execute("timeline.pdf.add") - tilia_state.current_time = 20 - commands.execute("timeline.pdf.add") - tilia_state.current_time = 30 - commands.execute("timeline.pdf.add") + for time in range(10): + commands.execute("timeline.pdf.add", time=time) assert pdf_tl[-1].get_data("page_number") == 2 diff --git a/tests/ui/cli/test_load_media.py b/tests/ui/cli/test_load_media.py index ca1cb9b3d..1d5e79c6b 100644 --- a/tests/ui/cli/test_load_media.py +++ b/tests/ui/cli/test_load_media.py @@ -11,6 +11,7 @@ EXAMPLE_MEDIA_SCALE_FACTOR, ) from tilia.requests import get, Get +from tilia.ui import commands from tilia.ui.cli.player import CLIYoutubePlayer @@ -50,7 +51,8 @@ def test_load_with_spaces(cli, tilia_errors, resources, tmp_path): def test_with_timelines_scale_yes(cli, tilia_state, marker_tl): - tilia_state.current_time = tilia_state.duration / 2 + commands.execute("media.seek", tilia_state.duration / 2) + marker_tl.create_marker(tilia_state.current_time) cli.parse_and_run(f"load-media {EXAMPLE_MEDIA_PATH} --scale-timelines yes") diff --git a/tests/ui/timelines/beat/test_beat_timeline_ui.py b/tests/ui/timelines/beat/test_beat_timeline_ui.py index 1f3f2f5fe..a859b2375 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -106,14 +106,14 @@ 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, tilia_state): + def test_create_update_next_measures_numbers(self, beat_tlui): beat_tlui.timeline.beat_pattern = [1] beat_tlui.timeline.measures_to_force_display = [0, 1, 2, 3] beat_tlui.create_beat(0) beat_tlui.create_beat(1) beat_tlui.create_beat(2) - tilia_state.current_time = 0.5 + commands.execute("media.seek", 0.5) commands.execute("timeline.beat.add") assert [get_displayed_measure_number(beat) for beat in beat_tlui] == [ @@ -570,8 +570,8 @@ def test_reject_delete_existing_beats(self, beat_tlui): class TestUndoRedo: - def test_undo_redo_add_beat(self, beat_tlui, tluis, tilia_state): - tilia_state.current_time = 10 + def test_undo_redo_add_beat(self, beat_tlui, tluis): + commands.execute("media.seek", 10) commands.execute("timeline.beat.add") commands.execute("edit.undo") diff --git a/tests/ui/timelines/harmony/test_harmony_timeline_ui.py b/tests/ui/timelines/harmony/test_harmony_timeline_ui.py index 75e89199d..6f57eaca4 100644 --- a/tests/ui/timelines/harmony/test_harmony_timeline_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_timeline_ui.py @@ -126,12 +126,10 @@ def test_roman_label_does_not_start_with_accidental_when_root_is_diatonic_sharp_ class TestCopyPaste: - def test_paste_multiple_to_harmony_with_mode_as_first_copied( - self, tilia_state, harmony_tlui - ): + def test_paste_multiple_to_harmony_with_mode_as_first_copied(self, harmony_tlui): add_harmony() add_mode() - tilia_state.current_time = 10 + commands.execute("media.seek", 10) add_harmony() click_harmony_ui(harmony_tlui.modes()[0]) diff --git a/tests/ui/timelines/harmony/test_harmony_ui.py b/tests/ui/timelines/harmony/test_harmony_ui.py index bb371e70b..ab15e2946 100644 --- a/tests/ui/timelines/harmony/test_harmony_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_ui.py @@ -24,17 +24,17 @@ def test_right_click(self, tlui): class TestCopyPaste: - def test_paste_single_into_timeline(self, tlui, tilia_state): + def test_paste_single_into_timeline(self, tlui): _, hui = tlui.create_harmony(0) click_harmony_ui(tlui[0]) commands.execute("timeline.component.copy") click_timeline_ui(tlui, 10) - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.component.paste") assert len(tlui) == 2 assert tlui[1].get_data("time") == 50 - def test_paste_multiple_into_timeline(self, tlui, tilia_state): + def test_paste_multiple_into_timeline(self, tlui): _, hui1 = tlui.create_harmony(0) _, hui2 = tlui.create_harmony(10) _, hui3 = tlui.create_harmony(20) @@ -43,7 +43,7 @@ def test_paste_multiple_into_timeline(self, tlui, tilia_state): click_harmony_ui(tlui[2], modifier="ctrl") commands.execute("timeline.component.copy") click_timeline_ui(tlui, 10) - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.component.paste") assert len(tlui) == 6 assert tlui[3].get_data("time") == 50 diff --git a/tests/ui/timelines/harmony/test_mode_ui.py b/tests/ui/timelines/harmony/test_mode_ui.py index 0fbadcd51..c2fb738cc 100644 --- a/tests/ui/timelines/harmony/test_mode_ui.py +++ b/tests/ui/timelines/harmony/test_mode_ui.py @@ -24,17 +24,17 @@ def test_right_click(self, tlui): class TestCopyPaste: - def test_paste_single_into_timeline(self, tlui, tilia_state): + def test_paste_single_into_timeline(self, tlui): tlui.create_mode(0) click_mode_ui(tlui[0]) commands.execute("timeline.component.copy") click_timeline_ui(tlui, 10) - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.component.paste") assert len(tlui) == 2 assert tlui[1].get_data("time") == 50 - def test_paste_multiple_into_timeline(self, tlui, tilia_state): + def test_paste_multiple_into_timeline(self, tlui): tlui.create_mode(0) tlui.create_mode(10) tlui.create_mode(20) @@ -43,7 +43,7 @@ def test_paste_multiple_into_timeline(self, tlui, tilia_state): click_mode_ui(tlui[2], modifier="ctrl") commands.execute("timeline.component.copy") click_timeline_ui(tlui, 90) - tilia_state.current_time = 50 + commands.execute("media.seek", 50) commands.execute("timeline.component.paste") assert len(tlui) == 6 assert tlui[3].get_data("time") == 50 diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py index ca11860e8..19f33732d 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py @@ -121,7 +121,7 @@ def test_add_pre_start(self, tlui): assert tlui[0].get_data("pre_start") != tlui[0].get_data("start") assert tlui[0].pre_start_handle - def test_add_post_end(self, tlui, tilia_state): + def test_add_post_end(self, tlui): tlui.create_hierarchy(0, 1, 1) tlui.select_element(tlui[0]) @@ -131,10 +131,10 @@ def test_add_post_end(self, tlui, tilia_state): assert tlui[0].get_data("post_end") != tlui[0].get_data("end") assert tlui[0].post_end_handle - def test_split(self, tlui, tilia_state): + def test_split(self, tlui): tlui.create_hierarchy(0, 1, 1) assert len(tlui) == 1 - tilia_state.current_time = 0.5 + commands.execute("media.seek", 0.5) commands.execute("timeline.hierarchy.split") assert len(tlui) == 2 diff --git a/tests/ui/timelines/marker/test_marker_timeline_ui.py b/tests/ui/timelines/marker/test_marker_timeline_ui.py index 17faa1b15..adbc2119f 100644 --- a/tests/ui/timelines/marker/test_marker_timeline_ui.py +++ b/tests/ui/timelines/marker/test_marker_timeline_ui.py @@ -34,8 +34,8 @@ class TestCreateDelete: - def test_create(self, marker_tlui, tluis, tilia_state): - tilia_state.current_time = 11 + def test_create_at_selected_time(self, marker_tlui, tluis): + commands.execute("media.seek", 11) commands.execute("timeline.marker.add") assert len(marker_tlui) == 1 @@ -55,9 +55,9 @@ def test_delete(self, marker_tlui): commands.execute("timeline.component.delete") assert len(marker_tlui) == 0 - def test_delete_multiple(self, marker_tlui, tilia_state): + def test_delete_multiple(self, marker_tlui): commands.execute("timeline.marker.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -83,9 +83,9 @@ def test_set_color(self, marker_tlui): self.set_color_on_all_markers(marker_tlui) assert marker_tlui[0].get_data("color") == self.TEST_COLOR - def test_set_color_multiple_markers(self, marker_tlui, tilia_state): + def test_set_color_multiple_markers(self, marker_tlui): commands.execute("timeline.marker.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") with undoable(): self.set_color_on_all_markers(marker_tlui) @@ -100,9 +100,9 @@ def test_reset_color(self, marker_tlui): commands.execute("timeline.component.reset_color") assert marker_tlui[0].get_data("color") is None - def test_reset_color_multiple_markers(self, marker_tlui, tilia_state): + def test_reset_color_multiple_markers(self, marker_tlui): commands.execute("timeline.marker.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") self.set_color_on_all_markers(marker_tlui) @@ -111,7 +111,7 @@ def test_reset_color_multiple_markers(self, marker_tlui, tilia_state): for marker in marker_tlui: assert marker.get_data("color") is None - def test_cancel_color_dialog(self, marker_tlui, tilia_state): + def test_cancel_color_dialog(self, marker_tlui): commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) with patch.object(QColorDialog, "getColor", return_value=QColor("invalid")): @@ -121,18 +121,18 @@ def test_cancel_color_dialog(self, marker_tlui, tilia_state): class TestCopyPaste: - def test_shortcut(self, marker_tlui, tilia_state): + def test_shortcut(self, marker_tlui): marker_tlui.create_marker(0) click_marker_ui(marker_tlui[0]) press_key("c", modifier=Qt.KeyboardModifier.ControlModifier) click_timeline_ui(marker_tlui, 50) - tilia_state.current_time = 10 + commands.execute("media.seek", 10) press_key("v", modifier=Qt.KeyboardModifier.ControlModifier) assert len(marker_tlui) == 2 - def test_paste_single_into_timeline(self, marker_tlui, tilia_state): + def test_paste_single_into_timeline(self, marker_tlui): marker_tlui.create_marker(0, label="copy me") # Must record explicitly, as there is no command to set a component's label post(Post.APP_STATE_RECORD, "set marker label") @@ -140,7 +140,7 @@ def test_paste_single_into_timeline(self, marker_tlui, tilia_state): click_marker_ui(marker_tlui[0]) commands.execute("timeline.component.copy") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) click_timeline_ui(marker_tlui, 50) with undoable(): @@ -150,9 +150,9 @@ def test_paste_single_into_timeline(self, marker_tlui, tilia_state): assert marker_tlui[1].get_data("time") == 10 assert marker_tlui[1].get_data("label") == "copy me" - def test_paste_single_into_selected_element(self, marker_tlui, tilia_state): + def test_paste_single_into_selected_element(self, marker_tlui): commands.execute("timeline.marker.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -167,9 +167,9 @@ def test_paste_single_into_selected_element(self, marker_tlui, tilia_state): assert len(marker_tlui) == 2 assert marker_tlui[1].get_data("label") == "copy me" - def test_paste_multiple_into_timeline(self, marker_tlui, tilia_state): + def test_paste_multiple_into_timeline(self, marker_tlui): for time, label in [(0, "first"), (10, "second"), (20, "third")]: - tilia_state.current_time = time + commands.execute("media.seek", time) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[-1]) commands.execute("timeline.element.inspect") @@ -181,7 +181,7 @@ def test_paste_multiple_into_timeline(self, marker_tlui, tilia_state): commands.execute("timeline.component.copy") click_timeline_ui(marker_tlui, 100) # deselect markers - tilia_state.current_time = 50 + commands.execute("media.seek", 50) with undoable(): commands.execute("timeline.component.paste") @@ -195,9 +195,9 @@ def test_paste_multiple_into_timeline(self, marker_tlui, tilia_state): assert marker_tlui[index].get_data("time") == time assert marker_tlui[index].get_data("label") == label - def test_paste_multiple_into_selected_element(self, marker_tlui, tilia_state): + def test_paste_multiple_into_selected_element(self, marker_tlui): for time, label in [(0, "first"), (10, "second"), (20, "third")]: - tilia_state.current_time = time + commands.execute("media.seek", time) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[-1]) commands.execute("timeline.element.inspect") @@ -268,7 +268,7 @@ def test_box_deselection(self, marker_tlui, tluis): class TestDrag: def test_drag(self, marker_tlui, tluis, tilia_state): tilia_state.duration = 100 - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -279,7 +279,7 @@ def test_drag(self, marker_tlui, tluis, tilia_state): def test_drag_beyond_start(self, marker_tlui, tluis, tilia_state): tilia_state.duration = 100 - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -290,7 +290,7 @@ def test_drag_beyond_start(self, marker_tlui, tluis, tilia_state): def test_drag_beyond_end(self, marker_tlui, tluis, tilia_state): tilia_state.duration = 100 - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -301,7 +301,7 @@ def test_drag_beyond_end(self, marker_tlui, tluis, tilia_state): class TestElementContextMenu: - def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): + def test_is_shown_on_right_click(self, marker_tlui, tluis): marker_tlui.create_marker(0) with patch.object(MarkerContextMenu, "exec") as mock: @@ -309,7 +309,7 @@ def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): mock.assert_called_once() - def test_has_the_right_options(self, marker_tlui, tluis, tilia_state): + def test_has_the_right_options(self, marker_tlui, tluis): marker_tlui.create_marker(0) context_menu = marker_tlui[0].CONTEXT_MENU_CLASS((marker_tlui[0])) @@ -332,13 +332,13 @@ class TestTimelineUIContextMenu: def get_context_menu(tluis, tl_index=0): return tluis[tl_index].CONTEXT_MENU_CLASS(tluis[tl_index]) - def test_is_shown_on_right_click(self, marker_tlui, tluis, tilia_state): + def test_is_shown_on_right_click(self, tluis, marker_tlui): with patch.object(MarkerTimelineUIContextMenu, "exec") as mock: click_timeline_ui(marker_tlui, 50, button="right") mock.assert_called_once() - def test_has_no_height_set_action(self, marker_tlui, tluis, tilia_state): + def test_has_no_height_set_action(self, marker_tlui, tluis): context_menu = self.get_context_menu(tluis) assert get_qaction("timeline.set_height") not in context_menu.actions() @@ -365,7 +365,7 @@ def test_has_no_move_up_action_when_first(self, tluis): assert "timeline.move_up" not in action_commands assert "timeline.move_down" in action_commands - def test_has_the_right_actions(self, marker_tlui, tluis, tilia_state): + def test_has_the_right_actions(self, marker_tlui, tluis): context_menu = self.get_context_menu(tluis) # As each context menu creates its own QActions, @@ -511,11 +511,9 @@ def test_set_comments_to_empty_string(self, marker_tlui, tluis): assert marker_tlui[0].get_data("comments") == "" - def test_set_attribute_with_multiple_selected( - self, marker_tlui, tluis, tilia_state - ): + def test_set_attribute_with_multiple_selected(self, marker_tlui, tluis): commands.execute("timeline.marker.add") - tilia_state.current_time = 10 + commands.execute("media.seek", 10) commands.execute("timeline.marker.add") click_marker_ui(marker_tlui[0]) @@ -608,9 +606,9 @@ def test_timeline_menu_has_right_actions(tluis, qtui, marker_tlui, tilia_state): assert get_qaction(a) in marker_submenu.actions() -def test_clear(tluis, qtui, marker_tlui, tilia_state): +def test_clear(tluis, qtui, marker_tlui): for i in range(10): - tilia_state.current_time = i + commands.execute("media.seek", i) commands.execute("timeline.marker.add") with patch_yes_or_no_dialog(True): @@ -619,7 +617,7 @@ def test_clear(tluis, qtui, marker_tlui, tilia_state): assert len(marker_tlui) == 0 -def test_delete(tluis, qtui, marker_tlui, tilia_state): +def test_delete(tluis, qtui, marker_tlui): with patch_yes_or_no_dialog(True): commands.execute("timeline.delete", marker_tlui) From 4b0d1f0c110fdf7ed8d669388c919b85b173f329 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 7 Mar 2026 11:59:03 +0100 Subject: [PATCH 45/47] test: double clicking pdf marker --- tests/ui/timelines/pdf/test_pdf_marker_ui.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/ui/timelines/pdf/test_pdf_marker_ui.py diff --git a/tests/ui/timelines/pdf/test_pdf_marker_ui.py b/tests/ui/timelines/pdf/test_pdf_marker_ui.py new file mode 100644 index 000000000..daed9e116 --- /dev/null +++ b/tests/ui/timelines/pdf/test_pdf_marker_ui.py @@ -0,0 +1,19 @@ +from unittest.mock import Mock + +from tilia.ui import commands + + +class TestDoubleClick: + def test_pdf_marker_seek(self, pdf_tlui, tilia_state): + commands.execute("timeline.pdf.add", time=10) + pdf_tlui[0].on_double_left_click(None) + + assert tilia_state.current_time == 10 + + def test_does_not_trigger_drag(self, pdf_tlui): + commands.execute("timeline.pdf.add") + mock = Mock() + pdf_tlui[0].setup_drag = mock + pdf_tlui[0].on_double_left_click(None) + + mock.assert_not_called() From cc1d14beac057cbacb7e43b18d62712286ae3338 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 7 Mar 2026 12:00:47 +0100 Subject: [PATCH 46/47] refactor: receive arguments from "timeline.pdf.add" command --- .../ui/timelines/pdf/test_pdf_timeline_ui.py | 56 +++++++++++++++++++ tilia/ui/timelines/pdf/timeline.py | 13 +++-- 2 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 tests/ui/timelines/pdf/test_pdf_timeline_ui.py diff --git a/tests/ui/timelines/pdf/test_pdf_timeline_ui.py b/tests/ui/timelines/pdf/test_pdf_timeline_ui.py new file mode 100644 index 000000000..79edcd6a7 --- /dev/null +++ b/tests/ui/timelines/pdf/test_pdf_timeline_ui.py @@ -0,0 +1,56 @@ +from tilia.ui import commands + + +class TestCreateComponent: + @staticmethod + def assert_pdf_marker(pdf_tlui, index: int, *, time: float, page_number: int): + assert pdf_tlui[index].get_data("time") == time + assert pdf_tlui[index].get_data("page_number") == page_number + + def test_create_single_with_command(self, pdf_tlui): + pdf_tlui.timeline.page_total = 2 + commands.execute("timeline.pdf.add", time=10, page_number=2) + + self.assert_pdf_marker(pdf_tlui, 0, time=10, page_number=2) + + def test_create_multiple_with_command(self, pdf_tlui): + pdf_tlui.timeline.page_total = 10 + commands.execute("timeline.pdf.add", time=10) + commands.execute("timeline.pdf.add", time=20) + commands.execute("timeline.pdf.add", time=30) + + assert len(pdf_tlui) == 3 + self.assert_pdf_marker(pdf_tlui, 0, time=10, page_number=1) + self.assert_pdf_marker(pdf_tlui, 1, time=20, page_number=2) + self.assert_pdf_marker(pdf_tlui, 2, time=30, page_number=3) + + def test_create_with_commands_passing_time(self, pdf_tlui): + pdf_tlui.timeline.page_total = 10 + commands.execute("media.seek", 99) + commands.execute("timeline.pdf.add", time=10) + + self.assert_pdf_marker(pdf_tlui, 0, time=10, page_number=1) + + def test_create_with_command_without_passing_time(self, pdf_tlui): + pdf_tlui.timeline.page_total = 10 + commands.execute("media.seek", 10) + commands.execute("timeline.pdf.add") + + self.assert_pdf_marker(pdf_tlui, 0, time=10, page_number=1) + + def test_create_with_command_passing_page_number(self, pdf_tlui): + pdf_tlui.timeline.page_total = 10 + commands.execute("timeline.pdf.add", time=10, page_number=5) + + self.assert_pdf_marker(pdf_tlui, 0, time=10, page_number=5) + + def test_create_with_command_without_passing_page_number(self, pdf_tlui): + pdf_tlui.timeline.page_total = 10 + commands.execute("timeline.pdf.add", time=10) + commands.execute("timeline.pdf.add", time=20) + commands.execute("timeline.pdf.add", time=30) + + assert len(pdf_tlui) == 3 + self.assert_pdf_marker(pdf_tlui, 0, time=10, page_number=1) + self.assert_pdf_marker(pdf_tlui, 1, time=20, page_number=2) + self.assert_pdf_marker(pdf_tlui, 2, time=30, page_number=3) diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index 896f24e9f..4ffc51653 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -115,16 +115,19 @@ def register_commands(cls, collection: TimelineUIs): icon="pdf_add", ) - def on_add(self, time: float | None = None): + def on_add(self, time: float | None = None, page_number: int | None = None): if time is None: time = get(Get.SELECTED_TIME) - page_number = min( - self.timeline.get_previous_page_number(time) + 1, self.timeline.page_total - ) + if not page_number: + requested_page_number = self.timeline.get_previous_page_number(time) + 1 + else: + requested_page_number = page_number + + page_number = min(max(requested_page_number, 1), self.timeline.page_total) pdf_marker, reason = self.timeline.create_component( - ComponentKind.PDF_MARKER, get(Get.SELECTED_TIME), page_number + ComponentKind.PDF_MARKER, time, page_number ) if not pdf_marker: tilia.errors.display(tilia.errors.ADD_PDF_MARKER_FAILED, reason) From a6da69c2b5e6491d943afd3c0c4aa901ad327890 Mon Sep 17 00:00:00 2001 From: Felipe Martins Date: Sat, 7 Mar 2026 12:13:50 +0100 Subject: [PATCH 47/47] test: double clicking harmony timeline components --- .../harmony/test_harmony_timeline_ui.py | 15 ++++++--- tests/ui/timelines/harmony/test_harmony_ui.py | 33 ++++++++++++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/tests/ui/timelines/harmony/test_harmony_timeline_ui.py b/tests/ui/timelines/harmony/test_harmony_timeline_ui.py index 6f57eaca4..ae90a78e9 100644 --- a/tests/ui/timelines/harmony/test_harmony_timeline_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_timeline_ui.py @@ -10,7 +10,7 @@ SHARP_SIGN = "`#" -def add_harmony(**kwargs): +def add_harmony(time: float | None = None, **kwargs): default_params = { "step": 0, "accidental": 0, @@ -21,11 +21,15 @@ def add_harmony(**kwargs): "level": 1, } default_params.update(kwargs) + with Serve(Get.FROM_USER_HARMONY_PARAMS, (True, default_params)): - tilia.ui.commands.execute("timeline.harmony.add_harmony") + if not time: + tilia.ui.commands.execute("timeline.harmony.add_harmony") + else: + tilia.ui.commands.execute("timeline.harmony.add_harmony", time) -def add_mode(**kwargs): +def add_mode(time: float | None = None, **kwargs): default_params = { "step": 0, "accidental": 0, @@ -34,7 +38,10 @@ def add_mode(**kwargs): } default_params.update(kwargs) with Serve(Get.FROM_USER_MODE_PARAMS, (True, default_params)): - tilia.ui.commands.execute("timeline.harmony.add_mode") + if not time: + tilia.ui.commands.execute("timeline.harmony.add_mode") + else: + tilia.ui.commands.execute("timeline.harmony.add_mode", time) class TestRomanNumeralDisplay: diff --git a/tests/ui/timelines/harmony/test_harmony_ui.py b/tests/ui/timelines/harmony/test_harmony_ui.py index ab15e2946..5e4dd7980 100644 --- a/tests/ui/timelines/harmony/test_harmony_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_ui.py @@ -1,8 +1,9 @@ -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest from tests.ui.timelines.harmony.interact import click_harmony_ui +from tests.ui.timelines.harmony.test_harmony_timeline_ui import add_harmony, add_mode from tests.ui.timelines.interact import click_timeline_ui from tilia.ui import commands @@ -119,3 +120,33 @@ def test_paste_multiple_into_element(self, tlui): assert len(tlui) == 6 for attr, value in attributes_to_copy.items(): assert target_hui.get_data(attr) == attributes_to_copy[attr] + + +class TestDoubleClick: + def test_harmony_seeks(self, harmony_tlui, tilia_state): + add_harmony(10) + harmony_tlui[0].on_double_left_click(None) + + assert tilia_state.current_time == 10 + + def test_harmony_does_not_trigger_drag(self, harmony_tlui): + add_harmony() + mock = Mock() + harmony_tlui[0].setup_drag = mock + harmony_tlui[0].on_double_left_click(None) + + mock.assert_not_called() + + def test_mode_seeks(self, harmony_tlui, tilia_state): + add_mode(10) + harmony_tlui[0].on_double_left_click(None) + + assert tilia_state.current_time == 10 + + def test_mode_does_not_trigger_drag(self, harmony_tlui): + add_mode() + mock = Mock() + harmony_tlui[0].setup_drag = mock + harmony_tlui[0].on_double_left_click(None) + + mock.assert_not_called()