nextpytk 0.4.6__tar.gz → 0.4.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextpytk
3
- Version: 0.4.6
3
+ Version: 0.4.7
4
4
  Summary: Accessible, declarative Tkinter applications from ordinary Python functions.
5
5
  Project-URL: Homepage, https://github.com/nishimotz/nextpytk
6
6
  Project-URL: Repository, https://github.com/nishimotz/nextpytk
@@ -495,6 +495,11 @@ panes and both gutters share a **single vertical scrollbar**, so the gutters
495
495
  stay in lock-step with the content (no drift). Logical line numbers
496
496
  (`1..n`) follow the pane content; the right pane's numbers update as you edit.
497
497
 
498
+ > **Note:** `line_numbers=True` takes precedence over `sync_yscroll`. The
499
+ > shared-scrollbar layout it installs keeps both panes (and both gutters)
500
+ > scrolled in lock-step, so passing `sync_yscroll=False` together with
501
+ > `line_numbers=True` has no effect. If the panes must scroll independently,
502
+ > leave `line_numbers` off.
498
503
 
499
504
  ### Wrap layout
500
505
 
@@ -471,6 +471,11 @@ panes and both gutters share a **single vertical scrollbar**, so the gutters
471
471
  stay in lock-step with the content (no drift). Logical line numbers
472
472
  (`1..n`) follow the pane content; the right pane's numbers update as you edit.
473
473
 
474
+ > **Note:** `line_numbers=True` takes precedence over `sync_yscroll`. The
475
+ > shared-scrollbar layout it installs keeps both panes (and both gutters)
476
+ > scrolled in lock-step, so passing `sync_yscroll=False` together with
477
+ > `line_numbers=True` has no effect. If the panes must scroll independently,
478
+ > leave `line_numbers` off.
474
479
 
475
480
  ### Wrap layout
476
481
 
@@ -299,7 +299,56 @@ visual row-major order). Planned to generalize for `side="bottom"` sections.
299
299
 
300
300
  ---
301
301
 
302
- ## Near term (post-0.4.5)
302
+ ## Snapshot (v0.4.7) — paired gutter logical-line fix
303
+
304
+ ### Bug fix: line-number gutters count logical lines, not display lines
305
+
306
+ - `Layout.paired(..., line_numbers=True)` gutters numbered **physical**
307
+ (wrapped) rows: `_reconcile_gutter` / `_populate_gutters` used
308
+ `text.index("end-1c")`, which counts display rows. With `wrap="word"` /
309
+ `"char"`, a long line that wrapped inflated the gutter count beyond the
310
+ real line count.
311
+ - Added `_logical_line_count(text)`, which counts newline characters (a
312
+ wrapped row adds no newline), adjusted for whether the buffer ends in a
313
+ newline. The gutter now mirrors true logical lines regardless of wrapping.
314
+
315
+ ### Bug fix: re-entry recursion in gutter sync
316
+
317
+ - Rewriting a gutter calls `update_idletasks()`, which fires the shared
318
+ scrollbar's `yscrollcommand` and re-enters `_reconcile_gutter` /
319
+ `_populate_gutters` through `_chain_yview` — an infinite recursion that
320
+ surfaced once the gutter logic changed.
321
+ - Added a per-gutter `_syncing_gutter` re-entry guard to both helpers.
322
+
323
+ ### Bug fix: `on_text_set` hook leak across re-runs
324
+
325
+ - `app.on_text_set()` hooks were never cleared by `clear_runtime()`, so
326
+ hooks (e.g. gutter line-number sync) accumulated across re-runs — such as
327
+ swap variants rebuilt at runtime — causing the gutter sync to run multiple
328
+ times per `text_set`.
329
+ - `clear_runtime()` now also clears `_text_set_hooks`.
330
+
331
+ ### Tests
332
+
333
+ - Added `test_paired_gutters_count_logical_lines_when_wrapped` (a long,
334
+ wrapping line must not inflate the gutter line numbers).
335
+ - Added `test_paired_gutter_sync_does_not_recursively_loop` (multiple
336
+ `text_set` calls do not recurse infinitely through the shared scrollbar).
337
+ - Added `test_clear_runtime_clears_text_set_hooks` (hooks are dropped on
338
+ `clear_runtime()`).
339
+ - `tests/test_paired.py` grew; full suite green.
340
+
341
+ ### Docs: `line_numbers=True` takes precedence over `sync_yscroll`
342
+
343
+ - Documented that enabling `line_numbers` installs a shared scrollbar that
344
+ always keeps both panes (and both gutters) in lock-step, so
345
+ `sync_yscroll=False` is ignored when `line_numbers=True`. Callers needing
346
+ independent pane scrolling must leave `line_numbers` off. Clarified in the
347
+ `Layout.paired` docstring and README (EN / JA).
348
+
349
+ ---
350
+
351
+ ## Near term (post-0.4.7)
303
352
 
304
353
  ### A11y
305
354
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nextpytk"
7
- version = "0.4.6"
7
+ version = "0.4.7"
8
8
  description = "Accessible, declarative Tkinter applications from ordinary Python functions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -356,6 +356,7 @@ class TkApp:
356
356
  self._text_inner.clear()
357
357
  self._text_scrollbars.clear()
358
358
  self._text_scroll_sync.clear()
359
+ self._text_set_hooks.clear()
359
360
  self._a11y_last_toggle.clear()
360
361
  self._current_view = None
361
362
  self._current_stage = None
@@ -783,6 +783,25 @@ def _place_paired_with_gutters(
783
783
  _bind_gutter_edit_sync(app, text_b, gutter_b)
784
784
 
785
785
 
786
+ def _logical_line_count(text: tk.Text) -> int:
787
+ """Return the number of logical (non-wrapped) lines in *text*.
788
+
789
+ ``index("end-1c")`` counts *display* lines: a long line that wraps to
790
+ several rows inflates the count when ``wrap`` is ``word``/``char``. We
791
+ count newline characters instead, which is wrap-independent. The last
792
+ logical line may or may not carry a trailing newline (``text_set`` omits
793
+ it; a hand-inserted buffer may keep it), so the count is adjusted by
794
+ whether the content ends with a newline.
795
+ """
796
+ try:
797
+ content = text.get("1.0", "end-1c")
798
+ except tk.TclError:
799
+ return 0
800
+ if not content:
801
+ return 0
802
+ return content.count("\n") + (0 if content.endswith("\n") else 1)
803
+
804
+
786
805
  def _reconcile_gutter(gutter: tk.Text, text: tk.Text) -> None:
787
806
  """Fill a gutter with logical line numbers, matching the text line count.
788
807
 
@@ -790,17 +809,26 @@ def _reconcile_gutter(gutter: tk.Text, text: tk.Text) -> None:
790
809
  gutter is rewritten so its scroll range matches the pane. Idempotent:
791
810
  rewriting only happens when the count differs.
792
811
  """
793
- n = int(text.index("end-1c").split(".")[0])
812
+ # Re-entry guard: rewriting the gutter fires yscrollcommand on the shared
813
+ # scrollbar (via update_idletasks below), which re-enters this function
814
+ # through _chain_yview. Without a guard that recursion never terminates.
815
+ if getattr(gutter, "_syncing_gutter", False):
816
+ return
817
+ n = _logical_line_count(text)
794
818
  gutter_lines = int(gutter.index("end-1c").split(".")[0])
795
819
  if gutter_lines == n:
796
820
  return
797
821
  lines = "\n".join(str(i) for i in range(1, n + 1))
798
- gutter.configure(state="normal")
799
- gutter.delete("1.0", "end")
800
- gutter.insert("1.0", lines)
801
- gutter.configure(state="disabled")
802
- # Ensure the new content is reflected in the shared scrollbar range.
803
- gutter.update_idletasks()
822
+ gutter._syncing_gutter = True # type: ignore[attr-defined]
823
+ try:
824
+ gutter.configure(state="normal")
825
+ gutter.delete("1.0", "end")
826
+ gutter.insert("1.0", lines)
827
+ gutter.configure(state="disabled")
828
+ # Ensure the new content is reflected in the shared scrollbar range.
829
+ gutter.update_idletasks()
830
+ finally:
831
+ gutter._syncing_gutter = False # type: ignore[attr-defined]
804
832
 
805
833
 
806
834
  def _chain_yview(
@@ -857,14 +885,20 @@ def _make_gutter(parent: tk.Misc, name: str) -> tk.Text:
857
885
 
858
886
  def _populate_gutters(gutter: tk.Text, text: tk.Text) -> None:
859
887
  """Fill a gutter with logical line numbers (1..n)."""
860
- n = int(text.index("end-1c").split(".")[0])
888
+ if getattr(gutter, "_syncing_gutter", False):
889
+ return
890
+ n = _logical_line_count(text)
861
891
  lines = "\n".join(str(i) for i in range(1, n + 1))
862
- gutter.configure(state="normal")
863
- gutter.delete("1.0", "end")
864
- gutter.insert("1.0", lines)
865
- gutter.configure(state="disabled")
866
- # Ensure the new content is reflected in the shared scrollbar range.
867
- gutter.update_idletasks()
892
+ gutter._syncing_gutter = True # type: ignore[attr-defined]
893
+ try:
894
+ gutter.configure(state="normal")
895
+ gutter.delete("1.0", "end")
896
+ gutter.insert("1.0", lines)
897
+ gutter.configure(state="disabled")
898
+ # Ensure the new content is reflected in the shared scrollbar range.
899
+ gutter.update_idletasks()
900
+ finally:
901
+ gutter._syncing_gutter = False # type: ignore[attr-defined]
868
902
 
869
903
 
870
904
  def _bind_gutter_edit_sync(app: TkApp, text: tk.Text, gutter: tk.Text) -> None:
@@ -1347,6 +1381,14 @@ class Layout:
1347
1381
  the gutters always stay in lock-step with the content (no drift). Line
1348
1382
  numbers are logical rows of the text widget (``1..n``).
1349
1383
 
1384
+ .. note::
1385
+ ``line_numbers=True`` takes precedence over ``sync_yscroll``: the
1386
+ shared-scrollbar layout it installs always keeps the two panes (and
1387
+ both gutters) scrolled in lock-step, so passing
1388
+ ``sync_yscroll=False`` with ``line_numbers=True`` has no effect. If
1389
+ you need the panes to scroll independently, do **not** enable
1390
+ ``line_numbers``.
1391
+
1350
1392
  Example::
1351
1393
 
1352
1394
  app.text("left", readonly=True, sync_yscroll_with="right")
File without changes
File without changes
File without changes
File without changes