From b1f5b878f6ee3dc14eb28ae08c37db314c8ccc73 Mon Sep 17 00:00:00 2001 From: Josh Wren Date: Mon, 27 Jul 2026 14:45:38 -0500 Subject: [PATCH 1/2] Add on-canvas page counter with luminance-based color Render a current/total fraction in the bottom-right of the canvas. The counter text auto-flips between black and white based on a luminance sample of the displayed page so it stays legible against both light and dark art. Hidden on text/info-overlay pages, single-page sources, and when no source is loaded. Covered by 17 new tests in tests/test_page_counter.py. Coverage remains at the pre-change 95.79% baseline. --- cdisplayagain.py | 71 +++++++++ docs/PARITY.md | 1 + tests/test_page_counter.py | 318 +++++++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 tests/test_page_counter.py diff --git a/cdisplayagain.py b/cdisplayagain.py index 875f75d..209cb5a 100644 --- a/cdisplayagain.py +++ b/cdisplayagain.py @@ -458,6 +458,7 @@ def __init__(self, master: tk.Tk, comic_path: Path): self._current_pil: Image.Image | None = None self._current_index: int = 0 self._canvas_image_id: int | None = None + self._page_counter_id: int | None = None # Lightweight caches - store PIL Image objects directly to avoid encode/decode roundtrip self._pil_cache: dict[str, Image.Image] = {} @@ -909,6 +910,8 @@ def _display_cached_image(self, img: Image.Image): self._canvas_image_id = self.canvas.create_image(x, y, image=self._tk_img, anchor=anchor) perf_log("canvas_update", time.perf_counter() - canvas_start) + self._update_page_counter() + def _display_image_fast(self, img: Image.Image): """Display PIL image with fast NEAREST resampling for instant preview.""" cw = max(1, self.canvas.winfo_width()) @@ -953,6 +956,8 @@ def _display_image_fast(self, img: Image.Image): self._canvas_image_id = self.canvas.create_image(x, y, image=self._tk_img, anchor=anchor) perf_log("display_fast_image", time.perf_counter() - imagetk_start) + self._update_page_counter() + def _update_from_cache(self, index: int, img: Image.Image): logging.info("Update from cache: index=%d, current_index=%d", index, self._current_index) @@ -1239,6 +1244,65 @@ def _update_title(self): f" ({self._current_index + 1}/{total})" ) + def _page_counter_text(self) -> str | None: + """Return the page counter fraction for the current page, or None to hide it.""" + if not self.source: + return None + name = self.source.pages[self._current_index] + if is_text_name(name): + return None + total = len(self.source.pages) + if total <= 1: + return None + return f"{self._current_index + 1}/{total}" + + def _page_counter_color(self) -> str: + """Pick black or white text based on the mean luminance of the current page.""" + if self._current_pil is None: + return "#ffffff" + try: + from PIL import ImageStat + + thumb = self._current_pil.convert("L").resize((32, 32), Image.Resampling.BILINEAR) + mean = ImageStat.Stat(thumb).mean[0] + except Exception: + return "#ffffff" + return "#000000" if mean >= 128 else "#ffffff" + + def _update_page_counter(self) -> None: + """Render (or hide) the page-counter fraction in the bottom-right of the canvas.""" + if self._page_counter_id is not None: + try: + self.canvas.delete(self._page_counter_id) + except tk.TclError: + pass + self._page_counter_id = None + + text = self._page_counter_text() + if text is None: + return + + cw = max(1, self.canvas.winfo_width()) + ch = max(1, self.canvas.winfo_height()) + margin = 12 + self._page_counter_id = self.canvas.create_text( + cw - margin, + ch - margin, + text=text, + anchor="se", + fill=self._page_counter_color(), + font="TkFixedFont 12", + ) + + def _clear_page_counter(self) -> None: + """Remove the page counter (called when canvas content is cleared).""" + if self._page_counter_id is not None: + try: + self.canvas.delete(self._page_counter_id) + except tk.TclError: + pass + self._page_counter_id = None + def _find_next_image_index(self, start_index: int) -> int | None: if not self.source: return None @@ -1251,10 +1315,12 @@ def _render_current(self): if not self.source: self.canvas.delete("all") self._canvas_image_id = None + self._page_counter_id = None return name = self.source.pages[self._current_index] if is_text_name(name): + self._clear_page_counter() self._render_info_with_image(name) self._update_title() return @@ -1287,11 +1353,13 @@ def _render_current(self): def _render_current_sync(self): if not self.source: + self._clear_page_counter() return render_start = time.perf_counter() name = self.source.pages[self._current_index] if is_text_name(name): + self._clear_page_counter() self._render_info_with_image(name) self._update_title() perf_log("render_current_sync", time.perf_counter() - render_start, "info_page") @@ -1350,6 +1418,7 @@ def _render_info_with_image(self, name: str) -> None: if image_index is None: self.canvas.delete("all") self._canvas_image_id = None + self._page_counter_id = None self._current_pil = None self._scaled_size = None self._scroll_offset = 0 @@ -1364,12 +1433,14 @@ def _render_info_with_image(self, name: str) -> None: if cached: self._display_cached_image(cached) self._show_info_overlay(name) + self._clear_page_counter() return self._get_worker().request_page( image_index, cw, ch, preload=False, render_generation=self._render_generation ) self._show_info_overlay(name) + self._clear_page_counter() def _show_info_overlay(self, name: str) -> None: if not self.source: diff --git a/docs/PARITY.md b/docs/PARITY.md index 518a955..ef350b0 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -31,6 +31,7 @@ These features match CDisplay's behavior and are fully implemented: - ✅ Sequential reading across comics in same directory (next/previous comic) - ✅ Mouse wheel scrolls/navigates (Windows/Mac/Linux X11 bindings) - ✅ Mouse drag to pan page +- ✅ Page counter (bottom-right current/total fraction, auto black/white by page luminance) ### Keyboard Shortcuts - ✅ `q` / `Q` / `x` / `X` quits the app diff --git a/tests/test_page_counter.py b/tests/test_page_counter.py new file mode 100644 index 0000000..27fc8b9 --- /dev/null +++ b/tests/test_page_counter.py @@ -0,0 +1,318 @@ +"""Tests for the on-canvas page counter feature.""" + +from __future__ import annotations + +from pathlib import Path + +import cdisplayagain + + +def _write_image(path: Path, size=(10, 10), color=(128, 128, 128)) -> None: + from PIL import Image + + img = Image.new("RGB", size, color=color) + img.save(path) + + +def _counter_text(viewer: cdisplayagain.ComicViewer) -> str | None: + """Return the canvas text of the page counter, or None if absent.""" + if viewer._page_counter_id is None: + return None + return viewer.canvas.itemcget(viewer._page_counter_id, "text") + + +def test_counter_shows_on_image_page(tk_root, tmp_path): + """Loading a multi-image source renders a counter on the first image page.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(255, 255, 255)) + _write_image(folder / "page2.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + + from PIL import Image + + viewer._display_cached_image(Image.new("RGB", (50, 50), color=(255, 255, 255))) + viewer.update() + + assert viewer._page_counter_id is not None + assert _counter_text(viewer) == "1/2" + + +def test_counter_updates_on_next_page(tk_root, tmp_path): + """The counter text advances with the page index.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(255, 255, 255)) + _write_image(folder / "page2.png", color=(255, 255, 255)) + _write_image(folder / "page3.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + + from PIL import Image + + viewer._display_cached_image(Image.new("RGB", (50, 50), color=(255, 255, 255))) + assert _counter_text(viewer) == "1/3" + + viewer._current_index = 1 + viewer._display_cached_image(Image.new("RGB", (50, 50), color=(255, 255, 255))) + assert _counter_text(viewer) == "2/3" + + +def test_counter_hidden_on_text_page(tk_root, tmp_path): + """Counter is hidden on text/info pages.""" + folder = tmp_path / "book" + folder.mkdir() + (folder / "info.txt").write_text("info") + _write_image(folder / "page1.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder) + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + + viewer._render_current_sync() + assert viewer._page_counter_id is None + + +def test_counter_hidden_on_info_overlay_with_image(tk_root, tmp_path): + """Counter stays hidden when info overlay is shown alongside image.""" + folder = tmp_path / "book" + folder.mkdir() + (folder / "info.txt").write_text("info") + _write_image(folder / "page1.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder) + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + + from PIL import Image + + cw = max(1, viewer.canvas.winfo_width()) + ch = max(1, viewer.canvas.winfo_height()) + viewer._image_cache[(1, cw, ch)] = Image.new("RGB", (50, 50), color=(255, 255, 255)) + + viewer._render_current_sync() + assert viewer._info_overlay is not None + assert viewer._page_counter_id is None + + +def test_counter_color_black_on_light_page(tk_root, tmp_path): + """Counter color is black when page luminance is high.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(255, 255, 255)) + _write_image(folder / "page2.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + + from PIL import Image + + viewer._current_pil = Image.new("RGB", (50, 50), color=(255, 255, 255)) + assert viewer._page_counter_color() == "#000000" + + +def test_counter_color_white_on_dark_page(tk_root, tmp_path): + """Counter color is white when page luminance is low.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(0, 0, 0)) + _write_image(folder / "page2.png", color=(0, 0, 0)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + + from PIL import Image + + viewer._current_pil = Image.new("RGB", (50, 50), color=(0, 0, 0)) + assert viewer._page_counter_color() == "#ffffff" + + +def test_counter_color_defaults_white_without_pil(tk_root, tmp_path): + """Counter color defaults to white when no PIL image is set.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png") + _write_image(folder / "page2.png") + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_pil = None + + assert viewer._page_counter_color() == "#ffffff" + + +def test_counter_color_falls_back_when_pil_invalid(tk_root, tmp_path): + """Counter color falls back to white when luminance sampling fails.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png") + _write_image(folder / "page2.png") + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + + from PIL import Image + + class BrokenImage(Image.Image): + @staticmethod + def resize(*args, **kwargs): + raise OSError("resize failed") + + viewer._current_pil = BrokenImage() + assert viewer._page_counter_color() == "#ffffff" + + +def test_update_page_counter_handles_stale_id(tk_root, tmp_path): + """_update_page_counter handles a stale counter id without raising.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(255, 255, 255)) + _write_image(folder / "page2.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + + viewer._update_page_counter() + real_id = viewer._page_counter_id + assert real_id is not None + + viewer.canvas.delete(real_id) + viewer._update_page_counter() + assert viewer._page_counter_id is not None + assert viewer._page_counter_id != real_id + + +def test_clear_page_counter_handles_missing_item(tk_root, tmp_path): + """_clear_page_counter swallows TclError when id no longer exists.""" + _write_image(tmp_path / "page1.png") + viewer = cdisplayagain.ComicViewer(tk_root, tmp_path / "page1.png") + viewer.update() + viewer._page_counter_id = 999999 + viewer._clear_page_counter() + assert viewer._page_counter_id is None + + +def test_clear_page_counter_swallows_tclerror(tk_root, tmp_path): + """_clear_page_counter swallows TclError raised by canvas.delete.""" + from unittest.mock import patch + + from cdisplayagain import tk as cdisplayagain_tk + + _write_image(tmp_path / "page1.png") + viewer = cdisplayagain.ComicViewer(tk_root, tmp_path / "page1.png") + viewer.update() + viewer._page_counter_id = 1 + + with patch.object( + viewer.canvas, + "delete", + side_effect=cdisplayagain_tk.TclError("already gone"), + ): + viewer._clear_page_counter() + assert viewer._page_counter_id is None + + +def test_update_page_counter_swallows_tclerror(tk_root, tmp_path): + """_update_page_counter swallows TclError raised by canvas.delete.""" + from unittest.mock import patch + + from cdisplayagain import tk as cdisplayagain_tk + + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(255, 255, 255)) + _write_image(folder / "page2.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + viewer._page_counter_id = 1 + + with patch.object( + viewer.canvas, + "delete", + side_effect=cdisplayagain_tk.TclError("already gone"), + ): + viewer._update_page_counter() + assert viewer._page_counter_id is not None + + +def test_counter_text_none_when_no_source(tk_root, tmp_path): + """_page_counter_text returns None with no source.""" + _write_image(tmp_path / "page1.png") + viewer = cdisplayagain.ComicViewer(tk_root, tmp_path / "page1.png") + viewer.source = None + + assert viewer._page_counter_text() is None + + +def test_counter_text_none_with_single_page(tk_root, tmp_path): + """Counter is suppressed when there is only one page in the source.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png") + + viewer = cdisplayagain.ComicViewer(tk_root, folder) + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + + assert viewer._page_counter_text() is None + + +def test_update_page_counter_creates_and_clears(tk_root, tmp_path): + """_update_page_counter creates an item when text exists, clears when it does not.""" + folder = tmp_path / "book" + folder.mkdir() + _write_image(folder / "page1.png", color=(255, 255, 255)) + _write_image(folder / "page2.png", color=(255, 255, 255)) + + viewer = cdisplayagain.ComicViewer(tk_root, folder / "page1.png") + viewer.source = cdisplayagain.load_directory(folder) + viewer.update() + viewer._current_index = 0 + + viewer._update_page_counter() + assert viewer._page_counter_id is not None + + viewer._clear_page_counter() + assert viewer._page_counter_id is None + + +def test_render_current_no_source_clears_counter(tk_root, tmp_path): + """_render_current with no source clears the counter id.""" + _write_image(tmp_path / "page1.png") + viewer = cdisplayagain.ComicViewer(tk_root, tmp_path / "page1.png") + viewer.update() + viewer.source = None + viewer._page_counter_id = 999 + + viewer._render_current() + assert viewer._page_counter_id is None + + +def test_render_current_sync_no_source_clears_counter(tk_root, tmp_path): + """_render_current_sync with no source clears the counter.""" + _write_image(tmp_path / "page1.png") + viewer = cdisplayagain.ComicViewer(tk_root, tmp_path / "page1.png") + viewer.update() + viewer.source = None + viewer._page_counter_id = 999 + + viewer._render_current_sync() + assert viewer._page_counter_id is None From 64ae07f8c01e0ee0a0dae4e4871daafeccedd86d Mon Sep 17 00:00:00 2001 From: Josh Wren Date: Mon, 27 Jul 2026 15:05:30 -0500 Subject: [PATCH 2/2] Dedupe _update_page_counter cleanup via _clear_page_counter --- cdisplayagain.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cdisplayagain.py b/cdisplayagain.py index 209cb5a..911d4b4 100644 --- a/cdisplayagain.py +++ b/cdisplayagain.py @@ -1271,12 +1271,7 @@ def _page_counter_color(self) -> str: def _update_page_counter(self) -> None: """Render (or hide) the page-counter fraction in the bottom-right of the canvas.""" - if self._page_counter_id is not None: - try: - self.canvas.delete(self._page_counter_id) - except tk.TclError: - pass - self._page_counter_id = None + self._clear_page_counter() text = self._page_counter_text() if text is None: