Refonte du popup : layout deux colonnes, jauges de performance et graphique signal

- Remplace les PopupMenuItem textuels par des PopupBaseMenuItem avec
  labels à gauche (dimmed) et valeurs alignées à droite
- Ajoute des headers de section (Performance, Signal) via PopupSeparatorMenuItem
- Ajoute des jauges horizontales pour Speed et Width avec layout vertical
  (label+valeur au-dessus, barre pleine largeur en dessous)
- Ajoute un graphique d'historique du signal (St.DrawingArea + Cairo)
  coloré selon la qualité (60 points, 5 min d'historique)
- Rafraîchit les données à l'ouverture du popup pour un affichage immédiat
- Max speed basé sur le théorique WiFi 7 4×4 MIMO 320 MHz (5760 Mbit/s)
- Max width basé sur le WiFi 7 (320 MHz)
This commit is contained in:
Jalil Arfaoui 2026-02-12 23:42:43 +01:00
parent b9c51fd010
commit a5ef20d4f1
2 changed files with 243 additions and 28 deletions

View file

@ -31,6 +31,20 @@ import type { GenerationCssClass, ChannelWidthMHz, SignalDbm } from './types.js'
const REFRESH_INTERVAL_SECONDS = 5; const REFRESH_INTERVAL_SECONDS = 5;
const PLACEHOLDER = '--' as const; const PLACEHOLDER = '--' as const;
// WiFi 7 theoretical max: 320 MHz, MCS 13 (4096-QAM 5/6), 4×4 MIMO, GI 0.8µs
const MAX_SPEED_MBPS = 5760;
const MAX_CHANNEL_WIDTH_MHZ = 320;
const MIN_SIGNAL_DBM = -90;
const MAX_SIGNAL_DBM = -30;
const SIGNAL_HISTORY_MAX = 60;
const SIGNAL_QUALITY_COLORS: Readonly<Record<string, [number, number, number]>> = {
Excellent: [0.2, 0.82, 0.48],
Good: [0.56, 0.94, 0.64],
Fair: [0.96, 0.83, 0.18],
Weak: [1.0, 0.47, 0.0],
Poor: [0.88, 0.11, 0.14],
};
type MenuItemId = type MenuItemId =
| 'ssid' | 'ssid'
@ -69,13 +83,20 @@ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
], ],
] as const; ] as const;
const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']);
export default class WifiSignalPlusExtension extends Extension { export default class WifiSignalPlusExtension extends Extension {
private indicator: PanelMenu.Button | null = null; private indicator: PanelMenu.Button | null = null;
private icon: St.Icon | null = null; private icon: St.Icon | null = null;
private label: St.Label | null = null; private label: St.Label | null = null;
private wifiService: WifiInfoService | null = null; private wifiService: WifiInfoService | null = null;
private refreshTimeout: number | null = null; private refreshTimeout: number | null = null;
private readonly menuItems = new Map<MenuItemId, PopupMenu.PopupMenuItem>(); private signalGraph: St.DrawingArea | null = null;
private readonly signalHistory: number[] = [];
private readonly menuItems = new Map<
MenuItemId,
{ item: PopupMenu.PopupBaseMenuItem; label: St.Label; value: St.Label; barFill?: St.Widget }
>();
enable(): void { enable(): void {
this.wifiService = new WifiInfoService(); this.wifiService = new WifiInfoService();
@ -100,6 +121,8 @@ export default class WifiSignalPlusExtension extends Extension {
this.wifiService = null; this.wifiService = null;
this.icon = null; this.icon = null;
this.label = null; this.label = null;
this.signalGraph = null;
this.signalHistory.length = 0;
this.menuItems.clear(); this.menuItems.clear();
} }
@ -130,28 +153,164 @@ export default class WifiSignalPlusExtension extends Extension {
const menu = this.indicator.menu as PopupMenu.PopupMenu; const menu = this.indicator.menu as PopupMenu.PopupMenu;
menu.box.add_style_class_name('wifi-signal-plus-popup'); menu.box.add_style_class_name('wifi-signal-plus-popup');
menu.connect('open-state-changed', (_menu, isOpen: boolean) => {
if (isOpen) this.refresh();
return undefined;
});
const sectionHeaders = ['', 'Performance', 'Signal'];
const SIGNAL_SECTION_INDEX = 2;
MENU_STRUCTURE.forEach((section, index) => { MENU_STRUCTURE.forEach((section, index) => {
for (const { id, label } of section) { if (sectionHeaders[index]) {
this.addMenuItem(menu, id, label); menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
} }
// Add separator between sections (not after last) if (index === SIGNAL_SECTION_INDEX) {
if (index < MENU_STRUCTURE.length - 1) { this.addSignalGraph(menu);
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); }
for (const { id, label } of section) {
this.addMenuItem(menu, id, label, ITEMS_WITH_BAR.has(id));
} }
}); });
} }
private addMenuItem(menu: PopupMenu.PopupMenu, id: MenuItemId, label: string): void { private addMenuItem(
const item = new PopupMenu.PopupMenuItem(`${label}: ${PLACEHOLDER}`, { reactive: false }); menu: PopupMenu.PopupMenu,
id: MenuItemId,
label: string,
withBar = false,
): void {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
item.add_style_class_name('wifi-popup-item');
const labelWidget = new St.Label({
text: label,
style_class: 'wifi-popup-label',
y_align: Clutter.ActorAlign.CENTER,
});
const valueWidget = new St.Label({
text: PLACEHOLDER,
style_class: 'wifi-popup-value',
x_expand: true,
x_align: Clutter.ActorAlign.END,
y_align: Clutter.ActorAlign.CENTER,
});
let barFill: St.Widget | undefined;
if (withBar) {
const box = new St.BoxLayout({ vertical: true, x_expand: true });
const row = new St.BoxLayout({ x_expand: true });
row.add_child(labelWidget);
row.add_child(valueWidget);
box.add_child(row);
const barTrack = new St.Widget({
style_class: 'wifi-bar-track',
x_expand: true,
});
barFill = new St.Widget({ style_class: 'wifi-bar-fill' });
barTrack.add_child(barFill);
box.add_child(barTrack);
item.add_child(box);
} else {
item.add_child(labelWidget);
item.add_child(valueWidget);
}
menu.addMenuItem(item); menu.addMenuItem(item);
this.menuItems.set(id, item); this.menuItems.set(id, { item, label: labelWidget, value: valueWidget, barFill });
} }
private updateMenuItem(id: MenuItemId, label: string, value: string): void { private addSignalGraph(menu: PopupMenu.PopupMenu): void {
const item = this.menuItems.get(id); const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
item?.label.set_text(`${label}: ${value}`); item.add_style_class_name('wifi-popup-item');
this.signalGraph = new St.DrawingArea({
style_class: 'wifi-signal-graph',
x_expand: true,
});
this.signalGraph.connect('repaint', () => this.drawSignalGraph());
item.add_child(this.signalGraph);
menu.addMenuItem(item);
}
private drawSignalGraph(): void {
if (!this.signalGraph) return;
const cr = this.signalGraph.get_context();
const [width, height] = this.signalGraph.get_surface_size();
if (width === 0 || height === 0) {
cr.$dispose();
return;
}
// Background
cr.setSourceRGBA(1, 1, 1, 0.05);
cr.rectangle(0, 0, width, height);
cr.fill();
if (this.signalHistory.length < 2) {
cr.$dispose();
return;
}
const mapY = (dbm: number): number => {
const normalized = Math.max(
0,
Math.min(1, (dbm - MIN_SIGNAL_DBM) / (MAX_SIGNAL_DBM - MIN_SIGNAL_DBM)),
);
return height * (1 - normalized);
};
const stepX = width / (SIGNAL_HISTORY_MAX - 1);
const startX = width - (this.signalHistory.length - 1) * stepX;
const latest = this.signalHistory[this.signalHistory.length - 1];
const quality = getSignalQuality(latest as SignalDbm);
const [r, g, b] = SIGNAL_QUALITY_COLORS[quality] ?? [0.2, 0.52, 0.89];
// Filled area
cr.moveTo(startX, height);
for (let i = 0; i < this.signalHistory.length; i++) {
cr.lineTo(startX + i * stepX, mapY(this.signalHistory[i]));
}
cr.lineTo(startX + (this.signalHistory.length - 1) * stepX, height);
cr.closePath();
cr.setSourceRGBA(r, g, b, 0.2);
cr.fill();
// Line on top
cr.moveTo(startX, mapY(this.signalHistory[0]));
for (let i = 1; i < this.signalHistory.length; i++) {
cr.lineTo(startX + i * stepX, mapY(this.signalHistory[i]));
}
cr.setSourceRGBA(r, g, b, 0.8);
cr.setLineWidth(1.5);
cr.stroke();
cr.$dispose();
}
private updateMenuItem(id: MenuItemId, value: string, barPercent?: number): void {
const entry = this.menuItems.get(id);
if (!entry) return;
entry.value.set_text(value);
if (entry.barFill !== undefined && barPercent !== undefined) {
const trackWidth = entry.barFill.get_parent()?.width ?? 0;
if (trackWidth > 0) {
entry.barFill.set_width(Math.round((barPercent / 100) * trackWidth));
}
}
} }
private refresh(): void { private refresh(): void {
@ -211,23 +370,54 @@ export default class WifiSignalPlusExtension extends Extension {
private showDisconnectedState(): void { private showDisconnectedState(): void {
for (const section of MENU_STRUCTURE) { for (const section of MENU_STRUCTURE) {
for (const { id, label } of section) { for (const { id } of section) {
const value = id === 'ssid' ? 'Not connected' : PLACEHOLDER; const value = id === 'ssid' ? 'Not connected' : PLACEHOLDER;
this.updateMenuItem(id, label, value); this.updateMenuItem(id, value, 0);
} }
} }
this.signalHistory.length = 0;
this.signalGraph?.queue_repaint();
} }
private showConnectedState(info: ConnectedInfo): void { private showConnectedState(info: ConnectedInfo): void {
this.updateMenuItem('ssid', 'Network', info.ssid); this.updateMenuItem('ssid', info.ssid);
this.updateMenuItem('generation', 'Generation', getGenerationDescription(info.generation)); this.updateMenuItem('generation', getGenerationDescription(info.generation));
this.updateMenuItem('band', 'Band', this.formatBand(info)); this.updateMenuItem('band', this.formatBand(info));
this.updateMenuItem('bitrate', 'Speed', this.formatBitrate(info)); this.updateMenuItem(
this.updateMenuItem('channelWidth', 'Width', this.formatChannelWidth(info.channelWidth)); 'bitrate',
this.updateMenuItem('mcs', 'Modulation', this.formatModulation(info)); this.formatBitrate(info),
this.updateMenuItem('signal', 'Signal', this.formatSignal(info.signalStrength)); this.getSpeedPercent(info),
this.updateMenuItem('security', 'Security', info.security); );
this.updateMenuItem('bssid', 'BSSID', info.bssid); this.updateMenuItem(
'channelWidth',
this.formatChannelWidth(info.channelWidth),
this.getWidthPercent(info.channelWidth),
);
this.updateMenuItem('mcs', this.formatModulation(info));
this.updateMenuItem('signal', this.formatSignal(info.signalStrength));
this.updateMenuItem('security', info.security);
this.updateMenuItem('bssid', info.bssid);
this.pushSignalHistory(info.signalStrength);
}
private pushSignalHistory(signalStrength: SignalDbm): void {
this.signalHistory.push(signalStrength as number);
if (this.signalHistory.length > SIGNAL_HISTORY_MAX) {
this.signalHistory.shift();
}
this.signalGraph?.queue_repaint();
}
private getSpeedPercent(info: ConnectedInfo): number {
const speed = Math.max(info.txBitrate ?? 0, info.rxBitrate ?? 0, info.bitrate);
return Math.min(100, (speed / MAX_SPEED_MBPS) * 100);
}
private getWidthPercent(width: ChannelWidthMHz | null): number {
if (width === null) return 0;
return Math.min(100, (width / MAX_CHANNEL_WIDTH_MHZ) * 100);
} }
private formatBand(info: ConnectedInfo): string { private formatBand(info: ConnectedInfo): string {

View file

@ -33,7 +33,7 @@
/* Tooltip popup */ /* Tooltip popup */
.wifi-signal-plus-popup { .wifi-signal-plus-popup {
padding: 12px; padding: 12px;
min-width: 280px; min-width: 320px;
} }
.wifi-popup-section { .wifi-popup-section {
@ -60,17 +60,42 @@
opacity: 0.8; opacity: 0.8;
} }
.wifi-popup-row { .wifi-popup-item {
padding: 2px 0; min-height: 0;
padding: 2px 8px;
} }
.wifi-popup-label { .wifi-popup-label {
opacity: 0.7; font-size: 1em;
margin-right: 8px; min-width: 7em;
padding: 0 0.5em;
color: rgba(255, 255, 255, 0.7);
} }
.wifi-popup-value { .wifi-popup-value {
font-size: 1em;
font-weight: 500; font-weight: 500;
padding: 0 0.5em;
}
/* Signal history graph */
.wifi-signal-graph {
height: 40px;
border-radius: 4px;
}
/* Bar gauges */
.wifi-bar-track {
height: 4px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 2px;
margin-top: 4px;
}
.wifi-bar-fill {
height: 4px;
border-radius: 2px;
background-color: #3584e4;
} }
/* Signal strength indicator */ /* Signal strength indicator */