Files
overlaynote/core/app.py
2026-05-24 10:52:44 +00:00

1248 lines
25 KiB
Python

# 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)