diff --git a/README.md b/README.md index 0eeaa160d..f19f9dbcd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Here are some examples TiLiA visualizations: - Toggling of timeline visibility - Export of audio segments based on analysis - Import timeline data from CSV files +- Command-line interface (see the CLI [help page](docs/cli-tutorial.md) for more information) + ## How to install and use TiLiA has releases for Windows, macOS and Linux. Instructions to download and run are [at the website](https://tilia-app.com/help/introduction/). 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/docs/cli-tutorial.md b/docs/cli-tutorial.md new file mode 100644 index 000000000..8304cf55a --- /dev/null +++ b/docs/cli-tutorial.md @@ -0,0 +1,356 @@ +# TiLiA CLI Tutorial + +This tutorial shows how the TiLiA CLI can be used to perform some common tasks. + +### Setup + +See [README.md](README.md) for instructions on how to clone the repository and install the dependencies. + +### Running the CLI + +The CLI can be run from the source code with: +```bash +python -m tilia.main --user-interface cli +``` + +TiLiA CLI is an interactive shell. You'll see ``>>>`` when it's ready for your input. + +### Getting Help + +For any command, you can get detailed help and examples by typing a command followed by `--help`: + +```bash +>>> timelines add --help +usage: main.py timelines add [-h] [--name NAME] [--height HEIGHT] [--beat-pattern BEAT_PATTERN [BEAT_PATTERN ...]] {hierarchy,hrc,marker,mrk,beat,bea,score,sco} + +positional arguments: + {hierarchy,hrc,marker,mrk,beat,bea,score,sco} + Kind of timeline to add + +options: + -h, --help show this help message and exit + --name NAME, -n NAME Name of the new timeline + --height HEIGHT, -e HEIGHT + Height of the timeline + --beat-pattern BEAT_PATTERN [BEAT_PATTERN ...], -b BEAT_PATTERN [BEAT_PATTERN ...] + Pattern as space-separated integers indicating beat count in a measure. Pattern will be repeated. Pattern '3 4', for instance, will alternate measures of 3 and 4 beats. + +Examples: + timelines add beat --name "Measures" --beat-pattern 4 + timelines add hierarchy --name "Form" + timelines add marker --name "Cadences" +``` + +--- + +## Creating a New Project from Media + +These commands can be used to create a TiLiA project, load a media file, create timelines, and organize them. + +Load a media file: +```bash +load-media "C:/path/to/audio.mp3" # Local file +load-media "https://www.youtube.com/watch?v=..." # YouTube video +``` + +Set the media duration manually: +```bash +metadata set-media-length 300 # 300 seconds = 5 minutes +``` + +Set some basic metadata about the piece: +```bash +metadata set title "Bohemian Rhapsody" +metadata set composer "Freddie Mercury" +metadata set performer "Queen" +``` + +Display metadata with: +```bash +metadata show +``` +The output should be something like this: +```bash +Title: Bohemian Rhapsody +Notes: +Composer: Freddie Mercury +Performer: Queen +... +Media length: 300.0 +``` + +Now that a media is loaded, and metadata is set, we can create timelines: +```bash +timelines add beat --name "Measures" --beat-pattern 4 +timelines add hierarchy --name "Form" +timelines add marker --name "Cadences" +``` + +To display existing timelines use: +```bash +timelines list +``` +This should output: +```bash ++------+----------+-----------+ +| ord. | name | kind | ++------+----------+-----------+ +| 1 | | Slider | +| 2 | Measures | Beat | +| 3 | Form | Hierarchy | +| 4 | Cadences | Marker | ++------+----------+-----------+ +``` + +Note that you must have a media loaded or a manually set duration to create timelines. + +## Organizing Timelines + +After creating timelines, you might want to reorder or remove some. + +```bash +timelines add hierarchy --name "Form" +timelines add marker --name "Themes" +timelines add beat --name "Measures" --beat-pattern 4 +timelines add marker --name "Cadences" +timelines list + +# Output ++------+----------+-----------+ +| ord. | name | kind | ++------+----------+-----------+ +| 1 | Form | Hierarchy | +| 2 | Themes | Marker | +| 3 | Measures | Beat | +| 4 | Cadences | Marker | ++------+----------+-----------+ +``` + +Reorder timelines: + +```bash +# Move "Measures" to the top (position 1) +timelines move name "Measures" 1 + +# Move the timeline at position 4 (Cadences) to position 2 +timelines move ordinal 4 2 +``` + +Remove a timeline we don't need anymore: +``` +timelines remove name "Themes" +``` + +Check the final arrangement: +``` +timelines list + +# Output ++------+----------+-----------+ +| ord. | name | kind | ++------+----------+-----------+ +| 1 | Measures | Beat | +| 2 | Cadences | Marker | +| 3 | Form | Hierarchy | ++------+----------+-----------+ +``` + +## Importing Annotations from CSV + +If you have annotations in CSV format (perhaps made with another tool), you can import them. + +First, let's look at the CSV files we'll import: + +**form.csv:** +```csv +start,end,level,label,comments +0.0,30.0,1,Introduction, +30.0,90.0,1,Verse 1, +35.0,50.0,2,Phrase A, +50.0,65.0,2,Phrase B, +90.0,150.0,1,Chorus, +``` + +**cadences.csv:** +```csv +time,label,comments +29.5,HC,Half Cadence +89.3,PAC,Perfect Authentic Cadence +149.8,PAC, +``` + +For detailed CSV format specifications, see: https://tilia-app.com/help/import + +To import the CSV files: + +```bash +# Set up the project +load-media "C:/audio/song.mp3" +timelines add hierarchy --name "Form" +timelines add marker --name "Cadences" + +# Import hierarchies from CSV (using time values) +timelines import hierarchy by-time --file "C:/data/form.csv" --target-name "Form" + +# Import markers from CSV (using time values) +timelines import marker by-time --file "C:/data/cadences.csv" --target-name "Cadences" + +``` +When you have beat/measure information, you can import annotations using measure numbers instead of absolute time. + +Here we will also be importing beats from a CSV file, but they could come from an existingTiLiA file. + +**beats.csv:** +```csv +time +0.0 +0.6 +1.2 +1.8 +2.4 +3.0 +``` +Hierarchy timeline CSV data: + +**form_measures.csv:** +```csv +start_measure,start_fraction,end_measure,end_fraction,level,label,comments +1,0.0,8,0.0,1,Introduction, +9,0.0,24,0.0,1,Verse, +9,0.0,16,0.0,2,Phrase A, +17,0.0,24,0.0,2,Phrase B, +``` + +Now import them: + +```bash +# Set up with a beat timeline +load-media "C:/audio/piece.mp3" +timelines add beat --name "Measures" --beat-pattern 4 +timelines add hierarchy --name "Form" + +# Import beat data first +timelines import beat --file "C:/data/beats.csv" --target-name "Measures" + +# Now import hierarchies using measure numbers +timelines import hierarchy by-measure --file "C:/data/form_measures.csv" --target-name "Form" --reference-tl-name "Measures" + +``` + +## Working with Existing TiLiA files + +You can also open and modify existing TiLiA files. + +Open an existing file: +```bash +open "C:/projects/song.tla" +timelines list + +# Output ++-----+----------+-----------+ +| ord. | name | kind | ++-----+----------+-----------+ +| 1 | Measures| Beat | +| 2 | Form | Hierarchy | +| 3 | Cadences| Marker | ++-----+----------+-----------+ +``` + +Now let's add a new timeline and import some data into it: + +```bash +timelines add marker --name "Dynamics" +timelines import marker by-time --file "C:/data/dynamics.csv" --target-name "Dynamics" +save "C:/projects/song.tla" --overwrite +``` + +We can also export the file to JSON for use in other tools: + +```bash +export "C:/exports/song.json" --overwrite +``` + +--- + +## Automation with CLI Scripts + +You can create a `.txt` file with a sequence of commands to automate repetitive tasks, so you don't have to type them every time. + +**process_song.txt** +``` +# Load and set up project +load-media "C:/audio/song.mp3" +metadata set title "Example Song" +metadata set composer "John Doe" + +# Create timeline structure +timelines add beat --name "Measures" --beat-pattern 4 +timelines add hierarchy --name "Form" +timelines add marker --name "Cadences" + +# Import all annotations +timelines import beat --file "C:/data/beats.csv" --target-name "Measures" +timelines import hierarchy by-measure --file "C:/data/form.csv" --target-name "Form" --reference-tl-name "Measures" +timelines import marker by-measure --file "C:/data/cadences.csv" --target-name "Cadences" --reference-tl-name "Measures" + +# Save the result +save "C:/projects/song.tla" --overwrite +``` + +Then run it from the CLI with the `script` command: + +```bash +# Run the script +script "C:/scripts/process_song.txt" +``` + +**Script features:** +- Lines starting with `#` are comments and are ignored +- Empty lines are ignored +- Commands execute sequentially +- Script stops on first error + +For processing multiple files, you can use any programming language to generate a single CLI script containing commands for each file. + +**batch_process.py**: + +```python +from pathlib import Path + +# Configuration +audio_dir = Path("C:/audio") +data_dir = Path("C:/data") +output_dir = Path("C:/projects") +script_file = Path("C:/temp/tilia_script.txt") + +# Files to process +songs = ["song1", "song2", "song3"] +script_content = "" + +for song in songs: + # Generate CLI script + script_content += f""" +# Process {song} +load-media "{audio_dir / song}.mp3" +metadata set title "{song}" + +# Create timelines +timelines add beat --name "Measures" --beat-pattern 4 +timelines add hierarchy --name "Form" +timelines add marker --name "Cadences" + +# Import data +timelines import beat --file "{data_dir / song}_beats.csv" --target-name "Measures" +timelines import hierarchy by-measure --file "{data_dir / song}_form.csv" --target-name "Form" --reference-tl-name "Measures" +timelines import marker by-measure --file "{data_dir / song}_cadences.csv" --target-name "Cadences" --reference-tl-name "Measures" + +# Save and clear +save "{output_dir / song}.tla" --overwrite +clear --force +""" + +# Write script +with open(script_file, 'w') as f: + f.write(script_content) + +``` 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..722d8a4a9 100644 --- a/tests/parsers/csv/test_markers_from_csv.py +++ b/tests/parsers/csv/test_markers_from_csv.py @@ -5,34 +5,29 @@ 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 tests.utils import undoable +from tilia.requests import Post, post +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 + # 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( - "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)), + undoable(), ): - actions.trigger(TiliaAction.IMPORT_CSV_MARKER_TIMELINE) - return status, errors + success = commands.execute("timelines.import.marker") + return success def test_markers_by_measure_from_csv(beat_tlui, marker_tlui): @@ -118,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 + 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 + 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 + 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 @@ -159,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 + 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/tests/test_app.py b/tests/test_app.py index 5ffda4082..b8dab9b21 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -8,13 +8,20 @@ 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 tests.utils import save_and_reopen from tilia.media.player import YouTubePlayer, QtAudioPlayer 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 +42,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 +61,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 +94,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 +107,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 +115,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 +152,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 +183,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 +201,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 +257,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 +271,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 +304,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 +320,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 +333,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 +356,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 +364,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 +372,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 +380,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 +388,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 +398,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 +410,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 +438,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 +446,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 +476,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 +485,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 +498,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 +508,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 +616,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 +629,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 +646,81 @@ 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) + + +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 + + +class TestIDs: + @staticmethod + def assert_ids_are_unique(tls, expected_id_count: int): + timeline_ids = {tl.id for tl in tls} + component_ids = set() + for tl in tls: + for component in tl: + component_ids.add(component.id) + + assert len(timeline_ids.union(component_ids)) == expected_id_count + + @staticmethod + def create_timelines_and_components(timeline_count: int, component_count: int): + """Helper function to create timelines and components in loops.""" + for i in range(timeline_count): + commands.execute("timelines.add.marker", name="") + for j in range(component_count): + commands.execute("timeline.marker.add", time=j) + + def test_timeline_ids_are_unique(self, tls, tluis): + timeline_count = 100 + self.create_timelines_and_components(timeline_count, 0) + self.assert_ids_are_unique(tls, timeline_count) + + def test_timeline_component_ids_are_unique(self, tls, tluis): + component_count = 100 + self.create_timelines_and_components(1, component_count) + assert len({c.id for c in tls[0]}) == component_count + + def test_ids_are_unique_between_timeline_and_components(self, tls, tluis): + timeline_count = 10 + component_count = 10 + self.create_timelines_and_components(timeline_count, component_count) + + self.assert_ids_are_unique( + tls, (timeline_count * component_count) + timeline_count + ) + + def test_are_unique_between_files_only_timelines(self, tls, tluis, tmp_path): + self.create_timelines_and_components(10, 0) + save_and_reopen(tmp_path) + self.create_timelines_and_components(10, 0) + self.assert_ids_are_unique(tls, 21) # 20 marker timelines + 1 slider timeline + + def test_are_unique_between_files_timelines_and_components( + self, tls, tluis, tmp_path + ): + self.create_timelines_and_components(5, 5) + save_and_reopen(tmp_path) + self.create_timelines_and_components(5, 5) + self.assert_ids_are_unique( + tls, 61 + ) # 10 marker timelines + 1 slider timeline + 50 components diff --git a/tests/test_dirs.py b/tests/test_dirs.py index 02dd73459..33f5b688c 100644 --- a/tests/test_dirs.py +++ b/tests/test_dirs.py @@ -33,11 +33,13 @@ 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: - Path(dir_path).mkdir() + Path(dir_path).mkdir(exist_ok=True) @patch("tilia.dirs._USER_DATA_DIR", Path("user_dir")) 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/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/base/test_timeline.py b/tests/timelines/base/test_timeline.py new file mode 100644 index 000000000..a5d3917d8 --- /dev/null +++ b/tests/timelines/base/test_timeline.py @@ -0,0 +1,21 @@ +from unittest.mock import patch + +from tilia.timelines.base.timeline import Timeline +from tilia.timelines.timeline_kinds import TimelineKind + + +def test_timeline_subclasses(): + assert len(Timeline.subclasses()) == len(TimelineKind) + + +def test_ensure_timeline_ui_subclasses_is_only_called_once(): + # Subclasses were already loaded in previous tests, so we reset the flag + Timeline.SUBCLASSES_ARE_LOADED = False + with patch.object( + Timeline, + "ensure_subclasses_are_available", + wraps=Timeline.ensure_subclasses_are_available, + ) as wrapped: + for _ in range(50): + Timeline.subclasses() + assert wrapped.call_count == 1 diff --git a/tests/timelines/base/test_validators.py b/tests/timelines/base/test_validators.py new file mode 100644 index 000000000..f38af83a1 --- /dev/null +++ b/tests/timelines/base/test_validators.py @@ -0,0 +1,34 @@ +from PyQt6.QtGui import QColor +from tilia.timelines.base.validators import validate_color + + +def test_validate_color_none(): + assert validate_color(None) is True + + +def test_validate_color_empty_string(): + assert validate_color("") is True + + +def test_validate_color_valid_name(): + assert validate_color("red") is True + assert validate_color("blue") is True + + +def test_validate_color_valid_hex(): + assert validate_color("#ff0000") is True + assert validate_color("#f00") is True + + +def test_validate_color_valid_qcolor(): + assert validate_color(QColor("red")) is True + + +def test_validate_color_invalid_string(): + assert validate_color("not-a-color") is False + + +def test_validate_color_invalid_type(): + assert validate_color(object()) is False + assert validate_color([]) is False + assert validate_color({}) is False 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/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/harmony/fixtures.py b/tests/timelines/harmony/fixtures.py index 476125865..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 @@ -10,15 +11,18 @@ 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 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/hierarchy/test_hierarchy_timeline.py b/tests/timelines/hierarchy/test_hierarchy_timeline.py index 543d7541e..73aade9e3 100644 --- a/tests/timelines/hierarchy/test_hierarchy_timeline.py +++ b/tests/timelines/hierarchy/test_hierarchy_timeline.py @@ -2,6 +2,7 @@ import itertools from tilia.exceptions import InvalidComponentKindError +from tilia.timelines.hierarchy.components import Hierarchy from tilia.timelines.hierarchy.timeline import ( HierarchyTLComponentManager, ) @@ -697,3 +698,85 @@ def test_overlapping_with_higher_unit_fails(self, hierarchy_tl): success, _ = hierarchy_tl.component_manager.group([hrc1, hrc2]) assert not success + + +class TestMerge: + ATTRS = ("label", "comments") + + @staticmethod + def create_units_to_merge(hierarchy_tl, count: int = 2) -> list[Hierarchy]: + hierarchies = [] + for i in range(count): + hrc, _ = hierarchy_tl.create_hierarchy(start=i, end=i + 1, level=1) + hierarchies.append(hrc) + + return hierarchies + + @staticmethod + def set_attr(hierarchy: Hierarchy, attr_name: str, value: str) -> None: + hierarchy.set_data(attr_name, value) + + @staticmethod + def get_attr(hierarchy: Hierarchy, attr_name: str) -> str | None: + return hierarchy.get_data(attr_name) + + @pytest.mark.parametrize("attr_name", ATTRS) + def test_preserves_left_when_right_is_empty(self, hierarchy_tl, attr_name): + hrcs = self.create_units_to_merge(hierarchy_tl, 2) + self.set_attr(hrcs[0], attr_name, "left") + + hierarchy_tl.merge(hrcs) + + assert self.get_attr(hierarchy_tl[0], attr_name) == "left" + + @pytest.mark.parametrize("attr_name", ATTRS) + def test_preserves_right_when_left_is_empty(self, hierarchy_tl, attr_name): + hrcs = self.create_units_to_merge(hierarchy_tl, 2) + self.set_attr(hrcs[1], attr_name, "right") + + hierarchy_tl.merge(hrcs) + + assert self.get_attr(hierarchy_tl[0], attr_name) == "right" + + @pytest.mark.parametrize("attr_name", ATTRS) + def test_preserves_middle_when_neighbours_are_empty(self, hierarchy_tl, attr_name): + hrcs = self.create_units_to_merge(hierarchy_tl, 3) + self.set_attr(hrcs[1], attr_name, "middle") + + hierarchy_tl.merge(hrcs) + + assert self.get_attr(hierarchy_tl[0], attr_name) == "middle" + + @pytest.mark.parametrize("attr_name", ATTRS) + def test_concatenates_when_both_units_have_it(self, hierarchy_tl, attr_name): + hrcs = self.create_units_to_merge(hierarchy_tl, 2) + self.set_attr(hrcs[0], attr_name, "left") + self.set_attr(hrcs[1], attr_name, "right") + + hierarchy_tl.merge(hrcs) + + new_value = self.get_attr(hierarchy_tl[0], attr_name) + assert new_value == "left" + hierarchy_tl.merge_separator + "right" + + @pytest.mark.parametrize("attr_name", ATTRS) + def test_concatenates_more_than_two(self, hierarchy_tl, attr_name): + hrcs = self.create_units_to_merge(hierarchy_tl, 4) + values = ["one", "two", "three", "four"] + for i, value in enumerate(values): + self.set_attr(hrcs[i], attr_name, value) + + hierarchy_tl.merge(hrcs) + + new_value = self.get_attr(hierarchy_tl[0], attr_name) + assert new_value == hierarchy_tl.merge_separator.join(values) + + @pytest.mark.parametrize("attr_name", ATTRS) + def test_ignores_empty_when_concatenating(self, hierarchy_tl, attr_name): + hrcs = self.create_units_to_merge(hierarchy_tl, 5) + self.set_attr(hrcs[1], attr_name, "first") + self.set_attr(hrcs[3], attr_name, "second") + + hierarchy_tl.merge(hrcs) + + new_value = self.get_attr(hierarchy_tl[0], attr_name) + assert new_value == "first" + hierarchy_tl.merge_separator + "second" 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/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/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/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/cli/components/test_cli_components_add.py b/tests/ui/cli/components/test_cli_components_add.py index 5b14ccd24..3b4863d07 100644 --- a/tests/ui/cli/components/test_cli_components_add.py +++ b/tests/ui/cli/components/test_cli_components_add.py @@ -1,53 +1,37 @@ -import argparse - -import pytest -from tests.mock import Serve -from tilia.requests.get import Get -from tilia.timelines.timeline_kinds import TimelineKind -from tilia.ui.cli.components.add import add +from tilia.ui.cli.timelines.utils import assert_error class TestAddBeat: def test_wrong_timeline_kind_raises_error(self, cli, tls): - tls.create_timeline(kind=TimelineKind.HIERARCHY_TIMELINE) + cli.parse_and_run("timeline add hierarchy") - namespace = argparse.Namespace(tl_ordinal=1, tl_name="") - with pytest.raises(ValueError): - add(TimelineKind.BEAT_TIMELINE, namespace) + with assert_error(): + cli.parse_and_run("component beat --tl-ordinal 1 --time 0") def test_bad_ordinal_raises_error(self, cli, tls): - with Serve(Get.FROM_USER_BEAT_PATTERN, (True, [4])): - tls.create_timeline(kind=TimelineKind.BEAT_TIMELINE) + cli.parse_and_run("timeline add beat --beat-pattern 4") - namespace = argparse.Namespace(tl_ordinal=0, tl_name="") - with pytest.raises(ValueError): - add(TimelineKind.BEAT_TIMELINE, namespace) + with assert_error(): + cli.parse_and_run("component beat --tl-ordinal 0 --time 0") def test_bad_name_raises_error(self, cli, tls): - with Serve(Get.FROM_USER_BEAT_PATTERN, (True, [4])): - tls.create_timeline(kind=TimelineKind.BEAT_TIMELINE, name="this") + cli.parse_and_run("timeline add beat --beat-pattern 4 --name this") - namespace = argparse.Namespace(tl_ordinal=None, tl_name="other") - with pytest.raises(ValueError): - add(TimelineKind.BEAT_TIMELINE, namespace) + with assert_error(): + cli.parse_and_run("component beat --tl-name other --time 0") def test_add_single(self, cli, tls): - with Serve(Get.FROM_USER_BEAT_PATTERN, (True, [4])): - tls.create_timeline(kind=TimelineKind.BEAT_TIMELINE) - - namespace = argparse.Namespace(tl_ordinal=1, tl_name=None, time=1) - add(TimelineKind.BEAT_TIMELINE, namespace) + cli.parse_and_run("timeline add beat --beat-pattern 4") + cli.parse_and_run("component beat --tl-ordinal 1 --time 1") assert tls[0].components[0].time == 1 def test_add_multiple(self, cli, tls): - with Serve(Get.FROM_USER_BEAT_PATTERN, (True, [4])): - tls.create_timeline(kind=TimelineKind.BEAT_TIMELINE) + cli.parse_and_run("timeline add beat --beat-pattern 4") for i in range(10): - namespace = argparse.Namespace(tl_ordinal=1, tl_name=None, time=i) - add(TimelineKind.BEAT_TIMELINE, namespace) + cli.parse_and_run(f"component beat --tl-ordinal 1 --time {i}") assert len(tls[0].components) == 10 for i in range(10): - assert i in tls[0].component_manager.beat_times + assert tls[0][i].get_data("time") == i 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 deleted file mode 100644 index dcfa6037d..000000000 --- a/tests/ui/cli/timelines/test_cli_timelines.py +++ /dev/null @@ -1,136 +0,0 @@ -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: - def test_list_timelines_no_timelines(self, cli): - with patch("builtins.print") as mock_print: - cli.parse_and_run("timeline list") - - printed = mock_print.call_args[0][0] - - assert "name" in printed - assert "ord" in printed - assert "kind" in printed - assert "1" not in printed - - def test_list_timelines_single_timeline(self, cli, hierarchy_tl): - hierarchy_tl.set_data("name", "test1") - - with patch("builtins.print") as mock_print: - cli.parse_and_run("timeline list") - - printed = mock_print.call_args[0][0] - - assert "test1" in printed - assert "Hierarchy" in printed - assert str(hierarchy_tl.ordinal) in printed - - def test_list_timelines_multiple_timelines(self, cli, tls): - for name in ["test1", "test2", "test3"]: - cli.parse_and_run("timeline add hrc --name " + name) - - with patch("builtins.print") as mock_print: - cli.parse_and_run("timelines list") - - printed = mock_print.call_args[0][0] - - assert "test1" in printed - assert "test2" in printed - assert "test3" in printed - - -class TestTimelineRemove: - def test_type_not_provided(self, cli, hierarchy_tl, tls): - cli.parse_and_run("timeline remove") - - 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) - - cli.parse_and_run("timeline remove name test") - - assert tls.is_empty - - def test_remove_by_name_multiple_timelines(self, cli, tls): - for name in ["test1", "test2", "test3"]: - cli.parse_and_run("timeline add mrk --name " + name) - - cli.parse_and_run("timeline remove name test1") - - assert len(tls) == 2 - - cli.parse_and_run("timeline remove name test2") - - 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) - - with patch("builtins.print") as mock_print: - cli.parse_and_run("timeline remove name othername") - - printed = mock_print.call_args[0][0] - assert "No timeline found" in printed - assert "othername" in printed - - def test_remove_by_name_when_no_timelines(self, cli): - with patch("builtins.print") as mock_print: - cli.parse_and_run("timeline remove name test") - - printed = mock_print.call_args[0][0] - assert "No timeline found" in printed - assert "test" in printed - - def test_remove_by_name_name_not_provide(self, cli, tls, hierarchy_tl): - cli.parse_and_run("timeline remove name") - - assert not tls.is_empty - - def test_remove_by_ordinal_one_timeline(self, cli, tls, hierarchy_tl): - cli.parse_and_run("timeline remove ordinal 1") - - assert len(tls) == 0 - - def test_remove_by_ordinal_multiple_timelines(self, cli, tls): - for i in range(3): - cli.parse_and_run('timeline add hrc --name ""') - - cli.parse_and_run("timeline remove ordinal 1") - - assert len(tls) == 2 - - cli.parse_and_run("timeline remove ordinal 2") - - assert len(tls) == 1 - - def test_remove_by_ordinal_not_found(self, cli, tls): - tls.create_timeline(TimelineKind.HIERARCHY_TIMELINE) - - with patch("builtins.print") as mock_print: - cli.parse_and_run("timeline remove ordinal 3") - - printed = mock_print.call_args[0][0] - assert "No timeline found" in printed - assert "3" in printed - - def test_remove_by_ordinal_when_no_timelines(self, cli, tls): - with patch("builtins.print") as mock_print: - cli.parse_and_run("timeline remove ordinal 1") - - printed = mock_print.call_args[0][0] - assert "No timeline found" in printed - assert "1" in printed - - def test_remove_by_ordinal_ordinal_not_provided(self, cli, tls, hierarchy_tl): - cli.parse_and_run("timeline remove ordinal") - - assert not tls.is_empty diff --git a/tests/ui/cli/timelines/test_cli_timelines_list.py b/tests/ui/cli/timelines/test_cli_timelines_list.py new file mode 100644 index 000000000..1e24a046c --- /dev/null +++ b/tests/ui/cli/timelines/test_cli_timelines_list.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + + +def test_list_timelines_no_timelines(cli): + with patch("builtins.print") as mock_print: + cli.parse_and_run("timeline list") + + printed = mock_print.call_args[0][0] + + assert "name" in printed + assert "ord" in printed + assert "kind" in printed + assert "1" not in printed + + +def test_list_timelines_single_timeline(cli, hierarchy_tl): + hierarchy_tl.set_data("name", "test1") + + with patch("builtins.print") as mock_print: + cli.parse_and_run("timeline list") + + printed = mock_print.call_args[0][0] + + assert "test1" in printed + assert "Hierarchy" in printed + assert str(hierarchy_tl.ordinal) in printed + + +def test_list_timelines_multiple_timelines(cli, tls): + for name in ["test1", "test2", "test3"]: + cli.parse_and_run("timeline add hrc --name " + name) + + with patch("builtins.print") as mock_print: + cli.parse_and_run("timelines list") + + printed = mock_print.call_args[0][0] + + assert "test1" in printed + assert "test2" in printed + assert "test3" in printed diff --git a/tests/ui/cli/timelines/test_cli_timelines_move.py b/tests/ui/cli/timelines/test_cli_timelines_move.py new file mode 100644 index 000000000..45d51f999 --- /dev/null +++ b/tests/ui/cli/timelines/test_cli_timelines_move.py @@ -0,0 +1,166 @@ +from tilia.ui.cli.timelines.utils import patch_warn, assert_warn + + +def _setup_two_timelines(cli): + cli.parse_and_run("timeline add marker --name first") + cli.parse_and_run("timeline add marker --name second") + + +def _setup_three_timelines(cli): + _setup_two_timelines(cli) + cli.parse_and_run("timeline add marker --name third") + + +class TestMoveByName: + def test_from_second_to_first_position(self, cli, tls): + _setup_two_timelines(cli) + + cli.parse_and_run("timeline move name second 1") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "first" + + def test_from_first_to_second_position(self, cli, tls): + _setup_two_timelines(cli) + + cli.parse_and_run("timeline move name first 2") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "first" + + def test_to_same_position(self, cli, tls): + _setup_two_timelines(cli) + + cli.parse_and_run("timeline move name first 1") + + assert tls[0].get_data("name") == "first" + assert tls[1].get_data("name") == "second" + + def test_two_positions_up(self, cli, tls): + _setup_three_timelines(cli) + + cli.parse_and_run("timeline move name third 1") + + assert tls[0].get_data("name") == "third" + assert tls[1].get_data("name") == "first" + assert tls[2].get_data("name") == "second" + + def test_two_positions_down(self, cli, tls): + _setup_three_timelines(cli) + + cli.parse_and_run("timeline move name first 3") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "third" + assert tls[2].get_data("name") == "first" + + def test_duplicate_names_exist(self, cli, tls): + cli.parse_and_run("timeline add marker --name duplicate") + cli.parse_and_run("timeline add marker --name duplicate") + cli.parse_and_run("timeline add marker --name other") + + with patch_warn() as mock_warn: + cli.parse_and_run("timeline move name duplicate 3") + + mock_warn.assert_called() + + assert tls[0].get_data("name") == "duplicate" + assert tls[1].get_data("name") == "other" + assert tls[2].get_data("name") == "duplicate" + + +class TestMoveByOrdinal: + def test_from_second_to_first_position(self, cli, tls): + _setup_two_timelines(cli) + + cli.parse_and_run("timeline move ordinal 2 1") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "first" + + def test_from_first_to_second_position(self, cli, tls): + _setup_two_timelines(cli) + + cli.parse_and_run("timeline move ordinal 1 2") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "first" + + def test_to_same_position(self, cli, tls): + _setup_two_timelines(cli) + + cli.parse_and_run("timeline move ordinal 1 1") + + assert tls[0].get_data("name") == "first" + assert tls[1].get_data("name") == "second" + + def test_two_positions_up(self, cli, tls): + _setup_three_timelines(cli) + + cli.parse_and_run("timeline move ordinal 3 1") + + assert tls[0].get_data("name") == "third" + assert tls[1].get_data("name") == "first" + assert tls[2].get_data("name") == "second" + + def test_two_positions_down(self, cli, tls): + _setup_three_timelines(cli) + + cli.parse_and_run("timeline move ordinal 1 3") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "third" + assert tls[2].get_data("name") == "first" + + def test_move_beyond_last_ordinal(self, cli, tls): + _setup_three_timelines(cli) + + with patch_warn() as mock_warn: + cli.parse_and_run("timeline move ordinal 1 4") + + mock_warn.assert_called() + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "third" + assert tls[2].get_data("name") == "first" + + def test_move_beyond_last_ordinal_only_one_timeline_exists(self, cli, tls): + cli.parse_and_run("timeline add marker --name only") + + with patch_warn() as mock_warn: + cli.parse_and_run("timeline move ordinal 1 0") + + mock_warn.assert_called() + + assert tls[0].get_data("name") == "only" + + def test_move_to_zero(self, cli, tls): + _setup_three_timelines(cli) + + with patch_warn() as mock_warn: + cli.parse_and_run("timeline move ordinal 3 0") + + mock_warn.assert_called() + + assert tls[0].get_data("name") == "third" + assert tls[1].get_data("name") == "first" + assert tls[2].get_data("name") == "second" + + def test_move_to_zero_only_one_timeline_exists(self, cli, tls): + cli.parse_and_run("timeline add marker --name only") + + # should warn that ordinal parameter is < 1 + with assert_warn(): + cli.parse_and_run("timeline move ordinal 1 0") + + assert tls[0].get_data("name") == "only" + + def test_move_to_negative_ordinal(self, cli, tls): + _setup_two_timelines(cli) + + # should warn that ordinal parameter is < 1 + with patch_warn(): + cli.parse_and_run("timeline move ordinal 2 -1") + + assert tls[0].get_data("name") == "second" + assert tls[1].get_data("name") == "first" diff --git a/tests/ui/cli/timelines/test_cli_timelines_remove.py b/tests/ui/cli/timelines/test_cli_timelines_remove.py new file mode 100644 index 000000000..e50361a19 --- /dev/null +++ b/tests/ui/cli/timelines/test_cli_timelines_remove.py @@ -0,0 +1,100 @@ +from unittest.mock import patch + +from tilia.timelines.timeline_kinds import TimelineKind + + +def test_remove_type_not_provided(cli, hierarchy_tl, tls): + cli.parse_and_run("timeline remove") + + assert not tls.is_empty + + +def test_remove_by_name_one_timeline(cli, tls): + cli.parse_and_run("timeline add hierarchy --name test") + cli.parse_and_run("timeline remove name test") + + assert tls.is_empty + + +def test_remove_by_name_multiple_timelines(cli, tls): + for name in ["test1", "test2", "test3"]: + cli.parse_and_run("timeline add mrk --name " + name) + + cli.parse_and_run("timeline remove name test1") + + assert len(tls) == 2 + + cli.parse_and_run("timeline remove name test2") + + assert len(tls) == 1 + + +def test_remove_by_name_not_found(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") + + printed = mock_print.call_args[0][0] + assert "No timeline found" in printed + assert "othername" in printed + + +def test_remove_by_name_when_no_timelines(cli): + with patch("builtins.print") as mock_print: + cli.parse_and_run("timeline remove name test") + + printed = mock_print.call_args[0][0] + assert "No timeline found" in printed + assert "test" in printed + + +def test_remove_by_name_name_not_provide(cli, tls, hierarchy_tl): + cli.parse_and_run("timeline remove name") + + assert not tls.is_empty + + +def test_remove_by_ordinal_one_timeline(cli, tls, hierarchy_tl): + cli.parse_and_run("timeline remove ordinal 1") + + assert len(tls) == 0 + + +def test_remove_by_ordinal_multiple_timelines(cli, tls): + for i in range(3): + cli.parse_and_run('timeline add hrc --name ""') + + cli.parse_and_run("timeline remove ordinal 1") + + assert len(tls) == 2 + + cli.parse_and_run("timeline remove ordinal 2") + + assert len(tls) == 1 + + +def test_remove_by_ordinal_not_found(cli, tls): + tls.create_timeline(TimelineKind.HIERARCHY_TIMELINE) + + with patch("builtins.print") as mock_print: + cli.parse_and_run("timeline remove ordinal 3") + + printed = mock_print.call_args[0][0] + assert "No timeline found" in printed + assert "3" in printed + + +def test_remove_by_ordinal_when_no_timelines(cli, tls): + with patch("builtins.print") as mock_print: + cli.parse_and_run("timeline remove ordinal 1") + + printed = mock_print.call_args[0][0] + assert "No timeline found" in printed + assert "1" in printed + + +def test_remove_by_ordinal_ordinal_not_provided(cli, tls, hierarchy_tl): + cli.parse_and_run("timeline remove ordinal") + + assert not tls.is_empty 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 285920c3b..37e5a4156 100644 --- a/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py +++ b/tests/ui/timelines/audiowave/test_audiowave_timeline_ui.py @@ -1,39 +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): - - post(Post.APP_RECORD_STATE, "test state") +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 6d3bc684a..1f3f2f5fe 100644 --- a/tests/ui/timelines/beat/test_beat_timeline_ui.py +++ b/tests/ui/timelines/beat/test_beat_timeline_ui.py @@ -1,12 +1,14 @@ from unittest.mock import MagicMock -from tests.mock import Serve +import pytest + +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 -from tilia.ui.actions import TiliaAction +from tilia.ui import commands from tilia.ui.windows import WindowKind @@ -37,7 +39,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 +58,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 +84,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 +101,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 +114,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", @@ -155,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): @@ -169,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): @@ -190,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): @@ -208,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] @@ -272,33 +272,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 +306,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 +352,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 +444,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 +459,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 +474,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,66 +514,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 patch_yes_or_no_dialog(True): + 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) + with patch_yes_or_no_dialog(False): + 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): - post(Post.APP_RECORD_STATE, "test state") - + 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,46 +588,44 @@ 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) + 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) - - post(Post.APP_RECORD_STATE, "test state") + 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) 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) + 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", @@ -630,7 +633,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 eeaae4594..ca11860e8 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,233 +376,230 @@ 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_RECORD_STATE, "test state") + 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) 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) + 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_RECORD_STATE, "test state") + 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_RECORD_STATE, "test state") + 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) 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) + 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_RECORD_STATE, "test state") + 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) 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) + 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_RECORD_STATE, "test state") + 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_RECORD_STATE, "test state") + 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_RECORD_STATE, "test state") + 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) + with patch_yes_or_no_dialog(False): + 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) + with patch_yes_or_no_dialog(True): + 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) 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) + with patch_yes_or_no_dialog(True): + 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) @@ -614,8 +607,8 @@ def test_with_siblings(self, tlui, user_actions): 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) + with patch_yes_or_no_dialog(True): + commands.execute("timeline.hierarchy.create_child") assert len(tlui) == 4 assert tlui[0].get_data("level") == 1 @@ -623,12 +616,42 @@ 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 + + +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/tests/ui/timelines/hierarchy/test_hierarchy_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_ui.py index 583451d36..2c3f29e71 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_ui.py @@ -264,6 +264,53 @@ def test_display_as_deselected_with_no_descendant(self, tlui): assert tlui[0].post_end_handle.isVisible() is False +class TestCommentsIndicator: + @staticmethod + def create_hierarchy_ui( + tlui, start: float = 0, end: float = 100, level: float = 1, **kwargs + ): + # We use a large "end" to ensure comments icon will fit. + tlui.create_hierarchy(start, end, level, **kwargs) + hui: HierarchyUI = tlui[0] + return hui + + def test_is_hidden_when_instantiated_without_comments(self, tlui): + hui = self.create_hierarchy_ui(tlui) + assert not hui.comments_icon.isVisible() + + def test_is_visible_when_instantiated_with_comments(self, tlui): + hui = self.create_hierarchy_ui(tlui, comments="something") + assert hui.comments_icon.isVisible() + + def test_is_displayed_when_comments_are_set(self, tlui): + hui = self.create_hierarchy_ui(tlui) + hui.set_data("comments", "something") + + assert hui.comments_icon.isVisible() + + def test_is_hidden_when_comments_are_deleted(self, tlui): + hui = self.create_hierarchy_ui(tlui, comments="something") + hui.set_data("comments", "") + assert not hui.comments_icon.isVisible() + + def test_still_visible_when_hierarchy_is_moved(self, tlui): + hui = self.create_hierarchy_ui(tlui, 0, 10, comments="something") + for i in range(10): + hui.set_data("start", i) + assert hui.comments_icon.isVisible() + hui.set_data("end", i + 10) + assert hui.comments_icon.isVisible() + + def test_is_hidden_when_hierarchy_is_too_small(self, tlui): + hui = self.create_hierarchy_ui(tlui, 0, 0.000001, comments="something") + assert not hui.comments_icon.isVisible() + + def test_is_shown_when_hierarchy_gets_big_enough(self, tlui): + hui = self.create_hierarchy_ui(tlui, 0, 0.000001, comments="something") + hui.set_data("end", 100) + assert hui.comments_icon.isVisible() + + class TestDoubleClick: def test_posts_seek(self, tlui): tlui.create_hierarchy(10, 15, 1) diff --git a/tests/ui/timelines/marker/test_marker_timeline_ui.py b/tests/ui/timelines/marker/test_marker_timeline_ui.py index 2c275e420..17faa1b15 100644 --- a/tests/ui/timelines/marker/test_marker_timeline_ui.py +++ b/tests/ui/timelines/marker/test_marker_timeline_ui.py @@ -1,11 +1,10 @@ from unittest.mock import patch -import pytest from PyQt6.QtCore import Qt 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, @@ -14,9 +13,16 @@ 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.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 +34,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 +132,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 +195,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 +224,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 +250,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 +266,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 +277,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 +288,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 +301,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,89 +309,141 @@ 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 - ): + @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") mock.assert_called_once() - def test_has_no_height_set_action( - self, marker_tlui, tluis, user_actions, tilia_state - ): - context_menu = marker_tlui.CONTEXT_MENU_CLASS(marker_tlui) + def test_has_no_height_set_action(self, marker_tlui, tluis, tilia_state): + context_menu = self.get_context_menu(tluis) - 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]) + 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, 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]) + 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 - @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): - context_menu = marker_tlui.CONTEXT_MENU_CLASS(marker_tlui) + def test_has_the_right_actions(self, marker_tlui, tluis, tilia_state): + context_menu = self.get_context_menu(tluis) - 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()] + + 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, 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 +451,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 +460,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 +470,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 +483,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 +494,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 +512,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 +531,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,22 +558,21 @@ 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") - 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()] == [ @@ -529,13 +581,13 @@ 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") + action = get_command_action(context_menu, "timeline.move_down") assert action with undoable(): action.trigger() @@ -546,10 +598,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 +608,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 patch_yes_or_no_dialog(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 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/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 50ca81444..7c803706a 100644 --- a/tests/ui/timelines/slider/test_slider_timeline_ui.py +++ b/tests/ui/timelines/slider/test_slider_timeline_ui.py @@ -1,16 +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): - - post(Post.APP_RECORD_STATE, "test state") +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 d4cf9b556..1ec4bfe96 100644 --- a/tests/ui/timelines/test_timeline_ui.py +++ b/tests/ui/timelines/test_timeline_ui.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from tests.mock import Serve @@ -5,10 +7,13 @@ 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 -from tilia.ui.actions import TiliaAction +from tilia.ui.timelines.base.timeline import TimelineUI +from tilia.ui.timelines.collection.collection import TimelineSelector +from tilia.ui import commands def get_time_shifted_args(args: dict[str, int], d_time) -> dict[str, int]: @@ -238,41 +243,182 @@ 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 == "" + + +def test_set_is_visible(tls, marker_tlui): + with undoable(): + commands.execute("timeline.set_is_visible", marker_tlui, False) + + assert not marker_tlui.view.isVisible() + + with undoable(): + commands.execute("timeline.set_is_visible", marker_tlui, True) + + assert marker_tlui.view.isVisible() + + +class TestOnTimelineCommand: + @staticmethod + def call_on_timeline_command(tluis, callback): + tluis.on_timeline_command( + [TimelineKind.MARKER_TIMELINE], callback, TimelineSelector.ALL + ) + + @staticmethod + def add_timeline(count=1): + for _ in range(count): + commands.execute("timelines.add.marker", name="") + + def test_command_callback_error_is_displayed_if_callback_is_function( + self, tls, tluis, tilia_errors + ): + def fail(*args, **kwargs): + raise ValueError + + self.add_timeline() + self.call_on_timeline_command(tluis, fail) + + tilia_errors.assert_error() + tilia_errors.assert_in_error_message(fail.__name__) + + def test_command_callback_error_is_displayed_if_callback_is_method( + self, tls, tluis, tilia_errors + ): + class Dummy: + def fail(self, *args, **kwargs): + raise ValueError + + dummy = Dummy() + self.add_timeline() + self.call_on_timeline_command(tluis, dummy.fail) + + tilia_errors.assert_error() + tilia_errors.assert_in_error_message(dummy.fail.__name__) + + def test_command_callback_error_is_displayed_if_callback_is_something_else( + self, tls, tluis, tilia_errors + ): + self.add_timeline() + self.call_on_timeline_command(tluis, "not a function") + + tilia_errors.assert_error() + tilia_errors.assert_in_error_message("not a function") + + def test_command_callback_return_invalid_value_single_timeline( + self, tluis, tilia_errors + ): + self.add_timeline() + + def return_invalid_value(*args, **kwargs): + return "not a boolean" + + self.call_on_timeline_command(tluis, return_invalid_value) + + tilia_errors.assert_error() + tilia_errors.assert_in_error_message("not a boolean") + + def test_command_callback_return_invalid_value_multiple_timelines( + self, tluis, tilia_errors + ): + self.add_timeline(count=3) + + def value_generator(): + yield True + yield True + yield "not a boolean" + + gen = value_generator() + + self.call_on_timeline_command(tluis, lambda *args, **kwargs: next(gen)) + + tilia_errors.assert_error() + + @pytest.mark.parametrize("return_value", [True, False]) + def test_command_callback_return_valid_value_single_timeline( + self, return_value, tluis, tilia_errors + ): + self.add_timeline() + + self.call_on_timeline_command(tluis, lambda *args, **kwargs: return_value) + + tilia_errors.assert_no_error() + + @pytest.mark.parametrize( + "return_values", + ( + [True, True, True], + [True, True, False], + [True, False, True], + [False, True, True], + [False, False, False], + ), + ) + def test_command_callback_return_valid_value_list( + self, return_values, tluis, tilia_errors + ): + self.add_timeline(count=3) + + def value_generator(): + yield return_values[0] + yield return_values[1] + yield return_values[2] + + gen = value_generator() + self.call_on_timeline_command(tluis, lambda *args, **kwargs: next(gen)) + + tilia_errors.assert_no_error() + + +def test_timeline_ui_subclasses(): + assert len(TimelineUI.subclasses()) == len(TimelineKind) + + +def test_ensure_timeline_ui_subclasses_is_only_called_once(): + # Subclasses were already loaded in previous tests, so we reset the flag + TimelineUI.SUBCLASSES_ARE_LOADED = False + with patch.object( + TimelineUI, + "ensure_subclasses_are_available", + wraps=TimelineUI.ensure_subclasses_are_available, + ) as wrapped: + for _ in range(50): + TimelineUI.subclasses() + assert wrapped.call_count == 1 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..2ee34c3a8 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -1,124 +1,143 @@ +from typing import Literal + import pytest +from PyQt6.QtCore import Qt +from PyQt6.QtTest import QTest from tests.mock import Serve 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 -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) - - -@pytest.fixture -def manage_timelines(qtui): - mt = ManageTimelines() - yield mt - mt.close() + assert ManageTimelines().list_widget.item(i).timeline_ui == tlui + + +class TestChangeTimelineVisibility: + @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 not marker_tlui.get_data("is_visible") + + 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") + + 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") + else: + assert not marker_tlui.get_data("is_visible") class TestChangeTimelineOrder: @pytest.fixture(autouse=True) - def setup_timelines(self, tls, user_actions): + def setup_timelines(self, tluis, 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): + @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, user_actions, 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() - user_actions.trigger(TiliaAction.EDIT_UNDO) + 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, user_actions, 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() - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + 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, user_actions, 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, user_actions, 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) - def test_decrease_ordinal_undo( - self, tls, user_actions, manage_timelines, setup_timelines - ): + assert_order_is_correct(tls, [tl1, tl0, tl2]) + + 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() - user_actions.trigger(TiliaAction.EDIT_UNDO) + 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, user_actions, 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() - user_actions.trigger(TiliaAction.EDIT_UNDO) - user_actions.trigger(TiliaAction.EDIT_REDO) + 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, user_actions, 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]) 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) diff --git a/tests/utils.py b/tests/utils.py index c8d93522c..e5c29edff 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,13 +1,16 @@ +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 from tilia.requests import get, Get, Post, post -from tilia.ui.actions import TiliaAction from tests.mock import patch_file_dialog +from tilia.ui import commands +from tilia.ui.commands import CommandQAction def get_blank_file_data(): @@ -81,29 +84,53 @@ 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) - assert get(Get.APP_STATE) == state_before - post(Post.EDIT_REDO) - assert get(Get.APP_STATE) == state_after - - -def reloadable(save_path, user_actions): + commands.execute("edit.undo") + + 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") + 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): """ 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,21 +139,29 @@ 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() 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: @@ -144,3 +179,16 @@ def get_main_window_menu(qtui, name): def get_actions_in_menu(menu: QMenu): return [action for action in menu.actions() if not action.isSeparator()] + + +def save_tilia_to_tmp_path(tmp_path, filename: str = "test") -> Path: + tmp_file_path = (tmp_path / (filename + ".tla")).resolve().__str__() + with patch_file_dialog(True, [tmp_file_path]): + commands.execute("file.save_as") + return tmp_file_path + + +def save_and_reopen(tmp_path, filename: str = "test") -> None: + file_path = save_tilia_to_tmp_path(tmp_path, filename) + post(Post.APP_CLEAR) + commands.execute("file.open", path=file_path) diff --git a/tilia/app.py b/tilia/app.py index f3224d101..8467aa803 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 @@ -36,7 +39,6 @@ def __init__( undo_manager: UndoManager, player: Player, ): - self._id_counter = itertools.count() self.player: Player | None = None self.file_manager = file_manager self.clipboard = clipboard @@ -44,9 +46,11 @@ def __init__( self.player = player self.duration = 0.0 self.should_scale_timelines = "prompt" + self.reset_id_generator() 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,19 +60,12 @@ 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_RECORD_STATE, self.on_record_state), - (Post.FILE_OPEN, self.on_open), - (Post.FILE_EXPORT, self.on_export), + (Post.APP_STATE_RECORD, self.on_record_state), (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 = { @@ -88,6 +85,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, @@ -111,7 +140,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) @@ -132,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() @@ -205,7 +235,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 @@ -245,11 +275,31 @@ def on_record_state(self, action, no_repeat=False, repeat_identifier=""): repeat_identifier=repeat_identifier, ) - def get_id(self) -> int: + def get_id(self) -> str: """ - Returns an id unique to the current file. + Returns an ID string. + IDs are unique accross timeline component and timelines. + Other IDs might contain duplicates. """ - return next(self._id_counter) + timeline_ids = {c.id for c in self.timelines} + component_lists = [ + tl.components for tl in self.timelines if tl.components is not None + ] + component_ids = set() + for component_list in component_lists: + component_ids = component_ids.union({c.id for c in component_list}) + existing_ids = timeline_ids.union(component_ids) + + next_id = next(self._id_counter) + # Find the highest existing ID and increment until counter is higher + if existing_ids: + highest_id = max(int(id_str) for id_str in existing_ids) + while next_id <= highest_id: + next_id = next(self._id_counter) + return str(next_id) + + def reset_id_generator(self): + self._id_counter = itertools.count() def on_media_duration_changed(self, duration: float): if not self.timelines.is_blank and duration != self.duration: @@ -377,6 +427,7 @@ def on_clear(self) -> None: self.player.clear() self.set_file_media_duration(0.0) self.undo_manager.clear() + self.reset_id_generator() post(Post.REQUEST_CLEAR_UI) def reset_undo_manager(self): 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 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/errors.py b/tilia/errors.py index 9ef63c16a..0373df743 100644 --- a/tilia/errors.py +++ b/tilia/errors.py @@ -102,10 +102,15 @@ 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{}", +) +INVALID_RETURN_VALUES_FOR_COMMAND = Error( + "Invalid return values for command", + "Invalid return values for command: {}\nValues: {}", ) 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/common.py b/tilia/file/common.py index 7f03908ff..de5f00e2a 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,10 @@ 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 file.media_path and 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) 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..b3bf688b0 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), @@ -65,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), } @@ -145,17 +147,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/media/player/qtplayer.py b/tilia/media/player/qtplayer.py index f44b46404..2d986628c 100644 --- a/tilia/media/player/qtplayer.py +++ b/tilia/media/player/qtplayer.py @@ -3,7 +3,13 @@ import time from PyQt6.QtCore import QUrl -from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput, QAudio +from PyQt6.QtMultimedia import ( + QMediaPlayer, + QAudioOutput, + QAudio, + QAudioDevice, + QMediaDevices, +) from .base import Player @@ -17,6 +23,9 @@ class QtPlayer(Player): def __init__(self): super().__init__() self.audio_output = QAudioOutput() + self.audio_output.setDevice(QAudioDevice()) + self.media_devices = QMediaDevices() + self.media_devices.audioOutputsChanged.connect(self.on_audio_outputs_changed) self._init_player() def _init_player(self): @@ -31,6 +40,13 @@ def on_media_load_done(self, path, start, end): def on_media_duration_available(self, duration): super().on_media_duration_available(duration / 1000) + def on_audio_outputs_changed(self) -> None: + """ + Connected to QMediaDevices.audioOutputsChanged. + Sets the audio output to the default output device. + """ + self.audio_output.setDevice(QAudioDevice()) + def _engine_load_media(self, media_path: str) -> bool: self._engine_stop() # Must be _engine_stop() instead of player.stop() to avoid freeze. self.player.setSource(QUrl.fromLocalFile(media_path)) 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/__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/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/requests/get.py b/tilia/requests/get.py index 8b569fce8..ce3bc1e30 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -9,8 +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() FROM_USER_BEAT_PATTERN = auto() @@ -52,22 +50,17 @@ class Get(Enum): SCORE_VIEWER = auto() TIMELINE = auto() TIMELINES = auto() - TIMELINES_BY_ATTR = auto() TIMELINE_BY_ATTR = auto() + TIMELINES_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() - TIMELINES_FROM_CLI = 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 230c6becb..85a85a1a0 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -6,113 +6,52 @@ class Post(Enum): - PDF_MARKER_ADD = auto() - AUTOSAVES_FOLDER_OPEN = auto() UI_EXIT = auto() APP_CLEAR = auto() - APP_FILE_LOAD = auto() + APP_FILE_LOADED = 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() - 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() PLAYER_CURRENT_LOOP_CHANGED = auto() PLAYER_CURRENT_TIME_CHANGED = auto() 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 +63,7 @@ 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 +73,13 @@ 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,14 +87,7 @@ 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() - WEBSITE_HELP_OPEN = auto() WINDOW_OPEN = auto() WINDOW_OPEN_DONE = auto() WINDOW_CLOSE = auto() diff --git a/tilia/settings.py b/tilia/settings.py index f311dfd73..9c143b224 100644 --- a/tilia/settings.py +++ b/tilia/settings.py @@ -68,6 +68,7 @@ class SettingsManager(QObject): "level_height_diff": 25, "divider_height": 10, "prompt_create_level_below": "true", + "merge_separator": "|", }, "marker_timeline": { "default_height": 30, 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/base/timeline.py b/tilia/timelines/base/timeline.py index 4a7dbccf7..d96f649b3 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 @@ -33,6 +36,17 @@ T = TypeVar("T", bound="Timeline") +class TimelineFlag(Enum): + COMPONENTS_COLORED = auto() + COMPONENTS_IMPORTABLE = auto() + COMPONENTS_COPYABLE = 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 = [] @@ -53,10 +67,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 @@ -68,6 +83,8 @@ def __init__( else: self.component_manager = None + self.ensure_subclasses_are_available() + def __iter__(self): return iter(self.components) @@ -110,6 +127,36 @@ def components(self): def default_height(self): return None + SUBCLASSES_ARE_LOADED = False + + @classmethod + def subclasses(cls): + if not cls.SUBCLASSES_ARE_LOADED: + 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: + try: + importlib.import_module(pkg) + except ModuleNotFoundError: + print(f"Could not find timeline class in {pkg}.") + + cls.SUBCLASSES_ARE_LOADED = True + + @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( @@ -469,14 +516,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() @@ -493,9 +550,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/base/validators.py b/tilia/timelines/base/validators.py index 2ed6e63a4..5edf87d8a 100644 --- a/tilia/timelines/base/validators.py +++ b/tilia/timelines/base/validators.py @@ -17,9 +17,16 @@ def validate_bool(value): def validate_color(value): - if value is None: + if value is None or value == "": + # None or "" mean the default color. + # 0 is a valid value for QColor, + # so we can't simply use `if not value`. return True - return QColor(value).isValid() + + try: + return QColor(value).isValid() + except TypeError: + return False def validate_read_only(_): 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/collection/collection.py b/tilia/timelines/collection/collection.py index eedc580bd..36cb62cf1 100644 --- a/tilia/timelines/collection/collection.py +++ b/tilia/timelines/collection/collection.py @@ -177,6 +177,20 @@ def clear_timelines(self): if TimelineFlag.NOT_CLEARABLE not in timeline.FLAGS: timeline.clear() + def permute_ordinal(self, tl1: Timeline, tl2: Timeline): + id_to_ordinal = { + tl1.id: tl2.get_data("ordinal"), + tl2.id: tl1.get_data("ordinal"), + } + + success = True + for id, ordinal in id_to_ordinal.items(): + success = success and self.set_timeline_data( + id, attr="ordinal", value=ordinal + ) + + return success + def _add_to_timelines(self, timeline: Timeline) -> None: self._timelines.append(timeline) # we don't have to update ordinal of any timeline because @@ -211,11 +225,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 +247,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 ( @@ -299,12 +313,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/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/hierarchy/timeline.py b/tilia/timelines/hierarchy/timeline.py index 37ce50d16..68797c1b9 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 @@ -393,6 +393,18 @@ def is_between_selected_units_and_has_same_parent(h: Hierarchy): if not success: return success, reason + # preseves label and comments from merged unitst l + attr_to_new_value = {} + for attr in ["label", "comments"]: + new_value = hierarchies[0].get_data(attr) + for unit in hierarchies[1:]: + if unit.get_data(attr): + if new_value: + new_value += self.timeline.merge_separator + new_value += unit.get_data(attr) + if new_value: + attr_to_new_value[attr] = new_value + for unit in hierarchies: post(Post.LOOP_IGNORE_COMPONENT, self.timeline.id, unit.id) self.delete_component(unit) @@ -402,6 +414,7 @@ def is_between_selected_units_and_has_same_parent(h: Hierarchy): start=hierarchies[0].start, end=hierarchies[-1].end, level=hierarchies[0].level, + **attr_to_new_value, ) if not merger_unit: @@ -422,11 +435,40 @@ def delete_component(self, component: Hierarchy, **kwargs) -> None: class HierarchyTimeline(Timeline): KIND = TimelineKind.HIERARCHY_TIMELINE COMPONENT_MANAGER_CLASS = HierarchyTLComponentManager + FLAGS = [ + TimelineFlag.COMPONENTS_COLORED, + TimelineFlag.COMPONENTS_COPYABLE, + TimelineFlag.COMPONENTS_IMPORTABLE, + ] @property 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 + + @property + def merge_separator(self): + """Separator to be placed between non-empty label and comments of merged units.""" + return settings.get("hierarchy_timeline", "merge_separator") + def setup_blank_timeline(self): """Create unit of level 1 encompassing whole timeline""" self.create_component( diff --git a/tilia/timelines/marker/timeline.py b/tilia/timelines/marker/timeline.py index 935bc3ec6..6e1219030 100644 --- a/tilia/timelines/marker/timeline.py +++ b/tilia/timelines/marker/timeline.py @@ -8,7 +8,11 @@ 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 +28,11 @@ def _validate_component_creation(self, _, time, *args, **kwargs): class MarkerTimeline(Timeline): KIND = TimelineKind.MARKER_TIMELINE COMPONENT_MANAGER_CLASS = MarkerTLComponentManager + FLAGS = [ + TimelineFlag.COMPONENTS_COLORED, + TimelineFlag.COMPONENTS_COPYABLE, + TimelineFlag.COMPONENTS_IMPORTABLE, + ] @property def default_height(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/score/timeline.py b/tilia/timelines/score/timeline.py index c8604fdf1..295b8dd29 100644 --- a/tilia/timelines/score/timeline.py +++ b/tilia/timelines/score/timeline.py @@ -12,7 +12,11 @@ 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): @@ -41,11 +45,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) @@ -68,11 +75,15 @@ class ScoreTimeline(Timeline): ] NOT_EXPORTABLE_ATTRS = ["svg_data", "viewer_beat_x"] COMPONENT_MANAGER_CLASS = ScoreTLComponentManager + FLAGS = [ + TimelineFlag.COMPONENTS_NOT_DELETABLE, + TimelineFlag.COMPONENTS_IMPORTABLE, + ] 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 +92,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 diff --git a/tilia/timelines/slider/timeline.py b/tilia/timelines/slider/timeline.py index 04728781e..49fd61560 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, @@ -26,6 +27,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/timelines/timeline_kinds.py b/tilia/timelines/timeline_kinds.py index 1cd90bd0c..f19952c80 100644 --- a/tilia/timelines/timeline_kinds.py +++ b/tilia/timelines/timeline_kinds.py @@ -14,24 +14,6 @@ class TimelineKind(Enum): AUDIOWAVE_TIMELINE = "AUDIOWAVE_TIMELINE" -COLORED_COMPONENTS = [ - TimelineKind.MARKER_TIMELINE, - TimelineKind.HIERARCHY_TIMELINE, - 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, -] -ALL = list(TimelineKind) - - def get_timeline_kind_from_string(string): string = string.upper() if not string.endswith("_TIMELINE"): @@ -40,23 +22,19 @@ 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]: - 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/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/clear.py b/tilia/ui/cli/clear.py index 84ec4e71e..21c0673ea 100644 --- a/tilia/ui/cli/clear.py +++ b/tilia/ui/cli/clear.py @@ -3,10 +3,14 @@ def setup_parser(subparsers): - parser = subparsers.add_parser("clear", exit_on_error=False) + parser = subparsers.add_parser( + "clear", + exit_on_error=False, + help="Clear current file. Deletes all timelines, metadata and unloads media.", + ) parser.add_argument( - "--force", action="store_true", help="Do not ask for confirmation." + "--force", action="store_true", help="Do not ask for confirmation" ) parser.set_defaults(func=save) diff --git a/tilia/ui/cli/components/add.py b/tilia/ui/cli/components/add.py index 1d5f782b8..09e46ff11 100644 --- a/tilia/ui/cli/components/add.py +++ b/tilia/ui/cli/components/add.py @@ -1,11 +1,11 @@ import argparse from functools import partial -from tilia.timelines.base.timeline import Timeline from tilia.timelines.component_kinds import ComponentKind from tilia.timelines.timeline_kinds import TimelineKind as TlKind +from tilia.ui.cli import io from tilia.ui.cli.io import output -from tilia.ui.cli.timelines.getters import get_timeline_by_name, get_timeline_by_ordinal +from tilia.ui.cli.timelines.utils import get_timeline_by_ordinal, get_timeline_by_name TL_KIND_TO_COMPONENT_KIND = { TlKind.BEAT_TIMELINE: ComponentKind.BEAT, @@ -21,25 +21,34 @@ def setup_parser(subparser): - subp = subparser.add_parser("beat", exit_on_error=False) + subp = subparser.add_parser( + "beat", + exit_on_error=False, + help="Add a beat component to a timeline", + epilog=""" +Examples: + components beat --tl-name "Measures" --time 10.5 + components beat --tl-ordinal 1 --time 20.0 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) tl_group = subp.add_mutually_exclusive_group(required=True) - tl_group.add_argument("--tl-ordinal", "-o", type=int, default=None) - tl_group.add_argument("--tl-name", "-n", type=str, default=None) - subp.add_argument("--time", "-t", type=float, required=True) + tl_group.add_argument( + "--tl-ordinal", + "-o", + type=int, + default=None, + help="Ordinal of the target timeline", + ) + tl_group.add_argument( + "--tl-name", "-n", type=str, default=None, help="Name of the target timeline" + ) + subp.add_argument( + "--time", "-t", type=float, required=True, help="Time position for the beat" + ) subp.set_defaults(func=partial(add, TlKind.BEAT_TIMELINE)) -def validate_timeline(namespace: argparse.Namespace, tl_kind: TlKind, tl: Timeline): - if not tl: - if namespace.tl_ordinal is not None: - raise ValueError(f"No timeline found with ordinal={namespace.tl_ordinal}") - else: - raise ValueError(f"No timeline found with name={namespace.tl_name}") - - if tl.KIND != tl_kind: - raise ValueError(f"Timeline {tl} is of wrong kind. Expected {tl_kind}") - - def get_component_params(cmp_kind: ComponentKind, namespace: argparse.Namespace): params = {} for attr in COMPONENT_KIND_TO_PARAMS[cmp_kind]: @@ -52,11 +61,16 @@ def add(tl_kind: TlKind, namespace: argparse.Namespace): name = namespace.tl_name if ordinal is not None: - tl = get_timeline_by_ordinal(ordinal) + success, tl = get_timeline_by_ordinal(ordinal) else: - tl = get_timeline_by_name(name) + success, tl = get_timeline_by_name(name) + + if not success: + return - validate_timeline(namespace, tl_kind, tl) + if tl.KIND != tl_kind: + io.error(f"Timeline {tl} is of wrong kind. Expected {tl_kind}") + return cmp_kind = TL_KIND_TO_COMPONENT_KIND[tl_kind] params = get_component_params(cmp_kind, namespace) diff --git a/tilia/ui/cli/components/setup.py b/tilia/ui/cli/components/setup.py index 787d4f402..63f7cd25b 100644 --- a/tilia/ui/cli/components/setup.py +++ b/tilia/ui/cli/components/setup.py @@ -3,7 +3,10 @@ def setup_parser(subparsers): tl = subparsers.add_parser( - "components", exit_on_error=False, aliases=["cmp", "component"] + "components", + exit_on_error=False, + aliases=["cmp", "component"], + help="Components operations. See `components -h` for more info.", ) tl_subparser = tl.add_subparsers(dest="component_command") diff --git a/tilia/ui/cli/export.py b/tilia/ui/cli/export.py index 63d04cbcc..b6ab28f4f 100644 --- a/tilia/ui/cli/export.py +++ b/tilia/ui/cli/export.py @@ -1,15 +1,29 @@ from pathlib import Path -from tilia.requests import post, Post +from tilia.ui import commands from tilia.ui.cli import io def setup_parser(subparsers): - parser = subparsers.add_parser("export", exit_on_error=False) + parser = subparsers.add_parser( + "export", + exit_on_error=False, + help="Export to JSON", + epilog=""" +JSON format is described in https://tilia-app.com/help/export#json-format + +Examples: + # Export to JSON + export /path/to/file.json + + # Export to JSON with overwrite + export /path/to/file.json --overwrite +""", + ) - parser.add_argument("path", help="Path to save file to.") + parser.add_argument("path", help="Path to export the file") parser.add_argument( - "--overwrite", action="store_true", help="Overwrite existing file." + "--overwrite", action="store_true", help="Overwrite existing file" ) parser.set_defaults(func=export) @@ -22,4 +36,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/generate_scripts.py b/tilia/ui/cli/generate_scripts.py index b434d4fed..04210bbee 100644 --- a/tilia/ui/cli/generate_scripts.py +++ b/tilia/ui/cli/generate_scripts.py @@ -12,7 +12,14 @@ def setup_parser(subparser, parse_and_run: Callable[[str], bool]): - generate_subp = subparser.add_parser("generate_scripts", exit_on_error=False) + generate_subp = subparser.add_parser( + "generate_scripts", + exit_on_error=False, + formatter_class=argparse.RawDescriptionHelpFormatter, + help="Function to generate scripts. See `generate_scripts -h` for more info.", + description=f"""Using input directory path, runs get_scripts to generate scripts, then asks to run. +get_scripts:{get_scripts.__doc__}""", + ) generate_subp.add_argument("path", type=str, nargs="+") generate_subp.set_defaults(func=partial(generate, parse_and_run)) diff --git a/tilia/ui/cli/io.py b/tilia/ui/cli/io.py index 66595b5f2..b9cc4f9a6 100644 --- a/tilia/ui/cli/io.py +++ b/tilia/ui/cli/io.py @@ -23,6 +23,14 @@ def tabulate(headers: list[str], data: list[tuple[str, ...]]) -> None: output(str(table)) +def warn(message: str) -> None: + output(message, Fore.YELLOW) + + +def error(message: str) -> None: + output(message, Fore.RED) + + def ask_for_string(prompt: str) -> str: """ Prompts the user for a string diff --git a/tilia/ui/cli/load_media.py b/tilia/ui/cli/load_media.py index 7a773336a..1bc4b0905 100644 --- a/tilia/ui/cli/load_media.py +++ b/tilia/ui/cli/load_media.py @@ -11,12 +11,22 @@ def setup_parser(subparsers, parse_and_run: Callable[[str], bool]): - parser = subparsers.add_parser("load-media", exit_on_error=False) + parser = subparsers.add_parser( + "load-media", + exit_on_error=False, + help="Load a media file", + epilog=""" +Examples: + load-media "C:/path/to/media.mp3" + load-media "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) parser.add_argument( "path", type=str, - help="Path to media.", + help="Path to media", ) parser.add_argument( @@ -25,14 +35,14 @@ def setup_parser(subparsers, parse_and_run: Callable[[str], bool]): type=str, choices=["yes", "no", "prompt"], default="prompt", - help="Automatically scale the media timeline.", + help="Automatically scale the media timeline", ) parser.add_argument( "--duration-if-error", type=float, default=None, - help="Duration to use if media could not be loaded.", + help="Duration to use if media could not be loaded", ) parser.set_defaults(func=partial(load_media, parse_and_run)) diff --git a/tilia/ui/cli/metadata/set.py b/tilia/ui/cli/metadata/set.py index 0d88d9882..cf107ea11 100644 --- a/tilia/ui/cli/metadata/set.py +++ b/tilia/ui/cli/metadata/set.py @@ -8,9 +8,18 @@ def setup_parser(subparsers): - parser = subparsers.add_parser("set", help="Set media metadata.") - parser.add_argument("field", type=str, help="Field name.") - parser.add_argument("value", type=str, help="Field value.") + parser = subparsers.add_parser( + "set", + help="Set a specific metadata field.", + epilog=""" +Examples: + metadata set title "Bohemian Rhapsody" + metadata set composer "Chico Buarque" +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("field", type=str, help="Field name to set.") + parser.add_argument("value", type=str, help="Value to set for the field.") parser.set_defaults(func=set_metadata) diff --git a/tilia/ui/cli/metadata/set_media_length.py b/tilia/ui/cli/metadata/set_media_length.py index 9141863ce..d5f39b52d 100644 --- a/tilia/ui/cli/metadata/set_media_length.py +++ b/tilia/ui/cli/metadata/set_media_length.py @@ -5,9 +5,9 @@ def setup_parser(subparsers): parser = subparsers.add_parser( - "set-media-length", help="Import metadata from JSON file." + "set-media-length", help="Set the media length manually." ) - parser.add_argument("value", type=float, help="Media length value.") + parser.add_argument("value", type=float, help="Media length value in seconds.") parser.set_defaults(func=set_media_length) diff --git a/tilia/ui/cli/metadata/setup.py b/tilia/ui/cli/metadata/setup.py index a0e5322b1..3380531e5 100644 --- a/tilia/ui/cli/metadata/setup.py +++ b/tilia/ui/cli/metadata/setup.py @@ -10,6 +10,6 @@ def setup_parser(subparsers): metadata_subp = parser.add_subparsers(dest="timeline_command") setup_import_parser(metadata_subp) - setup_show_parser(metadata_subp) - setup_set_media_length_parser(metadata_subp) setup_set_parser(metadata_subp) + setup_set_media_length_parser(metadata_subp) + setup_show_parser(metadata_subp) diff --git a/tilia/ui/cli/metadata/show.py b/tilia/ui/cli/metadata/show.py index 1ae64e8ea..8e29bed35 100644 --- a/tilia/ui/cli/metadata/show.py +++ b/tilia/ui/cli/metadata/show.py @@ -5,7 +5,7 @@ def setup_parser(subparsers): - parser = subparsers.add_parser("show", help="Import metadata from JSON file.") + parser = subparsers.add_parser("show", help="Show current media metadata.") parser.set_defaults(func=show) diff --git a/tilia/ui/cli/open.py b/tilia/ui/cli/open.py index 5c82e8c85..792b59138 100644 --- a/tilia/ui/cli/open.py +++ b/tilia/ui/cli/open.py @@ -1,13 +1,15 @@ from pathlib import Path -from tilia.requests import post, Post +from tilia.ui import commands from tilia.ui.path import ensure_tla_extension def setup_parser(subparsers): - parser = subparsers.add_parser("open", exit_on_error=False) + parser = subparsers.add_parser( + "open", exit_on_error=False, help="Open a TiLiA file" + ) - parser.add_argument("path", help="Path to TiLiA file.", type=str) + parser.add_argument("path", help="Path to TiLiA file", type=str) parser.set_defaults(func=open) @@ -16,4 +18,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/cli/quit.py b/tilia/ui/cli/quit.py index 7d16ee1f4..9bcdaaf5a 100644 --- a/tilia/ui/cli/quit.py +++ b/tilia/ui/cli/quit.py @@ -3,7 +3,9 @@ def setup_parser(subparsers): - _quit = subparsers.add_parser("quit", aliases=["exit", "q"]) + _quit = subparsers.add_parser( + "quit", aliases=["exit", "q"], help="Quit the application" + ) _quit.set_defaults(func=quit) diff --git a/tilia/ui/cli/save.py b/tilia/ui/cli/save.py index 2f1e4c6e1..a372c38d1 100644 --- a/tilia/ui/cli/save.py +++ b/tilia/ui/cli/save.py @@ -9,11 +9,23 @@ def setup_parser(subparsers): - parser = subparsers.add_parser("save", exit_on_error=False) + parser = subparsers.add_parser( + "save", + exit_on_error=False, + help="Save the current project", + epilog=""" +Examples: + # Save (prompts for overwrite if file exists) + save /path/to/file.tla + + # Save (overwrites without prompting) + save /path/to/file.tla --overwrite +""", + ) - parser.add_argument("path", help="Path to save file to.") + parser.add_argument("path", help="Path to save the file") parser.add_argument( - "--overwrite", action="store_true", help="Overwrite existing file." + "--overwrite", action="store_true", help="Overwrite existing file" ) parser.set_defaults(func=save) diff --git a/tilia/ui/cli/script.py b/tilia/ui/cli/script.py index 3953900cc..ce68f90cc 100644 --- a/tilia/ui/cli/script.py +++ b/tilia/ui/cli/script.py @@ -8,9 +8,13 @@ def setup_parser(subparsers, parse_and_run_func: Callable[[str], bool]): - script = subparsers.add_parser("script", exit_on_error=False) - script.add_argument("path", type=str) - script.add_argument("--encoding", "-e", type=str, nargs="?", default="utf-8") + script = subparsers.add_parser( + "script", exit_on_error=False, help="Run a CLI script from a text file" + ) + script.add_argument("path", type=str, help="Path to the script file") + script.add_argument( + "--encoding", "-e", type=str, nargs="?", default="utf-8", help="File encoding" + ) script.set_defaults(func=partial(run, parse_and_run_func)) 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/cli/timelines/add.py b/tilia/ui/cli/timelines/add.py index a3ddad067..c49c7cdf0 100644 --- a/tilia/ui/cli/timelines/add.py +++ b/tilia/ui/cli/timelines/add.py @@ -7,14 +7,37 @@ def setup_parser(subparser): - add_subp = subparser.add_parser("add", exit_on_error=False) + add_subp = subparser.add_parser( + "add", + exit_on_error=False, + help="Add a new timeline", + epilog=""" +Examples: + timelines add beat --name "Measures" --beat-pattern 4 + timelines add hierarchy --name "Form" + timelines add marker --name "Cadences" +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) add_subp.add_argument( "kind", choices=["hierarchy", "hrc", "marker", "mrk", "beat", "bea", "score", "sco"], + help="Kind of timeline to add", + ) + add_subp.add_argument( + "--name", "-n", type=str, default="", help="Name of the new timeline" + ) + add_subp.add_argument( + "--height", "-e", type=int, default=None, help="Height of the timeline" + ) + add_subp.add_argument( + "--beat-pattern", + "-b", + type=int, + nargs="+", + default=[4], + help="Pattern as space-separated integers indicating beat count in a measure. Pattern will be repeated. Pattern '3 4', for instance, will alternate measures of 3 and 4 beats.", ) - add_subp.add_argument("--name", "-n", type=str, default="") - add_subp.add_argument("--height", "-e", type=int, default=None) - add_subp.add_argument("--beat-pattern", "-b", type=int, nargs="+", default=[4]) add_subp.set_defaults(func=add) diff --git a/tilia/ui/cli/timelines/getters.py b/tilia/ui/cli/timelines/getters.py deleted file mode 100644 index 9d5e6d68e..000000000 --- a/tilia/ui/cli/timelines/getters.py +++ /dev/null @@ -1,12 +0,0 @@ -from tilia.requests import Get, get -from tilia.timelines.base.timeline import Timeline - - -def get_timeline_by_name(name: str) -> Timeline: - result = [tl for tl in get(Get.TIMELINES) if tl.name == name] - return result[0] if result else None - - -def get_timeline_by_ordinal(ordinal: int) -> Timeline | None: - result = [tl for tl in get(Get.TIMELINES) if tl.ordinal == ordinal] - return result[0] if result else None diff --git a/tilia/ui/cli/timelines/list.py b/tilia/ui/cli/timelines/list.py index 7736283ba..dc8d7ba8c 100644 --- a/tilia/ui/cli/timelines/list.py +++ b/tilia/ui/cli/timelines/list.py @@ -8,7 +8,9 @@ def pprint_tlkind(kind: TlKind) -> str: def setup_parser(subparser): - list_subp = subparser.add_parser("list", exit_on_error=False, aliases=["ls"]) + list_subp = subparser.add_parser( + "list", exit_on_error=False, aliases=["ls"], help="List all timelines" + ) list_subp.set_defaults(func=list) diff --git a/tilia/ui/cli/timelines/move.py b/tilia/ui/cli/timelines/move.py new file mode 100644 index 000000000..033eb6152 --- /dev/null +++ b/tilia/ui/cli/timelines/move.py @@ -0,0 +1,95 @@ +import argparse +from tilia.requests import get, Get +from tilia.timelines.base.timeline import Timeline +from tilia.ui.cli import io +from tilia.ui.cli.timelines.utils import get_timeline_by_name, get_timeline_by_ordinal + + +def setup_parser(subparser): + move_subp = subparser.add_parser( + "move", + exit_on_error=False, + aliases=["mv"], + help="Move a timeline to a new ordinal position. 1 is the top-most timeline.", + epilog=""" +Examples: + # Move timeline named "Measure" to first position (top-most) + timelines move name "Measure" 1 + + # Move timeline at 3rd position to 1st position + timelines move ordinal 3 1 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + move_subcommands = move_subp.add_subparsers(dest="type", required=True) + + # 'move by name' subcommand + by_name_subcommand = move_subcommands.add_parser( + "name", exit_on_error=False, help="Move timeline by name" + ) + by_name_subcommand.add_argument("name", help="Name of the timeline to move") + by_name_subcommand.add_argument( + "new_ordinal", type=int, help="New ordinal position" + ) + by_name_subcommand.set_defaults(func=move_timeline, by="name") + + # 'move by ordinal' subcommand + by_ordinal_subcommand = move_subcommands.add_parser( + "ordinal", exit_on_error=False, help="Move timeline by ordinal" + ) + by_ordinal_subcommand.add_argument( + "ordinal", type=int, help="Current ordinal of the timeline" + ) + by_ordinal_subcommand.add_argument( + "new_ordinal", type=int, help="New ordinal position" + ) + by_ordinal_subcommand.set_defaults(func=move_timeline, by="ordinal") + + +def move(timeline: Timeline, to: int) -> bool: + ordinal_to_timeline = {tl.get_data("ordinal"): tl for tl in get(Get.TIMELINES)} + + curr_ordinal = timeline.get_data("ordinal") + max_ordinal = max(ordinal_to_timeline.keys()) + if to == curr_ordinal: + return True + + if to < curr_ordinal: + if to < 1: + io.warn( + "Can't move to ordinal smaller than '1'. Moving to '1' instead. ", + ) + to = 1 + + sign = -1 + steps = curr_ordinal - to + else: + if to > max_ordinal: + io.warn( + f"Can't move to '{to}' is bigger than last ordinal '{max_ordinal}'. Moving to last ordinal. ", + ) + to = max_ordinal + sign = 1 + steps = to - curr_ordinal + + for _ in range(steps): + get(Get.TIMELINE_COLLECTION).permute_ordinal( + timeline, + ordinal_to_timeline[timeline.get_data("ordinal") + 1 * sign], + ) + + return True + + +def move_timeline(namespace): + attr_value = getattr(namespace, namespace.by) + + if namespace.by == "name": + success, tl = get_timeline_by_name(attr_value) + else: # ordinal + success, tl = get_timeline_by_ordinal(attr_value) + + if not success: + return # error has already been printed + + success = move(tl, namespace.new_ordinal) diff --git a/tilia/ui/cli/timelines/remove.py b/tilia/ui/cli/timelines/remove.py index eb25142ea..1fe2d5eae 100644 --- a/tilia/ui/cli/timelines/remove.py +++ b/tilia/ui/cli/timelines/remove.py @@ -1,40 +1,57 @@ +import argparse +from colorama import Fore + from tilia.requests import get, Get +from tilia.ui.cli import io +from tilia.ui.cli.timelines.utils import get_timeline_by_name, get_timeline_by_ordinal def setup_parser(subparser): - remove_subp = subparser.add_parser("remove", exit_on_error=False, aliases=["rm"]) + remove_subp = subparser.add_parser( + "remove", + exit_on_error=False, + aliases=["rm"], + help="Remove a timeline", + epilog=""" +Examples: + # Remove timeline named "Measures" + timelines remove name "Measures" + + # Remove top-most timeline + timelines remove ordinal 1 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) remove_subcommands = remove_subp.add_subparsers(dest="type", required=True) # 'remove by name' subcommand - remove_by_name_subc = remove_subcommands.add_parser("name", exit_on_error=False) - remove_by_name_subc.add_argument("name") - remove_by_name_subc.set_defaults(func=remove_by_name) + by_name_subcommand = remove_subcommands.add_parser( + "name", exit_on_error=False, help="Remove timeline by name" + ) + by_name_subcommand.add_argument("name", help="Name of the timeline to remove") + by_name_subcommand.set_defaults(func=remove_timeline, by="name") # 'remove by ordinal' subcommand - remove_by_ordinal_subc = remove_subcommands.add_parser( - "ordinal", exit_on_error=False + by_ordinal_subcommand = remove_subcommands.add_parser( + "ordinal", exit_on_error=False, help="Remove timeline by ordinal" ) - remove_by_ordinal_subc.add_argument("ordinal", type=int) - remove_by_ordinal_subc.set_defaults(func=remove_by_ordinal) - - -def remove_by_name(namespace): - tl = get(Get.TIMELINE_BY_ATTR, "name", namespace.name) - - if not tl: - raise ValueError(f"No timeline found with name={namespace.name}") - - print(f"Removing timeline {tl=}") - - get(Get.TIMELINE_COLLECTION).delete_timeline(tl) + by_ordinal_subcommand.add_argument( + "ordinal", type=int, help="Ordinal of the timeline to remove" + ) + by_ordinal_subcommand.set_defaults(func=remove_timeline, by="ordinal") -def remove_by_ordinal(namespace): - tl = get(Get.TIMELINE_BY_ATTR, "ordinal", namespace.ordinal) +def remove_timeline(namespace): + attr_value = getattr(namespace, namespace.by) - if not tl: - raise ValueError(f"No timeline found with ordinal={namespace.ordinal}") + if namespace.by == "name": + success, tl = get_timeline_by_name(attr_value) + else: # ordinal + success, tl = get_timeline_by_ordinal(attr_value) - print(f"Removing timeline {tl=}") + if not success: + return + else: + io.output(f"Removing timeline {tl=}", Fore.GREEN) get(Get.TIMELINE_COLLECTION).delete_timeline(tl) diff --git a/tilia/ui/cli/timelines/setup.py b/tilia/ui/cli/timelines/setup.py index 34366f9c8..72e43f8b7 100644 --- a/tilia/ui/cli/timelines/setup.py +++ b/tilia/ui/cli/timelines/setup.py @@ -2,15 +2,20 @@ from .add import setup_parser as setup_add_parser from .list import setup_parser as setup_list_parser from .remove import setup_parser as setup_remove_parser +from .move import setup_parser as setup_move_parser def setup_parser(subparsers): tl = subparsers.add_parser( - "timelines", exit_on_error=False, aliases=["tl", "timeline"] + "timelines", + exit_on_error=False, + aliases=["tl", "timeline"], + help="Timeline operations. See `timelines -h` for more info.", ) tl_subparser = tl.add_subparsers(dest="timeline_command") setup_add_parser(tl_subparser) + setup_import_parser(tl_subparser) setup_list_parser(tl_subparser) + setup_move_parser(tl_subparser) setup_remove_parser(tl_subparser) - setup_import_parser(tl_subparser) diff --git a/tilia/ui/cli/timelines/utils.py b/tilia/ui/cli/timelines/utils.py new file mode 100644 index 000000000..c26961e68 --- /dev/null +++ b/tilia/ui/cli/timelines/utils.py @@ -0,0 +1,56 @@ +from contextlib import contextmanager +from unittest.mock import patch + +from tilia.requests import get, Get +from tilia.ui.cli import io + + +def get_timeline_by_name(name: str): + tls = get(Get.TIMELINES_BY_ATTR, "name", name) + + if not tls: + io.error(f"No timeline found with name={name}") + return False, None + if len(tls) == 1: + return True, tls[0] + else: # len(tls) > 1 + io.warn(f"There is more than one timeline with name '{name}'") + return True, tls[0] + + +def get_timeline_by_ordinal(ordinal: int): + tl = get(Get.TIMELINE_BY_ATTR, "ordinal", ordinal) + + if not tl: + io.error(f"No timeline found with ordinal={ordinal}") + return False, None + else: + return True, tl + + +@contextmanager +def patch_warn(): + with patch("tilia.ui.cli.io.warn") as mock_warn: + yield mock_warn + + +@contextmanager +def assert_warn(): + with patch("tilia.ui.cli.io.warn") as mock_warn: + yield mock_warn + + mock_warn.assert_called() + + +@contextmanager +def patch_error(): + with patch("tilia.ui.cli.io.error") as mock_warn: + yield mock_warn + + +@contextmanager +def assert_error(): + with patch("tilia.ui.cli.io.error") as mock_error: + yield mock_error + + mock_error.assert_called() diff --git a/tilia/ui/cli/ui.py b/tilia/ui/cli/ui.py index a0ab68258..e4bcd6762 100644 --- a/tilia/ui/cli/ui.py +++ b/tilia/ui/cli/ui.py @@ -47,17 +47,17 @@ def __init__(self): serve(self, Get.FROM_USER_SHOULD_SAVE_CHANGES, on_ask_should_save_changes) def setup_parsers(self): - timelines.setup_parser(self.subparsers) - quit.setup_parser(self.subparsers) - save.setup_parser(self.subparsers) - load_media.setup_parser(self.subparsers, self.parse_and_run) + clear.setup_parser(self.subparsers) components.setup_parser(self.subparsers) - metadata.setup_parser(self.subparsers) + export.setup_parser(self.subparsers) generate_scripts.setup_parser(self.subparsers, self.parse_and_run) - script.setup_parser(self.subparsers, self.parse_and_run) + load_media.setup_parser(self.subparsers, self.parse_and_run) + metadata.setup_parser(self.subparsers) open.setup_parser(self.subparsers) - export.setup_parser(self.subparsers) - clear.setup_parser(self.subparsers) + quit.setup_parser(self.subparsers) + save.setup_parser(self.subparsers) + script.setup_parser(self.subparsers, self.parse_and_run) + timelines.setup_parser(self.subparsers) @staticmethod def parse_command(arg_string): diff --git a/tilia/ui/commands.py b/tilia/ui/commands.py new file mode 100644 index 000000000..b95f6215e --- /dev/null +++ b/tilia/ui/commands.py @@ -0,0 +1,156 @@ +""" +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 functools +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 + + +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" + + +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 = CommandQAction(name, 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: + raise ValueError( + f"Unregistered command: {command_name}.\nRegistered commands:\n{list(_name_to_callback.keys())}" + ) + + try: + return _name_to_callback[command_name](*args, **kwargs) + except Exception as e: + callback = _name_to_callback[command_name] + sig = inspect.signature(callback) + if isinstance(callback, functools.partial): + partial_message = ( + "Callback is a partial.\n" + + f"Partial args: {callback.args}\n" + + f"Partial kwargs: {callback.keywords}\n" + ) + callback = callback.func + else: + partial_message = "" + + message = f"Error executing command '{command_name}'. \n" + message += f"Callback: {callback.__module__}.{callback.__name__}{sig}\n" + message += partial_message + message += f"Called with args: {args}, kwargs: {kwargs}" + raise Exception(message) 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..0a1ede5f4 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] | str class TiliaMenu(QMenu): @@ -51,18 +53,18 @@ 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: TiliaMenuItem): + self.addAction(get_qaction(name)) - def get_submenu(self, cls: type[TiliaMenu]): + def get_submenu(self, cls: type[QMenu]): return self.class_to_submenu[cls] 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..f8faa2e77 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 @@ -36,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), @@ -75,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}") @@ -173,7 +165,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 a777a6720..a39b0047e 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() @@ -174,23 +169,14 @@ 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.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 @@ -302,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) @@ -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_RECORD_STATE, "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/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/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..9287b5fc0 100644 --- a/tilia/ui/timelines/audiowave/timeline.py +++ b/tilia/ui/timelines/audiowave/timeline.py @@ -1,13 +1,10 @@ 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 -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,26 +34,25 @@ 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): + 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) @@ -65,8 +61,11 @@ def on_side_arrow_press(self, side: Side): 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, } diff --git a/tilia/ui/timelines/base/context_menus.py b/tilia/ui/timelines/base/context_menus.py index 22ede7cc7..7b93efb7a 100644 --- a/tilia/ui/timelines/base/context_menus.py +++ b/tilia/ui/timelines/base/context_menus.py @@ -1,89 +1,91 @@ -from PyQt6.QtGui import QAction -from tilia.ui.actions import TiliaAction +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, 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() + self.add_default_actions() - def get_timeline_uis_to_permute(self): - return self.timeline_uis_to_permute - - 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(): - 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 = { 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) 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 = { 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) - def add_delete_timeline(self): - delete_timeline = QAction("Delete", self) - delete_timeline.triggered.connect( - lambda: post(Post.TIMELINE_DELETE_FROM_CONTEXT_MENU) - ) + 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 = CommandQAction("timeline.delete", self) + delete_timeline.setText("Delete") + delete_timeline.triggered.connect(on_delete_timeline) self.addAction(delete_timeline) + clear_timeline = CommandQAction("timeline.clear", self) + clear_timeline.setText("Clear") + clear_timeline.triggered.connect(on_clear_timeline) + self.addAction(clear_timeline) + class TimelineUIElementContextMenu(TiliaMenu): title = "TimelineElement" 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/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 885f89ba5..0606fd2d5 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -1,11 +1,15 @@ from __future__ import annotations import functools +import importlib +import os from abc import ABC +from pathlib import Path from typing import ( Any, TYPE_CHECKING, TypeVar, Optional, + Callable, ) from PyQt6.QtCore import Qt, QPoint @@ -23,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 @@ -84,9 +108,30 @@ def __bool__(self): def __lt__(self, other): return self.get_data("ordinal") < other.get_data("ordinal") + SUBCLASSES_ARE_LOADED = False + + @classmethod + def subclasses(cls): + if not cls.SUBCLASSES_ARE_LOADED: + 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) + + cls.SUBCLASSES_ARE_LOADED = True + @property def is_empty(self): - return len(self) == 0 + return self.timeline.is_empty @property def timeline(self): @@ -120,6 +165,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) @@ -178,15 +255,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: @@ -201,9 +269,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 ): @@ -456,7 +521,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}", @@ -510,3 +575,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/element.py b/tilia/ui/timelines/beat/element.py index d4d33ce24..9a5e6b5ab 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=[], @@ -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/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..cba78b600 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -1,19 +1,25 @@ -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 +44,145 @@ 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, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) + + self.timeline_ui: BeatTimelineUI + component, _ = self.timeline.create_component(ComponentKind.BEAT, time) + self.timeline.recalculate_measures() + return False if component is None else True + + @staticmethod + @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]: @@ -73,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/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 4ecafbc41..ded463510 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,43 +15,49 @@ 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 ..beat import BeatTimelineUI +from ..base.element import TimelineUIElement 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 success + + return wrapper class TimelineUIs: @@ -63,7 +70,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 = [] @@ -72,6 +81,7 @@ def __init__( self._setup_widgets(main_window) self._setup_requests() + self._setup_commands() self._setup_selection_box() self._setup_drag_tracking_vars() @@ -116,6 +126,283 @@ 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 part of the callback of commands that are used by for multiple timelines + (e.g. timeline.component.delete, timeline.component.set_color). + Gets the timelines to apply the command to based on `selector` and calls `callback` passing them as arguments. + 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 overridden 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__ if hasattr(callback, '__name__') else callback}'", + traceback.format_exc(), + ) + post(Post.APP_STATE_RECOVER, state_backup) + return + + if not self.validate_command_return_value( + method_name or callback.__name__, result + ): + tilia.errors.display( + tilia.errors.INVALID_RETURN_VALUES_FOR_COMMAND, + method_name or callback.__name__, + result, + ) + + success = all(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), @@ -152,14 +439,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), @@ -178,18 +457,21 @@ 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 = { (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), @@ -207,31 +489,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) @@ -396,27 +653,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): @@ -647,14 +888,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 = [] @@ -662,9 +903,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 ( @@ -680,6 +919,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() @@ -828,39 +1069,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: @@ -868,117 +1078,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_RECORD_STATE, 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_RECORD_STATE, 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_RECORD_STATE, f"timeline request: {request.name}") + return valid def get_timelines_uis_for_request( self, kinds: list[TlKind], selector: TimelineSelector @@ -1009,32 +1113,11 @@ 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_cli(_): - return [get(Get.TIMELINES_FROM_CLI)] - - 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.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, } try: @@ -1042,7 +1125,177 @@ 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): + return get(Get.TIMELINE_COLLECTION).permute_ordinal(tlui1, tlui2) + + @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) @@ -1051,7 +1304,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) @@ -1059,6 +1312,23 @@ def on_zoom(self, is_zoom_in: bool, 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) + success, errors = _on_import_to_timeline(self, tl_kind) + + if not success: + post(Post.APP_STATE_RESTORE, prev_state) + if errors: + tilia.errors.display(tilia.errors.CSV_IMPORT_FAILED, "\n".join(errors)) + elif success and errors: + tilia.errors.display( + tilia.errors.CSV_IMPORT_SUCCESS_ERRORS, "\n".join(errors) + ) + + return success + def _auto_scroll(self, media_time_change_reason, time) -> None: if any( [ @@ -1243,3 +1513,10 @@ 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): + 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 76% rename from tilia/ui/ui_import.py rename to tilia/ui/timelines/collection/import_.py index 6e0af693d..06c1089b2 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 TYPE_CHECKING import tilia.errors import tilia.parsers @@ -6,15 +8,17 @@ 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]]: +) -> 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: @@ -27,17 +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 timeline.components and not _confirm_timeline_overwrite_on_import(): - return "cancelled", ["User rejected components overwrite."] + if not timeline.is_empty and not _confirm_timeline_overwrite_on_import(): + 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", ["No beat timeline found for importing 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( @@ -52,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", ["No beat timeline found for importing by measure."] + return False, ["A beat timeline is required to import by measure."] beat_tl = get(Get.TIMELINE, beat_tlui.id) else: @@ -70,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() @@ -86,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(): @@ -119,10 +121,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] 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 deleted file mode 100644 index 93a4ffe0e..000000000 --- a/tilia/ui/timelines/collection/requests/element.py +++ /dev/null @@ -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/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/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..ce94f47fc 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,89 @@ def create_pasted_components( target_time + (harmony_time - reference_time), **harmony_data["by_component_value"], ) + + 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 + ) + + 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: float | None = None): + if time is None: + 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 2b6db2614..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), @@ -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/hierarchy/element.py b/tilia/ui/timelines/hierarchy/element.py index 975e24a82..c7f1f62e7 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) @@ -237,7 +242,7 @@ def update_color(self): self.body.set_fill(self.ui_color) def update_comments(self): - self.comments_icon.setVisible(bool(self.get_data("comments"))) + self.update_comments_icon_visibility() def update_label(self, start_x=None, end_x=None, level=None, height=None): # if called from update_position, @@ -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 @@ -306,8 +311,18 @@ def update_body_position(self, level, height, start_x, end_x): height, ) + def update_comments_icon_visibility(self): + width = QFontMetrics(QFont("Arial", 10)).horizontalAdvance( + self.comments_icon.toPlainText() + ) + visible = bool(self.get_data("comments")) and ( + width < self.end_x - self.start_x + ) + self.comments_icon.setVisible(visible) + def update_comments_icon_position(self, level, height, end_x): self.comments_icon.set_position(end_x, level, height) + self.update_comments_icon_visibility() def update_loop_icon_position(self, level, height, start_x): self.loop_icon.set_position(start_x, height, level) @@ -320,7 +335,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 +345,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 +367,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: @@ -382,10 +406,12 @@ def _setup_label(self): def _setup_comments_icon(self): self.comments_icon = HierarchyCommentsIcon( - self.end_x, self.timeline_ui.get_data("height"), self.get_data("level") + self.end_x, + self.get_data("level"), + self.timeline_ui.get_data("height"), ) self.scene.addItem(self.comments_icon) - self.comments_icon.setVisible(bool(self.get_data("comments"))) + self.update_comments_icon_visibility() def _setup_loop_icon(self): self.loop_icon = HierarchyLoopIcon( @@ -400,12 +426,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 +445,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 +474,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 +516,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 +537,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: @@ -721,20 +752,20 @@ class HierarchyCommentsIcon(CursorMixIn, QGraphicsTextItem): def __init__( self, end_x: float, - tl_height: int, level: int, + tl_height: int, ): super().__init__(cursor_shape=Qt.CursorShape.PointingHandCursor) self.setup_font() self.setPlainText(self.ICON) - self.set_position(end_x, tl_height, level) + self.set_position(end_x, level, tl_height) def setup_font(self): font = QFont("Arial", 6) self.setFont(font) self.setDefaultTextColor(QColor("black")) - def get_point(self, end_x: float, tl_height, level): + def get_point(self, end_x: float, level, tl_height): x = end_x + self.LEFT_MARGIN y = ( tl_height @@ -747,8 +778,8 @@ def get_point(self, end_x: float, tl_height, level): ) return QPointF(x, y) - def set_position(self, end_x, tl_height, level): - self.setPos(self.get_point(end_x, tl_height, level)) + def set_position(self, end_x, level, tl_height): + self.setPos(self.get_point(end_x, level, tl_height)) self.setZValue(level + 1) 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/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/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..6f6abc422 100644 --- a/tilia/ui/timelines/marker/timeline.py +++ b/tilia/ui/timelines/marker/timeline.py @@ -1,25 +1,27 @@ 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 -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 ( 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,36 +48,31 @@ 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, time: float | None = None): + if time is None: + time = get(Get.SELECTED_TIME) + component, failure_reason = self.timeline.create_component( + ComponentKind.MARKER, time ) + return bool(component) def _deselect_all_but_last(self): if len(self.selected_elements) > 1: 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/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/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/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..896f24e9f 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -11,25 +11,22 @@ 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 ( 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,13 +103,34 @@ 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: 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 ) + 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 ): @@ -128,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 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/__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): + ... diff --git a/tilia/ui/windows/manage_timelines.py b/tilia/ui/windows/manage_timelines.py index e9aa1a32e..321befe52 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()) @@ -112,10 +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) - - def get_timeline_uis_to_permute(self): - return self.list_widget.timeline_uis_to_permute + commands.execute("timeline.set_is_visible", timeline_ui, bool(state)) def get_current_timeline_ui(self): return self.list_widget.currentItem().timeline_ui @@ -150,7 +131,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 +184,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 +195,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 c571a908f..f08f01f16 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 @@ -226,7 +254,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 +335,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 +348,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 +373,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 +392,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 +403,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 = {} @@ -423,6 +451,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 +471,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 @@ -651,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 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