Changed folder structure to match the python package standards more and created setup.py for an easy installation.

This commit is contained in:
The Wobbler 2024-12-14 18:41:42 +01:00
parent b1b442b23a
commit b7a6ba567a
17 changed files with 48 additions and 12 deletions

3
bread_editor/__init__.py Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/python3
from bread_editor.main import start_from_command_line

View file

@ -0,0 +1,50 @@
#!/usr/bin/python3
from PyQt6.QtWidgets import QPlainTextEdit
from PyQt6.QtCore import Qt
class BinaryTextEdit(QPlainTextEdit): # rewrite QPlainTextEdit.keyPressEvent because it has no .setValidator()
def keyPressEvent(self, event):
allowed_keys = {"", "0", "1"}
if event.text() in allowed_keys:
cursor = self.textCursor()
position = cursor.position()
text = self.toPlainText()
text_length = len(text)
if not (position + 1) % 9 == 0 or event.text() in {"", None}: # dont overwrite the separator character
super().keyPressEvent(event)
# skip over the separator character when the cursor is right before it.
if (position + 2) % 9 == 0 and not event.text() in {"", None}:
if position == text_length - 1: # append to the input if the cursor is at the end
self.insertPlainText(" 00000000")
cursor.setPosition(position + 2)
self.setTextCursor(cursor)
elif event.key() == Qt.Key.Key_Backspace or event.key() == Qt.Key.Key_Delete:
# delete last byte when backspace or delete is pressed
text = self.toPlainText()
if len(text) >= 9:
cursor = self.textCursor()
position = cursor.position()
text = text[:-9] # delete last byte
self.setPlainText(text)
# calculate the new cursor position (by subtracting 9, we set the position to the same bit but one byte
# before and by floor dividing this by 9 we get the "byte index" and when we multiply this by 9, we get
# the character position of the first bit in that byte.)
position = (position - 9) // 9 * 9
cursor.setPosition(position)
self.setTextCursor(cursor)
else:
event.ignore()

View file

@ -0,0 +1,15 @@
#!/usr/bin/python3
def connect_gui(app):
app.gui.main_window.openFile.triggered.connect(app.file_actions.open_files)
app.gui.main_window.newFile.triggered.connect(lambda: app.file_actions.create_file())
app.gui.main_window.saveFile.triggered.connect(app.file_actions.save_current_file)
app.gui.main_window.saveFileAs.triggered.connect(app.file_actions.save_current_file_as)
app.gui.main_window.menuSettings.triggered.connect(app.gui.main_window.settingsDock.show)
app.gui.main_window.bitsAreSquaresSetting.stateChanged.connect(app.utils.update_style_in_all_bit_editors)
app.gui.main_window.highlightOnesSetting.stateChanged.connect(app.utils.update_style_in_all_bit_editors)
app.gui.main_window.openFileTabs.tabCloseRequested.connect(app.file_actions.close_current_file)
app.gui.QTMainWindow.closeEvent = app.utils.on_close

62
bread_editor/editor.py Normal file
View file

@ -0,0 +1,62 @@
#!/usr/bin/python3
from PyQt6.QtWidgets import QWidget, QVBoxLayout
from PyQt6.QtGui import QFont, QTextCharFormat, QColor
from bread_editor.binary_text_edit import BinaryTextEdit
from bread_editor.highlighting import Higlighter
class BitEditor:
font = QFont("Ubuntu Mono", 12)
one_format = QTextCharFormat()
one_format.setBackground(QColor(200, 150, 100))
def __init__(self, app, file):
self.app = app
self.file = file
self.not_saved = False
self.setup_gui()
def setup_gui(self):
# widget that contains the text input
self.widget = QWidget(self.app.gui.main_window.openFileTabs, objectName=self.file.path)
self.widget_layout = QVBoxLayout()
self.input = BinaryTextEdit()
self.input.setOverwriteMode(True)
self.widget_layout.addWidget(self.input)
self.input.setPlainText(self.app.utils.bstring_to_oz(self.file.content))
self.input.textChanged.connect(self.on_edit)
self.bit_highlighter = Higlighter()
self.bit_highlighter.add_mapping("1", self.one_format)
self.update_style()
self.widget.setLayout(self.widget_layout)
self.tab_index = self.app.gui.main_window.openFileTabs.addTab( # add a tab for the file in the top files list
self.widget,
self.file.name
)
def on_edit(self):
self.not_saved = True
def update_style(self):
square = self.app.settings.square_bits
spacing = 200 if square else 100 # add spacing when setting is checked
self.font.setLetterSpacing(QFont.SpacingType.PercentageSpacing, spacing)
self.input.setFont(self.font)
highlight_ones = self.app.settings.highlight_ones
highlighter_document = self.input.document() if highlight_ones else None
self.bit_highlighter.setDocument(highlighter_document)

1
bread_editor/example.txt Normal file
View file

@ -0,0 +1 @@
This is an example of how a text file looks like in binary!

122
bread_editor/file.py Normal file
View file

@ -0,0 +1,122 @@
#!/usr/bin/python3
import os.path
from PyQt6.QtWidgets import QFileDialog
from bread_editor.editor import BitEditor
MAX_FILE_SIZE = 262144 # 2^18
class FileActions:
def __init__(self, app):
self.app = app
def open_files(self):
dialog = QFileDialog(self.app.gui.QTMainWindow)
dialog.setWindowTitle("Open File")
dialog.setDirectory(self.app.utils.home_path)
dialog.setFileMode(QFileDialog.FileMode.ExistingFiles)
dialog.setNameFilters(["Binary (*.bin)", "Any (*)"])
dialog.setViewMode(QFileDialog.ViewMode.List)
if dialog.exec():
self.open_multiple_files(dialog.selectedFiles())
def create_file(self, content: bytes=(0).to_bytes(1)):
# open a dialog where the user can choose a new filepath and create an empty file at that path
# bytes=(0).to_bytes(1) creates a byte with only zeros in it
file_path, extension = QFileDialog.getSaveFileName(
caption="New File",
directory=self.app.utils.home_path,
filter="Binary (*.bin);;Any (*)",
)
if file_path == "":
return
if "Binary" in extension:
file_path = file_path.split(".")[0] + ".bin" # make sure it has the right extension
file = open(file_path, "bw") # create new empty file
file.write(content)
file.close()
self.app.open_files[file_path] = File(self.app, file_path, file_path.split("/")[-1]) # open file
def save_current_file(self):
current_tab = self.app.gui.main_window.openFileTabs.currentWidget()
current_file_path = current_tab.objectName()
self.app.open_files[current_file_path].save()
def save_current_file_as(self):
current_tab = self.app.gui.main_window.openFileTabs.currentWidget() # get currently open file
current_file_path = current_tab.objectName()
file = self.app.open_files[current_file_path]
oz_content = file.bit_editor.input.toPlainText() # convert user input to binary data
file_content = self.app.utils.oz_string_to_bstring(oz_content)
self.create_file(file_content)
def close_current_file(self):
current_file_path = self.app.gui.main_window.openFileTabs.currentWidget().objectName()
if self.app.open_files[current_file_path].bit_editor.not_saved:
save_or_not = self.app.utils.unsaved_changes_popup()
match save_or_not:
case "save":
self.app.open_files[current_file_path].save()
case "cancel":
return
self.app.open_files[current_file_path].close()
def save_all_files(self):
for file_path in self.app.open_files:
self.app.open_files[file_path].save()
def open_multiple_files(self, file_paths):
for file_path in file_paths:
if not file_path in self.app.open_files and os.path.isfile(file_path):
if os.path.getsize(file_path) > MAX_FILE_SIZE:
self.app.utils.ftb_popup.exec()
return
file = File(self.app, file_path, file_path.split("/")[-1])
self.app.open_files[file_path] = file
class File:
def __init__(self, app, path, name):
self.app = app
self.path = path
self.name = name
file = open(path, "rb")
file_content = file.read()
file.close()
self.content = file_content
self.bit_editor = BitEditor(self.app, self)
self.app.gui.main_window.openFileTabs.setCurrentIndex(self.app.gui.main_window.openFileTabs.count() - 1)
def close(self):
self.app.gui.main_window.openFileTabs.removeTab(self.bit_editor.tab_index)
del self.app.open_files[self.path]
def save(self):
oz_string = self.bit_editor.input.toPlainText()
data = self.app.utils.oz_string_to_bstring(oz_string)
file = open(self.path, "wb")
file.write(data)
file.close()
self.app.open_files[self.path].bit_editor.not_saved = False

View file

@ -0,0 +1 @@
#!/usr/bin/python3

View file

@ -0,0 +1 @@
#!/usr/bin/python3

View file

@ -0,0 +1,234 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>861</width>
<height>548</height>
</rect>
</property>
<property name="windowTitle">
<string>Bread Editor</string>
</property>
<property name="locale">
<locale language="English" country="Europe"/>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QTabWidget" name="openFileTabs">
<property name="enabled">
<bool>true</bool>
</property>
<property name="currentIndex">
<number>-1</number>
</property>
<property name="elideMode">
<enum>Qt::ElideNone</enum>
</property>
<property name="tabsClosable">
<bool>true</bool>
</property>
<property name="movable">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>861</width>
<height>20</height>
</rect>
</property>
<widget class="QMenu" name="fileMenu">
<property name="locale">
<locale language="English" country="Europe"/>
</property>
<property name="title">
<string>File</string>
</property>
<addaction name="newFile"/>
<addaction name="openFile"/>
<addaction name="separator"/>
<addaction name="saveFile"/>
<addaction name="saveFileAs"/>
</widget>
<widget class="QMenu" name="menuEdit">
<property name="locale">
<locale language="English" country="Europe"/>
</property>
<property name="title">
<string>Edit</string>
</property>
<addaction name="menuSettings"/>
</widget>
<addaction name="fileMenu"/>
<addaction name="menuEdit"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="settingsDock">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>124</height>
</size>
</property>
<property name="accessibleName">
<string/>
</property>
<property name="accessibleDescription">
<string/>
</property>
<property name="floating">
<bool>false</bool>
</property>
<property name="features">
<set>QDockWidget::DockWidgetClosable|QDockWidget::DockWidgetFloatable|QDockWidget::DockWidgetMovable</set>
</property>
<property name="windowTitle">
<string> Settings</string>
</property>
<attribute name="dockWidgetArea">
<number>1</number>
</attribute>
<widget class="QWidget" name="settingsDockContents">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="settingsTabs">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="appearanceSettingsTab">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="accessibleName">
<string/>
</property>
<property name="autoFillBackground">
<bool>false</bool>
</property>
<attribute name="title">
<string>Appearance</string>
</attribute>
<widget class="QCheckBox" name="highlightOnesSetting">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>111</width>
<height>21</height>
</rect>
</property>
<property name="text">
<string>Highlight ones</string>
</property>
</widget>
<widget class="QCheckBox" name="bitsAreSquaresSetting">
<property name="geometry">
<rect>
<x>10</x>
<y>30</y>
<width>119</width>
<height>21</height>
</rect>
</property>
<property name="text">
<string>Bits are squares</string>
</property>
<property name="tristate">
<bool>false</bool>
</property>
</widget>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="openFile">
<property name="checkable">
<bool>false</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
<property name="text">
<string>Open</string>
</property>
<property name="shortcut">
<string>Ctrl+O</string>
</property>
<property name="visible">
<bool>true</bool>
</property>
</action>
<action name="saveFile">
<property name="text">
<string>Save</string>
</property>
<property name="shortcut">
<string>Ctrl+S</string>
</property>
</action>
<action name="menuSettings">
<property name="text">
<string>Settings</string>
</property>
<property name="visible">
<bool>true</bool>
</property>
</action>
<action name="actionNew">
<property name="text">
<string>New</string>
</property>
</action>
<action name="actionOpen">
<property name="text">
<string>Open</string>
</property>
</action>
<action name="newFile">
<property name="text">
<string>New</string>
</property>
<property name="shortcut">
<string>Ctrl+N</string>
</property>
</action>
<action name="saveFileAs">
<property name="text">
<string>Save As</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -0,0 +1,23 @@
#!/usr/bin/python3
import os
working_dir = os.getcwd()
if not working_dir.split('/')[-1] == "raw_ui": # cd into the right directory if this gets executed from somewhere else
os.chdir(os.path.dirname(os.path.abspath(__file__)))
input_debug = input("Do you want to debug the gui scripts? (Make gui scripts executable.) (y/n): ")
debug = input_debug == "y"
params = "-o"
if debug:
params = "-xo"
paths = {
"main_window.ui": "main_window.py"
}
for ui_file, script_out in paths.items():
os.system(f"pyuic6 {ui_file} {params} ../{script_out}")

View file

@ -0,0 +1,20 @@
#!/usr/bin/python3
import re
from PyQt6.QtGui import QSyntaxHighlighter
class Higlighter(QSyntaxHighlighter):
def __init__(self, parent=None):
QSyntaxHighlighter.__init__(self, parent)
self.mappings = {}
def add_mapping(self, pattern, format):
self.mappings[pattern] = format
def highlightBlock(self, text):
for pattern, format in self.mappings.items():
for match in re.finditer(pattern, text):
start, end = match.span()
self.setFormat(start, end - start, format)

82
bread_editor/ipc.py Normal file
View file

@ -0,0 +1,82 @@
#!/usr/bin/python3
import sys
import socket
import json
import threading
HOST = "127.0.0.1"
PORT = 58592
class IPC:
def __init__(self, app):
self.app = app
self.first_instance = not self.already_running()
self.server_thread = threading.Thread(target=self.server)
self.server_thread.daemon = True
if self.first_instance:
self.server_thread.start()
else:
self.send_open_file_message(sys.argv[1:])
def already_running(self):
try:
with socket.create_connection((HOST, PORT), timeout=1):
return True
except (ConnectionRefusedError, OSError):
return False
def server(self):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
server_socket.bind((HOST, PORT))
server_socket.listen(1)
while True:
conn, addr = server_socket.accept()
with conn:
data = conn.recv(1024)
if data:
message = data.decode('utf-8')
self.evaluate_message(message)
def send(self, message):
try:
with socket.create_connection((HOST, PORT)) as client_socket:
client_socket.sendall(message.encode('utf-8'))
except Exception as error:
print("IPC-error:", error)
def evaluate_message(self, raw_message):
data_dict = json.loads(raw_message)
message = Message(data_dict["type"], data_dict["content"])
match message.type:
case "open_files":
self.app.open_files_queue += message.content
def send_open_file_message(self, filenames):
message = Message("open_files", filenames)
self.send(message.get_message_data())
class Message:
def __init__(self, message_type: str, content):
self.type = message_type
self.content = content
def get_message_data(self):
data_dict = self.__dict__
data_dict = dict(filter(lambda pair: not callable(pair[1]), data_dict.items())) # filter out functions
data = json.dumps(data_dict)
return data

56
bread_editor/main.py Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/python3
import sys
from PyQt6.QtCore import QTimer
from wobbl_tools.data_file import load_dataclass_json
from bread_editor.utils import Utils
from bread_editor.file import File, FileActions
from bread_editor.ui import GUI
from bread_editor.settings import Settings
from bread_editor.ipc import IPC
class BreadEditor:
def __init__(self):
self.utils = Utils(self)
self.settings = load_dataclass_json(Settings, f"{self.utils.editor_path}/settings.json")
self.file_actions = FileActions(self)
self.ipc = IPC(self)
if not self.ipc.first_instance:
return
self.gui = GUI(self)
self.gui.connect_gui(self)
self.open_files: dict[str, File] = {}
self.open_files_queue = []
self.files_queue_timer = QTimer(self.gui.QTMainWindow)
self.files_queue_timer.timeout.connect(self.utils.check_file_queue)
self.files_queue_timer.start(500)
def run(self):
if not self.ipc.first_instance:
return
self.gui.post_setup()
self.utils.popup_init()
self.utils.on_start()
self.gui.QTMainWindow.show()
sys.exit(self.gui.qt_app.exec())
def start_from_command_line():
editor = BreadEditor()
editor.run()
if __name__ == "__main__":
editor = BreadEditor()
editor.run()

12
bread_editor/settings.py Normal file
View file

@ -0,0 +1,12 @@
#!/usr/bin/python3
from dataclasses import dataclass, field
from typing import List
from bread_editor.utils import Utils
@dataclass
class Settings:
last_opened_files: List=field(default_factory=lambda: [f"{Utils.editor_path}/example.txt"])
highlight_ones: bool=False
square_bits: bool=False

32
bread_editor/ui.py Normal file
View file

@ -0,0 +1,32 @@
#!/usr/bin/python3
from PyQt6.QtWidgets import QApplication, QMainWindow
from bread_editor.gui.main_window import Ui_MainWindow
from bread_editor.connect_gui import connect_gui
class GUI:
def __init__(self, app):
self.app = app
self.qt_app = QApplication([])
self.QTMainWindow = QMainWindow()
self.main_window = Ui_MainWindow()
self.setup_gui()
self.connect_gui = connect_gui
def setup_gui(self):
self.main_window.setupUi(self.QTMainWindow)
self.main_window.settingsDock.hide()
def post_setup(self):
self.main_window.bitsAreSquaresSetting.blockSignals(True)
self.main_window.bitsAreSquaresSetting.setChecked(self.app.settings.square_bits)
self.main_window.bitsAreSquaresSetting.blockSignals(False)
self.main_window.highlightOnesSetting.setChecked(self.app.settings.highlight_ones)
self.app.utils.update_style_in_all_bit_editors()

134
bread_editor/utils.py Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/python3
import os
import sys
from pathlib import Path
from PyQt6.QtWidgets import QMessageBox
class Utils:
home_path = str(Path.home())
editor_path = os.path.dirname(os.path.abspath(__file__))
def __init__(self, app):
self.app = app
def popup_init(self):
self.usc_popup = QMessageBox() # create a popup window that notifies the user that we have unsaved changes
self.usc_popup.setWindowTitle("Unsaved Changes!") # usc means unsaved changes
self.usc_popup.setText("The file you are trying to close has unsaved changes.")
self.usc_popup.setStandardButtons(
QMessageBox.StandardButton.Save |
QMessageBox.StandardButton.Discard |
QMessageBox.StandardButton.Cancel
)
self.ftb_popup = QMessageBox() # dialog that says that the file is too big
self.ftb_popup.setWindowTitle("File Too Big!")
self.ftb_popup.setText("The file you are trying to open is too big!")
self.ftb_popup.setDetailedText(
"I am way too lazy to make the editor capable of handling big files,\n"
"but you could improve the editor, if this annoys you.\n"
"https://teapot.informationsanarchistik.de/Wobbl/Bread_Editor"
)
def unsaved_changes_popup(self): # show popup and simplify return values
button = self.usc_popup.exec()
match button:
case QMessageBox.StandardButton.Save:
return "save"
case QMessageBox.StandardButton.Discard:
return "discard"
case QMessageBox.StandardButton.Cancel:
return "cancel"
def bstring_to_oz(self, data): # convert binary data to a string of ones and zeros (oz)
oz_bytes = []
for byte in data:
oz_bytes.append(format(byte, "08b"))
oz_string = " ".join(oz_bytes)
return oz_string
def oz_string_to_bstring(self, oz_string): # convert a string of zeroes and ones to a binary string
oz_bytes = oz_string.split()
bytes_int = []
for byte in oz_bytes:
bytes_int.append(int(byte, 2))
binary_string = bytes(bytes_int)
return binary_string
def on_close(self, event):
changes = False
for file_path in self.app.open_files: # check for files that have unsaved changes
file = self.app.open_files[file_path]
if file.bit_editor.not_saved:
changes = True
break
if changes: # show a popup that informs the user that there are unsaved changes and ask them what to do
save_or_not = self.unsaved_changes_popup()
match save_or_not:
case "save":
self.app.file_actions.save_all_files()
self.close()
case "cancel":
event.ignore()
case "discard":
self.close()
else:
self.close()
def close(self):
# get paths of open files and save them to the settings to reopen them automatically on next start
file_keys = self.app.open_files.keys()
open_files = []
for file_path in file_keys: # convert dict keys to strings
open_files.append(str(file_path))
self.app.settings.last_opened_files = open_files
self.app.settings.save(f"{self.editor_path}/settings.json")
print("Bye!")
def update_style_in_all_bit_editors(self):
# update the highlighting and character spacing when a setting has changed
self.app.settings.highlight_ones = self.app.gui.main_window.highlightOnesSetting.isChecked()
self.app.settings.square_bits = self.app.gui.main_window.bitsAreSquaresSetting.isChecked()
for file_path in self.app.open_files:
editor = self.app.open_files[file_path].bit_editor
editor.update_style()
def on_start(self):
# either open the lastly opened files or open the files specified by the parameters
if len(sys.argv) == 1: # if no parameters were passed to the editor
file_paths = self.app.settings.last_opened_files
else:
file_paths = sys.argv[1:]
self.app.file_actions.open_multiple_files(file_paths)
def check_file_queue(self):
if not self.app.open_files_queue == []: # check for file paths in the queue that the ipc server put there
filenames = self.app.open_files_queue
self.app.open_files_queue = []
self.app.file_actions.open_multiple_files(filenames)