From bc8061d7084d889db676a3b88cfadfdc60304fac Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 23 May 2026 20:22:37 +0000 Subject: [PATCH] Upload files to "core" --- core/app.py | 942 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 942 insertions(+) create mode 100644 core/app.py diff --git a/core/app.py b/core/app.py new file mode 100644 index 0000000..c137065 --- /dev/null +++ b/core/app.py @@ -0,0 +1,942 @@ +# 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) \ No newline at end of file