Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/manager/event_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export function onRestacked(): void {

export const onSizeChanged = refreshRoundedCorners;

export const onFocusChanged = refreshShadow;
export const onFocusChanged = refreshRoundedCorners;

export const onSettingsChanged = refreshAllRoundedCorners;

Expand Down
53 changes: 53 additions & 0 deletions src/manager/event_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import type Meta from 'gi://Meta';
import type Shell from 'gi://Shell';
import type {RoundedWindowActor} from '../utils/types.js';

import GLib from 'gi://GLib';

import {logDebug} from '../utils/log.js';
import {prefs} from '../utils/settings.js';
import * as handlers from './event_handlers.js';
import {isChromiumWindow} from './utils.js';

/**
* The rounded corners effect has to perform some actions when differen events
Expand All @@ -29,10 +32,54 @@ export function enableEffect() {
// Add the effect to all windows when the extension is enabled.
const windowActors = global.get_window_actors();
logDebug(`Initial window count: ${windowActors.length}`);

for (const actor of windowActors) {
applyEffectTo(actor);
}

// When the extension is re-enabled after screen lock/unlock,
// Chromium-based browsers may render stale surfaces. The compositor
// skips repainting GLSL effects for unfocused windows, so briefly
// focusing each affected window forces a repaint and triggers our
// onFocusChanged handler which recomputes shader bounds.
if (windowActors.length > 0) {
deferredRefreshId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => {
const focusedWin = global.display.get_focus_window();
const chromiumWindows = global
.get_window_actors()
.map(a => a.metaWindow)
.filter(
(win): win is Meta.Window =>
win != null &&
win !== focusedWin &&
isChromiumWindow(win),
);

if (chromiumWindows.length > 0) {
const timestamp = global.get_current_time();
for (const [i, win] of chromiumWindows.entries()) {
GLib.timeout_add(GLib.PRIORITY_DEFAULT, i * 100, () => {
win.focus(timestamp);
return GLib.SOURCE_REMOVE;
});
}

// Restore focus to the originally focused window.
GLib.timeout_add(
GLib.PRIORITY_DEFAULT,
chromiumWindows.length * 100,
() => {
focusedWin?.focus(global.get_current_time());
return GLib.SOURCE_REMOVE;
},
);
}

deferredRefreshId = 0;
return GLib.SOURCE_REMOVE;
Comment on lines +46 to +79
Copy link
Copy Markdown
Owner

@flexagoon flexagoon Apr 16, 2026

Choose a reason for hiding this comment

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

I'm not sure this is the best way to redraw a window
There is an actor.queue_redraw() function, could you check if that works instead? (There's also .queue_relayout(), not sure which one of those would be correct here)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

So this suggests actor.queue_redraw() or queue_relayout() instead of focus cycling. I already tried actor.queue_redraw() earlier and it didn't work for background windows. The compositor skips repainting GLSL effects for occluded actors even with queue_redraw(). Only focusing the window forces the compositor to actually process the repaint.

});
}

// Add the effect to new windows when they are opened.
connect(
global.display,
Expand Down Expand Up @@ -74,13 +121,19 @@ export function enableEffect() {

/** Disable the effect for all windows. */
export function disableEffect() {
if (deferredRefreshId) {
GLib.source_remove(deferredRefreshId);
deferredRefreshId = 0;
}

for (const actor of global.get_window_actors()) {
removeEffectFrom(actor);
}

disconnectAll();
}

let deferredRefreshId = 0;
const connections: {object: GObject.Object; id: number}[] = [];

/**
Expand Down
61 changes: 47 additions & 14 deletions src/manager/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {RoundedCornersEffect} from '../effect/rounded_corners_effect.js';
import type {Bounds, RoundedCornerSettings} from '../utils/types.js';

import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Meta from 'gi://Meta';
import St from 'gi://St';

Expand Down Expand Up @@ -130,10 +131,11 @@ export function computeBounds(
};

// Kitty draws its window decoration by itself, so we need to manually
// clip its shadow and recompute the outer bounds for it.
// clip its shadow and recompute the outer bounds for it. Check wm_class
// first to avoid reading the pref for every non-kitty window.
if (
getPref('tweak-kitty-terminal') &&
actor.metaWindow.get_wm_class_instance() === 'kitty'
actor.metaWindow.get_wm_class_instance() === 'kitty' &&
getPref('tweak-kitty-terminal')
) {
const [x1, y1, x2, y2] = APP_SHADOWS.kitty;
const scale = windowScaleFactor(actor.metaWindow);
Expand Down Expand Up @@ -202,19 +204,17 @@ export function computeShadowActorOffset(
export function updateShadowActorStyle(
win: Meta.Window,
actor: St.Bin,
borderRadius = getPref('global-rounded-corner-settings').borderRadius,
borderRadius?: number,
shadow = getPref('focused-shadow'),
padding = getPref('global-rounded-corner-settings').padding,
padding?: RoundedCornerSettings['padding'],
) {
const {left, right, top, bottom} = padding;

// Increase border_radius when smoothing is on.
// Read global settings once to avoid repeated GSettings deserializations.
let adjustedBorderRadius = borderRadius;
const globalCfg = getPref('global-rounded-corner-settings');
if (globalCfg !== null) {
adjustedBorderRadius *= 1.0 + globalCfg.smoothing;
}
const effectiveBorderRadius = borderRadius ?? globalCfg.borderRadius;
const effectivePadding = padding ?? globalCfg.padding;

const {left, right, top, bottom} = effectivePadding;
const adjustedBorderRadius =
effectiveBorderRadius * (1.0 + globalCfg.smoothing);

// If there are two monitors with different scale factors, the scale of
// the window may be different from the scale that has to be applied in
Expand Down Expand Up @@ -322,6 +322,25 @@ export function shouldEnableEffect(
);
}

const CHROMIUM_WM_CLASSES = [
'brave-browser',
'chromium',
'google-chrome',
'microsoft-edge',
];

/**
* Check whether a window belongs to a Chromium-based browser. These apps
* render stale surfaces for unfocused windows after screen lock/unlock.
*
* @param win - The window to check.
* @returns Whether the window belongs to a Chromium-based browser.
*/
export function isChromiumWindow(win: Meta.Window): boolean {
const wmClass = win.get_wm_class_instance();
return wmClass != null && CHROMIUM_WM_CLASSES.includes(wmClass);
}

type AppType = 'LibAdwaita' | 'LibHandy' | 'Other';

/**
Expand All @@ -345,7 +364,21 @@ function getAppType(win: Meta.Window): AppType {

return 'Other';
} catch (e) {
logError(e);
// /proc/<pid>/maps is unreadable for processes owned by another user
// (e.g. flatpak sandboxes), and can disappear if the process exits
// between get_pid() and the read. Both are expected and benign.
const isExpected =
e instanceof GLib.Error &&
e.domain === Gio.io_error_quark() &&
(e.code === Gio.IOErrorEnum.PERMISSION_DENIED ||
e.code === Gio.IOErrorEnum.NOT_FOUND);
if (isExpected) {
logDebug(
`Could not read /proc/${win.get_pid()}/maps: ${(e as GLib.Error).message}`,
);
} else {
logError(e);
}
return 'Other';
}
}
8 changes: 6 additions & 2 deletions src/patch/add_shadow_in_overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,15 @@ const OverviewShadowActorClone = GObject.registerClass(
return;
}

const frameWidth = metaWindow.get_frame_rect().width;
if (frameWidth === 0) {
return;
}

// Scale the shadow by the same scale factor that the window preview
// is scaled by.
const containerScaleFactor =
windowContainerBox.get_width() /
metaWindow.get_frame_rect().width;
windowContainerBox.get_width() / frameWidth;
const paddings =
SHADOW_PADDING *
containerScaleFactor *
Expand Down
29 changes: 27 additions & 2 deletions src/utils/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export const Schema = {
/** The raw GSettings object for direct manipulation. */
export let prefs: Gio.Settings;

/**
* Cache of deserialized pref values. Reading a pref goes through D-Bus and
* Variant unpacking, so hot paths (per-frame shader updates, per-event
* refreshes) read the same keys many times per second. The cache is
* invalidated by connecting to the `changed` signal on init.
*/
const prefCache = new Map<SchemaKey, unknown>();
let prefCacheChangedId = 0;

/**
* Initialize the {@link prefs} object with existing GSettings.
*
Expand All @@ -64,22 +73,38 @@ export let prefs: Gio.Settings;
export function initPrefs(gSettings: Gio.Settings) {
resetOutdated(gSettings);
prefs = gSettings;
prefCache.clear();
prefCacheChangedId = prefs.connect('changed', (_, key) => {
prefCache.delete(key as SchemaKey);
});
}

/** Delete the {@link prefs} object for garbage collection. */
export function uninitPrefs() {
if (prefCacheChangedId) {
prefs.disconnect(prefCacheChangedId);
prefCacheChangedId = 0;
}
prefCache.clear();
(prefs as Gio.Settings | null) = null;
}

/**
* Get a preference from GSettings and convert it from a GLib Variant to a
* JavaScript type.
* JavaScript type. Values are cached and invalidated automatically when the
* underlying setting changes.
*
* @param key - The key of the preference to get.
* @returns The value of the preference.
*/
export function getPref<K extends SchemaKey>(key: K): Schema[K] {
return prefs.get_value(key).recursiveUnpack() as Schema[K];
const cached = prefCache.get(key);
if (cached !== undefined) {
return cached as Schema[K];
}
const value = prefs.get_value(key).recursiveUnpack() as Schema[K];
prefCache.set(key, value);
return value;
}

/**
Expand Down
Loading