# v3.1.0 app.py import contextlib import json import logging import os import subprocess import sys from dataclasses import dataclass from enum import Enum, auto from hashlib import sha256 from pathlib import Path from typing import Final 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 ( QFrame, QMenu, QSizeGrip, QSystemTrayIcon, QTextBrowser, QVBoxLayout, QWidget, ) from qframelesswindow import AcrylicWindow from config.profiles import THEMES from config.settings import ( APP_DIR, SettingsManager, ) from ui.editor import LinkTextEdit from ui.styles import build_stylesheet logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) STATE_FILE: Final[Path] = ( APP_DIR / "sticky_state.json" ) MARKDOWN_FILE: Final[Path] = ( APP_DIR / "sticky.md" ) 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 = 260 height: int = 300 theme_index: int = 0 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._closing = False self.settings_manager = ( SettingsManager.load() ) self.profile = ( self.settings_manager.profile ) self.window_state = ( WindowState.IDLE ) self.active_state = ( ActiveState.ACTIVE ) self.drag_offset = QPoint() self.last_saved_hash = "" self.last_preview_hash = "" self.startup_ready = False self.configure_font() self.build_ui() self.setup_timers() self.configure_window() self.build_tray() self.restore_state() self.apply_style() QTimer.singleShot( self.profile["window"][ "startup_stabilize_ms" ], self.finalize_startup, ) 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.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.setWindowFlags( Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.FramelessWindowHint ) self.setAttribute( Qt.WidgetAttribute.WA_TranslucentBackground, True, ) self.setAutoFillBackground(False) appearance = ( self.profile["appearance"] ) try: if appearance[ "enable_mica" ]: self.windowEffect.setMicaEffect( self.winId(), True, ) elif appearance[ "enable_acrylic" ]: self.windowEffect.setAcrylicEffect( self.winId(), "00000001", ) except Exception: logging.exception( "window_effect_failure" ) def build_ui(self) -> None: self.root = QWidget(self) self.root.setObjectName( "container" ) self.layout = QVBoxLayout( self.root ) window = self.profile["window"] margin = window[ "layout_margin" ] self.layout.setContentsMargins( margin, margin, margin, margin, ) self.layout.setSpacing( window["layout_spacing"] ) self.drag_handle = DragHandle( self.root ) self.drag_handle.setFixedHeight( window[ "drag_handle_height" ] ) self.drag_handle.drag_started.connect( self.start_drag ) self.drag_handle.drag_moved.connect( self.perform_drag ) self.drag_handle.drag_finished.connect( self.finish_drag ) self.editor = LinkTextEdit( self.root ) self.editor.setObjectName( "editor" ) self.editor.setFont( self.font_object ) self.editor.document().setDocumentMargin( 0 ) 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( 0, 0, 0, 0, ) content_layout.setSpacing(0) content_layout.addWidget( self.editor, 1, ) content_layout.addWidget( self.preview, 1, ) self.resize_grip = QSizeGrip( self.root ) self.layout.addWidget( self.drag_handle ) self.layout.addWidget( content, 1, ) self.layout.addWidget( self.resize_grip, 0, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight, ) def build_tray(self) -> None: 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.close ) 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 start_drag( self, global_pos: QPoint, ) -> None: self.window_state = ( WindowState.DRAGGING ) self.active_state = ( ActiveState.DRAGGING ) self.apply_style() self.drag_offset = ( global_pos - self.frameGeometry().topLeft() ) self.setWindowOpacity( self.profile["appearance"][ "opacity_dragging" ] ) def perform_drag( self, global_pos: QPoint, ) -> None: target = ( global_pos - self.drag_offset ) self.move(target) 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" ] ) def queue_save(self) -> None: self.save_timer.start( self.profile["editor"][ "save_debounce_ms" ] ) def queue_preview(self) -> None: 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(): return 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: markdown = ( self.editor.toPlainText() ) current_hash = sha256( markdown.encode("utf-8") ).hexdigest() if current_hash != self.last_saved_hash: MARKDOWN_FILE.write_text( markdown, encoding="utf-8", ) self.last_saved_hash = ( current_hash ) STATE_FILE.write_text( json.dumps( { "x": self.x(), "y": self.y(), "width": self.width(), "height": self.height(), }, indent=2, ), encoding="utf-8", ) def restore_state(self) -> None: if MARKDOWN_FILE.exists(): self.editor.setPlainText( MARKDOWN_FILE.read_text( encoding="utf-8", ) ) if not STATE_FILE.exists(): return try: state = json.loads( STATE_FILE.read_text( encoding="utf-8", ) ) self.resize( state["width"], state["height"], ) self.move( state["x"], state["y"], ) 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: path = ( APP_DIR / "settings.json" ) path.touch(exist_ok=True) try: if os.name == "nt": os.startfile(str(path)) return if sys.platform.startswith( "linux" ): subprocess.Popen( ["xdg-open", str(path)] ) return if sys.platform == "darwin": subprocess.Popen( ["open", str(path)] ) return except Exception: logging.exception( "open_settings_failure" ) def nativeEvent( self, eventType, message, ): if getattr( self, "_closing", False, ): return False, 0 try: return super().nativeEvent( eventType, message, ) except Exception: return False, 0 def resizeEvent(self, event) -> None: super().resizeEvent(event) root = getattr( self, "root", None, ) if root is None: return geometry = self.rect() if root.geometry() != geometry: root.setGeometry( geometry ) 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: self._closing = True with contextlib.suppress(Exception): self.save_state() with contextlib.suppress(Exception): self.save_timer.stop() with contextlib.suppress(Exception): self.preview_timer.stop() with contextlib.suppress(Exception): self.opacity_anim.stop() with contextlib.suppress(Exception): if hasattr(self, "tray"): self.tray.hide() super().closeEvent(event)