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
8 changes: 5 additions & 3 deletions menubar/popover.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,10 @@ def applyPanelScale(self) -> None:
if self.latest_state is None or self.panel is None:
return
scale = panel_scale(self.latest_state, self.panel)
_, natural_height = panel_window_state.resolve_panel_size(self.latest_state, self.panel)
panel_id = self.panel.id
if self.panel_scales.get(panel_id) == scale:
target = (scale, natural_height)
if self.panel_scales.get(panel_id) == target:
return
view = self.content_view
if not hasattr(view, "evaluateJavaScript_completionHandler_"):
Expand All @@ -213,11 +215,11 @@ def _completed(value: Any, error: Any) -> None:
and self.panel.id == panel_id
and self.content_view is view
):
self.panel_scales[panel_id] = scale
self.panel_scales[panel_id] = target

view.evaluateJavaScript_completionHandler_(
"typeof window.usageApplyPanelZoom === 'function' ? "
f"window.usageApplyPanelZoom({scale}) : false",
f"window.usageApplyPanelZoom({scale}, {natural_height}) : false",
_completed,
)

Expand Down
27 changes: 24 additions & 3 deletions panels/dynamic_height.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,16 @@
function naturalContentHeight() {
var root = document.documentElement;
var zoom = root.style.zoom;
var body = document.body;
// usageApplyPanelZoom expands this layout box while Chromium zoom is
// active. Release it as well as zoom, otherwise a subsequent measurement
// would report the compensated height rather than the natural content.
var bodyHeight = body ? body.style.height : "";
root.style.zoom = "normal";
if (body) body.style.height = "";
var wrap = document.querySelector(".wrap");
if (!wrap) {
if (body) body.style.height = bodyHeight;
root.style.zoom = zoom;
return null;
}
Expand Down Expand Up @@ -102,6 +109,7 @@
floors.forEach(function(floor) {
floor.element.style.minHeight = floor.minHeight;
});
if (body) body.style.height = bodyHeight;
root.style.zoom = zoom;
}
}
Expand Down Expand Up @@ -131,10 +139,23 @@
lastPostedHeight = null;
requestContentHeight();
};
window.usageApplyPanelZoom = function(scale) {
window.usageApplyPanelZoom = function(scale, naturalHeight) {
var value = Number(scale);
document.documentElement.style.zoom =
Number.isFinite(value) && value > 0 && value !== 1 ? String(value) : "normal";
var root = document.documentElement;
var body = document.body;
var height = Number(naturalHeight);
var scaled = Number.isFinite(value) && value > 0 && value !== 1;
root.style.zoom = scaled ? String(value) : "normal";
// Chromium's CSS zoom scales the paint output but leaves its layout box at
// the viewport height. Give that box the known natural content height so
// flex children are not shrunk and clipped before they are painted.
// naturalHeight is optional to keep pages invoked with the old one-arg
// signature working; a missing value simply keeps the legacy zoom-only
// behavior. Returning to scale 1 always removes the compensation.
if (body) {
body.style.height = scaled && Number.isFinite(height) && height > 0
? String(height) + "px" : "";
}
return true;
};
window.usageApplyState = function usageApplyStateWithDynamicHeight(state) {
Expand Down
8 changes: 8 additions & 0 deletions tests/test_dynamic_height.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def test_script_wraps_state_application_and_measures_without_height_constraints(
assert "lastPostedHeight = null;" in CONTENT_HEIGHT_SCRIPT
assert 'root.style.zoom = "normal"' in CONTENT_HEIGHT_SCRIPT
assert "window.usageApplyPanelZoom" in CONTENT_HEIGHT_SCRIPT
assert "function(scale, naturalHeight)" in CONTENT_HEIGHT_SCRIPT
assert 'body.style.height = ""' in CONTENT_HEIGHT_SCRIPT
assert 'String(height) + "px"' in CONTENT_HEIGHT_SCRIPT
assert "innerHeight /" not in CONTENT_HEIGHT_SCRIPT
# Panels draw their edges with padding on whichever layer wraps .wrap, and
# the viewport-based panels nest an extra padded .viewport in between, so
# the whole ancestor chain has to be released and measured — assuming a
Expand All @@ -46,6 +50,10 @@ def test_script_wraps_state_application_and_measures_without_height_constraints(
assert "paddingBottom" in CONTENT_HEIGHT_SCRIPT
assert 'querySelectorAll("[data-usage-height-floor]")' in CONTENT_HEIGHT_SCRIPT
assert 'floor.element.style.minHeight = floor.height + "px"' in CONTENT_HEIGHT_SCRIPT
# Measuring must release the Chromium-only height compensation before it
# reads the natural layout, then put it back before the next paint.
assert "var bodyHeight = body ? body.style.height" in CONTENT_HEIGHT_SCRIPT
assert "body.style.height = bodyHeight" in CONTENT_HEIGHT_SCRIPT


def test_world_cup_declares_its_pitch_height_floor() -> None:
Expand Down
8 changes: 7 additions & 1 deletion tests/test_menubar.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ def test_apply_panel_scale_caches_only_after_successful_javascript(
controller.content_view = view

monkeypatch.setattr(menubar_popover, "panel_scale", lambda state, active_panel: 0.7785)
monkeypatch.setattr(
panel_window_state,
"resolve_panel_size",
lambda state, active_panel: (320.0, 1000.0),
)
menubar_popover.PopoverViewController.applyPanelScale(controller)
calls[-1][1](False, None)

Expand All @@ -263,7 +268,8 @@ def test_apply_panel_scale_caches_only_after_successful_javascript(
menubar_popover.PopoverViewController.applyPanelScale(controller)
calls[-1][1](True, None)

assert controller.panel_scales == {"classic": 0.7785}
assert controller.panel_scales == {"classic": (0.7785, 1000.0)}
assert "usageApplyPanelZoom(0.7785, 1000.0)" in calls[-1][0]


def test_bar_color_thresholds() -> None:
Expand Down
46 changes: 46 additions & 0 deletions tests/test_wintray.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,22 +456,66 @@ def test_content_height_keeps_the_panels_natural_height(
assert controller.panel_height() == 1000


def test_failed_panel_zoom_does_not_resize_window_to_scaled_height(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mutations: list[tuple[str, int, int]] = []
zoom_ready = False

javascript: list[str] = []

def evaluate_js(code: str) -> bool:
javascript.append(code)
return zoom_ready

window = SimpleNamespace(
x=0,
y=0,
evaluate_js=evaluate_js,
resize=lambda width, height: mutations.append(("resize", width, height)),
move=lambda x, y: mutations.append(("move", x, y)),
)
controller = wintray._WindowsTrayController(mock=True, interval=60)
controller.window = window
controller._content_height = 1000
monkeypatch.setattr(controller, "_working_area", lambda: (0, 0, 1000, 800))
monkeypatch.setattr(
controller, "_work_area_for_point", lambda _point: (0, 0, 1000, 800)
)

controller._place_window()

assert mutations == []
assert "usageApplyPanelZoom(0.776, 1000)" in javascript[-1]

zoom_ready = True
controller._place_window()

assert mutations == [("resize", 295, 776), ("move", 693, 12)]


def test_background_window_mutation_is_dispatched_to_ui_thread(
monkeypatch: pytest.MonkeyPatch,
) -> None:
main_thread_id = threading.get_ident()
mutation_threads: list[int] = []
javascript_threads: list[int] = []
scheduling_threads: list[int] = []
scheduled_drains: list[Callable[[], None]] = []

def begin_invoke(callback: Callable[[], None]) -> None:
scheduling_threads.append(threading.get_ident())
scheduled_drains.append(callback)

def evaluate_js(_code: str) -> bool:
javascript_threads.append(threading.get_ident())
return True

native = SimpleNamespace(InvokeRequired=True, BeginInvoke=begin_invoke)
controller = wintray._WindowsTrayController(mock=True, interval=60)
controller.window = SimpleNamespace(
native=native,
evaluate_js=evaluate_js,
resize=lambda _width, _height: mutation_threads.append(threading.get_ident()),
move=lambda _x, _y: mutation_threads.append(threading.get_ident()),
)
Expand All @@ -485,10 +529,12 @@ def begin_invoke(callback: Callable[[], None]) -> None:
worker.join()

assert mutation_threads == []
assert javascript_threads == [worker.ident]
assert len(scheduling_threads) == 1
assert scheduling_threads[0] != main_thread_id
scheduled_drains.pop()()
assert mutation_threads == [main_thread_id, main_thread_id]
assert javascript_threads == [worker.ident]


def test_load_preferences_non_utf8(
Expand Down
50 changes: 33 additions & 17 deletions wintray/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -820,38 +820,41 @@ def panel_height(self) -> int:
return self._content_height or PANEL_HEIGHTS[self.active_panel_id]

def _apply_content_height(self, value: object) -> None:
self._dispatch_window_mutation(lambda: self._apply_content_height_on_ui_thread(value))
self._apply_content_height_now(value)

def _apply_content_height_on_ui_thread(self, value: object) -> None:
def _apply_content_height_now(self, value: object) -> None:
if self.stopping.is_set():
return
current_position = self._current_window_position()
work_area = self._work_area_for_point(current_position) or self._working_area()
maximum = (
float(work_area[3] - work_area[1] - 24)
if work_area is not None
else float(PANEL_HEIGHTS[self.active_panel_id])
)
height = clamp_content_height(value)
if height is None:
return
rounded = int(round(height))
if rounded == self._content_height:
# A newly loaded panel can have the same natural height as the
# previous one. Its first placement may have arrived before the
# page installed usageApplyPanelZoom, so give that document one
# more chance to converge when it reports its own height.
if self.visible:
self._place_window()
return
self._content_height = rounded
self._apply_panel_zoom(fit_scale(height, maximum))
if self.visible:
self._place_window_on_ui_thread()
self._place_window()

def _apply_panel_zoom(self, scale: float) -> None:
def _apply_panel_zoom(self, scale: float) -> bool:
if self.window is not None and hasattr(self.window, "evaluate_js"):
try:
self.window.evaluate_js(
result = self.window.evaluate_js(
"typeof window.usageApplyPanelZoom === 'function' && "
f"window.usageApplyPanelZoom({scale})"
f"window.usageApplyPanelZoom({scale}, {self.panel_height()})"
)
return result is True
except Exception:
logger.exception("Unable to apply panel zoom")
return False
# Lightweight window doubles do not embed a browser. There is no DOM
# to scale, so geometry-only tests can proceed normally.
return self.window is not None

def attach(self, icon: Any, window: Any) -> None:
self.icon = icon
Expand Down Expand Up @@ -1003,9 +1006,23 @@ def _default_window_position(
return (max(left + 12, right - width - 12), max(top + 12, bottom - height - 12))

def _place_window(self, *, force_default: bool = False) -> None:
self._dispatch_window_mutation(
lambda: self._place_window_on_ui_thread(force_default=force_default)
current_position = self._current_window_position()
work_area = self._work_area_for_point(current_position) or self._working_area()
Comment on lines +1009 to +1010

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive zoom from the target monitor

When the current native position and target anchor are on monitors with different work-area heights, this calculates the DOM zoom for the wrong monitor. For example, on the first show the hidden window may currently be on the primary monitor while _place_window_on_ui_thread() uses a saved position on a shorter secondary monitor; the page receives scale 1 here, but lines 1047-1053 resize it using the secondary monitor's smaller scale, recreating clipped content. The reverse mismatch also occurs with force_default=True. Resolve the same anchor/work area used by the queued placement before applying zoom, or pass the already-computed scale through to the UI mutation.

Useful? React with 👍 / 👎.

maximum = (
float(work_area[3] - work_area[1] - 24)
if work_area is not None
else float(PANEL_HEIGHTS[self.active_panel_id])
)
scale = fit_scale(self.panel_height(), maximum)
zoom_applied = self._apply_panel_zoom(scale)
if scale < 1.0 and not zoom_applied:
return
if force_default:
self._dispatch_window_mutation(
lambda: self._place_window_on_ui_thread(force_default=True)
)
else:
self._dispatch_window_mutation(self._place_window_on_ui_thread)

def _place_window_on_ui_thread(self, *, force_default: bool = False) -> None:
if self.window is None or self.stopping.is_set():
Expand Down Expand Up @@ -1034,7 +1051,6 @@ def _place_window_on_ui_thread(self, *, force_default: bool = False) -> None:
fitted_width, fitted_height, scale = fit_panel_size(PANEL_WIDTH, natural_height, maximum)
width = int(round(fitted_width))
height = int(round(fitted_height))
self._apply_panel_zoom(scale)
self.window.resize(width, height)
position = anchor if anchor is not None else self._default_window_position(
work_area, width, height
Expand Down