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