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
from textual_fspicker import FileOpen , FileSave
from pathlib import Path
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
import os
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 ) ,
2025-10-29 21:06:34 +11:00
Binding ( " ctrl+f " , " find " , " Find " , priority = True ) ,
Binding ( " ctrl+f1 " , " settings " , " Settings " )
2025-10-29 12:40:28 +11:00
]
def __init__ ( self , path : str ) :
super ( ) . __init__ ( )
self . path = path
2025-10-30 07:20:01 +11:00
self . config_handler = ConfigHandler ( self )
2025-10-29 12:40:28 +11:00
def compose ( self ) - > ComposeResult :
yield Header ( )
with Vertical ( id = " sidebar " ) :
with HorizontalGroup ( id = " sidebar-buttons " ) :
yield Button ( " 📂 " )
yield Button ( " 🔍 " )
2025-10-29 21:06:34 +11:00
with ContentSwitcher ( initial = " files " , id = " sidebar-switcher " ) :
2025-10-29 12:40:28 +11:00
with Vertical ( id = " files " ) :
yield Static ( " EXPLORER " )
2025-10-30 20:21:41 +11:00
yield CustomDirectoryTree ( self . path , id = " directory " )
2025-10-29 12:40:28 +11:00
with Vertical ( id = " editor " ) :
first_tab = Tab ( " New File " )
first_tab . file_path = None
yield Tabs (
first_tab ,
id = " file-tabs "
)
2025-10-31 07:34:52 +11:00
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 " ) ) ) )
2025-10-29 12:40:28 +11:00
#if os.name == "nt":
2025-10-30 18:24:43 +11:00
#with Vertical(id="console-container"):
# yield RichLog(id="console")
# yield Input(placeholder="> ", id="console-input")
2025-10-29 12:40:28 +11:00
#else:
# yield Terminal(command="bash", id="terminal")
yield Footer ( )
2025-10-30 07:53:19 +11:00
if bool ( int ( self . config_handler . get ( " plugins " , " enabled " ) ) ) == True :
yield PluginLoader ( )
2025-10-29 12:40:28 +11:00
2025-10-29 21:06:34 +11:00
def action_settings ( self ) :
self . push_screen ( SettingsScreen ( ) )
def get_system_commands ( self , screen ) :
2025-10-30 07:20:01 +11:00
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 ,
)
2025-10-29 21:06:34 +11:00
yield SystemCommand ( " Settings " , " Open the settings menu " , self . action_settings )
2025-10-29 12:40:28 +11:00
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 :
2025-10-30 18:29:18 +11:00
code_editor . text = " This file is in binary, it can ' t be opened. Sorry. "
2025-10-29 12:40:28 +11:00
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
2025-10-29 21:06:34 +11:00
with open ( result , " w " , encoding = " utf-8 " ) as f :
f . write ( self . query_one ( " #code-editor " ) . text )
2025-10-29 12:40:28 +11: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 ) :
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 ( )
2025-10-30 07:20:01 +11:00
self . config_handler . apply_settings ( )
2025-10-29 12:40:28 +11:00
if __name__ == " __main__ " :
app = Berry ( " ./ " )
2025-10-26 13:07:44 +11:00
app . run ( )