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 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 =
| 'ssid'
@ -69,13 +83,20 @@ const MENU_STRUCTURE: readonly MenuItemConfig[][] = [
],
] as const;
const ITEMS_WITH_BAR: ReadonlySet<MenuItemId> = new Set(['bitrate', 'channelWidth']);
export default class WifiSignalPlusExtension extends Extension {
private indicator: PanelMenu.Button | null = null;
private icon: St.Icon | null = null;
private label: St.Label | null = null;
private wifiService: WifiInfoService | 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 {
this.wifiService = new WifiInfoService();
@ -100,6 +121,8 @@ export default class WifiSignalPlusExtension extends Extension {
this.wifiService = null;
this.icon = null;
this.label = null;
this.signalGraph = null;
this.signalHistory.length = 0;
this.menuItems.clear();
}
@ -130,28 +153,164 @@ export default class WifiSignalPlusExtension extends Extension {
const menu = this.indicator.menu as PopupMenu.PopupMenu;
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) => {
for (const { id, label } of section) {
this.addMenuItem(menu, id, label);
if (sectionHeaders[index]) {
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(sectionHeaders[index]));
}
// Add separator between sections (not after last)
if (index < MENU_STRUCTURE.length - 1) {
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
if (index === SIGNAL_SECTION_INDEX) {
this.addSignalGraph(menu);
}
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 {
const item = new PopupMenu.PopupMenuItem(`${label}: ${PLACEHOLDER}`, { reactive: false });
menu.addMenuItem(item);
this.menuItems.set(id, item);
private addMenuItem(
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);
}
private updateMenuItem(id: MenuItemId, label: string, value: string): void {
const item = this.menuItems.get(id);
item?.label.set_text(`${label}: ${value}`);
menu.addMenuItem(item);
this.menuItems.set(id, { item, label: labelWidget, value: valueWidget, barFill });
}
private addSignalGraph(menu: PopupMenu.PopupMenu): void {
const item = new PopupMenu.PopupBaseMenuItem({ reactive: false });
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 {
@ -211,23 +370,54 @@ export default class WifiSignalPlusExtension extends Extension {
private showDisconnectedState(): void {
for (const section of MENU_STRUCTURE) {
for (const { id, label } of section) {
for (const { id } of section) {
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 {
this.updateMenuItem('ssid', 'Network', info.ssid);
this.updateMenuItem('generation', 'Generation', getGenerationDescription(info.generation));
this.updateMenuItem('band', 'Band', this.formatBand(info));
this.updateMenuItem('bitrate', 'Speed', this.formatBitrate(info));
this.updateMenuItem('channelWidth', 'Width', this.formatChannelWidth(info.channelWidth));
this.updateMenuItem('mcs', 'Modulation', this.formatModulation(info));
this.updateMenuItem('signal', 'Signal', this.formatSignal(info.signalStrength));
this.updateMenuItem('security', 'Security', info.security);
this.updateMenuItem('bssid', 'BSSID', info.bssid);
this.updateMenuItem('ssid', info.ssid);
this.updateMenuItem('generation', getGenerationDescription(info.generation));
this.updateMenuItem('band', this.formatBand(info));
this.updateMenuItem(
'bitrate',
this.formatBitrate(info),
this.getSpeedPercent(info),
);
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 {

View file

@ -33,7 +33,7 @@
/* Tooltip popup */
.wifi-signal-plus-popup {
padding: 12px;
min-width: 280px;
min-width: 320px;
}
.wifi-popup-section {
@ -60,17 +60,42 @@
opacity: 0.8;
}
.wifi-popup-row {
padding: 2px 0;
.wifi-popup-item {
min-height: 0;
padding: 2px 8px;
}
.wifi-popup-label {
opacity: 0.7;
margin-right: 8px;
font-size: 1em;
min-width: 7em;
padding: 0 0.5em;
color: rgba(255, 255, 255, 0.7);
}
.wifi-popup-value {
font-size: 1em;
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 */