Brown Note is a desktop notes application written in Python, using PyQt.
Notes are implemented as decoration-less windows, which can be dragged around the desktop and edited at will. Details in the notes, and their position on the desktop, is stored in a SQLite file database, via SQLAlchemy, with note details and positions being restored on each session.
Introduction to the data model
Data model
The storage of user notes in the app is handled by a SQLite file database via SQLAlchemy, using the declarative_base
interface. Each note stores its identifier (id
, primary key), the text content with a maximum length of 1000 chars, and the x
and y
positions on the screen.
Base = declarative_base()
class Note(Base):
__tablename__ = 'note'
id = Column(Integer, primary_key=True)
text = Column(String(1000), nullable=False)
x = Column(Integer, nullable=False, default=0)
y = Column(Integer, nullable=False, default=0)
The creation of database tables is handled automatically at startup, which also creates the database file `notes.db` if it does not exist. The created session is used for all subsequent database operations.
engine = create_engine('sqlite:///notes.db')
# Initalize the database if it is not already.
Base.metadata.create_all(engine)
# Create a session to handle updates.
Session = sessionmaker(bind=engine)
session = Session()
Creating new notes
Python automatically removes objects from memory when there are no further references to them. If we create new objects, but don't assignment to a variable outside of the scope (e.g. a function) they will be deleted automatically when leaving the scope. However, while the Python object will be cleared up, Qt/C++ expects things to hang around until explicitly deleted. This can lead to some weird side effects and should be avoided.
The solution is simple: just ensure you always have a Python reference to any PyQt object you're creating. In the case of our notes, we do this using a _ACTIVE_NOTES
dictionary. We add new notes to this dictionary as they are created.
_ACTIVE_NOTES = {}
The MainWindow
itself handles adding itself to this list, so we don't need to worry about it anywhere else. This means when we create a callback function to trigger creation of a new note, the slot to do this can be as simple as creating the window.
PyQt6 Crash Course — a new tutorial in your Inbox every day
Beginner-focused crash course explaining the basics with hands-on examples.
def create_new_note(obj=None):
MainWindow(obj)
Starting up
When starting up we want to recreate all our existing notes on the desktop. We can do this by querying the database for all Note
objects, and then creating a new MainWindow object for each one. If there aren't any we just create a blank note.
existing_notes = session.query(Note).all()
if len(existing_notes) == 0:
create_new_note()
else:
for note in existing_notes:
create_new_note(obj=note)
app.exec_()
The Note widget
The notes are implemented as QMainWindow
objects. The main in the object name might be a bit of a misnomer, since you can actually have as many of them as you like.
The design of the windows was defined first in Qt Designer, so we import this and call self.setupUi(self)
to intialize. We also need to add a couple of window hint flags to the window to get the style & behaviour we're looking for — Qt.FramelessWindowHint
removes the window decorations and Qt.WindowStaysOnTopHint
keeps the notes on top.
::python
class MainWindow(QMainWindow, Ui_MainWindow):
def __init__(self, *args, obj=None, **kwargs):
super().__init__(*args, **kwargs)
self.setupUi(self)
self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.show()
To complete the setup for notes we need to either store the existing Note
object (from the database) or create a new one. If we're starting with an existing note we load the settings into the current window, if we've created a new one we save it to the database.
::python
# Load/save note data, store this notes db reference.
if obj:
self.obj = obj
self.load()
else:
self.obj = Note()
self.save()
self.closeButton.pressed.connect(self.delete_window)
self.moreButton.pressed.connect(create_new_note)
self.textEdit.textChanged.connect(self.save)
# Flags to store dragged-dropped
self._drag_active = False
This initial save just stores the position + an empty string. On a subsequent load we would have the default empty note.
We define a method to handle loading the content of a database Note
object into the window, and a second to save the current settings back to the database.
::python
def load(self):
self.move(self.obj.x, self.obj.y)
self.textEdit.setHtml(self.obj.text)
_ACTIVE_NOTES[self.obj.id] = self
def save(self):
self.obj.x = self.x()
self.obj.y = self.y()
self.obj.text = self.textEdit.toHtml()
session.add(self.obj)
session.commit()
_ACTIVE_NOTES[self.obj.id] = self
Both methods store to `_ACTIVE_NOTES` even though this is redundant once the first storage has occurred. This is to ensure we have a reference to the object whether we're loading from the database or saving to it.
The last step to a working notes application is to handle mouse interactions with our note windows. The interaction requirements are very basic — click to activate and drag to reposition.
The interaction is managed via three event handlers mousePressEvent
, mouseMoveEvent
and mouseReleaseEvent
.
The press event detects a mouse down on the note window and registers the initial position.
::python
def mousePressEvent(self, e):
self.previous_pos = e.globalPos()
The move event is only active while the mouse button is pressed and reports each movement, updating the current position of the note window on the screen.
::python
def mouseMoveEvent(self, e):
delta = e.globalPos() - self.previous_pos
self.move(self.x() + delta.x(), self.y()+delta.y())
self.previous_pos = e.globalPos()
self._drag_active = True
Finally release takes the end position of the dragged window and writes it to the database, by calling save()
.
:::python
def mouseReleaseEvent(self, e):
if self._drag_active:
self.save()
self._drag_active = False
The delete note handler shows a confirmation message, then handles the delete of the Note
object from the database via db.session
. The
final step is to close the window and delete the reference to it from
_ACTIVE_NOTES
. We do this by id, allowing us to delete the PyQt object reference after the Qt object has been deleted.
::python
def delete_window(self):
result = QMessageBox.question(self, "Confirm delete", "Are you sure you want to delete this note?")
if result == QMessageBox.Yes:
note_id = self.obj.id
session.delete(self.obj)
session.commit()
self.close()
del _ACTIVE_NOTES[note_id]
Theming Notes
We want to add a bit of colour to our notes application and make them stand out on the desktop. While we could apply the colours to each element (e.g. using stylesheets) since we want to affect all windows there is a simpler way — setting the application palette.
First we create a new palette object with `QPalette()`, which will contain the current application palette defaults. Then we can override each colour in turn that we want to alter. The entries in a palette are identified by constants on `QPalette`, [see here for a full list]().
app = QApplication([])
app.setApplicationName("Brown Note")
app.setStyle("Fusion")
# Custom brown palette.
palette = QPalette()
palette.setColor(QPalette.Window, QColor(188,170,164))
palette.setColor(QPalette.WindowText, QColor(121,85,72))
palette.setColor(QPalette.ButtonText, QColor(121,85,72))
palette.setColor(QPalette.Text, QColor(121,85,72))
palette.setColor(QPalette.Base, QColor(188,170,164))
palette.setColor(QPalette.AlternateBase, QColor(188,170,164))
app.setPalette(palette)
Create GUI Applications with Python & Qt5 by Martin Fitzpatrick — (PyQt5 Edition) The hands-on guide to making apps with Python — Over 10,000 copies sold!