Files
overlaynote/core/app.py
2026-05-23 20:22:37 +00:00

942 lines
18 KiB
Python

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