361 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			361 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| from textual.app import App, ComposeResult, SystemCommand
 | |
| 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 textual_fspicker import FileOpen, FileSave
 | |
| 
 | |
| from pathlib import Path
 | |
| 
 | |
| from assets.theme_mappings import theme_mappings
 | |
| from plugin_loader import PluginLoader
 | |
| from settings import SettingsScreen
 | |
| from settings_store import ConfigHandler
 | |
| from directory_tree_custom import CustomDirectoryTree
 | |
| 
 | |
| from watchdog.observers import Observer
 | |
| from watchdog.events import FileSystemEventHandler
 | |
| 
 | |
| import os, sys
 | |
| 
 | |
| 
 | |
| class Watcher(FileSystemEventHandler):
 | |
| 	def __init__(self, app: App):
 | |
| 		super().__init__()
 | |
| 		self.app = app
 | |
| 
 | |
| 	async def on_any_event(self, event):
 | |
| 		await self.app.query_one(DirectoryTree).reload()
 | |
| 
 | |
| 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),
 | |
| 		Binding("ctrl+f1", "settings", "Settings")
 | |
| 	]
 | |
| 
 | |
| 	def __init__(self, path: str):
 | |
| 		super().__init__()
 | |
| 		self.path = path
 | |
| 		self.config_handler = ConfigHandler(self)
 | |
| 
 | |
| 	def compose(self) -> ComposeResult:
 | |
| 		yield Header()
 | |
| 		with Vertical(id="sidebar"):
 | |
| 			with HorizontalGroup(id="sidebar-buttons"):
 | |
| 				yield Button("📂")
 | |
| 				yield Button("🔍")
 | |
| 			
 | |
| 			with ContentSwitcher(initial="files", id="sidebar-switcher"):
 | |
| 				with Vertical(id="files"):
 | |
| 					yield Static("EXPLORER")
 | |
| 					yield CustomDirectoryTree(self.path, 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.", theme="css", id="code-editor", disabled=True, soft_wrap=True, show_line_numbers=bool(int(self.config_handler.get("editor", "line_numbers"))))
 | |
| 
 | |
| 			#if os.name == "nt":
 | |
| 			#with Vertical(id="console-container"):
 | |
| 			#		yield RichLog(id="console")
 | |
| 			#		yield Input(placeholder="> ", id="console-input")
 | |
| 			#else:
 | |
| 			#	yield Terminal(command="bash", id="terminal")
 | |
| 
 | |
| 		yield Footer()
 | |
| 		if bool(int(self.config_handler.get("plugins", "enabled"))) == True:
 | |
| 			yield PluginLoader()
 | |
| 
 | |
| 	def action_settings(self):
 | |
| 		self.push_screen(SettingsScreen())
 | |
| 
 | |
| 	def get_system_commands(self, screen):
 | |
| 		yield SystemCommand(
 | |
| 			"Quit the application",
 | |
| 			"Quit the application as soon as possible",
 | |
| 			self.action_quit,
 | |
| 		)
 | |
| 
 | |
| 		if screen.query("HelpPanel"):
 | |
| 			yield SystemCommand(
 | |
| 				"Hide keys and help panel",
 | |
| 				"Hide the keys and widget help panel",
 | |
| 				self.action_hide_help_panel,
 | |
| 			)
 | |
| 		else:
 | |
| 			yield SystemCommand(
 | |
| 				"Show keys and help panel",
 | |
| 				"Show help for the focused widget and a summary of available keys",
 | |
| 				self.action_show_help_panel,
 | |
| 			)
 | |
| 			
 | |
| 		yield SystemCommand("Settings", "Open the settings menu", self.action_settings)
 | |
| 
 | |
| 	async def chose_file_to_open(self, result):
 | |
| 		if result == None: return
 | |
| 		result = str(result)
 | |
| 		if self.open_file == result:
 | |
| 			return
 | |
| 		
 | |
| 		def is_within_directory(file_path: str, directory: str) -> bool:
 | |
| 			file_path = Path(file_path).resolve()
 | |
| 			directory = Path(directory).resolve()
 | |
| 			return directory in file_path.parents
 | |
| 		
 | |
| 		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 = result
 | |
| 		inside_dir = is_within_directory(result, self.path)
 | |
| 
 | |
| 		self.sub_title = os.path.basename(self.open_file) if inside_dir else self.open_file
 | |
| 
 | |
| 		if result not in self.file_tabs:
 | |
| 			new_tab = Tab(os.path.basename(result) if inside_dir else result)
 | |
| 			new_tab.tooltip = str(new_tab.label)
 | |
| 			setattr(new_tab, "file_path", result)
 | |
| 			await tabs.add_tab(new_tab)
 | |
| 			self.file_tabs[result] = new_tab
 | |
| 			tabs.active = new_tab.id
 | |
| 		else:
 | |
| 			tabs.active = self.file_tabs[result].id
 | |
| 
 | |
| 	def action_open(self):
 | |
| 		self.app.push_screen(FileOpen(), self.chose_file_to_open)
 | |
| 
 | |
| 	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 opened. Sorry."
 | |
| 				code_editor.language = None
 | |
| 				code_editor.disabled = True
 | |
| 
 | |
| 			f.close()
 | |
| 		else:
 | |
| 			code_editor.text = ""
 | |
| 			code_editor.language = None
 | |
| 			code_editor.disabled = False
 | |
| 
 | |
| 		code_editor.focus()
 | |
| 
 | |
| 
 | |
| 	@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, "w", encoding="utf-8") as f:
 | |
| 			f.write(self.query_one("#code-editor").text)
 | |
| 
 | |
| 		tabs: Tabs = self.query_one("#file-tabs")
 | |
| 		tabs.active_tab.label = os.path.basename(result)
 | |
| 		self.notify(f"Saved to {result} successfully.", title="Done!", markup=False)
 | |
| 		self.query_one(DirectoryTree).reload()
 | |
| 
 | |
| 	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 action_quit(self):
 | |
| 		self.observer.stop()
 | |
| 		self.observer.join()
 | |
| 		return super().action_quit()
 | |
| 
 | |
| 	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.observer = Observer()
 | |
| 		self.observer.schedule(Watcher(self), path=self.path)
 | |
| 		self.observer.start()
 | |
| 
 | |
| 		self.config_handler.apply_settings()
 | |
| 
 | |
| 
 | |
| if __name__ == "__main__":
 | |
| 
 | |
| 
 | |
| 	working_path = os.getcwd() if len(sys.argv) == 1 else sys.argv[1]
 | |
| 	app = Berry(working_path)
 | |
| 	app.run() | 
