mirror of
https://github.com/alhan/noteki.git
synced 2026-08-23 05:30:19 +00:00
- Modern sticky notes app for Windows - PySide6 full-screen transparent canvas - System tray integration, global Alt+N hotkey - Session management, undo/redo, auto-save - Custom color palette with dynamic contrast - Pin notes always-on-top, Tab navigation - Windows Named Mutex single-instance guard
418 lines
16 KiB
Python
418 lines
16 KiB
Python
"""
|
||
canvas_window.py — Tam ekran transparan canvas penceresi
|
||
- WindowStaysOnTopHint KALDIRILDI (notlar arkaya geçebilir)
|
||
- App focus kaybettiğinde auto-hide
|
||
- "+" butonu sadece not yokken görünür
|
||
- Pinned not → top-level window yönetimi
|
||
"""
|
||
|
||
import ctypes
|
||
import ctypes.wintypes
|
||
import logging
|
||
from typing import Any, Dict, Optional
|
||
|
||
from PySide6.QtCore import QPoint, Qt, Signal, QTimer
|
||
from PySide6.QtGui import QColor, QPainter, QShortcut, QKeySequence
|
||
from PySide6.QtWidgets import QApplication, QMenu, QWidget
|
||
|
||
from .add_button import AddButton
|
||
from .constants import (
|
||
ADD_BUTTON_MARGIN,
|
||
ADD_BUTTON_SIZE,
|
||
NOTE_CASCADE_OFFSET,
|
||
NOTE_COLORS,
|
||
NOTE_DEFAULT_HEIGHT,
|
||
NOTE_DEFAULT_WIDTH,
|
||
NOTE_MAX_COUNT,
|
||
)
|
||
from .note_widget import NoteWidget
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
GWL_EXSTYLE = -20
|
||
WS_EX_TRANSPARENT = 0x00000020
|
||
WS_EX_LAYERED = 0x00080000
|
||
|
||
|
||
def _set_click_through(hwnd: int, enabled: bool) -> None:
|
||
try:
|
||
style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
|
||
if enabled:
|
||
style |= WS_EX_TRANSPARENT | WS_EX_LAYERED
|
||
else:
|
||
style &= ~WS_EX_TRANSPARENT
|
||
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style)
|
||
except Exception as exc:
|
||
logger.warning("Click-through ayarlanamadi: %s", exc)
|
||
|
||
|
||
class CanvasWindow(QWidget):
|
||
"""Tam ekran transparan canvas."""
|
||
|
||
# Sinyaller
|
||
new_note_requested = Signal(int, int)
|
||
note_deleted = Signal(str)
|
||
note_content_changed = Signal(str, str)
|
||
note_position_changed = Signal(str, int, int)
|
||
note_size_changed = Signal(str, int, int)
|
||
note_color_changed = Signal(str, str)
|
||
note_font_size_changed = Signal(str, int)
|
||
note_save_requested = Signal(str)
|
||
note_new_requested = Signal(str)
|
||
note_pin_toggled = Signal(str, bool)
|
||
undo_requested = Signal()
|
||
note_settings_requested = Signal() # Settings gear request signal
|
||
|
||
def __init__(self, parent=None) -> None:
|
||
super().__init__(parent)
|
||
self._notes: Dict[str, NoteWidget] = {}
|
||
self._pinned_notes: Dict[str, NoteWidget] = {} # pin edilmiş notlar (top-level)
|
||
self._visible: bool = False
|
||
self._last_cascade = QPoint(80, 80)
|
||
self._only_show_in_tray = True
|
||
self._colors_dict = NOTE_COLORS
|
||
|
||
self._setup_window()
|
||
self._setup_add_button()
|
||
self._setup_app_state_watcher()
|
||
|
||
# Canvas-level Shortcut for Ctrl+Z to undo deleted notes
|
||
self._undo_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self)
|
||
self._undo_shortcut.activated.connect(self.undo_requested.emit)
|
||
|
||
# ── Pencere Kurulum ─────────────────────────────────────────────
|
||
|
||
def _setup_window(self) -> None:
|
||
# WindowStaysOnTopHint KALDIRILD — arkaya geçebilsin
|
||
flags = Qt.FramelessWindowHint | Qt.Tool
|
||
self.setWindowFlags(flags)
|
||
self.setAttribute(Qt.WA_TranslucentBackground, True)
|
||
self.setAttribute(Qt.WA_NoSystemBackground, True)
|
||
screen = QApplication.primaryScreen().geometry()
|
||
self.setGeometry(screen)
|
||
self.hide()
|
||
|
||
def _setup_add_button(self) -> None:
|
||
self._add_btn = AddButton(self)
|
||
self._reposition_add_button()
|
||
self._add_btn.clicked.connect(self._on_add_clicked)
|
||
|
||
def _reposition_add_button(self) -> None:
|
||
x = self.width() - ADD_BUTTON_SIZE - ADD_BUTTON_MARGIN
|
||
y = self.height() - ADD_BUTTON_SIZE - ADD_BUTTON_MARGIN
|
||
self._add_btn.move(x, y)
|
||
|
||
def _setup_app_state_watcher(self) -> None:
|
||
"""Uygulama focus kaybettiğinde canvas'ı gizle."""
|
||
QApplication.instance().applicationStateChanged.connect(
|
||
self._on_app_state_changed
|
||
)
|
||
|
||
def _on_app_state_changed(self, state) -> None:
|
||
from PySide6.QtCore import Qt as _Qt
|
||
if state == _Qt.ApplicationInactive and self._visible:
|
||
# Kısa gecikme — kendi dialog'larımızın açılmasını engelleme
|
||
QTimer.singleShot(100, self._check_still_inactive)
|
||
|
||
def _check_still_inactive(self) -> None:
|
||
if QApplication.applicationState() != Qt.ApplicationActive and self._visible:
|
||
self.hide_canvas()
|
||
|
||
# ── Toggle ──────────────────────────────────────────────────────
|
||
|
||
def toggle_visibility(self) -> None:
|
||
if self._visible:
|
||
self.hide_canvas()
|
||
else:
|
||
self.show_canvas()
|
||
|
||
def show_canvas(self) -> None:
|
||
if self._visible:
|
||
return
|
||
self._visible = True
|
||
self.show()
|
||
self.raise_()
|
||
self.activateWindow()
|
||
_set_click_through(int(self.winId()), False)
|
||
self._add_btn.raise_()
|
||
logger.debug("Canvas gosterildi.")
|
||
|
||
def hide_canvas(self) -> None:
|
||
if not self._visible:
|
||
return
|
||
self._visible = False
|
||
self.hide()
|
||
logger.debug("Canvas gizlendi.")
|
||
|
||
@property
|
||
def is_visible(self) -> bool:
|
||
return self._visible
|
||
|
||
# ── Çizim ───────────────────────────────────────────────────────
|
||
|
||
def paintEvent(self, event) -> None:
|
||
painter = QPainter(self)
|
||
painter.setCompositionMode(QPainter.CompositionMode_Clear)
|
||
painter.fillRect(self.rect(), Qt.transparent)
|
||
painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
|
||
painter.end()
|
||
|
||
# ── Fare Olayları ────────────────────────────────────────────────
|
||
|
||
def mousePressEvent(self, event) -> None:
|
||
if event.button() == Qt.LeftButton:
|
||
self.hide_canvas()
|
||
elif event.button() == Qt.RightButton:
|
||
self._show_ctx(event.globalPosition().toPoint())
|
||
event.accept()
|
||
|
||
def keyPressEvent(self, event) -> None:
|
||
if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier:
|
||
self.undo_requested.emit()
|
||
elif event.key() == Qt.Key_Escape:
|
||
self.hide_canvas()
|
||
else:
|
||
super().keyPressEvent(event)
|
||
|
||
# ── Context Menü (boş alan) ──────────────────────────────────────
|
||
|
||
def _show_ctx(self, global_pos: QPoint) -> None:
|
||
menu = QMenu(self)
|
||
menu.setStyleSheet(self._menu_style())
|
||
add_a = menu.addAction("✚ Yeni Not Ekle")
|
||
menu.addSeparator()
|
||
show_all_a = menu.addAction("👁 Tum Notlari One Getir")
|
||
undo_a = menu.addAction("↩ Geri Al (Ctrl+Z)")
|
||
show_all_a.setEnabled(len(self._notes) > 0)
|
||
|
||
action = menu.exec(global_pos)
|
||
if action == add_a:
|
||
local = self.mapFromGlobal(global_pos)
|
||
self.new_note_requested.emit(local.x(), local.y())
|
||
elif action == show_all_a:
|
||
for w in self._notes.values(): w.raise_()
|
||
elif action == undo_a:
|
||
self.undo_requested.emit()
|
||
|
||
# ── Not Ekleme / Silme ───────────────────────────────────────────
|
||
|
||
def add_note(self, note_data: Dict[str, Any]) -> NoteWidget:
|
||
widget = NoteWidget(note_data, colors_dict=self._colors_dict, parent=self)
|
||
nid = widget.note_id
|
||
|
||
widget.delete_requested.connect(lambda i: self.note_deleted.emit(i))
|
||
widget.content_changed.connect(self.note_content_changed)
|
||
widget.position_changed.connect(self.note_position_changed)
|
||
widget.size_changed.connect(self.note_size_changed)
|
||
widget.color_changed.connect(self.note_color_changed)
|
||
widget.font_size_changed.connect(self.note_font_size_changed)
|
||
widget.save_requested.connect(self.note_save_requested)
|
||
widget.new_note_requested.connect(self.note_new_requested)
|
||
widget.pin_toggled.connect(self._on_pin_toggled)
|
||
widget.raise_requested.connect(self._raise_note)
|
||
widget.focus_gained.connect(self._on_note_focus)
|
||
widget.settings_requested.connect(self.note_settings_requested.emit)
|
||
widget.tab_requested.connect(self._on_tab_navigate)
|
||
|
||
self._notes[nid] = widget
|
||
widget.show()
|
||
widget.raise_()
|
||
self._add_btn.raise_()
|
||
self._update_add_btn()
|
||
# Eğer not başlangıçta pinned ise, onu top-level yapalım!
|
||
if note_data.get("pinned", False):
|
||
self._on_pin_toggled(nid, True)
|
||
return widget
|
||
|
||
def set_colors_dict(self, colors: Dict[str, str]) -> None:
|
||
self._colors_dict = colors
|
||
for nid, w in self.iter_notes():
|
||
w.set_colors_dict(colors)
|
||
|
||
|
||
def remove_note(self, note_id: str) -> None:
|
||
# Hem normal hem pinned dict'ten kontrol et
|
||
widget = self._notes.pop(note_id, None) or self._pinned_notes.pop(note_id, None)
|
||
if widget:
|
||
widget.hide()
|
||
widget.deleteLater()
|
||
self._update_add_btn()
|
||
|
||
# ── Pin (always-on-top per note) ─────────────────────────────────
|
||
|
||
def _on_pin_toggled(self, note_id: str, pinned: bool) -> None:
|
||
widget = self._notes.get(note_id) or self._pinned_notes.get(note_id)
|
||
if not widget:
|
||
return
|
||
|
||
if pinned:
|
||
# Canvas child → top-level WindowStaysOnTopHint
|
||
global_pos = widget.mapToGlobal(QPoint(0, 0))
|
||
self._notes.pop(note_id)
|
||
widget.setParent(None)
|
||
|
||
pflags = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
|
||
if getattr(self, "_only_show_in_tray", True):
|
||
pflags |= Qt.Tool
|
||
else:
|
||
pflags |= Qt.Window
|
||
|
||
widget.setWindowFlags(pflags)
|
||
widget.setAttribute(Qt.WA_StyledBackground, True)
|
||
widget.move(global_pos)
|
||
widget.show()
|
||
widget.raise_()
|
||
self._pinned_notes[note_id] = widget
|
||
logger.debug("Not pinlendi (top-level): %s", note_id)
|
||
else:
|
||
# Top-level → canvas child
|
||
if note_id in self._pinned_notes:
|
||
self._pinned_notes.pop(note_id)
|
||
global_pos = widget.pos() # zaten global (top-level iken)
|
||
widget.setParent(self)
|
||
widget.setWindowFlags(Qt.Widget)
|
||
widget.setAttribute(Qt.WA_StyledBackground, True)
|
||
local_pos = self.mapFromGlobal(global_pos)
|
||
widget.move(local_pos)
|
||
if self._visible:
|
||
widget.show()
|
||
self._notes[note_id] = widget
|
||
self._add_btn.raise_()
|
||
logger.debug("Not unpin edildi (canvas child): %s", note_id)
|
||
|
||
self.note_pin_toggled.emit(note_id, pinned)
|
||
|
||
# ── Kaskad Pozisyon ─────────────────────────────────────────────
|
||
|
||
def get_next_cascade_position(self) -> QPoint:
|
||
if not self._notes and not self._pinned_notes:
|
||
self._last_cascade = QPoint(80, 80)
|
||
return self._last_cascade
|
||
pos = QPoint(
|
||
self._last_cascade.x() + NOTE_CASCADE_OFFSET,
|
||
self._last_cascade.y() + NOTE_CASCADE_OFFSET,
|
||
)
|
||
screen = QApplication.primaryScreen().geometry()
|
||
if pos.x() + NOTE_DEFAULT_WIDTH > screen.width(): pos.setX(80)
|
||
if pos.y() + NOTE_DEFAULT_HEIGHT > screen.height(): pos.setY(80)
|
||
self._last_cascade = pos
|
||
return pos
|
||
|
||
def clamp_notes_to_screen(self) -> None:
|
||
screen = QApplication.primaryScreen().geometry()
|
||
cx, cy = 20, 20
|
||
for nid, w in list(self._notes.items()) + list(self._pinned_notes.items()):
|
||
x, y, ww, hh = w.x(), w.y(), w.width(), w.height()
|
||
if x < 0 or x + ww > screen.width() or y < 0 or y + hh > screen.height():
|
||
w.move(cx, cy)
|
||
self.note_position_changed.emit(nid, cx, cy)
|
||
cx += NOTE_CASCADE_OFFSET; cy += NOTE_CASCADE_OFFSET
|
||
|
||
# ── Slot'lar ────────────────────────────────────────────────────
|
||
|
||
def _on_add_clicked(self) -> None:
|
||
pos = self.get_next_cascade_position()
|
||
self.new_note_requested.emit(pos.x(), pos.y())
|
||
|
||
def _raise_note(self, note_id: str) -> None:
|
||
w = self._notes.get(note_id) or self._pinned_notes.get(note_id)
|
||
if w:
|
||
w.raise_()
|
||
if note_id in self._notes:
|
||
self._add_btn.raise_()
|
||
|
||
def _on_note_focus(self, note_id: str) -> None:
|
||
for nid, w in {**self._notes, **self._pinned_notes}.items():
|
||
w.set_active(nid == note_id)
|
||
|
||
def _on_tab_navigate(self, note_id: str, shift: bool) -> None:
|
||
"""Tab/Shift+Tab ile notlar arasi gezinme."""
|
||
# Butun notlari sirali liste haline getir
|
||
all_notes = list(self._notes.items()) + list(self._pinned_notes.items())
|
||
if len(all_notes) < 2:
|
||
return
|
||
# Su anki notun indeksini bul
|
||
current_idx = None
|
||
for i, (nid, _) in enumerate(all_notes):
|
||
if nid == note_id:
|
||
current_idx = i
|
||
break
|
||
if current_idx is None:
|
||
return
|
||
# Hedef indeks
|
||
if shift:
|
||
target_idx = (current_idx - 1) % len(all_notes)
|
||
else:
|
||
target_idx = (current_idx + 1) % len(all_notes)
|
||
target_id, target_widget = all_notes[target_idx]
|
||
target_widget.focus_text()
|
||
|
||
def _update_add_btn(self) -> None:
|
||
total = len(self._notes) + len(self._pinned_notes)
|
||
# + butonu sadece hiç not yokken görünür
|
||
if total == 0:
|
||
self._add_btn.setVisible(True)
|
||
self._add_btn.set_enabled_state(True)
|
||
elif total >= NOTE_MAX_COUNT:
|
||
self._add_btn.setVisible(True)
|
||
self._add_btn.set_enabled_state(False)
|
||
else:
|
||
self._add_btn.setVisible(False)
|
||
|
||
# ── Public API ───────────────────────────────────────────────────
|
||
|
||
@property
|
||
def note_count(self) -> int:
|
||
return len(self._notes) + len(self._pinned_notes)
|
||
|
||
def get_note_widget(self, note_id: str) -> Optional[NoteWidget]:
|
||
return self._notes.get(note_id) or self._pinned_notes.get(note_id)
|
||
|
||
def iter_notes(self):
|
||
return list({**self._notes, **self._pinned_notes}.items())
|
||
|
||
def focus_note(self, note_id: str) -> None:
|
||
w = self.get_note_widget(note_id)
|
||
if w: w.focus_text()
|
||
|
||
def set_only_show_in_tray(self, enabled: bool) -> None:
|
||
"""Sadece trayda göster veya görev çubuğunda göster ayarını dinamik uygular."""
|
||
self._only_show_in_tray = enabled
|
||
flags = Qt.FramelessWindowHint
|
||
if enabled:
|
||
flags |= Qt.Tool
|
||
else:
|
||
flags |= Qt.Window
|
||
|
||
self.setWindowFlags(flags)
|
||
if self._visible:
|
||
self.show()
|
||
|
||
# Tüm pinned notların pencerelerini de güncelle
|
||
for nid, w in list(self._pinned_notes.items()):
|
||
pos = w.pos()
|
||
w.setParent(None)
|
||
pflags = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint
|
||
if enabled:
|
||
pflags |= Qt.Tool
|
||
else:
|
||
pflags |= Qt.Window
|
||
w.setWindowFlags(pflags)
|
||
w.setAttribute(Qt.WA_StyledBackground, True)
|
||
w.move(pos)
|
||
w.show()
|
||
w.raise_()
|
||
|
||
def _menu_style(self) -> str:
|
||
return """
|
||
QMenu {
|
||
background:#1E1E2E; color:#CDD6F4;
|
||
border:1px solid #45475A; border-radius:10px; padding:6px;
|
||
font-family:'Segoe UI'; font-size:13px;
|
||
}
|
||
QMenu::item { padding:7px 22px; border-radius:6px; }
|
||
QMenu::item:selected { background:#313244; }
|
||
QMenu::item:disabled { color:#585B70; }
|
||
QMenu::separator { height:1px; background:#45475A; margin:4px 0; }
|
||
"""
|