# v4.2.1 app.py import atexit import json import logging import os import signal import subprocess import sys import traceback from dataclasses import dataclass from enum import Enum, auto from hashlib import sha256 from PyQt6.QtCore import ( QEasingCurve, QPoint, QPropertyAnimation, QRect, QTimer, Qt, pyqtSignal, ) from PyQt6.QtGui import ( QAction, QColor, QFont, QGuiApplication, QIcon, QMouseEvent, QPainter, QPixmap, ) from PyQt6.QtWidgets import ( QApplication, QFrame, QMenu, QSizeGrip, QSystemTrayIcon, QTextBrowser, QVBoxLayout, QWidget, ) from qframelesswindow import AcrylicWindow from config.profiles import THEMES from config.settings import ( APP_DIR, MARKDOWN_FILE, SETTINGS_FILE, STATE_FILE, SettingsManager, atomic_write, ) from ui.editor import LinkTextEdit from ui.styles import build_stylesheet logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) class WindowState(Enum): IDLE = auto() DRAGGING = auto() class ActiveState(Enum): ACTIVE = auto() INACTIVE = auto() DRAGGING = auto() @dataclass(slots=True) class PersistedState: x: int = 100 y: int = 100 width: int = 250 height: int = 260 class DragHandle(QFrame): drag_started = pyqtSignal(QPoint) drag_moved = pyqtSignal(QPoint) drag_finished = pyqtSignal() def mousePressEvent( self, event: QMouseEvent, ) -> None: if ( event.button() == Qt.MouseButton.LeftButton ): self.drag_started.emit( event.globalPosition().toPoint() ) super().mousePressEvent(event) def mouseMoveEvent( self, event: QMouseEvent, ) -> None: if ( event.buttons() & Qt.MouseButton.LeftButton ): self.drag_moved.emit( event.globalPosition().toPoint() ) super().mouseMoveEvent(event) def mouseReleaseEvent( self, event: QMouseEvent, ) -> None: self.drag_finished.emit() super().mouseReleaseEvent(event) class StickyNoteApp(AcrylicWindow): def __init__(self): super().__init__() self._is_quitting = False self._force_close = False self.settings_manager = ( SettingsManager.load() ) self.ensure_runtime_files() self.profile = ( self.settings_manager.profile ) self.window_state = ( WindowState.IDLE ) self.active_state = ( ActiveState.ACTIVE ) self.drag_offset = QPoint() self.drag_origin = QPoint() self.pending_drag = False self.last_saved_hash = "" self.last_preview_hash = "" self.startup_ready = False self.configure_font() self.setup_timers() self.build_ui() self.configure_window() QTimer.singleShot( 80, self.restore_state, ) QTimer.singleShot( 120, self.initialize_effects, ) QTimer.singleShot( 220, self.build_tray, ) QTimer.singleShot( 260, self.apply_style, ) QTimer.singleShot( 320, self.finalize_startup, ) self.register_shutdown_hooks() def ensure_runtime_files(self) -> None: APP_DIR.mkdir( parents=True, exist_ok=True, ) if not SETTINGS_FILE.exists(): self.settings_manager.save() if not MARKDOWN_FILE.exists(): atomic_write( MARKDOWN_FILE, "", ) if not STATE_FILE.exists(): atomic_write( STATE_FILE, json.dumps( { "x": 100, "y": 100, "width": 250, "height": 260, }, indent=2, ), ) def configure_font(self) -> None: self.font_object = QFont() self.font_object.setFamilies( self.profile["editor"][ "font_family" ] ) self.font_object.setPointSize( self.profile["editor"][ "font_size" ] ) self.font_object.setHintingPreference( QFont.HintingPreference.PreferFullHinting ) self.font_object.setStyleStrategy( QFont.StyleStrategy.PreferAntialias | QFont.StyleStrategy.PreferQuality ) def setup_timers(self) -> None: self.save_timer = QTimer(self) self.save_timer.setSingleShot( True ) self.save_timer.timeout.connect( self.save_state ) self.preview_timer = QTimer( self ) self.preview_timer.setSingleShot( True ) self.preview_timer.timeout.connect( self.show_preview ) self.drag_timer = QTimer(self) self.drag_timer.setSingleShot( True ) self.drag_timer.timeout.connect( self.activate_delayed_drag ) self.opacity_anim = ( QPropertyAnimation( self, b"windowOpacity", ) ) self.opacity_anim.setDuration( self.profile["appearance"][ "focus_animation_ms" ] ) self.opacity_anim.setEasingCurve( QEasingCurve.Type.OutCubic ) def configure_window(self) -> None: window = self.profile["window"] self.resize( window["width"], window["height"], ) self.setMinimumSize( window["min_width"], window["min_height"], ) self.setWindowFlag( Qt.WindowType.WindowStaysOnTopHint, True, ) self.setWindowFlag( Qt.WindowType.FramelessWindowHint, True, ) self.setAttribute( Qt.WidgetAttribute.WA_TranslucentBackground, True, ) self.setAttribute( Qt.WidgetAttribute.WA_NoSystemBackground, True, ) self.setAutoFillBackground( False ) def build_ui(self) -> None: self.root = QWidget(self) self.root.setObjectName( "container" ) self.root.setGeometry( self.rect() ) self.layout = QVBoxLayout( self.root ) self.layout.setContentsMargins( 4, 4, 4, 4, ) self.layout.setSpacing(0) self.titleBar.hide() self.titleBar.setFixedHeight(0) self.drag_handle = DragHandle( self.root ) self.drag_handle.hide() self.drag_handle.setFixedHeight(0) self.editor = LinkTextEdit( self.root ) self.editor.setObjectName( "editor" ) self.editor.setFont( self.font_object ) self.editor.document().setDocumentMargin( 0 ) self.editor.setViewportMargins( 0, 0, 0, 0, ) self.editor.setAcceptRichText( False ) self.editor.setMouseTracking( True ) self.editor.setStyleSheet(""" QTextEdit { margin: 0px; padding: 0px; border: none; background: transparent; } QTextEdit > QWidget { margin: 0px; padding: 0px; border: none; background: transparent; } """) self.editor.textChanged.connect( self.queue_save ) self.editor.textChanged.connect( self.queue_preview ) self.preview = QTextBrowser( self.root ) self.preview.setObjectName( "preview" ) self.preview.setFont( self.font_object ) self.preview.hide() content = QWidget(self.root) content_layout = QVBoxLayout( content ) content_layout.setContentsMargins( 4, 4, 4, 4, ) content_layout.setSpacing(1) content_layout.addWidget( self.editor, 1, ) content_layout.addWidget( self.preview, 1, ) self.resize_grip = QSizeGrip( self.root ) self.layout.addWidget( content, 1, ) self.layout.addWidget( self.resize_grip, 0, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, ) self.editor.setFocus() def build_tray(self) -> None: if not QSystemTrayIcon.isSystemTrayAvailable(): return if not self.profile[ "behavior" ]["enable_tray"]: return self.tray = QSystemTrayIcon( self ) self.tray.setIcon( self.create_tray_icon() ) menu = QMenu() open_settings = QAction( "Open Settings", self, ) open_settings.triggered.connect( self.open_settings ) cycle_theme = QAction( "Cycle Theme", self, ) cycle_theme.triggered.connect( self.cycle_theme ) quit_action = QAction( "Quit", self, ) quit_action.triggered.connect( self.safe_exit ) menu.addAction(open_settings) menu.addAction(cycle_theme) menu.addSeparator() menu.addAction(quit_action) self.tray.setContextMenu(menu) self.tray.show() def create_tray_icon(self) -> QIcon: pixmap = QPixmap(32, 32) pixmap.fill( Qt.GlobalColor.transparent ) painter = QPainter(pixmap) painter.setRenderHint( QPainter.RenderHint.Antialiasing, True, ) painter.setBrush( QColor( 255, 255, 255, 220, ) ) painter.setPen( Qt.PenStyle.NoPen ) painter.drawRoundedRect( 6, 6, 20, 20, 6, 6, ) painter.end() return QIcon(pixmap) def finalize_startup(self) -> None: self.startup_ready = True self.setWindowOpacity( self.profile["appearance"][ "opacity_active" ] ) self.repaint() def current_theme(self): theme_name = self.profile[ "theme" ] for theme in THEMES: if theme.name == theme_name: return theme return THEMES[0] def apply_style(self) -> None: theme = self.current_theme() border = { ActiveState.ACTIVE: theme.border_active, ActiveState.INACTIVE: theme.border_inactive, ActiveState.DRAGGING: theme.border_dragging, }[ self.active_state ] background = ( theme.background if self.active_state != ActiveState.INACTIVE else theme.background_inactive ) self.root.setStyleSheet( build_stylesheet( theme, border, background, self.profile["editor"][ "font_size" ], self.profile["editor"][ "font_family" ], self.profile["window"][ "layout_margin" ], ) ) def initialize_effects(self) -> None: appearance = ( self.profile["appearance"] ) try: hwnd = int(self.winId()) if not hwnd: return if appearance[ "enable_mica" ]: self.windowEffect.setMicaEffect( hwnd, True, ) elif appearance[ "enable_acrylic" ]: self.windowEffect.setAcrylicEffect( hwnd, "00000001", ) self.update() self.repaint() except Exception: logging.exception( "window_effect_failure" ) def activate_delayed_drag(self) -> None: if not self.pending_drag: return self.window_state = ( WindowState.DRAGGING ) self.active_state = ( ActiveState.DRAGGING ) self.apply_style() self.setWindowOpacity( self.profile["appearance"][ "opacity_dragging" ] ) def mousePressEvent(self, event) -> None: if ( event.button() == Qt.MouseButton.LeftButton ): self.pending_drag = True self.drag_origin = ( event.globalPosition() .toPoint() ) self.drag_offset = ( self.drag_origin - self.frameGeometry().topLeft() ) if hasattr( self, "drag_timer", ): self.drag_timer.start(180) super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: if not ( event.buttons() & Qt.MouseButton.LeftButton ): return global_pos = ( event.globalPosition() .toPoint() ) if ( self.pending_drag and ( global_pos - self.drag_origin ).manhattanLength() > 6 ): if ( self.window_state == WindowState.DRAGGING ): self.move( global_pos - self.drag_offset ) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event) -> None: if hasattr( self, "drag_timer", ): self.drag_timer.stop() self.pending_drag = False if ( self.window_state == WindowState.DRAGGING ): self.finish_drag() super().mouseReleaseEvent(event) def finish_drag(self) -> None: self.window_state = ( WindowState.IDLE ) self.active_state = ( ActiveState.ACTIVE ) self.apply_style() self.setWindowOpacity( self.profile["appearance"][ "opacity_active" ] ) self.queue_save() def queue_save(self) -> None: if not hasattr( self, "save_timer", ): return self.save_timer.start( self.profile["editor"][ "save_debounce_ms" ] ) def queue_preview(self) -> None: if not hasattr( self, "preview_timer", ): return if not self.profile[ "editor" ][ "enable_markdown_preview" ]: return self.preview.hide() self.editor.show() self.preview_timer.start( self.profile["editor"][ "preview_delay_ms" ] ) def show_preview(self) -> None: if ( self.editor.hasFocus() or not self.isActiveWindow() ): return self.preview.setFocusPolicy( Qt.FocusPolicy.NoFocus ) markdown = ( self.editor.toPlainText() ) current_hash = sha256( markdown.encode("utf-8") ).hexdigest() if ( current_hash == self.last_preview_hash ): return self.last_preview_hash = ( current_hash ) self.preview.setMarkdown( markdown ) self.editor.hide() self.preview.show() def save_state(self) -> None: try: APP_DIR.mkdir( parents=True, exist_ok=True, ) markdown = ( self.editor.toPlainText() ) current_hash = sha256( markdown.encode("utf-8") ).hexdigest() if ( current_hash != self.last_saved_hash ): atomic_write( MARKDOWN_FILE, markdown, ) self.last_saved_hash = ( current_hash ) atomic_write( STATE_FILE, json.dumps( { "x": self.x(), "y": self.y(), "width": self.width(), "height": self.height(), }, indent=2, ), ) except Exception: logging.exception( "save_state_failure" ) def restore_state(self) -> None: try: self.ensure_runtime_files() markdown = ( MARKDOWN_FILE.read_text( encoding="utf-8", ) ) self.editor.setPlainText( markdown ) state = json.loads( STATE_FILE.read_text( encoding="utf-8", ) ) self.resize( max( state.get( "width", 250, ), 200, ), max( state.get( "height", 260, ), 100, ), ) self.move( state.get("x", 100), state.get("y", 100), ) self.clamp_to_screen() except Exception: logging.exception( "restore_state_failure" ) def clamp_to_screen(self) -> None: screen = ( QGuiApplication.primaryScreen() ) if not screen: return geometry = ( screen.availableGeometry() ) rect = QRect( self.x(), self.y(), self.width(), self.height(), ) if not geometry.intersects(rect): self.move( geometry.center() - self.rect().center() ) def cycle_theme(self) -> None: names = [ theme.name for theme in THEMES ] current = self.profile[ "theme" ] index = names.index(current) next_index = ( index + 1 ) % len(names) self.profile["theme"] = ( names[next_index] ) self.settings_manager.save() self.apply_style() def open_settings(self) -> None: SETTINGS_FILE.touch( exist_ok=True ) try: if os.name == "nt": os.startfile( str(SETTINGS_FILE) ) return if sys.platform.startswith( "linux" ): subprocess.Popen( [ "xdg-open", str(SETTINGS_FILE), ] ) return if sys.platform == "darwin": subprocess.Popen( [ "open", str(SETTINGS_FILE), ] ) return except Exception: logging.exception( "open_settings_failure" ) def moveEvent(self, event) -> None: super().moveEvent(event) if ( getattr( self, "startup_ready", False, ) and hasattr( self, "save_timer", ) ): self.queue_save() def resizeEvent(self, event) -> None: super().resizeEvent(event) root = getattr( self, "root", None, ) if root is not None: geometry = self.rect() if ( root.geometry() != geometry ): root.setGeometry( geometry ) if ( getattr( self, "startup_ready", False, ) and hasattr( self, "save_timer", ) ): self.queue_save() def focusInEvent(self, event) -> None: super().focusInEvent(event) self.active_state = ( ActiveState.ACTIVE ) self.apply_style() self.setWindowOpacity( self.profile["appearance"][ "opacity_active" ] ) def focusOutEvent(self, event) -> None: super().focusOutEvent(event) if ( self.window_state == WindowState.DRAGGING ): return self.active_state = ( ActiveState.INACTIVE ) self.apply_style() self.setWindowOpacity( self.profile["appearance"][ "opacity_inactive" ] ) def closeEvent(self, event) -> None: if ( self._is_quitting or self._force_close ): try: self.save_state() except Exception: traceback.print_exc() event.accept() return event.ignore() self.hide() def register_shutdown_hooks(self) -> None: app = QApplication.instance() def handle_shutdown(*_) -> None: self.safe_exit() atexit.register( handle_shutdown ) for sig in ( signal.SIGINT, signal.SIGTERM, ): try: signal.signal( sig, handle_shutdown, ) except Exception: pass if app is not None: app.aboutToQuit.connect( lambda: setattr( self, "_is_quitting", True, ) ) def safe_exit(self) -> None: if self._is_quitting: return self._is_quitting = True try: self.save_state() except Exception: traceback.print_exc() try: if hasattr( self, "save_timer", ): self.save_timer.stop() if hasattr( self, "preview_timer", ): self.preview_timer.stop() except Exception: traceback.print_exc() try: if hasattr( self, "tray", ): self.tray.hide() self.tray.deleteLater() except Exception: traceback.print_exc() try: self.hide() self.deleteLater() except Exception: traceback.print_exc() app = QApplication.instance() if app is not None: app.quit() try: app.quit() except Exception: sys.exit(0)