tkinter façade: a callable option that receives Tk's OWN arguments
bind and -command hand a Python callable either nothing or one Event, and
the dispatcher (pxxcb) covers both. Tk also calls options with its own
argument lists, and those have no path through the façade yet:
| option | Tk calls it with |
|---|---|
-yscrollcommand / -xscrollcommand |
first last (two fractions) |
a scrollbar's -command |
moveto <frac> or scroll <n> units|pages |
-validatecommand, -postcommand, trace handlers |
their own argument sets |
For the scroll pair this does not matter today: CPython's tkinter does not call
back into Python for them either — it wires Tcl straight to the other widget's
subcommand, and TkiOptScrollCmd now does the same (receiver → widget path,
option name → subcommand). Anything that is NOT a widget method is refused
loudly there rather than wired to something wrong.
What is missing is the general shape:
- a registry entry that records "this callback takes Tcl's raw arguments";
- a dispatcher branch that packs
argv[2..]into Python values instead of building an Event; - call bridges for 2 and 3 arguments —
pycallback_call2/3for a bound method, and a bound-fn / closure that accepts more than one own parameter (todaypyboundfn_call_ptrpasses exactly one).
Until it lands, a plain def or lambda as a scroll command fails with a message naming the limit.
Why it was filed
songformatter's settings.py writes both canonical spellings
(Scrollbar(..., command=self.canvas.yview) and
canvas.configure(yscrollcommand=self.scrollbar.set)). Passing a bound method
into the façade's former AnsiString option compiled silently and handed Tcl a
garbage script; the event loop then hung inside update. See
[[bug-nilpy-bound-method-coerced-to-string]].
Moved to blocked/ 2026-07-31 — the missing piece is runtime, not façade
Re-read against the code rather than the ticket text. Of the three things listed under "what is missing", the first two are façade work Track B owns — a registry flag and a dispatcher branch. The third is not:
call bridges for 2 and 3 arguments —
pycallback_call2/3for a bound method, and a bound-fn / closure that accepts more than one own parameter (todaypyboundfn_call_ptrpasses exactly one).
pycallback_call0/1 live in compiler/builtin/pylib.pas:581 and
pyboundfn_call_ptr in pyeval.pas:1870 — shared NilPy runtime, which Track B
does not edit. Without them the façade half has nothing to call, so building
the registry and the dispatcher branch first would land dead code.
Split and filed as [[feature-nilpy-multi-arg-callback-bridges]] (Track N).
Tagged for later: when that lands, the remaining work here is small and
entirely in lib/pcl/tkinter.pas.
Nothing regresses in the meantime. The case that motivated this ticket — the
scrollbar pair — turned out not to need it at all: CPython's tkinter does not
call back into Python for yscrollcommand either, it wires Tcl straight to the
other widget's subcommand, and TkiOptScrollCmd now does the same and refuses
loudly for anything that is not a widget method. Nothing enters the regression
suite from here until the bridges exist.
2026-08-03 — dependency recorded in frontmatter
The 2026-07-31 note above identified the blocker in prose (the shared-runtime
pycallback_call2/3 / multi-arg pyboundfn_call_ptr bridges) but no
blocked-by: edge existed, so the board could not see it: this ticket ranked as
ready and would have been handed to a Track B agent who cannot edit
compiler/builtin/pylib.pas. The bridges have their own ticket —
[[feature-nilpy-multi-arg-callback-bridges]] — and it is now the recorded edge,
so priority propagates to it and this surfaces only once it lands.
Unblocked 2026-08-09 (Track B): the blocker is satisfied in practice
Swept as part of checking whether Track B's blocked tickets were still really blocked — the pattern this session kept hitting is that they were not. The capability this ticket waited on was MEASURED working on the current pin (v252); the evidence is recorded on the blocker itself, which Track N still owns formally closing.
blocked-by removed here so the ticket stops hiding from progress.sh ready.
2026-08-10 — actually MOVED out of blocked/
The 2026-08-09 note above removed blocked-by and said the ticket should stop
hiding from progress.sh ready — but the FILE was never moved out of
blocked/, so it went on hiding anyway. ready reads the directory, not the
frontmatter. Moved to backlog/ now; the unblocking claim above is unchanged
and is the one that matters.
2026-08-10 (Track B): implemented, and the bridges were already there
All three pieces done, entirely in lib/pcl/tkinter.pas as the 2026-07-31 note
predicted — no Track N edit was needed after all.
The blocker was real but misnamed. This ticket waited on
pycallback_call2/3, and those still do not exist. What DOES exist, and covers
the same need better, is pyvar_callv0..3 in pyeval — public, and
explicitly covering all four callable shapes (bound method, closure, lifted
bound-fn, plain compiled def), because NilPy's own dynamic call sites lower to
them. So the façade never has to know which kind it is holding.
Worth recording that the 2026-08-09 unblocking reached the right conclusion from
the wrong evidence: what it measured was a NilPy program calling f(4, 5) —
Python-to-Python — which is a different capability from Pascal-to-Python, the
one this ticket needs. The conclusion survived only because pyvar_callv*
happened to exist.
What landed:
gTkCbRaw[]— a per-slot flag saying "this handler takes Tcl's own arguments", plusTkiRegisterRawCallback.- A dispatcher branch in
TkiCbDispatch: a raw slot goes toTkiCallRaw, which packsargv[2..]as string Variants and callspyvar_callv0..3by count, instead of building anEventwhose fields are a BINDING's % substitutions and mean nothing here. TkiOptScrollCmdnow accepts any callable instead ofHalt(1). A widget method still wires Tcl-to-Tcl (what CPython does — no Python in the loop); everything else gets a raw slot.
Above three arguments it refuses and does not call. -validatecommand
offers eight substitutions, which is past what the bridges cover; calling with
the first three would be a silent truncation, and a handler running on the wrong
arguments is worse than one that did not run.
Measured working
examples/tk/callbacks.npy (extended, run under Xvfb — it is compile-only in
the suite because it needs a display):
scroll 0.0 1.0 <- a BOUND METHOD, two Tk fractions
lambda scroll 0.0 1.0 <- a LAMBDA, same
...
scroll 0.5 0.6 <- after yview moveto 0.5, values track
lambda scroll 0.25 0.35
A plain def was verified the same way. This is the case that used to die at
configure time with "a plain callable cannot receive Tk's scroll arguments yet".
One thing measured that contradicts a comment in pylib
A plain def in an option arrives as tag 2 — its code ADDRESS boxed as an
integer — and pycallback_is is False for it. pylib's comment above
pycallback_call0 says a plain def reaches it "as the same pair with a nil
receiver"; in this position it does not. pycall_value agrees with the
measurement, not the comment ("a plain compiled def: the value IS its code
address").
The consequence is worth stating because the façade cannot fix it: a def and a
plain integer have the SAME representation here, so yscrollcommand=5 is
indistinguishable from a callable and would jump to address 5. CPython raises
TypeError because it keeps the two distinct. Left alone rather than papered over
with a range check, which would reject valid low addresses and still accept high
integers. Filed as a note, not a workaround.
Gate note: the suite ASSERTS this, and caught me breaking it
lib-test runs callbacks.npy under xvfb-run and compares its last six
lines. Appending scroll output changed that tail, so the first gate run went RED
— correctly, on my own change. The expectation now covers the new lines.
The assertion deliberately prints scroll ok True True rather than the
fractions: the values depend on the geometry the window manager hands out, so
asserting them would make the suite environment-sensitive without proving
anything more than "two arguments arrived", which is the whole feature.
(The RED was also a reminder to read the WHOLE gate log — tail -5 showed
crtl_exp2 as the last echoed command and the real failure was a silent
@if block further down, which sent me looking at the wrong test first.)
Log
- 2026-08-10 — resolved, commit e0986af40.