diff --git a/src/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 2ab03a0..fa47eed 100644 --- a/src/indicator/defaultMenu.ts +++ b/src/indicator/defaultMenu.ts @@ -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'); @@ -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(); @@ -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; } @@ -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); @@ -327,19 +321,15 @@ 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; @@ -347,14 +337,19 @@ export default class DefaultMenu implements CurrentMenu { 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, }); }); diff --git a/src/indicator/monitorNames.ts b/src/indicator/monitorNames.ts new file mode 100644 index 0000000..95c6130 --- /dev/null +++ b/src/indicator/monitorNames.ts @@ -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 => { + const sorted = Array.from(new Set(values)).sort((a, b) => a - b); + const ranks = new Map(); + 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(); + keys.forEach(key => occurrences.set(key, (occurrences.get(key) ?? 0) + 1)); + const counters = new Map(); + 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(); + monitors.forEach(m => { + const group = groups.get(m.name); + if (group) group.push(m); + else groups.set(m.name, [m]); + }); + + const renamed = new Map(); + 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 }; + }); +};