Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/pdf_equilibrist/core/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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é ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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.

Expand All @@ -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
----
Expand Down Expand Up @@ -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):
Expand All @@ -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()

Expand All @@ -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
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/pdf_equilibrist/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 35 additions & 8 deletions src/pdf_equilibrist/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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()
Expand All @@ -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"))
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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"
Expand All @@ -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 ────────────────────────────────────────

Expand Down
184 changes: 184 additions & 0 deletions src/pdf_equilibrist/ui/quick_access.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading