initial setup

This commit is contained in:
2026-07-23 14:53:57 +10:00
parent 9461e7cc7f
commit 074791f430
5 changed files with 354 additions and 0 deletions

119
src/widgets/context_menu.py Normal file
View File

@@ -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

View File

@@ -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))

85
src/widgets/prompt.py Normal file
View File

@@ -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")

View File

@@ -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")