from textual.app import App, ComposeResult from textual.widgets import Header, Footer, ContentSwitcher, DirectoryTree, Static, Button, TextArea, Tabs, Tab, RichLog, Input from textual.containers import HorizontalGroup, Vertical from textual.binding import Binding from textual_window import Window from textual import on from assets.theme_mappings import theme_mappings from textual_fspicker import FileOpen, FileSave import subprocess import os class Berry(App): CSS_PATH = "assets/style.tcss" SUB_TITLE = "New File" BINDINGS = [ Binding("ctrl+o", "open", "Open File"), Binding("ctrl+n", "new", "New File"), Binding("ctrl+s", "save", "Save"), Binding("ctrl+shift+s", "save_as", "Save As...", priority=True), Binding("ctrl+f", "find", "Find", priority=True) ] def compose(self) -> ComposeResult: yield Header() with Vertical(id="sidebar"): with HorizontalGroup(id="sidebar-buttons"): yield Button("📂") yield Button("🔍") with ContentSwitcher(initial="files"): with Vertical(id="files"): yield Static("EXPLORER") yield DirectoryTree("./", id="directory") with Vertical(id="editor"): first_tab = Tab("New File") first_tab.file_path = None yield Tabs( first_tab, id="file-tabs" ) yield TextArea.code_editor(placeholder="This file is empty.", language="python", theme="css", id="code-editor", disabled=True) with Vertical(id="console-container"): yield RichLog(id="console") yield Input(placeholder="> ", id="console-input") yield Footer() def on_input_submitted(self, event: Input.Submitted): if event.input.id != "console-input": return self.run_command(event.input.value) event.input.clear() def run_command(self, command: str): console = self.query_one("#console") console.write(f"> {command}") result = subprocess.check_output(command, shell=True, text=True) console.write(result) def action_find(self): try: self.query_one("#find-window") return except: find_window = Window( Vertical( HorizontalGroup( Input(placeholder="Find"), Static("0 of 0", id="num-matches"), Button("↑", flat=True), Button("↓", flat=True), ), HorizontalGroup( Input(placeholder="Replace"), ), ), icon="🔍", start_open=True, allow_resize=False, allow_maximize=False, id="find-window", mode="temporary", name="Find & Replace" ) self.mount(find_window) async def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected): if self.open_file == str(event.path): return self.file_clicked = True self.switching = True tabs: Tabs = self.query_one("#file-tabs") if self.open_file not in self.unsaved_files: if self.open_file: self.file_tabs.pop(self.open_file) tabs.remove_tab(tabs.active_tab) self.open_file = str(event.path) self.sub_title = os.path.basename(self.open_file) if str(event.path) not in self.file_tabs: new_tab = Tab(os.path.basename(str(event.path))) new_tab.tooltip = str(new_tab.label) setattr(new_tab, "file_path", str(event.path)) await tabs.add_tab(new_tab) self.file_tabs[str(event.path)] = new_tab tabs.active = new_tab.id else: tabs.active = self.file_tabs[str(event.path)].id @on(Tabs.TabActivated) def on_tab_shown(self, event: Tabs.TabActivated): if self.file_clicked: self.file_clicked = False else: self.open_file = getattr(event.tab, "file_path") self.switching = True code_editor: TextArea = self.query_one("#code-editor") if self.open_file: try: f = open(self.open_file, "r", encoding="utf-8") except Exception as e: self.notify(f"Failed to open the file: {e}") return try: if self.open_file in self.unsaved_files: code_editor.text = self.unsaved_files[self.open_file]["current"] else: code_editor.text = f.read() file_extension = self.open_file dot_count = file_extension.count(".") if dot_count == 1: if file_extension.startswith("."): file_extension = file_extension.removeprefix(".") else: file_extension = file_extension.rsplit(".", 1)[1] elif dot_count > 1: file_extension = file_extension.rsplit(".", 1)[1] code_editor.language = theme_mappings.get(file_extension, None) code_editor.disabled = False except UnicodeDecodeError: code_editor.text = "This file is in binary, it can't be openned. Sorry." code_editor.language = None code_editor.disabled = True f.close() else: code_editor.text = "" code_editor.language = None code_editor.disabled = False @on(Window.Minimized) def window_minimized(self, event: Window.Minimized): event.window.remove_window() def on_text_area_changed(self, event: TextArea.Changed): if event.text_area.id != "code-editor": return if self.switching: self.switching = False return tabs: Tabs = self.query_one("#file-tabs") if self.open_file: if not self.open_file in self.unsaved_files: with open(self.open_file, "r", encoding="utf-8") as f: if f.read() == event.text_area.text: # TODO: figure out why im guetting what seems like a race conidition which is making this if statement needed return self.unsaved_files[self.open_file] = {"current": event.text_area.text, "original": f.read()} tabs.active_tab.tooltip = f"Unsaved changes in {tabs.active_tab.label}" tabs.active_tab.label = "[d orange]●[/] " + str(tabs.active_tab.label) else: self.unsaved_files[self.open_file]["current"] = event.text_area.text if self.unsaved_files[self.open_file]["original"] == self.unsaved_files[self.open_file]["current"]: tabs.active_tab.label = os.path.basename(self.open_file) tabs.active_tab.tooltip = str(tabs.active_tab.label) self.unsaved_files.pop(self.open_file) def action_new(self): tabs: Tabs = self.query_one("#file-tabs") new_tab = Tab("New File") setattr(new_tab, "file_path", None) tabs.add_tab(new_tab) tabs.active = new_tab.id self.open_file = None self.switching = True code_editor: TextArea = self.query_one("#code-editor") code_editor.disabled = False code_editor.text = "" def done_saving(self, result): if result is None: return with open(result, "wb") as f: f.write(self.query_one("#code-editor").text.encode()) self.notify(f"Saved to {result} successfully.", title="Done!", markup=False) def action_save_as(self): self.push_screen(FileSave(), callback=self.done_saving) def action_save(self): if self.open_file == None and self.query_one("#code-editor").disabled == False: self.action_save_as() # dont bother saving if there are no new changes if not self.open_file in self.unsaved_files: return with open(self.open_file, "w") as f: f.write(self.unsaved_files[self.open_file]["current"]) tabs: Tabs = self.query_one("#file-tabs") tabs.active_tab.label = os.path.basename(self.open_file) tabs.active_tab.tooltip = str(tabs.active_tab.label) self.unsaved_files.pop(self.open_file) self.notify("Saved.") def on_ready(self): # src/main.py: Tab<> self.file_tabs = {} self.open_file = None self.unsaved_files = {} # list of paths self.switching = False self.file_clicked = False self.query_one("#console").write("Run a command below.") if __name__ == "__main__": app = Berry() app.run()