fix(astro-menu): stop the taskbar poll resetting the window list's scroll
The workspace/window panel couldn't be scrolled: it snapped back to the top
about once a second. Nothing to do with the hologram overlay (a DrawingArea
with can_target=False that only ever queue_draw()s itself) — it was the 1 Hz
refresh that start_polling() runs while the menu is open. Both of its
callbacks called _rebuild_panel(), which did _clear(self._panel) and rebuilt
every widget, including a fresh Gtk.ScrolledWindow for the list. Emptying
scrolled content collapses the scroll adjustment's upper bound, so its value
is clamped back to 0 and the viewport jumps to the top.
The panel scaffolding (header, layout stack + switcher, list scroller) is now
built once in _build_panel_scaffold() and refreshed in place by _update_panel:
* The layout tabs are the same for every workspace — only the selection is
per-workspace, and _sync_layout_controls() already refreshes that — so
they no longer get torn down either.
* _update_window_list() compares the sorted window addresses with the last
set. Unchanged: row contents (title, ws N, icon) are updated in place, so
a retitled window can't move the viewport. Changed: only the rows are
rebuilt, and both scrollers' offsets are saved and restored on idle, once
the new rows are allocated and the adjustment's upper is no longer stale.
* _rebuild_strip() gets the same signature guard, so the poll stops
resetting the compact strip's horizontal scroll and no longer tears down a
grouped app's instance popover while it's open. It keys on addresses only:
strip titles are tooltip/popover-only and refresh on the next real change.
Applied identically to hyprlua and hyprdrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
parent
ed8fe12855
commit
68b3f2bdd0
|
|
@ -55,6 +55,11 @@ class Taskbar(Gtk.Box):
|
|||
self._dir_dds = {}
|
||||
self._fit_sws = {}
|
||||
self._poll_id: int | None = None
|
||||
# Content signatures, so a poll that found nothing new rebuilds nothing
|
||||
# (see _build_panel_scaffold for why that matters). None = never built.
|
||||
self._strip_sig: list | None = None
|
||||
self._list_order: list | None = None
|
||||
self._rows: dict = {} # window address -> row widgets, for in-place updates
|
||||
|
||||
# The workspace/window panel. It is NOT appended here: the menu window mounts
|
||||
# `panel_widget` into the quad region so that expanding COLLAPSES this strip's
|
||||
|
|
@ -64,6 +69,7 @@ class Taskbar(Gtk.Box):
|
|||
self._panel_scroll = Gtk.ScrolledWindow(
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True)
|
||||
self._panel_scroll.set_child(self._panel)
|
||||
self._build_panel_scaffold()
|
||||
|
||||
header = Gtk.CenterBox()
|
||||
title = Gtk.Label(label="Open windows", xalign=0.0)
|
||||
|
|
@ -166,21 +172,31 @@ class Taskbar(Gtk.Box):
|
|||
def _on_ws(self, ok: bool, data) -> None:
|
||||
if ok and isinstance(data, dict):
|
||||
self._active_ws = data.get("id")
|
||||
self._rebuild_panel()
|
||||
self._update_panel()
|
||||
|
||||
def _on_clients(self, ok: bool, data) -> None:
|
||||
self._clients = data if ok and isinstance(data, list) else []
|
||||
self._rebuild_strip()
|
||||
self._rebuild_panel()
|
||||
self._update_panel()
|
||||
|
||||
# -- compact strip -----------------------------------------------------
|
||||
def _rebuild_strip(self) -> None:
|
||||
self._clear(self._row)
|
||||
groups: dict[str, list] = {}
|
||||
for w in self._clients:
|
||||
if not w.get("mapped", True) or not w.get("class"):
|
||||
continue
|
||||
groups.setdefault(w["class"], []).append(w)
|
||||
# Same reasoning as the panel list: rebuild only when the strip's contents
|
||||
# actually change, so a poll can't reset its horizontal scroll — or tear down
|
||||
# a grouped app's instance popover while it is open. Keyed on addresses only:
|
||||
# a retitled window doesn't change anything visible here (titles appear in the
|
||||
# tooltip/popover, refreshed on the next real change).
|
||||
sig = [(cls, tuple(w.get("address") for w in wins))
|
||||
for cls, wins in sorted(groups.items())]
|
||||
if sig == self._strip_sig:
|
||||
return
|
||||
self._strip_sig = sig
|
||||
self._clear(self._row)
|
||||
if not groups:
|
||||
self._row.append(Gtk.Label(label="No open windows"))
|
||||
return
|
||||
|
|
@ -219,16 +235,22 @@ class Taskbar(Gtk.Box):
|
|||
return btn
|
||||
|
||||
# -- pop-open panel: layout controls + window list ---------------------
|
||||
def _rebuild_panel(self) -> None:
|
||||
self._clear(self._panel)
|
||||
|
||||
# The panel scaffolding is built ONCE, here, and every later refresh updates it in
|
||||
# place. It used to be rebuilt from scratch on each refresh (_clear(self._panel) +
|
||||
# fresh widgets, including a fresh ScrolledWindow for the window list) — and since
|
||||
# start_polling() refreshes once a second while the menu is open, that made the
|
||||
# list impossible to scroll: emptying the scrolled content collapses the scroll
|
||||
# adjustment's upper bound, so its value is clamped back to 0 and the viewport
|
||||
# snapped to the top roughly once a second.
|
||||
def _build_panel_scaffold(self) -> None:
|
||||
# tabbed layout selector for the active workspace: a tab per layout, and a
|
||||
# per-layout options page underneath that switches with the tab. Selecting a
|
||||
# tab applies that layout to the currently focused workspace.
|
||||
ws = self._active_ws
|
||||
hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0)
|
||||
hdr.add_css_class("section-title")
|
||||
self._panel.append(hdr)
|
||||
# tab applies that layout to the currently focused workspace. The tabs are the
|
||||
# same for every workspace — only the *selection* is per-workspace, and that is
|
||||
# what _sync_layout_controls() refreshes — so this is built once too.
|
||||
self._ws_hdr = Gtk.Label(label="Workspace ? layout", xalign=0.0)
|
||||
self._ws_hdr.add_css_class("section-title")
|
||||
self._panel.append(self._ws_hdr)
|
||||
|
||||
self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values])
|
||||
self._fit_sws = {} # layout name -> Gtk.Switch
|
||||
|
|
@ -240,23 +262,72 @@ class Taskbar(Gtk.Box):
|
|||
self._panel.append(switcher)
|
||||
self._panel.append(self._layout_stack)
|
||||
self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch)
|
||||
self._sync_layout_controls()
|
||||
|
||||
self._panel.append(Gtk.Separator())
|
||||
|
||||
# per-window rows: [focus/jump] [pull here]
|
||||
self._listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
self._list_scroll = Gtk.ScrolledWindow(
|
||||
vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
self._list_scroll.set_child(self._listing)
|
||||
self._panel.append(self._list_scroll)
|
||||
|
||||
def _update_panel(self) -> None:
|
||||
ws = self._active_ws
|
||||
self._ws_hdr.set_label(f"Workspace {ws if ws is not None else '?'} layout")
|
||||
self._sync_layout_controls()
|
||||
if getattr(self, "_cols_lbl", None) is not None:
|
||||
self._cols_lbl.set_label(str(self._read_cols()))
|
||||
self._update_window_list()
|
||||
|
||||
def _update_window_list(self) -> None:
|
||||
wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")]
|
||||
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
|
||||
(w.get("title") or w.get("class") or "").lower()))
|
||||
if not wins:
|
||||
self._panel.append(Gtk.Label(label="No open windows", xalign=0.0))
|
||||
order = [w.get("address") for w in wins]
|
||||
if order == self._list_order:
|
||||
# Same windows in the same order: refresh the row contents in place, so a
|
||||
# retitled window (browser tab switch, shell cwd) costs no rebuild and
|
||||
# therefore cannot move the scroll position.
|
||||
for w in wins:
|
||||
refs = self._rows.get(w.get("address"))
|
||||
if refs is not None:
|
||||
self._set_row_content(refs, w)
|
||||
return
|
||||
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scroller.set_child(listing)
|
||||
|
||||
# The list really did change shape. Rebuild just the rows (not the scroller)
|
||||
# and put the viewport back where the user left it, once the new rows have been
|
||||
# allocated — until then the adjustment's upper bound is still stale. Both
|
||||
# scrollers are saved: which one actually scrolls depends on how much height
|
||||
# the mounted panel gets, and restoring an already-zero offset is a no-op.
|
||||
saved = [(sw.get_vadjustment(), sw.get_vadjustment().get_value())
|
||||
for sw in (self._panel_scroll, self._list_scroll)]
|
||||
self._list_order = order
|
||||
self._rows = {}
|
||||
self._clear(self._listing)
|
||||
if not wins:
|
||||
self._listing.append(Gtk.Label(label="No open windows", xalign=0.0))
|
||||
for w in wins:
|
||||
listing.append(self._window_row(w))
|
||||
self._panel.append(scroller)
|
||||
self._listing.append(self._window_row(w))
|
||||
GLib.idle_add(self._restore_offsets, saved)
|
||||
|
||||
@staticmethod
|
||||
def _restore_offsets(saved) -> bool:
|
||||
for adj, value in saved:
|
||||
adj.set_value(min(value, max(0.0, adj.get_upper() - adj.get_page_size())))
|
||||
return False
|
||||
|
||||
def _set_row_content(self, refs, w) -> None:
|
||||
icon, title_lbl, ws_lbl = refs
|
||||
title = w.get("title") or w.get("class") or "?"
|
||||
if title_lbl.get_label() != title:
|
||||
title_lbl.set_label(title)
|
||||
ws_text = f"ws {w.get('workspace', {}).get('id')}"
|
||||
if ws_lbl.get_label() != ws_text:
|
||||
ws_lbl.set_label(ws_text)
|
||||
icon_name = self._icon_for(w.get("class", ""))
|
||||
if icon.get_icon_name() != icon_name:
|
||||
icon.set_from_icon_name(icon_name)
|
||||
|
||||
def _window_row(self, w) -> Gtk.Widget:
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
|
@ -271,8 +342,12 @@ class Taskbar(Gtk.Box):
|
|||
lbl.append(icon)
|
||||
wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32)
|
||||
lbl.append(wl)
|
||||
lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0))
|
||||
ws_lbl = Gtk.Label(label=f"ws {wsid}", xalign=1.0)
|
||||
lbl.append(ws_lbl)
|
||||
name.set_child(lbl)
|
||||
# Keep the mutable bits reachable so a refresh can update this row instead of
|
||||
# replacing it (the address is the row's identity and never changes).
|
||||
self._rows[w.get("address")] = (icon, wl, ws_lbl)
|
||||
name.set_tooltip_text("Jump to window")
|
||||
name.connect("clicked", lambda *_a: self._focus(w))
|
||||
row.append(name)
|
||||
|
|
@ -367,21 +442,21 @@ class Taskbar(Gtk.Box):
|
|||
# Reflect the current per-ws layout / direction / focus_fit in the tabs+options.
|
||||
#
|
||||
# This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution
|
||||
# order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row
|
||||
# (once from the clients query, once from the activeworkspace one) — each
|
||||
# rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed:
|
||||
# order, and refresh() can trigger _update_panel() (hence this) twice in a row
|
||||
# (once from the clients query, once from the activeworkspace one), so several
|
||||
# syncs can be in flight at once. Two failure modes existed:
|
||||
# 1. The 3 callbacks used to each toggle the shared `self._syncing` flag
|
||||
# independently (True at start, False at end). Whichever resolved *first*
|
||||
# dropped the guard while the others — in particular the tab-selection one,
|
||||
# which programmatically flips the visible stack child — were still in
|
||||
# flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user
|
||||
# had clicked and re-apply stale data via `layouts.set(...)`.
|
||||
# 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could
|
||||
# 2. A second `_sync_layout_controls()` call (from the redundant refresh) could
|
||||
# land while the first's callbacks were still pending, so a stale callback
|
||||
# from sync #1 could decrement sync #2's pending-count or apply sync #1's
|
||||
# (possibly wrong-workspace) data into sync #2's freshly built widgets.
|
||||
# (possibly wrong-workspace) data into sync #2's widgets.
|
||||
# A generation token invalidates any sync superseded by a newer one outright —
|
||||
# if the user switches workspaces (or a redundant rebuild fires) mid-flight, only
|
||||
# if the user switches workspaces (or a redundant refresh fires) mid-flight, only
|
||||
# the latest call's callbacks are allowed to touch anything, and self._syncing
|
||||
# only lifts once *that* generation's 3 calls have all resolved.
|
||||
self._sync_generation = getattr(self, "_sync_generation", 0) + 1
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ class Taskbar(Gtk.Box):
|
|||
self._dir_dds = {}
|
||||
self._fit_sws = {}
|
||||
self._poll_id: int | None = None
|
||||
# Content signatures, so a poll that found nothing new rebuilds nothing
|
||||
# (see _build_panel_scaffold for why that matters). None = never built.
|
||||
self._strip_sig: list | None = None
|
||||
self._list_order: list | None = None
|
||||
self._rows: dict = {} # window address -> row widgets, for in-place updates
|
||||
|
||||
# The workspace/window panel. It is NOT appended here: the menu window mounts
|
||||
# `panel_widget` into the quad region so that expanding COLLAPSES this strip's
|
||||
|
|
@ -64,6 +69,7 @@ class Taskbar(Gtk.Box):
|
|||
self._panel_scroll = Gtk.ScrolledWindow(
|
||||
hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True)
|
||||
self._panel_scroll.set_child(self._panel)
|
||||
self._build_panel_scaffold()
|
||||
|
||||
header = Gtk.CenterBox()
|
||||
title = Gtk.Label(label="Open windows", xalign=0.0)
|
||||
|
|
@ -166,21 +172,31 @@ class Taskbar(Gtk.Box):
|
|||
def _on_ws(self, ok: bool, data) -> None:
|
||||
if ok and isinstance(data, dict):
|
||||
self._active_ws = data.get("id")
|
||||
self._rebuild_panel()
|
||||
self._update_panel()
|
||||
|
||||
def _on_clients(self, ok: bool, data) -> None:
|
||||
self._clients = data if ok and isinstance(data, list) else []
|
||||
self._rebuild_strip()
|
||||
self._rebuild_panel()
|
||||
self._update_panel()
|
||||
|
||||
# -- compact strip -----------------------------------------------------
|
||||
def _rebuild_strip(self) -> None:
|
||||
self._clear(self._row)
|
||||
groups: dict[str, list] = {}
|
||||
for w in self._clients:
|
||||
if not w.get("mapped", True) or not w.get("class"):
|
||||
continue
|
||||
groups.setdefault(w["class"], []).append(w)
|
||||
# Same reasoning as the panel list: rebuild only when the strip's contents
|
||||
# actually change, so a poll can't reset its horizontal scroll — or tear down
|
||||
# a grouped app's instance popover while it is open. Keyed on addresses only:
|
||||
# a retitled window doesn't change anything visible here (titles appear in the
|
||||
# tooltip/popover, refreshed on the next real change).
|
||||
sig = [(cls, tuple(w.get("address") for w in wins))
|
||||
for cls, wins in sorted(groups.items())]
|
||||
if sig == self._strip_sig:
|
||||
return
|
||||
self._strip_sig = sig
|
||||
self._clear(self._row)
|
||||
if not groups:
|
||||
self._row.append(Gtk.Label(label="No open windows"))
|
||||
return
|
||||
|
|
@ -219,16 +235,22 @@ class Taskbar(Gtk.Box):
|
|||
return btn
|
||||
|
||||
# -- pop-open panel: layout controls + window list ---------------------
|
||||
def _rebuild_panel(self) -> None:
|
||||
self._clear(self._panel)
|
||||
|
||||
# The panel scaffolding is built ONCE, here, and every later refresh updates it in
|
||||
# place. It used to be rebuilt from scratch on each refresh (_clear(self._panel) +
|
||||
# fresh widgets, including a fresh ScrolledWindow for the window list) — and since
|
||||
# start_polling() refreshes once a second while the menu is open, that made the
|
||||
# list impossible to scroll: emptying the scrolled content collapses the scroll
|
||||
# adjustment's upper bound, so its value is clamped back to 0 and the viewport
|
||||
# snapped to the top roughly once a second.
|
||||
def _build_panel_scaffold(self) -> None:
|
||||
# tabbed layout selector for the active workspace: a tab per layout, and a
|
||||
# per-layout options page underneath that switches with the tab. Selecting a
|
||||
# tab applies that layout to the currently focused workspace.
|
||||
ws = self._active_ws
|
||||
hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0)
|
||||
hdr.add_css_class("section-title")
|
||||
self._panel.append(hdr)
|
||||
# tab applies that layout to the currently focused workspace. The tabs are the
|
||||
# same for every workspace — only the *selection* is per-workspace, and that is
|
||||
# what _sync_layout_controls() refreshes — so this is built once too.
|
||||
self._ws_hdr = Gtk.Label(label="Workspace ? layout", xalign=0.0)
|
||||
self._ws_hdr.add_css_class("section-title")
|
||||
self._panel.append(self._ws_hdr)
|
||||
|
||||
self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values])
|
||||
self._fit_sws = {} # layout name -> Gtk.Switch
|
||||
|
|
@ -240,23 +262,72 @@ class Taskbar(Gtk.Box):
|
|||
self._panel.append(switcher)
|
||||
self._panel.append(self._layout_stack)
|
||||
self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch)
|
||||
self._sync_layout_controls()
|
||||
|
||||
self._panel.append(Gtk.Separator())
|
||||
|
||||
# per-window rows: [focus/jump] [pull here]
|
||||
self._listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
self._list_scroll = Gtk.ScrolledWindow(
|
||||
vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
self._list_scroll.set_child(self._listing)
|
||||
self._panel.append(self._list_scroll)
|
||||
|
||||
def _update_panel(self) -> None:
|
||||
ws = self._active_ws
|
||||
self._ws_hdr.set_label(f"Workspace {ws if ws is not None else '?'} layout")
|
||||
self._sync_layout_controls()
|
||||
if getattr(self, "_cols_lbl", None) is not None:
|
||||
self._cols_lbl.set_label(str(self._read_cols()))
|
||||
self._update_window_list()
|
||||
|
||||
def _update_window_list(self) -> None:
|
||||
wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")]
|
||||
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
|
||||
(w.get("title") or w.get("class") or "").lower()))
|
||||
if not wins:
|
||||
self._panel.append(Gtk.Label(label="No open windows", xalign=0.0))
|
||||
order = [w.get("address") for w in wins]
|
||||
if order == self._list_order:
|
||||
# Same windows in the same order: refresh the row contents in place, so a
|
||||
# retitled window (browser tab switch, shell cwd) costs no rebuild and
|
||||
# therefore cannot move the scroll position.
|
||||
for w in wins:
|
||||
refs = self._rows.get(w.get("address"))
|
||||
if refs is not None:
|
||||
self._set_row_content(refs, w)
|
||||
return
|
||||
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scroller.set_child(listing)
|
||||
|
||||
# The list really did change shape. Rebuild just the rows (not the scroller)
|
||||
# and put the viewport back where the user left it, once the new rows have been
|
||||
# allocated — until then the adjustment's upper bound is still stale. Both
|
||||
# scrollers are saved: which one actually scrolls depends on how much height
|
||||
# the mounted panel gets, and restoring an already-zero offset is a no-op.
|
||||
saved = [(sw.get_vadjustment(), sw.get_vadjustment().get_value())
|
||||
for sw in (self._panel_scroll, self._list_scroll)]
|
||||
self._list_order = order
|
||||
self._rows = {}
|
||||
self._clear(self._listing)
|
||||
if not wins:
|
||||
self._listing.append(Gtk.Label(label="No open windows", xalign=0.0))
|
||||
for w in wins:
|
||||
listing.append(self._window_row(w))
|
||||
self._panel.append(scroller)
|
||||
self._listing.append(self._window_row(w))
|
||||
GLib.idle_add(self._restore_offsets, saved)
|
||||
|
||||
@staticmethod
|
||||
def _restore_offsets(saved) -> bool:
|
||||
for adj, value in saved:
|
||||
adj.set_value(min(value, max(0.0, adj.get_upper() - adj.get_page_size())))
|
||||
return False
|
||||
|
||||
def _set_row_content(self, refs, w) -> None:
|
||||
icon, title_lbl, ws_lbl = refs
|
||||
title = w.get("title") or w.get("class") or "?"
|
||||
if title_lbl.get_label() != title:
|
||||
title_lbl.set_label(title)
|
||||
ws_text = f"ws {w.get('workspace', {}).get('id')}"
|
||||
if ws_lbl.get_label() != ws_text:
|
||||
ws_lbl.set_label(ws_text)
|
||||
icon_name = self._icon_for(w.get("class", ""))
|
||||
if icon.get_icon_name() != icon_name:
|
||||
icon.set_from_icon_name(icon_name)
|
||||
|
||||
def _window_row(self, w) -> Gtk.Widget:
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
|
|
@ -271,8 +342,12 @@ class Taskbar(Gtk.Box):
|
|||
lbl.append(icon)
|
||||
wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32)
|
||||
lbl.append(wl)
|
||||
lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0))
|
||||
ws_lbl = Gtk.Label(label=f"ws {wsid}", xalign=1.0)
|
||||
lbl.append(ws_lbl)
|
||||
name.set_child(lbl)
|
||||
# Keep the mutable bits reachable so a refresh can update this row instead of
|
||||
# replacing it (the address is the row's identity and never changes).
|
||||
self._rows[w.get("address")] = (icon, wl, ws_lbl)
|
||||
name.set_tooltip_text("Jump to window")
|
||||
name.connect("clicked", lambda *_a: self._focus(w))
|
||||
row.append(name)
|
||||
|
|
@ -367,21 +442,21 @@ class Taskbar(Gtk.Box):
|
|||
# Reflect the current per-ws layout / direction / focus_fit in the tabs+options.
|
||||
#
|
||||
# This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution
|
||||
# order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row
|
||||
# (once from the clients query, once from the activeworkspace one) — each
|
||||
# rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed:
|
||||
# order, and refresh() can trigger _update_panel() (hence this) twice in a row
|
||||
# (once from the clients query, once from the activeworkspace one), so several
|
||||
# syncs can be in flight at once. Two failure modes existed:
|
||||
# 1. The 3 callbacks used to each toggle the shared `self._syncing` flag
|
||||
# independently (True at start, False at end). Whichever resolved *first*
|
||||
# dropped the guard while the others — in particular the tab-selection one,
|
||||
# which programmatically flips the visible stack child — were still in
|
||||
# flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user
|
||||
# had clicked and re-apply stale data via `layouts.set(...)`.
|
||||
# 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could
|
||||
# 2. A second `_sync_layout_controls()` call (from the redundant refresh) could
|
||||
# land while the first's callbacks were still pending, so a stale callback
|
||||
# from sync #1 could decrement sync #2's pending-count or apply sync #1's
|
||||
# (possibly wrong-workspace) data into sync #2's freshly built widgets.
|
||||
# (possibly wrong-workspace) data into sync #2's widgets.
|
||||
# A generation token invalidates any sync superseded by a newer one outright —
|
||||
# if the user switches workspaces (or a redundant rebuild fires) mid-flight, only
|
||||
# if the user switches workspaces (or a redundant refresh fires) mid-flight, only
|
||||
# the latest call's callbacks are allowed to touch anything, and self._syncing
|
||||
# only lifts once *that* generation's 3 calls have all resolved.
|
||||
self._sync_generation = getattr(self, "_sync_generation", 0) + 1
|
||||
|
|
|
|||
Loading…
Reference in New Issue