diff --git a/requirements.txt b/requirements.txt index 8575fc6..d44a2a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ pymp3 textual textual-slider textual-plot +textual-fspicker numpy msgpack-numpy pedalboard diff --git a/src/main.py b/src/main.py index 39e98dc..2c8ca6b 100644 --- a/src/main.py +++ b/src/main.py @@ -65,7 +65,7 @@ if __name__ == "__main__": test_project.channels.append(piano_channel) test_project.write_to_file("test_project.tdp")""" - test_project = Project.from_file("test_project.tdp") + test_project = Project()#.from_file("test_project.tdp") # start the ui print("Starting UI...") diff --git a/src/test_project.tdp b/src/test_project.tdp index 8a5b7a4..8840da7 100644 Binary files a/src/test_project.tdp and b/src/test_project.tdp differ diff --git a/src/ui/app.py b/src/ui/app.py index e15ffec..844e7a7 100644 --- a/src/ui/app.py +++ b/src/ui/app.py @@ -2,12 +2,15 @@ from textual.app import App, ComposeResult from textual.widgets import Footer, Tab, Tabs, Header from textual import on, events +from textual_fspicker import FileOpen, FileSave, Filters + from ui.widgets.sidebar import Sidebar from ui.widgets.timeline import Timeline, TimelineRow from ui.widgets.project_settings import ProjectSettings from ui.widgets.channel import Channel +from ui.widgets.context_menu import ContextMenu, NoSelectStatic -from project import ProjectChannel +from project import ProjectChannel, Project class AppUI(App): @@ -19,6 +22,9 @@ class AppUI(App): self.last_zoom_level = self.zoom_level self.project = project + self.open_project_path = None + self.first_tab_click = True # stupid events firing when the app is first composed :/ + @on(events.Key) async def key_pressed(self, event: events.Key): if event.key == "space": @@ -29,22 +35,140 @@ class AppUI(App): timeline.song_player.play_project(self.app.project) def create_channel(self, name: str): + new_project_channel = ProjectChannel( + name + ) + self.query_one("#channels").mount(Channel( len(self.project.channels), + new_project_channel, name, + ), before=-1) - self.query_one("#rows").mount(TimelineRow()) + self.query_one("#rows").mount(TimelineRow(new_project_channel)) - self.project.channels.append(ProjectChannel( - name - )) + self.project.channels.append(new_project_channel) + + def handle_menu_click(self, choice: str): + # user clicked off the menu + if choice == None: return + + match choice: + case "Save": + if not self.open_project_path: + self.handle_menu_click("Save as") # just move it to save as + return + + self.project.write_to_file(self.open_project_path) + self.notify("Saved project.") + + case "Save as": + def callback(path: str): + if path == None: return + self.open_project_path = path + self.project.write_to_file(path) + self.notify(f"Saved as \"{path}\"", title="Saved") + + self.push_screen(FileSave( + filters=Filters( + ("Any", lambda _: True), + ("TerminalDAW Project File", lambda p: p.suffix.lower() == ".tdp") + ) + ), callback=callback) + + case "Open": + def callback(path: str): + if path == None: return + + self.project = Project.from_file(path) + self.open_project_path = path + + ### load all the ui elements + # sidebar channels + sidebar_channels = self.query_one("#channels") + + # we cant use sidebar_channels.remove_children() because that would + # also remove the "Add Channel" button + for child in sidebar_channels.children: + if child.id != "add-channel": + child.remove() + + for i, channel in enumerate(self.app.project.channels): + sidebar_channels.mount(Channel( + i, + channel, + channel.name, + channel.mute, + channel.solo, + channel.pan, + channel.volume + ), before=-1) + + # timeline tracks + timeline = self.query_one(Timeline) + rows = timeline.query_one("#rows") + + for channel in self.project.channels: + row = TimelineRow(channel) + + + row.styles.width = timeline.bar_offset * self.project.song_length + + for chunk in channel.chunks: + + if chunk.chunk_type == ChunkType.CHUNK: + row.mount(Chunk(chunk_name=chunk.name, bar_pos=chunk.position)) + elif chunk.chunk_type == ChunkType.AUDIO: + row.mount(AudioChunk(chunk.audio_data, chunk.sample_rate, chunk.name, chunk.position)) + + rows.mount(row) + + self.notify(f"Loaded \"{path}\"") + + self.push_screen(FileOpen( + filters=Filters( + ("Any", lambda _: True), + ("TerminalDAW Project File", lambda p: p.suffix.lower() == ".tdp") + ) + ), callback=callback) + + case _: + self.notify("Sorry, that isn't implemented yet... ;-;", severity="warning") + + @on(Tabs.TabActivated) + async def top_menu_tab_clicked(self, event: Tabs.TabActivated): + if self.first_tab_click: + event.tabs.active = None + self.first_tab_click = False + return + + options = { + "File": [ + "Save", + "Save as", + "Open", + "Render", + "Settings" + ], + "Edit": [ + "Copy", + "Paste", + "Cut" + ], + "About": [ + "Chookspace repo", + "Copyright" + ] + } + + self.app.push_screen(ContextMenu(options[event.tab.label], self.app.mouse_position + (0, 1)), callback=self.handle_menu_click) def compose(self) -> ComposeResult: - with Tabs(id="top-menu"): - yield Tab("File") - yield Tab("Edit") - yield Tab("View") - yield Tab("Help") + yield Tabs( + Tab("File"), + Tab("Edit"), + Tab("About"), + id="top-menu") yield Sidebar() yield Timeline() yield ProjectSettings() diff --git a/src/ui/widgets/channel.py b/src/ui/widgets/channel.py index d5762ba..ddc18d6 100644 --- a/src/ui/widgets/channel.py +++ b/src/ui/widgets/channel.py @@ -6,6 +6,8 @@ from textual_slider import Slider from textual import on, events +from project import ProjectChannel + class Channel(VerticalGroup): DEFAULT_CSS = """ @@ -46,9 +48,10 @@ class Channel(VerticalGroup): } """ - def __init__(self, channel_index: int, channel_name: str = "", muted: bool = False, solo: bool = False, pan: float = 0, volume: float = 0): + def __init__(self, channel_index: int, project_channel: ProjectChannel, channel_name: str = "", muted: bool = False, solo: bool = False, pan: float = 0, volume: float = 0): super().__init__() self.channel_index = channel_index + self.project_channel = project_channel self.channel_name = channel_name self.muted = muted self.solo = solo diff --git a/src/ui/widgets/context_menu.py b/src/ui/widgets/context_menu.py new file mode 100644 index 0000000..d7469b7 --- /dev/null +++ b/src/ui/widgets/context_menu.py @@ -0,0 +1,119 @@ +# totally not stolen from my code for my chat app Portal ;) +from __future__ import annotations +from textual.screen import ModalScreen +from textual.containers import Container +from textual.widgets import Static +from textual.geometry import Offset +from textual.message import Message +from textual.visual import VisualType +from textual import on, events + + +class NoSelectStatic(Static): + """This class is used in window.py and windowbar.py to create buttons.""" + + @property + def allow_select(self) -> bool: + return False + + +class ButtonStatic(NoSelectStatic): + """This class is used in window.py, windowbar.py, and switcher.py to create buttons.""" + + class Pressed(Message): + def __init__(self, button: ButtonStatic) -> None: + super().__init__() + self.button = button + + @property + def control(self) -> ButtonStatic: + return self.button + + def __init__( + self, + content: VisualType = "", + *, + expand: bool = False, + shrink: bool = False, + markup: bool = True, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + super().__init__( + content=content, + expand=expand, + shrink=shrink, + markup=markup, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + self.click_started_on: bool = False + + def on_mouse_down(self, event: events.MouseDown) -> None: + + self.add_class("pressed") + self.click_started_on = True + + def on_mouse_up(self, event: events.MouseUp) -> None: + + self.remove_class("pressed") + if self.click_started_on: + self.post_message(self.Pressed(self)) + self.click_started_on = False + + def on_leave(self, event: events.Leave) -> None: + + self.remove_class("pressed") + self.click_started_on = False + + +class ContextMenu(ModalScreen): + DEFAULT_CSS = """ + ContextMenu { + background: $background 0%; + align: left top; + } + + #menu_container { + padding: 0 1; + background: $surface; + width: 21; + border: tall $surface-lighten-1; + & > ButtonStatic { + content-align: left middle; + &:hover { background: $panel-lighten-2; } + &.pressed { background: $primary; } + + } + } + """ + + def __init__(self, options: list[str], offset: Offset): + super().__init__() + self.options = options + self.mouse_offset = offset + + def on_mouse_up(self, event: events.MouseUp): + if not self.query_one("#menu_container").region.contains(event.screen_x, event.screen_y): + self.dismiss(None) + + @on(ButtonStatic.Pressed) + async def thingy(self, event: ButtonStatic.Pressed): + self.dismiss(event.button.content) + + def compose(self): + with Container(id="menu_container"): + for option in self.options: + if isinstance(option, str): + yield ButtonStatic(option) + else: + yield option + + def on_mount(self): + menu_container = self.query_one("#menu_container") + menu_container.styles.height = len(menu_container.children) + 2 + menu_container.offset = self.mouse_offset \ No newline at end of file diff --git a/src/ui/widgets/project_settings.py b/src/ui/widgets/project_settings.py index 71a24c3..858cd99 100644 --- a/src/ui/widgets/project_settings.py +++ b/src/ui/widgets/project_settings.py @@ -16,9 +16,12 @@ class ProjectSettings(Horizontal): align-vertical: middle; padding: 0 1; - Button { + #play-button { max-width: 5; } + #tempo-tap { + max-width: 7; + } #song-bpm { max-width: 12; @@ -54,6 +57,7 @@ class ProjectSettings(Horizontal): yield Static(" BPM: ") yield Input("120", placeholder="120", valid_empty=False, type="integer", max_length=3, id="song-bpm") + yield Button("TAP", flat=True, id="tempo-tap") yield Static(" Time Signature: ") yield Input("4/4", placeholder="4/4", valid_empty=False, id="song-time-sig") \ No newline at end of file diff --git a/src/ui/widgets/timeline.py b/src/ui/widgets/timeline.py index 6f9f2dc..0afed20 100644 --- a/src/ui/widgets/timeline.py +++ b/src/ui/widgets/timeline.py @@ -26,6 +26,10 @@ class TimelineRow(Horizontal): } } """ + + def __init__(self, project_channel): + super().__init__() + self.project_channel = project_channel class Timeline(Vertical): DEFAULT_CSS = """ @@ -126,7 +130,7 @@ class Timeline(Vertical): with VerticalScroll(id="rows"): for channel in self.app.project.channels: - with TimelineRow() as row: + with TimelineRow(channel) as row: row.styles.width = self.bar_offset * self.app.project.song_length diff --git a/src/ui/widgets/top_menu.py b/src/ui/widgets/top_menu.py deleted file mode 100644 index 38216b0..0000000 --- a/src/ui/widgets/top_menu.py +++ /dev/null @@ -1 +0,0 @@ -from textual.widgets import Tabs, Tab