Strata/strata@edu4rdshl.dev/ui/panel.js
Eduard Tolosa 3d41f39ad9 fix: bundle dbus.js in the pack and share logError via util.js
extension.js imports ./dbus.js, but gnome-extensions pack never bundled it,
so the packed extension (the EGO zip) was missing the module and would fail
to load. Add dbus.js and the new util.js to the pack sources.

util.js holds the shared logError helper, replacing the per-file copies in
extension.js and panel.js and the raw console.error calls in clipboardItem.js.
2026-06-25 20:29:02 -05:00

764 lines
30 KiB
JavaScript

/* panel.js - Strata clipboard popup panel. */
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { ClipboardItem } from './clipboardItem.js';
import { logError } from '../util.js';
const SEARCH_DEBOUNCE_MS = 150;
const LOAD_MORE_THRESHOLD = 200;
export class StrataPanel {
constructor(proxy, settings) {
this._proxy = proxy;
this._settings = settings;
this._pageSize = settings.get_int('page-size');
this._pageSizeChangedId = settings.connect('changed::page-size',
() => { this._pageSize = settings.get_int('page-size'); });
/** @type {{ id: string, mimeType: string, preview: string }[]} */
this._items = [];
/** @type {Map<string, ClipboardItem>} id → widget */
this._widgets = new Map();
/** @type {Map<string, string>} id → cache file path (shared across widget lifetimes) */
this._thumbCache = new Map();
/** Pagination state for the non-search view. */
this._loadedOffset = 0; // how many items we've already pulled
this._hasMore = true; // false once daemon returns less than a full page
this._loadingMore = false; // re-entrancy guard for scroll-driven loads
/** Search state. */
this._searchQuery = ''; // current search string ('' = no search)
this._searchDebounceId = null; // pending GLib timeout for debounce
this._searchEpoch = 0; // monotonic counter to discard stale search responses
this._searchResults = []; // full match snapshot (metadata) for the active query
this._searchRendered = 0; // how many of _searchResults have widgets so far
this._resultsEpoch = -1; // epoch whose results currently fill _searchResults
this._visible = false;
this._grab = null; // Clutter.Grab from Main.pushModal
this._hoveredWidget = null; // currently mouse-hovered ClipboardItem
this._activeWidget = null; // most recently copied item
this._buildUI();
// Theme: apply light/dark to the panel box and react to changes.
// The light.css overrides (loaded by extension.js) only take effect
// while the `.strata-theme-light` class is present on `_box`.
this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
this._applyTheme();
this._themeChangedId = this._settings.connect('changed::theme',
() => this._applyTheme());
this._colorSchemeChangedId = this._interfaceSettings.connect('changed::color-scheme',
() => this._applyTheme());
// Trigger the initial load as soon as the daemon owns its bus name.
// At extension boot the daemon may still be starting (its D-Bus name
// not yet owned); the proxy fires notify::g-name-owner when that
// changes, so we listen for the first transition to a non-null owner
// and only then issue GetHistory. If the daemon is already up, the
// owner is already set and we load immediately.
this._tryInitialLoad();
this._nameOwnerId = this._proxy.connect('notify::g-name-owner',
() => this._tryInitialLoad());
}
/** True when the effective theme is light. `auto` follows the system
* color-scheme (anything other than prefer-dark counts as light). */
_effectiveIsLight() {
const mode = this._settings.get_string('theme');
if (mode === 'light') return true;
if (mode === 'dark') return false;
return this._interfaceSettings.get_string('color-scheme') !== 'prefer-dark';
}
_applyTheme() {
if (!this._box) return;
if (this._effectiveIsLight())
this._box.add_style_class_name('strata-theme-light');
else
this._box.remove_style_class_name('strata-theme-light');
}
_tryInitialLoad() {
if (this._initialLoaded) return;
if (!this._proxy.g_name_owner) return;
this._initialLoaded = true;
this._loadHistory(0, this._pageSize).then(ok => {
if (!ok) this._initialLoaded = false;
});
}
_buildUI() {
this._overlay = new St.Widget({
layout_manager: new Clutter.FixedLayout(),
visible: false,
reactive: true,
});
Main.layoutManager.addChrome(this._overlay);
// Close when clicking outside the panel box.
// With pushModal active, ALL pointer events are delivered to _overlay,
// so this handler sees every click on screen.
this._overlay.connect('button-press-event', (_actor, event) => {
const [cx, cy] = event.get_coords();
const [bx, by] = this._box.get_transformed_position();
const [bw, bh] = this._box.get_transformed_size();
if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh)
return Clutter.EVENT_PROPAGATE;
this.close();
return Clutter.EVENT_STOP;
});
// pushModal intercepts all motion events at the overlay level, so CSS
// :hover never fires on child actors. Use event.get_source() to let
// Clutter do the hit-test, then walk up to find the owning ClipboardItem.
// This is O(tree-depth) instead of O(n-widgets) per mouse-move event.
this._overlay.connect('motion-event', (_actor, event) => {
let source = event.get_source();
let found = null;
while (source && source !== this._overlay) {
if (source instanceof ClipboardItem) {
found = source;
break;
}
source = source.get_parent();
}
if (found !== this._hoveredWidget) {
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = found;
found?.add_style_class_name('strata-item-hovered');
}
return Clutter.EVENT_PROPAGATE;
});
this._overlay.connect('leave-event', () => {
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = null;
return Clutter.EVENT_PROPAGATE;
});
this._box = new St.BoxLayout({
style_class: 'strata-panel',
vertical: true,
reactive: true,
});
const header = new St.BoxLayout({
style_class: 'strata-header',
x_expand: true,
});
const title = new St.Label({
text: 'Clipboard',
style_class: 'strata-title',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
const clearBtn = new St.Button({
label: 'Clear all',
style_class: 'strata-clear-btn',
y_align: Clutter.ActorAlign.CENTER,
});
clearBtn.connect('clicked', () => this._clearAll());
header.add_child(title);
header.add_child(clearBtn);
this._searchEntry = new St.Entry({
hint_text: 'Search…',
style_class: 'strata-search',
x_expand: true,
can_focus: true,
});
// Tag the placeholder label so the light theme can recolor it. By
// default it inherits a light-on-dark Shell color that is illegible on
// the light panel; dark mode is unaffected (no base rule).
this._searchEntry.get_hint_actor()?.add_style_class_name('strata-search-hint');
this._searchEntry.get_clutter_text().connect('text-changed', () => {
this._scheduleSearch(this._searchEntry.get_text());
});
this._searchEntry.get_clutter_text().connect('key-press-event', (_actor, event) => {
if (event.get_key_symbol() === Clutter.KEY_Down) {
this._focusItem(0);
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
this._scrollView = new St.ScrollView({
style_class: 'strata-scroll',
x_expand: true,
y_expand: true,
overlay_scrollbars: true,
});
this._itemList = new St.BoxLayout({
style_class: 'strata-item-list',
vertical: true,
x_expand: true,
});
this._scrollView.set_child(this._itemList);
const vadj = this._scrollView.get_vadjustment();
if (vadj) {
vadj.connect('notify::value', () => this._maybeLoadMore());
vadj.connect('notify::upper', () => this._maybeLoadMore());
}
this._box.add_child(header);
this._box.add_child(this._searchEntry);
this._box.add_child(this._scrollView);
this._overlay.add_child(this._box);
this._overlay.connect('key-press-event', (_actor, event) => {
if (event.get_key_symbol() === Clutter.KEY_Escape) {
this.close();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
}
toggle() {
this._visible ? this.close() : this.open();
}
open() {
if (this._visible) return;
this._visible = true;
this._overlay.show();
this._positionPanel();
// Re-position after one frame so bottom/center use the fully allocated height.
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
if (this._visible) this._positionPanel();
return GLib.SOURCE_REMOVE;
});
// pushModal grabs ALL input and delivers it to _overlay - this is the
// proper GNOME Shell pattern for "click outside to close" dialogs.
this._grab = Main.pushModal(this._overlay, {
actionMode: Shell.ActionMode.POPUP,
});
this._searchEntry.set_text('');
// Reset to non-search view - but DON'T reload; history may already be populated.
this._searchQuery = '';
// If the initial history load failed (e.g. daemon wasn't ready at startup),
// retry now that the user is asking to see the list.
if (this._items.length === 0 && this._loadedOffset === 0 && this._hasMore) {
this._loadHistory(0, this._pageSize).catch(e =>
logError('open: history reload failed', e));
}
global.stage.set_key_focus(this._searchEntry.get_clutter_text());
}
close() {
if (!this._visible) return;
this._visible = false;
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = null;
if (this._grab) {
Main.popModal(this._grab);
this._grab = null;
}
this._overlay.hide();
}
/** Prepend a newly added item. Wrapped in try/catch so one bad item can't
* break subsequent additions. */
prependItem(id, mimeType, preview) {
try {
this._items.unshift({ id, mimeType, preview });
this._loadedOffset += 1;
if (this._widgets.has(id)) {
this._widgets.get(id)?.destroy();
this._widgets.delete(id);
}
if (this._searchQuery && !this._matchesSearch(preview, mimeType)) {
return;
}
const widget = this._makeItemWidget(id, mimeType, preview);
this._widgets.set(id, widget);
this._itemList.insert_child_at_index(widget, 0);
this._setActiveWidget(widget);
} catch (e) {
logError(`prependItem failed for id=${id} mime=${mimeType}`, e);
this._items = this._items.filter(i => i.id !== id);
}
}
// Substring match used for newly-arrived items while a search is active.
// Approximates FTS5 prefix match until the next debounced search refresh.
_matchesSearch(preview, mimeType) {
if (mimeType?.startsWith('image/')) return false;
if (!preview) return false;
const haystack = preview.toLowerCase();
return this._searchQuery.toLowerCase().split(/\s+/)
.filter(Boolean).every(tok => haystack.includes(tok));
}
removeItem(id) {
this._items = this._items.filter(i => i.id !== id);
// Keep the search snapshot in sync so a deleted/pruned item sitting in
// the not-yet-rendered tail can't be re-created as a phantom row when
// scrolling appends the next page. Adjust _searchRendered if the removed
// item was within the already-rendered range so the index stays aligned.
const sIdx = this._searchResults.findIndex(r => r.id === id);
if (sIdx !== -1) {
this._searchResults.splice(sIdx, 1);
if (sIdx < this._searchRendered) this._searchRendered--;
}
const widget = this._widgets.get(id);
if (widget) {
const wasActive = widget === this._activeWidget;
const wasHovered = widget === this._hoveredWidget;
if (wasActive) this._activeWidget = null;
if (wasHovered) this._hoveredWidget = null;
widget.destroy();
this._widgets.delete(id);
if (wasActive) {
const first = this._getVisibleItems()[0];
if (first) this._setActiveWidget(first);
}
}
}
clearItems() {
this._items = [];
this._searchResults = [];
this._searchRendered = 0;
this._resultsEpoch = -1; // invalidate the search snapshot so a scroll can't render stale rows
this._hoveredWidget = null;
this._activeWidget = null;
this._widgets.clear();
this._itemList.destroy_all_children();
}
destroy() {
if (this._pageSizeChangedId) {
this._settings.disconnect(this._pageSizeChangedId);
this._pageSizeChangedId = 0;
}
if (this._themeChangedId) {
this._settings.disconnect(this._themeChangedId);
this._themeChangedId = 0;
}
if (this._colorSchemeChangedId && this._interfaceSettings) {
this._interfaceSettings.disconnect(this._colorSchemeChangedId);
this._colorSchemeChangedId = 0;
}
this._interfaceSettings = null;
if (this._nameOwnerId) {
this._proxy.disconnect(this._nameOwnerId);
this._nameOwnerId = 0;
}
if (this._searchDebounceId) {
GLib.Source.remove(this._searchDebounceId);
this._searchDebounceId = null;
}
this.close();
this.clearItems();
Main.layoutManager.removeChrome(this._overlay);
this._overlay.destroy();
this._overlay = null;
}
async _loadHistory(offset, limit) {
if (!this._overlay) return false;
try {
const [json] = await this._proxy.GetHistoryAsync(offset, limit);
if (!this._overlay) return false;
const items = JSON.parse(json);
const BATCH = 20;
for (let i = 0; i < items.length; i += BATCH) {
if (!this._overlay) return false;
// eslint-disable-next-line no-await-in-loop -- batches deliberately yield to the main loop
await new Promise(resolve => {
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
const end = Math.min(i + BATCH, items.length);
for (let j = i; j < end; j++) this._appendItemFromMeta(items[j]);
resolve();
return GLib.SOURCE_REMOVE;
});
});
}
this._loadedOffset = offset + items.length;
this._hasMore = items.length >= limit;
if (offset === 0 && items.length > 0) {
const firstWidget = this._widgets.get(items[0].id);
if (firstWidget) this._setActiveWidget(firstWidget);
}
return true;
} catch (e) {
logError('GetHistory failed', e);
return false;
}
}
_appendItemFromMeta(meta) {
try {
const id = meta.id;
const mimeType = meta.mime_type;
const preview = meta.content_text ?? '';
if (this._widgets.has(id)) return;
this._items.push({ id, mimeType, preview });
const widget = this._makeItemWidget(id, mimeType, preview);
this._widgets.set(id, widget);
this._itemList.add_child(widget);
} catch (e) {
logError(`_appendItemFromMeta failed for id=${meta?.id}`, e);
}
}
_maybeLoadMore() {
if (this._loadingMore) return;
const adj = this._scrollView.get_vadjustment();
if (!adj || adj.upper <= adj.page_size) return;
const distanceToBottom = adj.upper - (adj.value + adj.page_size);
if (distanceToBottom > LOAD_MORE_THRESHOLD) return;
if (this._searchQuery) {
// Search mode: render the next page from the in-memory match
// snapshot (no re-query, so a scroll cannot race the search).
this._renderSearchPage(this._searchEpoch);
} else {
if (!this._hasMore) return;
this._loadingMore = true;
this._loadHistory(this._loadedOffset, this._pageSize)
.finally(() => { this._loadingMore = false; });
}
}
/** Render the next page-size slice of `_searchResults` into the list.
* Bounded per call so a broad query never builds every match at once;
* scrolling calls this again until the snapshot is exhausted. */
async _renderSearchPage(epoch) {
if (epoch !== this._searchEpoch || !this._overlay) return;
// The snapshot must belong to this epoch. During a new query's fetch the
// epoch is already bumped while _searchResults still holds the previous
// set; rendering then would paint stale rows (and race the real render).
if (this._resultsEpoch !== epoch) return;
if (this._loadingMore) return;
const start = this._searchRendered;
if (start >= this._searchResults.length) return;
this._loadingMore = true;
try {
const end = Math.min(start + this._pageSize, this._searchResults.length);
const BATCH = 20;
for (let i = start; i < end; i += BATCH) {
if (epoch !== this._searchEpoch || !this._overlay) return;
const renderChunk = () => {
const e = Math.min(i + BATCH, end);
for (let j = i; j < e; j++)
this._appendItemFromMeta(this._searchResults[j]);
};
// First chunk runs synchronously so the list never paints empty
// between clear and first append. Later chunks yield to idle.
if (i === 0) {
renderChunk();
} else {
// eslint-disable-next-line no-await-in-loop -- batches deliberately yield to the main loop
await new Promise(resolve => {
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
renderChunk();
resolve();
return GLib.SOURCE_REMOVE;
});
});
}
}
this._searchRendered = end;
} finally {
// Only release the guard if we still own the current search. If a
// newer search superseded us mid-render, it now owns the guard and
// must not be cleared by this stale render.
if (epoch === this._searchEpoch) this._loadingMore = false;
}
}
_makeItemWidget(id, mimeType, preview) {
const widget = new ClipboardItem(id, mimeType, preview, {
proxy: this._proxy,
thumbCache: this._thumbCache,
});
widget.connect('activate', () => {
this._setActiveWidget(widget);
if (this._settings?.get_boolean('move-activated-to-top'))
this._moveItemToTop(id, widget);
this._onItemActivated(id);
});
widget.connect('delete', () => {
this._proxy.DeleteItemRemote(id, () => {});
});
widget.connect('key-press-event', (_actor, event) => {
const sym = event.get_key_symbol();
const items = this._getVisibleItems();
const idx = items.indexOf(widget);
if (sym === Clutter.KEY_Down) {
if (idx < items.length - 1) {
global.stage.set_key_focus(items[idx + 1]);
this._ensureVisible(items[idx + 1]);
}
return Clutter.EVENT_STOP;
}
if (sym === Clutter.KEY_Up) {
if (idx <= 0) {
global.stage.set_key_focus(this._searchEntry.get_clutter_text());
} else {
global.stage.set_key_focus(items[idx - 1]);
this._ensureVisible(items[idx - 1]);
}
return Clutter.EVENT_STOP;
}
if (sym === Clutter.KEY_Escape) {
this.close();
return Clutter.EVENT_STOP;
}
return Clutter.EVENT_PROPAGATE;
});
return widget;
}
_focusItem(idx) {
const items = this._getVisibleItems();
if (items.length === 0) return;
global.stage.set_key_focus(items[Math.max(0, Math.min(idx, items.length - 1))]);
}
_getVisibleItems() {
return this._itemList.get_children().filter(c => c.visible);
}
_ensureVisible(widget) {
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
if (!this._overlay) return GLib.SOURCE_REMOVE; // panel destroyed before idle ran
try {
const adj = this._scrollView.get_vadjustment();
if (!adj || adj.page_size === 0) return GLib.SOURCE_REMOVE;
// Allocation box coords share the same space as adj.value.
const box = widget.get_allocation_box();
const cur = adj.value;
const pageSize = adj.page_size;
if (box.y1 < cur)
adj.value = box.y1;
else if (box.y2 > cur + pageSize)
adj.value = box.y2 - pageSize;
} catch (e) {
logError('ensureVisible error', e);
}
return GLib.SOURCE_REMOVE;
});
}
async _onItemActivated(id) {
try {
const [mimeType, content] = await this._proxy.GetItemContentAsync(id);
this._writeToClipboard(mimeType, content);
} catch (e) {
logError('GetItemContent failed', e);
}
this.close();
}
_writeToClipboard(mimeType, bytes) {
try {
if (mimeType.startsWith('text/') || mimeType === 'UTF8_STRING') {
const text = new TextDecoder('utf-8').decode(bytes);
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, text);
} else {
const source = Meta.SelectionSourceMemory.new(
mimeType,
GLib.Bytes.new(bytes)
);
global.display.get_selection().set_owner(
Meta.SelectionType.SELECTION_CLIPBOARD,
source
);
}
} catch (e) {
logError('Clipboard write error', e);
}
}
async _clearAll() {
try {
await this._proxy.ClearHistoryAsync();
} catch (e) {
logError('ClearHistory failed', e);
}
}
/** Debounce search box keystrokes; collapse rapid edits into one query. */
_scheduleSearch(query) {
if (this._searchDebounceId) {
GLib.Source.remove(this._searchDebounceId);
this._searchDebounceId = null;
}
const trimmed = (query ?? '').trim();
this._searchDebounceId = GLib.timeout_add(
GLib.PRIORITY_DEFAULT, SEARCH_DEBOUNCE_MS, () => {
this._searchDebounceId = null;
this._runSearch(trimmed).catch(e =>
logError('search failed', e));
return GLib.SOURCE_REMOVE;
});
}
/** Apply a search query (empty = reset to the recent-history view).
* All state-mutating writes are guarded with an epoch so a fast-cancelled
* search cannot overwrite the results of a newer one. */
async _runSearch(query) {
const epoch = ++this._searchEpoch;
// A new search (or reset) supersedes any in-flight page render. Release
// the shared loading guard so this fresh render is never blocked by a
// stale one; the stale render bails on its own epoch check and, per the
// epoch-ownership check in _renderSearchPage, won't clear the guard out
// from under us.
this._loadingMore = false;
if (!query) {
this._searchQuery = '';
this._searchResults = [];
this._searchRendered = 0;
this._clearListDom();
this._items = [];
this._loadedOffset = 0;
this._hasMore = true;
// Don't await: if the user starts a new search mid-load, the epoch
// guard inside _appendItemFromMeta's caller would still let stale
// rows leak in. So check the epoch before letting any rows in.
const ePromise = this._loadHistory(0, this._pageSize);
ePromise.then(() => {
if (epoch !== this._searchEpoch) {
// A newer search superseded us; nuke whatever we appended.
this._clearListDom();
this._items = [];
}
}).catch(e => logError('reset-history failed', e));
return;
}
this._searchQuery = query;
// Fetch the full match set, bounded by the configured history size, so
// search covers everything that is stored (not an arbitrary cap).
const limit = this._settings.get_int('max-history');
let json;
try {
[json] = await this._proxy.SearchHistoryAsync(query, limit);
} catch (e) {
logError('SearchHistory failed', e);
return;
}
if (epoch !== this._searchEpoch || !this._overlay) return; // stale or destroyed
let results;
try {
results = JSON.parse(json);
} catch (e) {
logError('SearchHistory: bad JSON', e);
return;
}
this._clearListDom();
this._items = [];
// Snapshot the full match set and render it lazily, a page at a time,
// so a broad query on a large history never builds thousands of row
// widgets up front. Browse-mode pagination stays off; search paging is
// driven by _renderSearchPage over this snapshot (see _maybeLoadMore).
this._searchResults = results;
this._searchRendered = 0;
this._resultsEpoch = epoch;
this._hasMore = false;
this._loadedOffset = 0;
await this._renderSearchPage(epoch);
}
/** Tear down all rendered item widgets (without touching the data model). */
_clearListDom() {
this._hoveredWidget = null;
this._activeWidget = null;
this._widgets.forEach(w => w.destroy());
this._widgets.clear();
this._itemList.destroy_all_children();
}
_setActiveWidget(widget) {
this._activeWidget?.remove_style_class_name('strata-item-active');
this._activeWidget = widget ?? null;
widget?.add_style_class_name('strata-item-active');
}
_moveItemToTop(id, widget) {
const idx = this._items.findIndex(i => i.id === id);
if (idx > 0) {
const [entry] = this._items.splice(idx, 1);
this._items.unshift(entry);
}
this._itemList.set_child_at_index(widget, 0);
}
_positionPanel() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const PANEL_W = Math.min(
this._settings?.get_int('panel-width') ?? 480,
monitor.width * 0.9
);
const MAX_H = Math.min(
this._settings?.get_int('panel-max-height') ?? 600,
monitor.height * 0.85
);
this._box.set_width(PANEL_W);
// Let height shrink to content; cap at MAX_H so the scroll view kicks in
// when there are many items.
this._box.set_height(-1);
this._box.style = `max-height: ${MAX_H}px;`;
const position = this._settings?.get_string('panel-position') ?? 'top-center';
const MARGIN = 16; // px gap from screen edge
let x, y;
if (position.endsWith('left'))
x = monitor.x + MARGIN;
else if (position.endsWith('right'))
x = monitor.x + monitor.width - PANEL_W - MARGIN;
else // center
x = monitor.x + Math.round((monitor.width - PANEL_W) / 2);
// Vertical - snap to shell bars so the panel feels native
const topBarH = Main.layoutManager.panelBox?.height ?? 32;
if (position.startsWith('top')) {
y = monitor.y + topBarH + MARGIN;
} else {
// For bottom/center we need the actual rendered height, not MAX_H.
// get_preferred_height(-1) returns [minH, naturalH] synchronously.
const [, naturalH] = this._box.get_preferred_height(-1);
const actualH = Math.min(naturalH, MAX_H);
if (position.startsWith('bottom'))
y = monitor.y + monitor.height - actualH - MARGIN;
else // center
y = monitor.y + Math.round((monitor.height - actualH) / 2);
}
this._box.set_position(Math.round(x), Math.round(y));
// Overlay fills the screen for click-outside detection.
this._overlay.set_position(monitor.x, monitor.y);
this._overlay.set_size(monitor.width, monitor.height);
}
}