2025-10-29 21:06:34 +11:00
from textual . app import App , ComposeResult , SystemCommand
2025-10-29 12:40:28 +11:00
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
2026-05-16 21:51:10 +10:00
from textual_fspicker import FileOpen , FileSave , SelectDirectory
2025-10-29 12:40:28 +11:00
from pathlib import Path
2026-05-16 21:18:21 +10:00
from home_page import HomePage
2025-10-29 12:40:28 +11:00
from assets . theme_mappings import theme_mappings
from plugin_loader import PluginLoader
2025-10-29 13:11:51 +11:00
from settings import SettingsScreen
2025-10-30 07:20:01 +11:00
from settings_store import ConfigHandler
2025-10-30 20:21:41 +11:00
from directory_tree_custom import CustomDirectoryTree
2025-10-29 12:40:28 +11:00
from watchdog . observers import Observer
from watchdog . events import FileSystemEventHandler
2025-10-31 07:39:40 +11:00
import os , sys
2025-10-29 12:40:28 +11:00
class Watcher ( FileSystemEventHandler ) :
2026-05-16 17:50:51 +10:00
def __init__ ( self , app : App ) :
super ( ) . __init__ ( )
self . app = app
2025-10-29 12:40:28 +11:00
2026-05-16 17:50:51 +10:00
async def on_any_event ( self , event ) :
await self . app . query_one ( DirectoryTree ) . reload ( )
2025-10-29 12:40:28 +11:00
class Berry ( App ) :
2026-05-16 17:50:51 +10:00
CSS_PATH = " assets/style.tcss "
SUB_TITLE = " New File "
2026-05-16 21:18:21 +10:00
2026-05-16 17:50:51 +10:00
def __init__ ( self , path : str ) :
2026-05-16 21:18:21 +10:00
2026-05-16 17:50:51 +10:00
super ( ) . __init__ ( )
2026-05-16 21:51:10 +10:00
self . config_handler = ConfigHandler ( self )
self . bindings = eval ( self . config_handler . get ( " editor " , " bindings " ) )
2026-05-16 17:50:51 +10:00
self . path = path
2026-05-16 21:18:21 +10:00
self . BINDINGS = (
Binding ( self . bindings [ " new " ] , " new " , " New File " ) ,
Binding ( self . bindings [ " open " ] , " open " , " Open File " ) ,
2026-05-16 21:51:10 +10:00
Binding ( self . bindings [ " open-folder " ] , " open_folder " , " Open Folder " ) ,
2026-05-16 21:18:21 +10:00
Binding ( self . bindings [ " save " ] , " save " , " Save File " ) ,
Binding ( self . bindings [ " save-as " ] , " save_as " , " Save File As... " ) ,
2026-05-16 21:31:57 +10:00
Binding ( self . bindings [ " find " ] , " find " , " Find and Replace " , priority = True ) , # this is priority because otherwise TextArea overrides it
2026-05-16 21:18:21 +10:00
Binding ( self . bindings [ " settings " ] , " settings " , " Settings " ) ,
)
for binding in self . BINDINGS :
self . _bindings . _add_binding ( binding )
self . refresh_bindings ( )
self . current_editor = None
self . file_tabs = { }
2026-05-16 17:50:51 +10:00
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 " ) :
2026-05-16 21:18:21 +10:00
first_tab = Tab ( " Home " )
first_tab . file_path = " berry://home "
self . file_tabs [ " berry://home " ] = first_tab
2026-05-16 17:50:51 +10:00
yield Tabs (
first_tab ,
id = " file-tabs "
)
2026-05-16 21:18:21 +10:00
with Vertical ( ) as editor :
yield HomePage ( )
self . current_editor = editor
2026-05-16 17:50:51 +10:00
#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 )
2026-05-16 21:51:10 +10:00
async def choose_folder_to_open ( self , result ) :
if result == None : return
self . path = result
tabs : Tabs = self . query_one ( " #file-tabs " )
tabs . clear ( )
first_tab = Tab ( " Home " )
first_tab . file_path = " berry://home "
self . file_tabs [ " berry://home " ] = first_tab
tabs . add_tab ( first_tab )
self . current_editor . remove_children ( )
self . current_editor . mount ( HomePage ( ) )
file_tree : CustomDirectoryTree = self . query_one ( " #directory " )
file_tree . path = result
def action_open_folder ( self ) :
self . push_screen ( SelectDirectory ( ) , self . choose_folder_to_open )
2026-05-16 17:50:51 +10:00
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 " ) ,
) ,
) ,
2026-05-16 21:31:57 +10:00
icon = " " ,
2026-05-16 17:50:51 +10:00
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
def open_text_editor ( self , file_content : bytes , file_extension : str ) :
2026-05-16 21:18:21 +10:00
code_editor = TextArea . code_editor ( placeholder = " This file is empty. " , theme = " css " , disabled = True , soft_wrap = bool ( int ( self . config_handler . get ( " editor " , " word_wrap " ) ) ) , show_line_numbers = bool ( int ( self . config_handler . get ( " editor " , " line_numbers " ) ) ) )
self . current_editor . mount ( code_editor )
2026-05-16 17:50:51 +10:00
code_editor . language = theme_mappings . get ( file_extension , None )
try :
code_editor . text = file_content . decode ( " utf-8 " )
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
code_editor . focus ( )
2026-05-16 21:18:21 +10:00
def open_editor ( self , file_extension : str , file_content : bytes ) :
2026-05-16 17:50:51 +10:00
default_editors : dict = eval ( self . config_handler . get ( " editor " , " default_editors " ) )
editor_for_file = default_editors . get ( file_extension , None )
2026-05-16 21:18:21 +10:00
self . current_editor . remove_children ( )
2026-05-16 17:50:51 +10:00
if not editor_for_file :
self . open_text_editor ( file_content , file_extension )
@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 " )
2026-05-16 21:18:21 +10:00
if self . open_file and self . open_file . startswith ( r " berry:// " ) :
return
2026-05-16 17:50:51 +10:00
self . switching = True
2026-05-16 21:18:21 +10:00
2026-05-16 17:50:51 +10:00
content = b " "
file_extension = " "
if self . open_file :
try :
f = open ( self . open_file , " rb " )
except Exception as e :
self . notify ( f " Failed to open the file: { e } " )
return
# if the file is unsaved, use the content we wrote into it
# otherwise, read the CURRENT state of the file
if self . open_file in self . unsaved_files :
content = self . unsaved_files [ self . open_file ] [ " current " ]
else :
content = f . read ( )
# get the file extension
file_extension = self . open_file . split ( " . " ) [ - 1 ] if " . " in self . open_file else " "
# close the file
f . close ( )
self . open_editor ( file_extension , content )
@on ( Window . Minimized )
def window_minimized ( self , event : Window . Minimized ) :
event . window . remove_window ( )
def on_text_area_changed ( self , event : TextArea . Changed ) :
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
2026-05-16 21:18:21 +10:00
# open empty editor
self . open_editor ( " " , b " " )
2026-05-16 17:50:51 +10:00
def done_saving ( self , result ) :
if result is None : return
2026-05-16 21:31:57 +10:00
#with open(result, "w", encoding="utf-8") as f:
# f.write(self.query_one("#code-editor").text)
2026-05-16 17:50:51 +10:00
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 ) :
2026-05-16 21:31:57 +10:00
if self . open_file == None :
2026-05-16 17:50:51 +10:00
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 ) :
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 ( )
2025-10-30 07:20:01 +11:00
2025-10-31 07:39:40 +11:00
2025-10-29 12:40:28 +11:00
if __name__ == " __main__ " :
2025-10-31 07:39:40 +11:00
2026-05-16 17:50:51 +10:00
working_path = os . getcwd ( ) if len ( sys . argv ) == 1 else sys . argv [ 1 ]
app = Berry ( working_path )
app . run ( )