diff --git a/src/extension.ts b/src/extension.ts index f4cc6c1..112e0f7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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> = { + 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 = 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(); + 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 }); + 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); + } + 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 { - const item = this.menuItems.get(id); - item?.label.set_text(`${label}: ${value}`); + 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 { diff --git a/stylesheet.css b/stylesheet.css index 5a25a12..4a54901 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -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 */