From 074791f430eaa20e9c904ebfc43c34c9b17e138b Mon Sep 17 00:00:00 2001 From: SpookyDervish Date: Thu, 23 Jul 2026 14:53:57 +1000 Subject: [PATCH] initial setup --- src/main.py | 15 ++++ src/widgets/context_menu.py | 119 ++++++++++++++++++++++++ src/widgets/directory_tree_custom.py | 129 +++++++++++++++++++++++++++ src/widgets/prompt.py | 85 ++++++++++++++++++ src/widgets/text_editor.py | 6 ++ 5 files changed, 354 insertions(+) create mode 100644 src/main.py create mode 100644 src/widgets/context_menu.py create mode 100644 src/widgets/directory_tree_custom.py create mode 100644 src/widgets/prompt.py create mode 100644 src/widgets/text_editor.py diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..33c21d8 --- /dev/null +++ b/src/main.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult + +from textual.widgets import Footer +from widgets.text_editor import TextEditor + + +class Berry(App): + def compose(self) -> ComposeResult: + yield TextEditor(language="python") + yield Footer() + + +if __name__ == "__main__": + app = Berry() + app.run() \ No newline at end of file diff --git a/src/widgets/context_menu.py b/src/widgets/context_menu.py new file mode 100644 index 0000000..fecc0b8 --- /dev/null +++ b/src/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: hkey $panel; + & > 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/widgets/directory_tree_custom.py b/src/widgets/directory_tree_custom.py new file mode 100644 index 0000000..02d5a76 --- /dev/null +++ b/src/widgets/directory_tree_custom.py @@ -0,0 +1,129 @@ +from textual.widgets import DirectoryTree, Rule +from textual.widgets.tree import TreeNode +from textual.events import MouseDown + +from prompt import Prompt +from context_menu import ContextMenu, NoSelectStatic + +import os, shutil + + +class CustomDirectoryTree(DirectoryTree): + + ICON_NODE = " " + ICON_NODE_EXPANDED = " " + ICON_FILE = " " + + def __init__(self, path, *, name = None, id = None, classes = None, disabled = False): + super().__init__(path, name=name, id=id, classes=classes, disabled=disabled) + self.right_clicked_node: TreeNode | None = None + + + + def context_menu_chosen(self, file_name: str, result: str): + if result == "Delete": + def delete_confirm(will_delete: bool | None): + if will_delete == True: + if os.path.isfile(self.right_clicked_node.data.path): + os.remove(self.right_clicked_node.data.path) + else: + shutil.rmtree(self.right_clicked_node.data.path) + self.reload() + + self.notify(f"Deleted \"{self.right_clicked_node.data.path}\".") + self.right_clicked_node = None + + self.app.push_screen(Prompt(f"Are you sure you want to delete \"{self.right_clicked_node.label}\"?", "confirm", "Confirm deletion"), delete_confirm) + elif result == "Rename": + def rename_confirm(new_name: str | None): + if new_name == None: return + if new_name.strip() == "": + self.notify("Filename can't be empty.", title="Failed to rename", severity="error") + return + + os.rename(self.right_clicked_node.data.path, os.path.join(os.path.dirname(self.right_clicked_node.data.path), new_name)) + self.reload() + + self.notify("Renamed successfully.") + + self.right_clicked_node = None + + self.app.push_screen(Prompt(f"Enter the new name for \"{self.right_clicked_node.label}\".", "string", "Rename"), rename_confirm) + elif result == "New folder": + def new_folder(folder_name: str | None): + if folder_name == None: return + if folder_name.strip() == "": + self.notify("Folder name can't be empty.", title="Failed to create folder", severity="error") + return + + new_folder_path = os.path.join(self.right_clicked_node.data.path, folder_name) + try: + os.mkdir(new_folder_path) + self.reload() + except Exception as e: + self.notify(str(e), title="Failed to create folder", severity="error") + return + + self.app.push_screen(Prompt("Enter the name of the new folder.", "string", "Create New Folder"), new_folder) + elif result == "New file": + def new_file(file_name: str | None): + if file_name == None: return + if file_name.strip() == "": + self.notify("File name can't be empty.", title="Failed to create file", severity="error") + return + + new_file_path = os.path.join(self.right_clicked_node.data.path, file_name) + + if os.path.isfile(new_file_path): + self.notify("That file already exists.", title="Failed to create file", severity="error") + return + + try: + with open(new_file_path, "w") as f: + pass + self.reload() + except Exception as e: + self.notify(str(e), title="Failed to create file", severity="error") + return + + self.app.push_screen(Prompt("Enter the name of the new file", "string", "Create New File"), new_file) + elif result == "Open with...": + file_extension = file_name.rsplit(".")[1] + default_editors = eval(self.app.config_handler.get("editor", "default_editors")) + default_editor = default_editors.get(file_extension, "Text Editor") + + def open_with(chosen_editor): + self.notify(str(chosen_editor)) + + self.app.push_screen(Prompt( + "Editor for file:", + "list", + "Choose editor for file", + values=[ + default_editor + " (Default)", + "Text Editor" + ], + allow_blank=False + ), open_with) + + def on_mouse_down(self, event: MouseDown): + if event.button != 3 or not "line" in event.style.meta: + return + selected_node = self.get_node_at_line(event.style.meta["line"]) + self.right_clicked_node = selected_node + + spacer = NoSelectStatic(f'[d]{"-" * 17}[/]') + + options = None + if self._safe_is_dir(self.right_clicked_node.data.path): + options = ["New folder", "New file", spacer, "Delete", "Rename"] + else: + options = ["Delete", "Rename", spacer, "Open with..."] + + file_name = str(self.right_clicked_node.label) if len(self.right_clicked_node.label) <= 17 else self.right_clicked_node.label[:14] + "..." + + self.app.push_screen(ContextMenu( + [NoSelectStatic(f"[b]{file_name}[/]"), NoSelectStatic(f'[d]{"-" * 17}[/]')] + options, + event.screen_offset + ), lambda result: self.context_menu_chosen(file_name, result)) + diff --git a/src/widgets/prompt.py b/src/widgets/prompt.py new file mode 100644 index 0000000..be841d9 --- /dev/null +++ b/src/widgets/prompt.py @@ -0,0 +1,85 @@ +from textual.screen import ModalScreen +from textual.containers import Vertical +from textual.widgets import Static, Button, Input, Select +from textual.containers import HorizontalGroup, Center +from textual.binding import Binding +from typing import Literal + + +class Prompt(ModalScreen): + DEFAULT_CSS = """ + Prompt { + align: center middle; + + #window { + max-width: 50; + height: auto; + border: panel $accent; + + #question { + width: 100%; + margin: 2; + margin-top: 1; + } + + #bottom { + dock: bottom; + margin-bottom: 1; + margin-left: 1; + + #yes { + margin-left: 1; + margin-right: 2; + } + + #confirm-string { + margin-left: 1; + } + + Input { + max-width: 25; + } + } + } + } + """ + + BINDINGS = [ + Binding("escape", "close", "Close") + ] + + def action_close(self): + self.dismiss(None) + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "yes": + self.dismiss(True) + elif event.button.id == "no": + self.dismiss(False) + elif event.button.id == "confirm-string": + self.dismiss(self.query_one("#input").value) + elif event.button.id == "confirm-list": + self.dismiss(self.query_one("#select").value) + + def __init__(self, question: str, prompt_type: Literal["confirm", "string"], title: str = "Confirm", **kwargs): + super().__init__() + self.question = question + self.window_title = title + self.prompt_type = prompt_type + self.kwargs = kwargs + + def compose(self): + with Vertical(id="window") as window: + window.border_title = self.window_title + yield Static(self.question, id="question") + + with HorizontalGroup(id="bottom"): + if self.prompt_type == "confirm": + yield Button("Yes", variant="success", id="yes") + yield Button("No", variant="error", id="no") + elif self.prompt_type == "string": + yield Input(id="input", **self.kwargs) + yield Button("Confirm", variant="success", id="confirm-string") + elif self.prompt_type == "list": + yield Select.from_values(**self.kwargs, id="select") + yield Button("Confirm", variant="success", id="confirm-list") \ No newline at end of file diff --git a/src/widgets/text_editor.py b/src/widgets/text_editor.py new file mode 100644 index 0000000..543a61c --- /dev/null +++ b/src/widgets/text_editor.py @@ -0,0 +1,6 @@ +from textual.widgets import TextArea + + +class TextEditor(TextArea): + def __init__(self, text = "", language = None, read_only = False): + super().__init__(text, language=language, placeholder="Type here . . .", read_only=read_only, theme="css") \ No newline at end of file