From 2bc16813144f5e25736f44808f6074129998f082 Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 14:10:11 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20barre=20d'acc=C3=A8s=20rapide?= =?UTF-8?q?=20(Annuler,=20R=C3=A9tablir,=20Ouvrir,=20Enregistrer,=20Enregi?= =?UTF-8?q?strer=20sous)=20+=20R=C3=A9tablir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rétablir n'existait pas : Document.redo() / can_redo (pile de rétablissement, vidée par toute nouvelle modification), action Édition › Rétablir (Ctrl+Y, Ctrl+Maj+Z). - ui/quick_access.py : cinq boutons à icônes dessinées au QPainter, placés après le nom de l'application ; chacun reflète une QAction du menu (état activé, texte, raccourci) et agit sur le document de l'onglet actif. Co-Authored-By: Claude Sonnet 5.5 --- src/pdf_equilibrist/core/document.py | 22 ++++- src/pdf_equilibrist/ui/main_window.py | 28 +++++- src/pdf_equilibrist/ui/quick_access.py | 131 +++++++++++++++++++++++++ src/pdf_equilibrist/ui/title_bar.py | 6 ++ tests/test_core/test_document_redo.py | 65 ++++++++++++ translations/pdf_equilibrist_en.ts | 4 + 6 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 src/pdf_equilibrist/ui/quick_access.py create mode 100644 tests/test_core/test_document_redo.py diff --git a/src/pdf_equilibrist/core/document.py b/src/pdf_equilibrist/core/document.py index b9001ea..1ec50bd 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,6 +118,7 @@ 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() @@ -178,6 +180,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 +191,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 +220,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/ui/main_window.py b/src/pdf_equilibrist/ui/main_window.py index 0db51d9..796191c 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() diff --git a/src/pdf_equilibrist/ui/quick_access.py b/src/pdf_equilibrist/ui/quick_access.py new file mode 100644 index 0000000..5737056 --- /dev/null +++ b/src/pdf_equilibrist/ui/quick_access.py @@ -0,0 +1,131 @@ +""" +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 +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)) + + +def _floppy(p: QPainter, dx: float = 0, dy: float = 0, s: float = 1.0) -> None: + def r(x, y, w, h): + return QRectF(dx + x * s, dy + y * s, w * s, h * s) + p.drawRoundedRect(r(2.5, 2.5, 13, 13), 1.6 * s, 1.6 * s) + p.drawRect(r(5.5, 2.5, 7, 4)) # volet métallique + p.drawRect(r(5.5, 10, 7, 5.5)) # étiquette + + +def _pencil(p: QPainter) -> None: + p.drawLine(QPointF(9.5, 16.5), QPointF(16.5, 9.5)) # corps + p.drawLine(QPointF(9.5, 16.5), QPointF(8.5, 18)) # pointe + p.drawLine(QPointF(14.8, 7.8), QPointF(18, 11)) # gomme + + +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) + 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: + _floppy(p) + else: # SAVE_AS : disquette réduite + crayon + _floppy(p, -1.5, -1.5, 0.85) + p.setPen(_pen(color, 1.4)) + _pencil(p) + 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_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.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 From 0e20f690bef7549397189796409e8fcf9a5f9106 Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 14:13:41 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(ui):=20ic=C3=B4nes=20Enregistrer=20/?= =?UTF-8?q?=20Enregistrer=20sous=20dans=20le=20style=20disquette=20=C3=A0?= =?UTF-8?q?=20coin=20coup=C3=A9=20+=20crayon=20plein?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/pdf_equilibrist/ui/quick_access.py | 91 ++++++++++++++++++++------ 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/src/pdf_equilibrist/ui/quick_access.py b/src/pdf_equilibrist/ui/quick_access.py index 5737056..fe6b231 100644 --- a/src/pdf_equilibrist/ui/quick_access.py +++ b/src/pdf_equilibrist/ui/quick_access.py @@ -12,7 +12,7 @@ from __future__ import annotations from PyQt6.QtWidgets import QWidget, QHBoxLayout, QPushButton -from PyQt6.QtGui import QPainter, QPainterPath, QPen, QColor, QKeySequence, QAction +from PyQt6.QtGui import QPainter, QPainterPath, QPen, QColor, QKeySequence, QAction, QPixmap, QPolygonF from PyQt6.QtCore import Qt, QPointF, QRectF from pdf_equilibrist import theme @@ -50,18 +50,47 @@ def _folder(p: QPainter) -> None: p.drawLine(QPointF(2, 8.5), QPointF(15, 8.5)) -def _floppy(p: QPainter, dx: float = 0, dy: float = 0, s: float = 1.0) -> None: - def r(x, y, w, h): - return QRectF(dx + x * s, dy + y * s, w * s, h * s) - p.drawRoundedRect(r(2.5, 2.5, 13, 13), 1.6 * s, 1.6 * s) - p.drawRect(r(5.5, 2.5, 7, 4)) # volet métallique - p.drawRect(r(5.5, 10, 7, 5.5)) # étiquette - - -def _pencil(p: QPainter) -> None: - p.drawLine(QPointF(9.5, 16.5), QPointF(16.5, 9.5)) # corps - p.drawLine(QPointF(9.5, 16.5), QPointF(8.5, 18)) # pointe - p.drawLine(QPointF(14.8, 7.8), QPointF(18, 11)) # gomme +# 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): @@ -83,7 +112,8 @@ def paintEvent(self, _event): color = theme.qcolor("#D8D8D8" if enabled else "#555555") p.setPen(_pen(color)) p.setBrush(Qt.BrushStyle.NoBrush) - p.translate((SIZE - 18) / 2, (SIZE - 18) / 2) + 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: @@ -93,11 +123,34 @@ def paintEvent(self, _event): elif self.kind == OPEN: _folder(p) elif self.kind == SAVE: - _floppy(p) - else: # SAVE_AS : disquette réduite + crayon - _floppy(p, -1.5, -1.5, 0.85) - p.setPen(_pen(color, 1.4)) - _pencil(p) + 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() From 3466ed9a242f9042f643cb160d81a44426faf0ad Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 14:20:21 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20ic=C3=B4nes=20actives=20en=20gris=20?= =?UTF-8?q?fonc=C3=A9=20(th=C3=A8me=20clair)=20;=20Enregistrer=20sous=20me?= =?UTF-8?q?t=20=C3=A0=20jour=20le=20chemin=20du=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - theme : #D8D8D8 (icône active) -> #3A3A3A en clair ; les icônes grisées restent gris clair. - Document.save(path, rebind=True) : après Enregistrer sous, le document pointe sur le nouveau fichier (onglet, barre d'état, fichiers récents, Ctrl+S suivant). Sans rebind (export), inchangé. Co-Authored-By: Claude Sonnet 5.5 --- src/pdf_equilibrist/core/document.py | 7 ++++++- src/pdf_equilibrist/theme.py | 2 +- src/pdf_equilibrist/ui/main_window.py | 15 +++++++++++---- tests/test_core/test_document_modified.py | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/pdf_equilibrist/core/document.py b/src/pdf_equilibrist/core/document.py index 1ec50bd..9ade4be 100644 --- a/src/pdf_equilibrist/core/document.py +++ b/src/pdf_equilibrist/core/document.py @@ -122,7 +122,7 @@ def open(self, path: str | Path): 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. @@ -131,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 ---- @@ -167,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): 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 796191c..d82cf44 100644 --- a/src/pdf_equilibrist/ui/main_window.py +++ b/src/pdf_equilibrist/ui/main_window.py @@ -769,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" @@ -791,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/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 From 22bbae8f31f08c849c99925b2dd34e471b3c8432 Mon Sep 17 00:00:00 2001 From: Paul WOISARD Date: Sun, 18 Oct 2026 14:49:14 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(i18n):=20recompiler=20le=20.qm=20angl?= =?UTF-8?q?ais=20(R=C3=A9tablir)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- translations/pdf_equilibrist_en.qm | Bin 44242 -> 44298 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/translations/pdf_equilibrist_en.qm b/translations/pdf_equilibrist_en.qm index 14bb88c1e248366d8009ce149cf34ecb338eeecb..13dcb447bcbc2381dcbdcdc13ab2c12ef3aefa06 100644 GIT binary patch delta 3208 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 delta 3136 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 W_|{cuXr}{FESLiwi)XaXN&P>QJg5Eu