diff --git a/src/pdf_equilibrist/core/document.py b/src/pdf_equilibrist/core/document.py index b9001ea..9ade4be 100644 --- a/src/pdf_equilibrist/core/document.py +++ b/src/pdf_equilibrist/core/document.py @@ -73,6 +73,7 @@ def __init__(self): self.fitz_doc: pymupdf.Document | None = None self.path: Path | None = None self._undo_stack: deque[bytes] = deque(maxlen=_UNDO_MAX) + self._redo_stack: deque[bytes] = deque(maxlen=_UNDO_MAX) self._modified = False # ── Propriété ────────────────────────────────────────────────────────────── @@ -117,10 +118,11 @@ def open(self, path: str | Path): self.path = Path(path) self.fitz_doc = pymupdf.open(str(self.path)) self._undo_stack.clear() + self._redo_stack.clear() self._set_modified(False) self.changed.emit() - def save(self, path: str | Path | None = None): + def save(self, path: str | Path | None = None, *, rebind: bool = False): """ Sauvegarde le document sur le disque. @@ -129,6 +131,9 @@ def save(self, path: str | Path | None = None): path : str | Path | None Chemin de destination. Si ``None``, sauvegarde en place (écrase le fichier source). + rebind : bool + « Enregistrer sous » : une fois l'écriture réussie, le document pointe sur ce nouveau + fichier (``path``), qui devient la cible des prochains enregistrements. Note ---- @@ -165,6 +170,8 @@ def save(self, path: str | Path | None = None): pass # Atteint seulement si l'écriture a réussi (une exception remonte avant) : # le document n'est « enregistré » que si le fichier l'est vraiment. + if rebind and path: + self.path = target self._set_modified(False) def close(self): @@ -178,6 +185,8 @@ def close(self): self.fitz_doc.close() self.fitz_doc = None self.path = None + self._undo_stack.clear() + self._redo_stack.clear() self._set_modified(False) self.changed.emit() @@ -187,13 +196,24 @@ def checkpoint(self): """Sauvegarde l'état courant dans la pile d'annulation (avant toute modif).""" if self.fitz_doc: self._undo_stack.append(self.fitz_doc.tobytes()) + self._redo_stack.clear() # une nouvelle modification invalide les états « à rétablir » self.mark_modified() # toute modification du PDF passe par ici avant d'agir def undo(self) -> bool: """Restaure le dernier état sauvegardé. Retourne True si annulation effectuée.""" if not self._undo_stack or not self.fitz_doc: return False - state = self._undo_stack.pop() + self._redo_stack.append(self.fitz_doc.tobytes()) # pour pouvoir rétablir + return self._restore(self._undo_stack.pop()) + + def redo(self) -> bool: + """Rétablit la modification annulée. Retourne True si rétablissement effectué.""" + if not self._redo_stack or not self.fitz_doc: + return False + self._undo_stack.append(self.fitz_doc.tobytes()) # sans vider la pile de rétablissement + return self._restore(self._redo_stack.pop()) + + def _restore(self, state: bytes) -> bool: self.fitz_doc.close() self.fitz_doc = pymupdf.open(stream=state, filetype="pdf") self.mark_modified() # l'état restauré peut différer du fichier : on reste prudent @@ -205,6 +225,11 @@ def can_undo(self) -> bool: """True si au moins un état est disponible dans la pile d'annulation.""" return len(self._undo_stack) > 0 + @property + def can_redo(self) -> bool: + """True si au moins un état est disponible dans la pile de rétablissement.""" + return len(self._redo_stack) > 0 + # ── Rendu ───────────────────────────────────────────────────────────────── def render_page(self, page_num: int, zoom: float = 1.0) -> QPixmap: diff --git a/src/pdf_equilibrist/theme.py b/src/pdf_equilibrist/theme.py index 8459d07..882bcf4 100644 --- a/src/pdf_equilibrist/theme.py +++ b/src/pdf_equilibrist/theme.py @@ -35,7 +35,7 @@ "#575757": "#B5B5B5", "#585858": "#B5B5B5", "#606060": "#A5A5A5", "#666666": "#9A9A9A", "#6a6a6a": "#A8A8A8", # textes - "#f0f0f0": "#1A1A1A", "#ffffff": "#111111", "#cccccc": "#3F3F3F", + "#f0f0f0": "#1A1A1A", "#ffffff": "#111111", "#cccccc": "#3F3F3F", "#d8d8d8": "#3A3A3A", "#c8c8c8": "#444444", "#c0c0c0": "#4A4A4A", "#aaaaaa": "#555555", "#888888": "#6E6E6E", # accent au survol diff --git a/src/pdf_equilibrist/ui/main_window.py b/src/pdf_equilibrist/ui/main_window.py index 0db51d9..d82cf44 100644 --- a/src/pdf_equilibrist/ui/main_window.py +++ b/src/pdf_equilibrist/ui/main_window.py @@ -227,6 +227,13 @@ def __init__(self): # ── Menu bar ───────────────────────────────────────────────────────── self._menu_bar = self._build_menu_bar() + # Barre d'accès rapide de la barre de titre : miroir des actions du menu + qa = self._title_bar.quick_access + qa.bind("undo", self._act_undo) + qa.bind("redo", self._act_redo) + qa.bind("open", self._act_open) + qa.bind("save", self._act_save) + qa.bind("save_as", self._act_save_as) # ── Status bar ─────────────────────────────────────────────────────── self._status_file = QLabel(self.tr("Aucun document ouvert")) @@ -339,6 +346,11 @@ def _undo(self): if self._current_doc.undo(): self.viewer.clear_search() + def _redo(self): + """Rétablit la dernière modification annulée (Ctrl+Y).""" + if self._current_doc.redo(): + self.viewer.clear_search() + def _build_menu_bar(self) -> QWidget: from PyQt6.QtWidgets import QMenuBar mb = QMenuBar() @@ -353,10 +365,10 @@ def _build_menu_bar(self) -> QWidget: fichier = mb.addMenu(self.tr("Fichier")) - act_open = QAction(self.tr("Ouvrir…"), self) - act_open.setShortcut(QKeySequence.StandardKey.Open) - act_open.triggered.connect(self._open_file_dialog) - fichier.addAction(act_open) + self._act_open = QAction(self.tr("Ouvrir…"), self) + self._act_open.setShortcut(QKeySequence.StandardKey.Open) + self._act_open.triggered.connect(self._open_file_dialog) + fichier.addAction(self._act_open) # Sous-menu "Ouvrir récemment" — peuplé dynamiquement self._recent_menu = fichier.addMenu(self.tr("Ouvrir récemment")) @@ -399,6 +411,12 @@ def _build_menu_bar(self) -> QWidget: self._act_undo.setEnabled(False) edition.addAction(self._act_undo) + self._act_redo = QAction(self.tr("Rétablir"), self) + self._act_redo.setShortcuts([QKeySequence("Ctrl+Y"), QKeySequence("Ctrl+Shift+Z")]) + self._act_redo.triggered.connect(self._redo) + self._act_redo.setEnabled(False) + edition.addAction(self._act_redo) + act_search = QAction(self.tr("Rechercher…"), self) act_search.setShortcut(QKeySequence.StandardKey.Find) act_search.triggered.connect(lambda: self._search_bar.toggle()) @@ -561,6 +579,7 @@ def _set_ribbon_enabled(self, enabled: bool): act.setEnabled(enabled) if not enabled: self._act_undo.setEnabled(False) + self._act_redo.setEnabled(False) # ── Gestion multi-documents ─────────────────────────────────────────────── @@ -721,6 +740,7 @@ def _on_doc_changed(self): self.tr("{0} page").format(n) if n <= 1 else self.tr("{0} pages").format(n)) self._set_ribbon_enabled(True) self._act_undo.setEnabled(doc.can_undo) + self._act_redo.setEnabled(doc.can_redo) else: self._update_status() @@ -749,10 +769,11 @@ def _save(self) -> bool: return False def _save_doc(self, doc: Document, path: Path | None = None) -> bool: - """Enregistre ``doc`` (en place, ou sous ``path``). En cas d'échec : le document reste - ouvert et modifié, et l'utilisateur est prévenu. ``True`` si l'écriture a réussi.""" + """Enregistre ``doc`` (en place, ou sous ``path``, qui devient alors son fichier). En cas + d'échec : le document reste ouvert et modifié, et l'utilisateur est prévenu. + ``True`` si l'écriture a réussi.""" try: - doc.save(path) + doc.save(path, rebind=path is not None) except Exception as e: show_error(self, self.tr("Enregistrement impossible"), self.tr("« {0} » n'a pas pu être enregistré :\n{1}\n\n" @@ -771,7 +792,13 @@ def _save_as(self) -> bool: str(doc.path), self.tr("PDF (*.pdf)")) if not path: return False - return self._save_doc(doc, Path(ensure_suffix(path, ".pdf"))) + if not self._save_doc(doc, Path(ensure_suffix(path, ".pdf"))): + return False + # Le document pointe maintenant sur le nouveau fichier : onglet, barre d'état, récents + self._on_doc_changed() + self._add_recent(str(doc.path)) + self._status_file.setText(self.tr("Enregistré : {0}").format(doc.path.name)) + return True # ── Modifications non enregistrées ──────────────────────────────────────── diff --git a/src/pdf_equilibrist/ui/quick_access.py b/src/pdf_equilibrist/ui/quick_access.py new file mode 100644 index 0000000..fe6b231 --- /dev/null +++ b/src/pdf_equilibrist/ui/quick_access.py @@ -0,0 +1,184 @@ +""" +ui/quick_access.py — Barre d'accès rapide de la barre de titre +============================================================== +Cinq boutons (Annuler, Rétablir, Ouvrir, Enregistrer, Enregistrer sous) placés après le nom de +l'application. Chaque bouton est le miroir d'une ``QAction`` du menu de ``MainWindow`` : il la +déclenche au clic, et reprend son état activé/désactivé, son texte et son raccourci. Les +actions agissent donc sur le document de l'onglet actif, comme le menu et les raccourcis. + +Les icônes sont dessinées au ``QPainter`` (pas de police ni de fichier image) : elles suivent +le thème clair/sombre via ``theme.qcolor``. +""" +from __future__ import annotations + +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QPushButton +from PyQt6.QtGui import QPainter, QPainterPath, QPen, QColor, QKeySequence, QAction, QPixmap, QPolygonF +from PyQt6.QtCore import Qt, QPointF, QRectF + +from pdf_equilibrist import theme + +UNDO, REDO, OPEN, SAVE, SAVE_AS = "undo", "redo", "open", "save", "save_as" +ORDER = (UNDO, REDO, OPEN, SAVE, SAVE_AS) +SIZE = 26 + + +def _pen(color: QColor, width: float = 1.6) -> QPen: + pen = QPen(color, width) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + return pen + + +def _arrow(p: QPainter) -> None: + """Flèche arrondie vers la gauche (Annuler) dans une boîte de 18 × 18.""" + p.drawArc(QRectF(4.5, 4.5, 11, 9), 0, 180 * 16) # demi-cercle supérieur + p.drawLine(QPointF(4.5, 9), QPointF(4.5, 12)) # descente sur la pointe + p.drawLine(QPointF(4.5, 12), QPointF(1.8, 9.4)) # branches de la pointe + p.drawLine(QPointF(4.5, 12), QPointF(7.2, 9.4)) + + +def _folder(p: QPainter) -> None: + path = QPainterPath() + path.moveTo(2, 14.5) + path.lineTo(2, 4) + path.lineTo(6.8, 4) + path.lineTo(8.4, 6) + path.lineTo(15, 6) + path.lineTo(15, 14.5) + path.closeSubpath() + p.drawPath(path) + p.drawLine(QPointF(2, 8.5), QPointF(15, 8.5)) + + +# Disquette et crayon dessinés dans une boîte de 1200 × 1200 (réduite à 18 × 18 par ``_to_box``). +_STROKE = 85.0 + + +def _floppy_paths(full: bool) -> list[QPainterPath]: + """Contour (coin haut-droit coupé), volet du haut et étiquette du bas. ``full`` = tracé complet ; + sinon le côté droit, le bas et l'étiquette s'arrêtent avant le crayon.""" + body = QPainterPath() + body.moveTo(1012, 1012 if full else 520) + body.lineTo(1012, 392) + body.lineTo(800, 187) + body.lineTo(290, 187) + body.quadTo(187, 187, 187, 290) + body.lineTo(187, 910) + body.quadTo(187, 1012, 290, 1012) + body.lineTo(1012 if full else 570, 1012) + flap = QPainterPath() + flap.moveTo(362, 187) + flap.lineTo(362, 360) + flap.quadTo(362, 412, 412, 412) + flap.lineTo(712, 412) + flap.quadTo(762, 412, 762, 360) + flap.lineTo(762, 187) + label = QPainterPath() + label.moveTo(337, 1012) + label.lineTo(337, 690) + label.quadTo(337, 637, 390, 637) + label.lineTo(812, 637) + if full: + label.quadTo(862, 637, 862, 690) + label.lineTo(862, 1012) + return [body, flap, label] + + +_PENCIL = QPolygonF([QPointF(990, 650), QPointF(1100, 760), QPointF(1100, 790), QPointF(800, 1080), + QPointF(640, 1125), QPointF(615, 1100), QPointF(665, 940), QPointF(960, 650)]) + + +def _to_box(p: QPainter) -> None: + p.translate((SIZE - 18) / 2, (SIZE - 18) / 2) + p.scale(18 / 1200, 18 / 1200) + + +class QuickButton(QPushButton): + def __init__(self, kind: str, parent=None): + super().__init__(parent) + self.kind = kind + self.setFixedSize(SIZE, SIZE) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) # ne vole pas le focus clavier au viewer + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + enabled = self.isEnabled() + if enabled and (self.underMouse() or self.isDown()): + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(theme.qcolor("#3A3A3A" if self.isDown() else "#2E2E2E")) + p.drawRoundedRect(QRectF(self.rect()).adjusted(1, 1, -1, -1), 5, 5) + color = theme.qcolor("#D8D8D8" if enabled else "#555555") + p.setPen(_pen(color)) + p.setBrush(Qt.BrushStyle.NoBrush) + if self.kind in (UNDO, REDO, OPEN): + p.translate((SIZE - 18) / 2, (SIZE - 18) / 2) + if self.kind == UNDO: + _arrow(p) + elif self.kind == REDO: + p.translate(18, 0) + p.scale(-1, 1) # image miroir de la flèche d'annulation + _arrow(p) + elif self.kind == OPEN: + _folder(p) + elif self.kind == SAVE: + p.resetTransform() + _to_box(p) + p.setPen(_pen(color, _STROKE)) + for path in _floppy_paths(full=True): + p.drawPath(path) + else: # SAVE_AS : disquette + crayon plein, séparés par un halo + dpr = self.devicePixelRatioF() + layer = QPixmap(int(SIZE * dpr), int(SIZE * dpr)) + layer.setDevicePixelRatio(dpr) + layer.fill(Qt.GlobalColor.transparent) + lp = QPainter(layer) + lp.setRenderHint(QPainter.RenderHint.Antialiasing) + _to_box(lp) + lp.setPen(_pen(color, _STROKE)) + lp.setBrush(Qt.BrushStyle.NoBrush) + for path in _floppy_paths(full=False): + lp.drawPath(path) + lp.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear) # halo autour du crayon + lp.setPen(_pen(color, 190)) + lp.setBrush(color) + lp.drawPolygon(_PENCIL) + lp.end() + p.resetTransform() + p.drawPixmap(0, 0, layer) + _to_box(p) + p.setPen(_pen(color, 60)) + p.setBrush(color) + p.drawPolygon(_PENCIL) + p.end() + + +class QuickAccessBar(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 0, 6, 0) + layout.setSpacing(2) + self._buttons: dict[str, QuickButton] = {} + for kind in ORDER: + btn = QuickButton(kind) + self._buttons[kind] = btn + layout.addWidget(btn) + + def bind(self, kind: str, action: QAction) -> None: + """Relie le bouton ``kind`` à ``action`` : clic → déclenche l'action ; l'état activé, + le texte et le raccourci de l'action sont répercutés sur le bouton.""" + btn = self._buttons[kind] + btn.clicked.connect(action.trigger) + + def sync(): + btn.setEnabled(action.isEnabled()) + keys = action.shortcuts() + hint = keys[0].toString(QKeySequence.SequenceFormat.NativeText) if keys else "" + label = action.text().replace("&", "").rstrip("…") + btn.setToolTip(f"{label} ({hint})" if hint else label) + btn.update() + + action.changed.connect(sync) + sync() diff --git a/src/pdf_equilibrist/ui/title_bar.py b/src/pdf_equilibrist/ui/title_bar.py index 9e28e25..8cbdb7d 100644 --- a/src/pdf_equilibrist/ui/title_bar.py +++ b/src/pdf_equilibrist/ui/title_bar.py @@ -55,6 +55,7 @@ from PyQt6.QtCore import Qt, pyqtSignal, QSize, QRectF, QEvent import time from pdf_equilibrist import theme +from pdf_equilibrist.ui.quick_access import QuickAccessBar ACCENT = "#6BBF4E" @@ -236,6 +237,11 @@ def __init__(self, parent=None): "color: #606060; font-size: 11px; padding: 0 10px 0 4px;") layout.addWidget(self._app_name) + # Barre d'accès rapide (Annuler, Rétablir, Ouvrir, Enregistrer, Enregistrer sous) : + # reliée aux actions du menu par MainWindow (voir ui/quick_access.py) + self.quick_access = QuickAccessBar() + layout.addWidget(self.quick_access) + # Séparateur visuel entre le nom et les onglets sep = QFrame() sep.setFrameShape(QFrame.Shape.VLine) diff --git a/tests/test_core/test_document_modified.py b/tests/test_core/test_document_modified.py index 50e0a94..5d1ee53 100644 --- a/tests/test_core/test_document_modified.py +++ b/tests/test_core/test_document_modified.py @@ -80,3 +80,20 @@ def test_close_and_reopen_reset(pdf_path): assert not doc.is_modified doc.open(pdf_path) assert not doc.is_modified + + +def test_save_as_rebinds_the_path(pdf_path, tmp_path): + doc = Document() + doc.open(pdf_path) + doc.checkpoint() + new = tmp_path / "copie.pdf" + doc.save(new, rebind=True) + assert doc.path == new and new.exists() + assert not doc.is_modified + + +def test_plain_save_to_other_path_keeps_the_path(pdf_path, tmp_path): + doc = Document() + doc.open(pdf_path) + doc.save(tmp_path / "export.pdf") # sans rebind : copie, le document garde son fichier + assert doc.path == pdf_path diff --git a/tests/test_core/test_document_redo.py b/tests/test_core/test_document_redo.py new file mode 100644 index 0000000..5d0532c --- /dev/null +++ b/tests/test_core/test_document_redo.py @@ -0,0 +1,65 @@ +"""Document : Annuler / Rétablir.""" +import pymupdf +import pytest + +from pdf_equilibrist.core.document import Document + + +@pytest.fixture +def doc(tmp_path): + path = tmp_path / "a.pdf" + d = pymupdf.open() + d.new_page() + d.save(path) + d.close() + document = Document() + document.open(path) + return document + + +def _add_page(document): + document.checkpoint() + document.fitz_doc.new_page() + + +def test_nothing_to_redo_initially(doc): + assert not doc.can_redo + assert not doc.redo() + + +def test_undo_then_redo_restores_the_change(doc): + _add_page(doc) + assert len(doc.fitz_doc) == 2 + assert doc.undo() + assert len(doc.fitz_doc) == 1 and doc.can_redo + assert doc.redo() + assert len(doc.fitz_doc) == 2 + assert not doc.can_redo and doc.can_undo # on peut ré-annuler + + +def test_new_change_clears_redo(doc): + _add_page(doc) + doc.undo() + assert doc.can_redo + _add_page(doc) # nouvelle modification : l'ancien futur n'a plus de sens + assert not doc.can_redo + + +def test_several_steps(doc): + _add_page(doc) + _add_page(doc) + assert len(doc.fitz_doc) == 3 + doc.undo() + doc.undo() + assert len(doc.fitz_doc) == 1 + doc.redo() + doc.redo() + assert len(doc.fitz_doc) == 3 + assert not doc.redo() + + +def test_close_clears_stacks(doc): + _add_page(doc) + doc.undo() + doc.close() + assert not doc.can_undo and not doc.can_redo diff --git a/translations/pdf_equilibrist_en.qm b/translations/pdf_equilibrist_en.qm index 14bb88c..13dcb44 100644 Binary files a/translations/pdf_equilibrist_en.qm and b/translations/pdf_equilibrist_en.qm differ diff --git a/translations/pdf_equilibrist_en.ts b/translations/pdf_equilibrist_en.ts index 00323ba..d384984 100644 --- a/translations/pdf_equilibrist_en.ts +++ b/translations/pdf_equilibrist_en.ts @@ -453,6 +453,10 @@ The document stays open with your changes. Ne pas enregistrer Don't save + + Rétablir + Redo + PrintDialog