From 154fc31c19d9a0960cfc420808d13f9ba5f12895 Mon Sep 17 00:00:00 2001 From: Bear Date: Thu, 23 Jul 2026 10:30:48 -0600 Subject: [PATCH 1/2] feat: disambiguate same-named monitors with relative position labels When two or more monitors share the exact same display name, the indicator menu now appends their relative position to the name, e.g. "DELL U2720Q (left)" / "DELL U2720Q (right)", "(top)" / "(bottom)" for stacked monitors, and combined labels like "(top left)" for grids. Mirrored monitors (no distinct position) fall back to numbering, and any colliding labels are numbered so names are always unique. Positions are computed from monitor center points on both the GNOME 49+ Meta.Monitor path (now enriched with width/height from the shell's monitors) and the Gdk subprocess fallback. Position phrases are translated as whole strings so word order can differ per language. --- src/indicator/defaultMenu.ts | 43 +++++----- src/indicator/monitorNames.ts | 154 ++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 src/indicator/monitorNames.ts diff --git a/src/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 2ab03a0..451d4e9 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; @@ -350,11 +340,18 @@ export default class DefaultMenu implements CurrentMenu { // MetaLogicalMonitor has x, y as direct properties const x = (logicalMonitor as any).x ?? 0; const y = (logicalMonitor as any).y ?? 0; + // take width and height from the shell's monitor with the + // same index, since MetaLogicalMonitor does not expose them + const shellMonitor = shellMonitors.find( + (m) => m.index === logicalMonitor.get_number(), + ); monitorsDetails.push({ name: metaMonitor.get_display_name(), index: logicalMonitor.get_number(), x, y, + 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 }; + }); +}; From 5eab6b4f36daff5e4e1dcecfacac4cd28e3a98b0 Mon Sep 17 00:00:00 2001 From: Bear Date: Mon, 27 Jul 2026 10:01:53 -0600 Subject: [PATCH 2/2] fix: read monitor geometry from shell monitors for position labels MetaLogicalMonitor exposes no properties to GJS (only get_monitors() and get_number()), so reading logicalMonitor.x/y always yielded undefined and every monitor fell back to (0, 0). Same-named monitors then computed identical center points and were numbered (1)/(2) instead of getting directional labels like (left)/(right). Take x and y from the shell's monitor matched by index, alongside the width and height already sourced there. --- src/indicator/defaultMenu.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/indicator/defaultMenu.ts b/src/indicator/defaultMenu.ts index 451d4e9..fa47eed 100644 --- a/src/indicator/defaultMenu.ts +++ b/src/indicator/defaultMenu.ts @@ -337,19 +337,17 @@ 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; - // take width and height from the shell's monitor with the - // same index, since MetaLogicalMonitor does not expose them + // 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, });