Files
noteki/src/canvas_window.py
git_alhan 9f3a599b79 Initial commit: Noteki v1.0
- 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
2026-05-21 04:13:45 +03:00

418 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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; }
"""