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
51 changes: 23 additions & 28 deletions src/indicator/defaultMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Layout from '../components/layout/Layout';
import { _ } from '../translations';
import { widgetOrientation } from '../utils/gnomesupport';
import { createButton, createIconButton } from './utils';
import { disambiguateMonitorNames, MonitorDetails } from './monitorNames';

const debug = logger('DefaultMenu');

Expand Down Expand Up @@ -105,14 +106,7 @@ class LayoutsRow extends St.BoxLayout {

public updateMonitorName(
showMonitorName: boolean,
monitorsDetails: {
name: string;
index?: number;
x?: number;
y?: number;
height?: number;
width?: number;
}[],
monitorsDetails: MonitorDetails[],
) {
if (!showMonitorName) this._label.hide();
else this._label.show();
Expand Down Expand Up @@ -278,16 +272,13 @@ export default class DefaultMenu implements CurrentMenu {
}

// GNOME 49+ has Meta.Monitor with get_display_name()
const monitorsDetails: {
name: string;
index: number;
x: number;
y: number;
}[] | undefined = this._get_display_name();
const monitorsDetails: MonitorDetails[] | undefined =
this._get_display_name();

if (monitorsDetails) {
const disambiguated = disambiguateMonitorNames(monitorsDetails);
this._layoutsRows.forEach((lr) =>
lr.updateMonitorName(true, monitorsDetails),
lr.updateMonitorName(true, disambiguated),
);
return;
}
Expand All @@ -313,8 +304,11 @@ export default class DefaultMenu implements CurrentMenu {
if (pr.get_successful()) {
debug(stdout);
const parsedMonitorsDetails = JSON.parse(stdout);
const disambiguated = disambiguateMonitorNames(
parsedMonitorsDetails,
);
this._layoutsRows.forEach((lr) =>
lr.updateMonitorName(true, parsedMonitorsDetails),
lr.updateMonitorName(true, disambiguated),
);
} else {
debug('error:', stderr);
Expand All @@ -327,34 +321,35 @@ export default class DefaultMenu implements CurrentMenu {
}

// Use GNOME 49+'s Meta.Monitor with get_display_name()
private _get_display_name() {
private _get_display_name(): MonitorDetails[] | undefined {
const monitorManager = global.backend.get_monitor_manager();
if (!monitorManager.get_logical_monitors) return undefined;

const logicalMonitors = monitorManager.get_logical_monitors();
if (!logicalMonitors || logicalMonitors.length <= 0) return undefined;

const monitorsDetails: {
name: string;
index: number;
x: number;
y: number;
}[] = [];
const shellMonitors = getMonitors();
const monitorsDetails: MonitorDetails[] = [];
logicalMonitors.forEach(logicalMonitor => {
const metaMonitors = logicalMonitor.get_monitors();
if (metaMonitors.length <= 0) return;

const metaMonitor = metaMonitors[0];
if (!metaMonitor.get_display_name) return;

// MetaLogicalMonitor has x, y as direct properties
const x = (logicalMonitor as any).x ?? 0;
const y = (logicalMonitor as any).y ?? 0;
// MetaLogicalMonitor exposes no geometry to GJS (only
// get_monitors() and get_number()), so take x, y, width and
// height from the shell's monitor with the same index
const shellMonitor = shellMonitors.find(
(m) => m.index === logicalMonitor.get_number(),
);
monitorsDetails.push({
name: metaMonitor.get_display_name(),
index: logicalMonitor.get_number(),
x,
y,
x: shellMonitor?.x ?? 0,
y: shellMonitor?.y ?? 0,
width: shellMonitor?.width,
height: shellMonitor?.height,
});
});

Expand Down
154 changes: 154 additions & 0 deletions src/indicator/monitorNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { _ } from '../translations';

export interface MonitorDetails {
name: string;
index?: number;
x?: number;
y?: number;
width?: number;
height?: number;
}

// clustering tolerance (in pixels) used when no monitor size is available
const FALLBACK_CLUSTER_TOLERANCE = 100;

// Half of the smallest known monitor dimension: two monitors whose centers
// are closer than this on an axis are considered aligned on that axis
const clusterTolerance = (group: MonitorDetails[]): number => {
const sizes = group
.flatMap(m => [m.width ?? 0, m.height ?? 0])
.filter(size => size > 0);
return sizes.length > 0
? Math.min(...sizes) / 2
: FALLBACK_CLUSTER_TOLERANCE;
};

// Group values into clusters: a value starts a new cluster when it is
// farther than the tolerance from the start of the current cluster.
// Returns the cluster rank of each value (0 = leftmost/topmost)
const clusterRanks = (
values: number[],
tolerance: number
): Map<number, number> => {
const sorted = Array.from(new Set(values)).sort((a, b) => a - b);
const ranks = new Map<number, number>();
let rank = 0;
let clusterStart = sorted.length > 0 ? sorted[0] : 0;
sorted.forEach(value => {
if (value - clusterStart > tolerance) {
rank++;
clusterStart = value;
}
ranks.set(value, rank);
});
return ranks;
};

const horizontalKey = (rank: number, count: number): string => {
if (count <= 1) return '';
if (rank === 0) return 'left';
if (rank === count - 1) return 'right';
return 'middle';
};

const verticalKey = (rank: number, count: number): string => {
if (count <= 1) return '';
if (rank === 0) return 'top';
if (rank === count - 1) return 'bottom';
return 'middle';
};

// Whole phrases are translated (instead of composing translated words)
// since word order differs between languages
const translatePositionKey = (key: string): string => {
switch (key) {
case 'left':
return _('left');
case 'right':
return _('right');
case 'top':
return _('top');
case 'bottom':
return _('bottom');
case 'middle':
return _('middle');
case 'top left':
return _('top left');
case 'top middle':
return _('top middle');
case 'top right':
return _('top right');
case 'middle left':
return _('middle left');
case 'middle right':
return _('middle right');
case 'bottom left':
return _('bottom left');
case 'bottom middle':
return _('bottom middle');
case 'bottom right':
return _('bottom right');
default:
return key;
}
};

// Compute a position label (e.g. "left", "top right") for each monitor of
// a group sharing the same name. Labels are unique and non-empty within
// the group: duplicated labels (e.g. mirrored monitors, or four monitors
// in a row where two are "middle") are numbered
const computePositionLabels = (group: MonitorDetails[]): string[] => {
const centersX = group.map(m => (m.x ?? 0) + (m.width ?? 0) / 2);
const centersY = group.map(m => (m.y ?? 0) + (m.height ?? 0) / 2);
const tolerance = clusterTolerance(group);
const columnRanks = clusterRanks(centersX, tolerance);
const rowRanks = clusterRanks(centersY, tolerance);
const columns = Math.max(...Array.from(columnRanks.values())) + 1;
const rows = Math.max(...Array.from(rowRanks.values())) + 1;

const keys = group.map((m, i) => {
const h = horizontalKey(columnRanks.get(centersX[i])!, columns);
const v = verticalKey(rowRanks.get(centersY[i])!, rows);
if (v.length > 0 && h.length > 0)
return v === 'middle' && h === 'middle' ? 'middle' : `${v} ${h}`;
return v.length > 0 ? v : h;
});

const occurrences = new Map<string, number>();
keys.forEach(key => occurrences.set(key, (occurrences.get(key) ?? 0) + 1));
const counters = new Map<string, number>();
return keys.map(key => {
const label = translatePositionKey(key);
if ((occurrences.get(key) ?? 0) <= 1) return label;
const n = (counters.get(key) ?? 0) + 1;
counters.set(key, n);
return label.length > 0 ? `${label} ${n}` : `${n}`;
});
};

// Append the relative position (e.g. "(left)", "(top right)") to the name
// of the monitors sharing their name with another monitor, leaving unique
// names untouched. Does not mutate the given monitors
export const disambiguateMonitorNames = (
monitors: MonitorDetails[]
): MonitorDetails[] => {
const groups = new Map<string, MonitorDetails[]>();
monitors.forEach(m => {
const group = groups.get(m.name);
if (group) group.push(m);
else groups.set(m.name, [m]);
});

const renamed = new Map<MonitorDetails, string>();
groups.forEach((group, name) => {
if (group.length <= 1) return;
computePositionLabels(group).forEach((label, i) =>
renamed.set(group[i], `${name} (${label})`)
);
});

return monitors.map(m => {
const newName = renamed.get(m);
return newName === undefined ? { ...m } : { ...m, name: newName };
});
};