1// Monitor (MonitorSettings.swift): o que a barra de menus e o painel mostram 2// sobre o Mac, e quando avisar. A prévia da barra fica presa no topo 3// enquanto os dois cards dela rolam por baixo, como a seção fixada do app. 4 5import { notify } from "../../desktop/notifications.js"; 6import { focusAnchor } from "../settings.js"; 7import { live, divider, reorderable } from "./shortcuts.js"; 8import { PANEL_SECTIONS } from "./general.js"; 9import { activeIconNode } from "./energy.js"; 10 11// ----- Métricas da barra de menus (MenuBarMetric) ------------------------------------- 12 13export const METRICS = [ 14 { id: "cpu", key: "menuBarCPU", symbol: "cpu", feature: "monitorCPU", title: L => L.S.monitorShowCPU }, 15 { id: "cpuTemperature", key: "menuBarCPUTemperature", symbol: "cpu", feature: "monitorCPU", title: L => L.S.monitorShowCPUTemperature }, 16 { id: "gpu", key: "menuBarGPU", symbol: "rectangle.connected.to.line.below", feature: "monitorGPU", title: L => L.S.monitorShowGPU }, 17 { id: "gpuTemperature", key: "menuBarGPUTemperature", symbol: "rectangle.connected.to.line.below", feature: "monitorGPU", title: L => L.S.monitorShowGPUTemperature }, 18 { id: "memory", key: "menuBarMemory", symbol: "memorychip", feature: "monitorMemory", title: L => L.S.monitorShowMemory }, 19 { id: "battery", key: "menuBarBattery", symbol: "battery.100", feature: "monitorPower", title: L => L.S.batteryLabel }, 20 { id: "batteryTime", key: "menuBarBatteryTime", symbol: "clock", feature: "monitorPower", title: L => L.batteryTime.title }, 21 { id: "batteryTemperature", key: "menuBarBatteryTemperature", symbol: "battery.100", feature: "monitorPower", title: L => L.S.monitorShowBatteryTemperature }, 22 { id: "peripheralBattery", key: "menuBarPeripheralBattery", symbol: "keyboard", feature: "monitorPower", title: L => L.S.monitorShowPeripheralBattery }, 23 { id: "network", key: "menuBarNetwork", symbol: "network", feature: "monitorNetwork", title: L => L.S.monitorShowNetwork }, 24 { id: "diskUsage", key: "menuBarDiskUsage", symbol: "internaldrive", feature: "monitorDisk", title: L => L.S.monitorItemDiskUsage }, 25 { id: "diskActivity", key: "menuBarDiskActivity", symbol: "internaldrive.fill", feature: "monitorDisk", title: L => L.S.monitorItemDiskActivity }, 26 { id: "connectedDevices", key: "menuBarConnectedDevices", symbol: "cable.connector", feature: "connectedDevices", title: L => L.connectedDevices.title }, 27 { id: "power", key: "menuBarPower", symbol: "powerplug.fill", feature: "monitorPower", title: L => L.S.monitorShowPowerLabel }, 28 { id: "fanSpeed", key: "menuBarFanSpeed", symbol: "fanblades", feature: "fanControl", title: L => L.fanControl.menuBarTitle }, 29]; 30const metricByID = id => METRICS.find(metric => metric.id === id); 31 32/** Defaults.sanitizedMenuBarMetricOrder: a ordem salva e, no fim, as que faltarem. */ 33export function metricOrder(store) { 34 const ids = METRICS.map(metric => metric.id); 35 const result = []; 36 for (const raw of String(store.get("menuBarMetricOrder") || "").split(",")) { 37 const values = raw === "temperature" ? ["cpuTemperature", "gpuTemperature", "batteryTemperature"] : [raw]; 38 for (const value of values) if (ids.includes(value) && !result.includes(value)) result.push(value); 39 } 40 return [...result, ...ids.filter(id => !result.includes(id))]; 41} 42 43/** As métricas ligadas, instaladas e possÃveis neste MacBook Pro, na ordem da barra. */ 44export const enabledMetrics = store => metricOrder(store).map(metricByID).filter(metric => store.get(metric.key) && store.isInstalled(metric.feature)); 45 46const METRIC_KEYS = METRICS.map(metric => metric.key); 47const PREVIEW_KEYS = [...METRIC_KEYS, "menuBarMetricOrder", "menuBarCombineTemperatures", "menuBarMetricAppearance", "menuBarUsageBarNormalColor", 48 "menuBarUsageBarElevatedColor", "menuBarUsageBarCriticalColor", "menuBarUsageBarMediumThreshold", "menuBarUsageBarHighThreshold", 49 "menuBarNetworkUploadFirst", "menuBarMemoryStyle", "menuBarDiskStyle", "temperatureUnit", "menuBarMetricSpacing", "menuBarHideIconWithMetrics", 50 "menuBarSeparateMetrics", "monitorMemoryMetric", "keepAwakeActiveIcon", "menuBarIconSymbol", "installed"]; 51 52// ----- Leituras simuladas ---------------------------------------------------------------- 53 54// As leituras vêm do monitor simulado do painel, para a prévia mostrar os 55// mesmos números da barra de menus; sem ele, uma amostra própria anda sozinha. 56let panelMetrics = null; 57const panelReady = import("../../panel/metrics.js").then(module => { panelMetrics = module; }).catch(() => {}); 58 59const sample = { 60 cpu: 0.23, cpuTemp: 48, gpu: 0.11, gpuTemp: 42, memUsed: 0.61, memApp: 0.44, pressure: "normal", down: 1.25e6, up: 86e3, 61 diskUsed: 0.58, diskTotal: 995e9, diskRead: 12e3, diskWrite: 2.2e6, battery: 87, charging: false, pluggedIn: false, 62 batteryMinutes: 342, batteryTemp: 31, watts: 9, fans: [1850, 1902], usb: 3, peripherals: [["MOU", 64], ["KBD", 85]], 63}; 64const drift = (value, step, min, max) => Math.min(max, Math.max(min, value + (Math.random() * 2 - 1) * step)); 65function advance() { 66 sample.cpu = drift(sample.cpu, 0.07, 0.04, 0.62); 67 sample.cpuTemp = drift(sample.cpuTemp, 1.5, 41, 63); 68 sample.gpu = drift(sample.gpu, 0.04, 0.02, 0.4); 69 sample.gpuTemp = drift(sample.gpuTemp, 1, 37, 55); 70 sample.memUsed = drift(sample.memUsed, 0.01, 0.55, 0.7); 71 sample.memApp = drift(sample.memApp, 0.01, 0.38, 0.5); 72 sample.down = Math.max(800, drift(sample.down, 6e5, 2e3, 4.8e6)); 73 sample.up = Math.max(400, drift(sample.up, 5e4, 1e3, 4.2e5)); 74 sample.diskRead = drift(sample.diskRead, 4e5, 0, 3e6); 75 sample.diskWrite = drift(sample.diskWrite, 6e5, 0, 4e6); 76 sample.watts = drift(sample.watts, 1.5, 5, 22); 77 sample.fans = sample.fans.map(rpm => Math.round(drift(rpm, 40, 1780, 2100))); 78} 79 80/** Copia a amostra do painel para os campos da prévia, quando ela existe. */ 81function adoptPanelSample() { 82 const snap = panelMetrics?.snapshot; 83 if (!snap) return; 84 const num = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback); 85 sample.cpu = num(snap.cpu, sample.cpu); 86 sample.gpu = num(snap.gpu, sample.gpu); 87 sample.cpuTemp = num(snap.cpuTemperature, sample.cpuTemp); 88 sample.gpuTemp = num(snap.gpuTemperature, sample.gpuTemp); 89 const memory = snap.memory || {}; 90 if (memory.total) { 91 sample.memUsed = num(memory.used, 0) / memory.total; 92 sample.memApp = num(memory.app, 0) / memory.total; 93 } 94 if (memory.pressure) sample.pressure = memory.pressure; 95 sample.down = num(snap.network?.down, sample.down); 96 sample.up = num(snap.network?.up, sample.up); 97 const disk = (snap.disks || []).find(item => item.internal) || snap.disks?.[0]; 98 if (disk?.total) { 99 sample.diskTotal = disk.total; 100 sample.diskUsed = num(disk.used, 0) / disk.total; 101 sample.diskRead = num(disk.read, sample.diskRead); 102 sample.diskWrite = num(disk.write, sample.diskWrite); 103 } 104 const power = snap.power || {}; 105 if (power.charge != null) sample.battery = Math.round(num(power.charge, 0.87) * 100); 106 sample.charging = !!power.charging; 107 sample.pluggedIn = !!power.pluggedIn; 108 sample.batteryMinutes = power.timeRemaining == null ? null : Math.round(num(power.timeRemaining, 0)); 109 sample.batteryTemp = num(power.batteryTemperature, sample.batteryTemp); 110 sample.watts = num(power.system, sample.watts); 111 if (Array.isArray(snap.fans) && snap.fans.length) sample.fans = snap.fans.map(fan => Math.round(num(fan.rpm ?? fan, 0))); 112 if (Array.isArray(snap.devices)) sample.usb = snap.devices.length; 113 if (Array.isArray(snap.peripherals) && snap.peripherals.length) { 114 const labels = { keyboard: "KBD", mouse: "MOU", trackpad: "TRK", headphones: "AUD" }; 115 sample.peripherals = snap.peripherals.map(device => [labels[device.kind] || "PER", Math.round(num(device.percent, 0))]); 116 } 117} 118 119const percent = fraction => `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`; 120const fahrenheit = store => store.get("temperatureUnit") === "fahrenheit"; 121const tempCompact = (store, celsius) => `${Math.round(fahrenheit(store) ? celsius * 9 / 5 + 32 : celsius)}°`; 122const tempSuffix = store => (fahrenheit(store) ? "°F" : "°C"); 123/** MetricFormat.temperature: "90 °C" / "194 °F". */ 124export const tempText = (store, celsius) => `${Math.round(fahrenheit(store) ? celsius * 9 / 5 + 32 : celsius)} ${tempSuffix(store)}`; 125 126/** MetricFormat.bytesPerSecCompact: base 1024, uma casa abaixo de 10. */ 127function rate(bytes) { 128 const units = ["B", "K", "M", "G", "T"]; 129 let value = Math.max(0, bytes); 130 let index = 0; 131 while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; } 132 const text = index > 0 && value < 10 ? value.toFixed(1) : String(Math.round(value)); 133 return `${text}${units[index]}`; 134} 135const gigabytes = fraction => `${Math.round((fraction * sample.diskTotal) / 1e9)} GB`; 136const batterySymbol = level => (sample.charging ? "battery.100.bolt" : level >= 85 ? "battery.100" : level >= 60 ? "battery.75" : level >= 35 ? "battery.50" : level >= 10 ? "battery.25" : "battery.0"); 137 138// ----- Blocos da barra (MenuBarRenderer, estilo "dense") --------------------------------- 139 140/** Os grupos de blocos para estas métricas, como o app os junta. */ 141function blockGroups(ctx, metrics) { 142 const { h, store } = ctx; 143 const bars = store.get("menuBarMetricAppearance") === "bars"; 144 const combine = !bars && store.get("menuBarCombineTemperatures") !== false; 145 const standard = store.get("menuBarMetricSpacing") === "standard"; 146 const enabled = new Set(metrics.map(metric =>
146 metric.id)); 147 const groups = []; 148 const done = new Set(); 149 150 const valueBlock = (label, value, reserve, dot = null) => h("span.pe-mb-value", 151 h("span.pe-mb-label", label), 152 h("span.pe-mb-number", { style: standard && reserve ? { minWidth: `${reserve}px` } : null }, 153 dot ? h(`i.pe-mb-dot.is-${dot}`) : null, value ? h("span", value) : null)); 154 const barBlock = (label, fraction, dot = null) => { 155 const medium = Number(store.get("menuBarUsageBarMediumThreshold")) || 70; 156 const high = Number(store.get("menuBarUsageBarHighThreshold")) || 90; 157 const level = fraction * 100 >= high ? "Critical" : fraction * 100 >= medium ? "Elevated" : "Normal"; 158 const color = store.get(`menuBarUsageBar${level}Color`) || { Normal: "#64D2FF", Elevated: "#FFD60A", Critical: "#FF453A" }[level]; 159 return h("span.pe-mb-bar", 160 h("span.pe-mb-letters", [...label.slice(0, 3)].map(letter => h("span", letter))), 161 dot ? h(`i.pe-mb-dot.is-${dot}`) : null, 162 h("span.pe-mb-gauge", h("i", { style: { height: `${Math.max(1, 13.8 * Math.round(fraction * 15) / 15)}px`, background: color } }))); 163 }; 164 const rateBlock = (top, bottom) => h("span.pe-mb-rate", h("span", top), h("span", bottom)); 165 166 for (const metric of metrics) { 167 switch (metric.id) { 168 case "cpu": case "cpuTemperature": case "gpu": case "gpuTemperature": { 169 const gpu = metric.id.startsWith("gpu"); 170 const name = gpu ? "GPU" : "CPU"; 171 const usageID = gpu ? "gpu" : "cpu"; 172 const tempID = gpu ? "gpuTemperature" : "cpuTemperature"; 173 const usage = gpu ? sample.gpu : sample.cpu; 174 const temp = gpu ? sample.gpuTemp : sample.cpuTemp; 175 if (combine) { 176 if (done.has(name)) break; 177 done.add(name); 178 const hasUsage = enabled.has(usageID); 179 const hasTemp = enabled.has(tempID); 180 const value = [hasUsage ? percent(usage) : null, hasTemp ? tempCompact(store, temp) : null].filter(Boolean).join(" ");
181 groups.push([valueBlock(hasUsage ? name : name + tempSuffix(store), value, hasUsage && hasTemp ? 56 : 30)]); 182 } else if (metric.id === usageID) { 183 groups.push([bars ? barBlock(name, usage) : valueBlock(name, percent(usage), 30)]); 184 } else { 185 groups.push([valueBlock(name + tempSuffix(store), tempCompact(store, temp), 30)]); 186 } 187 break; 188 } 189 case "memory": { 190 const style = store.get("menuBarMemoryStyle") || "percent"; 191 const dot = style === "percent" ? null : "normal"; 192 const used = store.get("monitorMemoryMetric") === "app" ? sample.memApp : sample.memUsed; 193 const pressure = dot && sample.pressure; 194 groups.push([bars ? barBlock("RAM", used, pressure) : valueBlock("RAM", style === "dot" ? "" : percent(used), style === "dot" ? 0 : 30, pressure)]); 195 break; 196 } 197 case "battery": case "batteryTemperature": { 198 if (combine) { 199 if (done.has("BAT")) break; 200 done.add("BAT"); 201 if (enabled.has("battery") && enabled.has("batteryTemperature")) { 202 groups.push([valueBlock("BAT", `${sample.battery}% ${tempCompact(store, sample.batteryTemp)}`, 56)]); 203 } else if (enabled.has("battery")) { 204 groups.push([h("span.pe-mb-battery", ctx.sf(batterySymbol(sample.battery), { size: 15.5 }), h("span", `${sample.battery}%`))]); 205 } else { 206 groups.push([valueBlock("BAT" + tempSuffix(store), tempCompact(store, sample.batteryTemp), 30)]); 207 } 208 } else if (metric.id === "battery") { 209 groups.push([h("span.pe-mb-battery", ctx.sf(batterySymbol(sample.battery), { size: 15.5 }), h("span", `${sample.battery}%`))]); 210 } else { 211 groups.push([valueBlock("BAT" + tempSuffix(store), tempCompact(store, sample.batteryTemp), 30)]); 212 } 213 break; 214 } 215 case "batteryTime": 216 // Na tomada ou carregando não há estimativa: o bloco some, como no app. 217 if (sample.batteryMinutes != null) groups.push([valueBlock("BAT", `${Math.floor(sample.batteryMinutes / 60)}h ${sample.batteryMinutes % 60}m`, 54)]); 218 else if (!sample.pluggedIn && !sample.charging) groups.push([valueBlock("BAT", "...", 54)]); 219 break; 220 case "peripheralBattery": { 221 const [first, ...rest] = [...sample.peripherals].sort((a, b) => a[1] - b[1]); 222 groups.push([valueBlock(first[0], `${first[1]}%${rest.length ? `+${Math.min(9, rest.length)}` : ""}`, 46)]); 223 break; 224 } 225 case "network": { 226 const lines = [`â${rate(sample.down)}`, `â${rate(sample.up)}`]; 227 if (store.get("menuBarNetworkUploadFirst")) lines.reverse(); 228 groups.push([rateBlock(...lines)]); 229 break; 230 } 231 case "diskUsage": { 232 const style = store.get("menuBarDiskStyle") || "percent"; 233 if (bars && style === "percent") groups.push([barBlock("DSK", sample.diskUsed)]); 234 else groups.push([valueBlock("DSK", style === "free" ? gigabytes(1 - sample.diskUsed) : style === "used" ? gigabytes(sample.diskUsed) : percent(sample.diskUsed), style === "percent" ? 30 : 53)]); 235 break; 236 } 237 case "diskActivity": 238 groups.push([rateBlock(`R${rate(sample.diskRead)}`, `W${rate(sample.diskWrite)}`)]); 239 break; 240 case "connectedDevices": 241 groups.push([valueBlock(ctx.L.connectedDevices.menuBarLabel, String(sample.usb), 0)]); 242 break; 243 case "power": 244 groups.push([valueBlock("PWR", `${Math.round(sample.watts)}W`, 25)]); 245 break; 246 case "fanSpeed": 247 groups.push([valueBlock("RPM", sample.fans.join("/"), 0)]); 248 break; 249 } 250 } 251 return groups; 252} 253 254/** StatusItemController.metricStatusGroups: um item por métrica, ou por par uso+temperatura. */ 255function separateGroups(store, metrics) { 256 const combine = store.get("menuBarMetricAppearance") !== "bars" && store.get("menuBarCombineTemperatures") !== false; 257 if (!combine) return metrics.map(metric => [metric]); 258 const pairs = { cpu: "cpu", cpuTemperature: "cpu", gpu: "gpu", gpuTemperature: "gpu", battery: "battery", batteryTemperature: "battery" }; 259 const out = []; 260 const seen = new Map(); 261 for (const metric of metrics) { 262 const pair = pairs[metric.id]; 263 if (!pair) { out.push([metric]); continue; } 264 if (seen.has(pair)) seen.get(pair).push(metric); 265 else { const group = [metric]; seen.set(pair, group); out.push(group); } 266 } 267 return out; 268} 269 270function blocksLine(ctx, groups) { 271 const gap = ctx.store.get("menuBarMetricSpacing") === "standard" ? "is-standard" : "
271is-compact"; 272 return ctx.h(`span.pe-mb-line.${gap}`, groups.flat()); 273} 274 275/** MenuBarMetricsPreview: o canto da barra de menus, ao vivo. */ 276function preview(ctx) { 277 const { h, store } = ctx; 278 const bar = h("div.pe-mb-preview", { "aria-hidden": "true" }); 279 const render = () => { 280 const metrics = enabledMetrics(store); 281 const separate = !!store.get("menuBarSeparateMetrics"); 282 const items = separate ? separateGroups(store, metrics).map(group => blockGroups(ctx, group)).filter(groups => groups.length) : []; 283 const lines = separate ? [] : blockGroups(ctx, metrics); 284 // Um sinal (atualização, microfone mudo) traria o Ãcone de volta; a prévia não finge saber deles. 285 const iconHidden = !!store.get("menuBarHideIconWithMetrics") && (lines.length > 0 || items.length > 0); 286 const glyph = h("span.pe-mb-glyph", activeIconNode(ctx)); 287 bar.replaceChildren( 288 ctx.sf("wifi", { size: 12, class: "pe-mb-system" }), 289 ctx.sf("battery.75", { size: 12, class: "pe-mb-system" }), 290 ...items.map(groups => h("span.pe-mb-item", blocksLine(ctx, groups))), 291 !iconHidden || lines.length ? h("span.pe-mb-main", iconHidden ? null : glyph, lines.length ? blocksLine(ctx, lines) : null) : null); 292 }; 293 adoptPanelSample(); 294 render(); 295 ctx.scope.on(PREVIEW_KEYS, render); 296 // As leituras seguem o intervalo de atualização escolhido. 297 let timer = null; 298 let closed = false; 299 const schedule = () => { 300 clearInterval(timer); 301 if (panelMetrics?.onSample) return; 302 const seconds = [1, 2, 5].includes(Number(store.get("monitorInterval"))) ? Number(store.get("monitorInterval")) : 2; 303 timer = setInterval(() => { advance(); if (enabledMetrics(store).length) render(); }, seconds * 1000); 304 }; 305 schedule(); 306 ctx.scope.on("monitorInterval", schedule); 307 ctx.scope.add(() => { closed = true; clearInterval(timer); }); 308 panelReady.then(() => { 309 if (closed || !panelMetrics?.onSample) return; 310 clearInterval(timer); 311 adoptPanelSample(); 312 render(); 313 ctx.scope.add(panelMetrics.onSample(() => { adoptPanelSample(); if (enabledMetrics(store).length) render(); })); 314 }); 315 return bar; 316} 317 318// ----- MonitorToken ------------------------------------------------------------------------- 319 320/** 321 * Um rótulo de item com o visto, e, quando o item tem opções, uma segunda 322 * metade com o resumo que abre um popover. 323 */ 324function token(ctx, { symbol, title, get, set, keys = [], available = () => true, selected = null, options = null, summary = () => "", large = false, onSelect = null, id = null }) { 325 const { h, ui } = ctx; 326 const check = h("button.pe-token-check", { type: "button", "aria-label": title, on: { click: event => { event.stopPropagation(); if (available()) set(!get()); } } }); 327 const label = h("button.pe-token-label", { 328 type: "button", 329 on: { click: event => { event.stopPropagation(); if (!available()) return; if (onSelect) onSelect(); else set(!get()); } }, 330 }, ctx.sf(symbol, { size: large ? 15 : 12 }), h("span", title)); 331 const summaryText = h("span.pe-token-summary"); 332 const more = options ? h("button.pe-token-more", { 333 type: "button", 334 on: { 335 click: event => { 336 event.stopPropagation(); 337 if (more.disabled) return; 338 ui.popover(more, ui.withScope(ctx.scope, () => h("div.pe-token-pop", h("div.pe-token-pop-title", ctx.sf(symbol, { size: 13 }), h("span", title)), options()))); 339 }, 340 }, 341 }, summaryText, ctx.sf("chevron.down", { size: 7, weight: "bold" })) : null; 342 const el = h(`div.pe-token${large ? ".is-large" : ""}`, { "data-reorder-id": id, title }, 343 h("div.pe-token-main", check, label), 344 more ? [h("span.pe-token-divider"), more] : null); 345 const sync = () => { 346 const included = !!get(); 347 const on = included && available(); 348 el.classList.toggle("is-on", on); 349 el.classList.toggle("is-unavailable", !available()); 350 el.classList.toggle("is-selected", !!selected?.()); 351 check.replaceChildren(ctx.sf(included ? "checkmark.circle.fill" : "circle", { size: large ? 15 : 13 })); 352 check.setAttribute("aria-pressed", String(included)); 353 if (more) { 354 summaryText.textContent = summary(); 355 more.disabled = !on; 356 more.setAttribute("aria-label", `${title}: ${summary()}`); 357 } 358 }; 359 if (keys.length) ctx.scope.on(keys, sync); 360 sync(); 361 el.sync = sync; 362 return el; 363} 364 365/** MonitorTokenOption: Ãcone, texto e um interruptor pequeno. */ 366function tokenOption(ctx, { symbol, title, tint, get, set, key }) { 367 const { h, ui } = ctx; 368 return h("div.pe-token-option", 369 h("span.pe-token-option-icon", { style: tint ? { color: tint } : null }, ctx.sf(symbol, { size: 11 })), 370 h("span.pe-token-option-title", title), 371 h("span.spacer"), 372 ui.toggle({ size: "small", label: title, key, get, set, watch: key ? [] : undefined })); 373} 374 375// ----- Página --------------------------------------------------------------------------------- 376 377const state = { block: null }; 378 379export default function render(ctx) { 380 const { h, ui, L, store } = ctx; 381 const heading = (title, symbol) => h("div.pe-mon-heading", divider(ctx), h("h2.pe-mon-heading-title", ctx.sf(symbol, { size: 15 }), h("span", title))); 382 383 return h("div.s-page.pe-monitor", 384 h("div.s-page-header", h("h1.s-page-title", L.S.tabMonitor), h("p.s-page-intro", L.settingsPages.monitorDescription)), 385 heading(L.S.monitorMenuBarSection, "menubar.rectangle"), 386 h("div.pe-mon-section", 387 h("div.pe-mb-sticky", preview(ctx)), 388 menuBarCard(ctx), 389 styleCard(ctx)), 390 heading(L.S.monitorPanelSection, "macwindow"), 391 panelCard(ctx), 392 ui.when(["installed"], () => store.isInstalled("fanControl"), fanControlCard(ctx)), 393 heading(L.monitorLayout.shared, "slider.horizontal.3"), 394 readingsCard(ctx), 395 alertsCard(ctx)); 396} 397 398export function focus(ctx, { anchor }) {
399 if (anchor) focusAnchor(anchor); 400} 401 402function menuBarCard(ctx) { 403 const { h, ui, L, store } = ctx; 404 const S = L.S; 405 const summaryFor = metric => { 406 if (metric.id === "memory") return (store.get("menuBarMemoryStyle") || "percent") !== "percent" ? S.monitorMemoryPressureDot : L.mouseClickDebounce.moreOptions; 407 if (metric.id === "network") return store.get("menuBarNetworkUploadFirst") ? "â â" : "â â"; 408 if (metric.id === "diskUsage") return { free: S.diskMenuBarAvailableSpace, used: S.diskMenuBarUsedSpace }[store.get("menuBarDiskStyle")] || S.diskMenuBarUsedPercentage; 409 return ""; 410 }; 411 const optionsFor = (local, metric) => { 412 if (metric.id === "memory") return () => tokenOption(local, { 413 symbol: "circle.fill", title: S.monitorMemoryPressureDot, tint: "var(--green)", 414 get: () => (store.get("menuBarMemoryStyle") || "percent") !== "percent", 415 set: on => store.set("menuBarMemoryStyle", on ? "both" : "percent"), 416 }); 417 if (metric.id === "network") return () => tokenOption(local, { symbol: "arrow.up.arrow.down", title: S.monitorNetworkUploadFirst, key: "menuBarNetworkUploadFirst" }); 418 if (metric.id === "diskUsage") return () => local.ui.popup({ 419 key: "menuBarDiskStyle", label: S.diskMenuBarStyleLabel, 420 options: [{ value: "percent", label: S.diskMenuBarUsedPercentage }, { value: "free", label: S.diskMenuBarAvailableSpace }, { value: "used", label: S.diskMenuBarUsedSpace }], 421 }); 422 return null; 423 }; 424 const grid = live(ctx, ["installed"], local => { 425 const visible = metricOrder(store).map(metricByID).filter(metric => store.isInstalled(metric.feature)); 426 const el = h("div.pe-token-grid", visible.map(metric => token(local, { 427 id: metric.id, symbol: metric.symbol, title: metric.title(L), 428 get: () => !!store.get(metric.key), set: on => store.set(metric.key, on), 429 keys: [metric.key, "menuBarMemoryStyle", "menuBarNetworkUploadFirst", "menuBarDiskStyle"], 430 options: optionsFor(local, metric), summary: () => summaryFor(metric), 431 }))); 432 reorderable(el, { 433 onMove: () => {}, 434 onEnd: () => { 435 // A ordem é a lista inteira: as métricas de recursos ausentes guardam o lugar. 436 const dom = [...el.children].map(tile => tile.dataset.reorderId); 437 const shown = new Set(dom); 438 let index = 0; 439 store.set("menuBarMetricOrder", metricOrder(store).map(id => (shown.has(id) ? dom[index++] : id)).join(",")); 440 }, 441 }); 442 return el; 443 }, "div.pe-token-group"); 444 return ui.card( 445 h("p.pe-callout", S.monitorMenuBarCaption), 446 grid, 447 ui.caption(L.notchEditor.reorderHint)); 448} 449 450function styleCard(ctx) { 451 const { h, ui, L, store } = ctx; 452 const S = L.S; 453 const look = L.menuBarAppearance; 454 const isBars = () => store.get("menuBarMetricAppearance") === "bars"; 455 const mockup = bars => h("span.pe-style-mock", 456 bars 457 ? [["CPU", 0.42], ["RAM", 0.61]].map(([label, fraction]) => h("span.pe-mb-bar", 458 h("span.pe-mb-letters", [...label].map(letter => h("span", letter))), 459 h("span.pe-mb-gauge", h("i", { style: { height: `${13.8 * fraction}px`, background: "rgb(99, 209, 255)" } })))) 460 : [["CPU", "42%"], ["RAM", "61%"]].map(([label, value]) => h("span.pe-mb-value", h("span.pe-mb-label", label), h("span.pe-mb-number", h("span", value))))); 461 const choices = [["values", look.values], ["bars", look.bars]].map(([value, title]) => h("button.pe-style", { 462 type: "button", "aria-label": title, on: { click: () => store.set("menuBarMetricAppearance", value) }, 463 }, mockup(value === "bars"), h("span.pe-style-label", title))); 464 const syncChoices = () => choices.forEach((choice, index) => { 465 const on = (index === 1) === isBars(); 466 choice.classList.toggle("is-selected", on); 467 choice.setAttribute("aria-pressed", String(on)); 468 }); 469 ctx.scope.on("menuBarMetricAppearance", syncChoices); 470 syncChoices(); 471 472 const colorRow = (title, key, fallback) => { 473 const input = h("input.pe-color-input", { type: "color", "aria-label": title }); 474 const sync = () => { input.value = /^#[0-9a-f]{6}$/i.test(store.get(key) || "") ? store.get(key) : fallback; }; 475 input.addEventListener("input", () => store.set(key, input.value.toUpperCase())); 476 ctx.scope.on(key, sync); 477 sync(); 478 return h("label.pe-color-row", h("span", title), h("span.spacer"), h("span.pe-color-well", input)); 479 }; 480 const stepperRow = (title, key, fallback, set) => h("div.pe-stepper-row", 481 h("span", title), h("span.spacer"), 482 ui.text([key], () => `${store.get(key) ?? fallback}%`, "span.tnum.pe-stepper-value"), 483 ui.stepper({ get: () => Number(store.get(key) ?? fallback), set, min: 0, max: 100, showValue: false })); 484 const medium = () => Number(store.get("menuBarUsageBarMediumThreshold")) || 70; 485 const high = () => Number(store.get("menuBarUsageBarHighThreshold")) || 90; 486 const barSettings = h("div.pe-stack.pe-bar-settings", 487 h("p.pe-subhead.is-strong", look.customize), 488 colorRow(look.normalColor, "menuBarUsageBarNormalColor", "#64D2FF"), 489 colorRow(look.mediumColor, "menuBarUsageBarElevatedColor", "#FFD60A"), 490 colorRow(look.highColor, "menuBarUsageBarCriticalColor", "#FF453A"), 491 divider(ctx), 492 // Médio fica sempre abaixo de Alto. 493 stepperRow(look.mediumFrom, "menuBarUsageBarMediumThreshold", 70, value => store.set("menuBarUsageBarMediumThreshold", Math.min(Math.max(1, value), Math.max(1, high() - 1)))), 494 stepperRow(look.highFrom, "menuBarUsageBarHighThreshold", 90, value => store.set("menuBarUsageBarHighThreshold", Math.min(100, Math.max(medium() + 1, value))))); 495 496 const spacing = ui.segmented({
497 key: "menuBarMetricSpacing", label: S.menuBarSpacingLabel, 498 options: [{ value: "standard", label: S.menuBarSpacingStandard }, { value: "compact", label: S.menuBarSpacingCompact }], 499 }); 500 const separate = ui.toggleRow({ symbol: "rectangle.split.3x1", title: S.monitorSeparateMenuBarMetrics, caption: S.monitorSeparateMenuBarMetricsCaption, key: "menuBarSeparateMetrics" }); 501 const separateCaption = separate.querySelector(".s-row-caption"); 502 const syncSeparate = () => { separateCaption.hidden = isBars(); }; 503 ctx.scope.on("menuBarMetricAppearance", syncSeparate); 504 syncSeparate(); 505 506 return ui.card({ title: look.label }, 507 h("div.pe-style-row", choices), 508 ui.caption(look.caption), 509 ui.when(["menuBarMetricAppearance"], isBars, barSettings), 510 ui.when(["menuBarMetricAppearance"], () => !isBars(), 511 ui.toggleRow({ symbol: "thermometer.medium", title: S.monitorCombineTemperatures, caption: S.monitorCombineTemperaturesCaption, key: "menuBarCombineTemperatures" })), 512 divider(ctx), 513 ui.row({ symbol: "arrow.left.and.right", title: S.menuBarSpacingLabel, accessory: spacing }), 514 ui.toggleRow({ symbol: "eye.slash", title: S.menuBarHideIconToggle, caption: S.menuBarHideIconCaption, key: "menuBarHideIconWithMetrics" }), 515 separate); 516} 517 518// ----- No painel ---------------------------------------------------------------------------- 519 520const graphKeyOf = { cpu: "monitorGraphCPU", gpu: "monitorGraphGPU", memory: "monitorGraphMemory", net: "monitorGraphNetwork", disk: "monitorGraphDisk", power: "monitorGraphPower", battery: "monitorGraphBattery" }; 521 522function panelItems(L, store) { 523 const S = L.S; 524 const has = feature => store.isInstalled(feature); 525 return { 526 system: [ 527 (has("monitorCPU") || has("monitorGPU")) && { title: S.temperatures, symbol: "thermometer.medium", key: "monitorSysTemps" }, 528 has("monitorCPU") && { title: S.cpuLabel, symbol: "cpu", key: "monitorSysCPU", graph: graphKeyOf.cpu }, 529 has("monitorGPU") && { title: S.gpuLabel, symbol: "rectangle.connected.to.line.below", key: "monitorSysGPU", graph: graphKeyOf.gpu }, 530 has("monitorMemory") && { title: S.memorySection, symbol: "memorychip", key: "monitorSysMemory", graph: graphKeyOf.memory }, 531 { title: S.monitorItemUptime, symbol: "clock", key: "monitorSysUptime" }, 532 ], 533 network: [ 534 { title: S.monitorItemNetSpeed, symbol: "speedometer", key: "monitorNetSpeed", graph: graphKeyOf.net }, 535 { title: S.networkApps, symbol: "app.badge", key: "monitorNetApps" }, 536 { title: S.monitorItemNetTotals, symbol: "sum", key: "monitorNetTotals" }, 537 { title: S.networkIPAddresses, symbol: "network", key: "monitorNetAddresses" }, 538 { title: S.monitorItemNetTest, symbol: "gauge.with.needle", key: "monitorNetTest" }, 539 ], 540 disk: [ 541 { title: S.monitorItemDiskUsage, symbol: "internaldrive", key: "monitorDiskUsage" }, 542 { title: S.monitorItemDiskActivity, symbol: "arrow.up.arrow.down", key: "monitorDiskActivity", graph: graphKeyOf.disk }, 543 { title: S.monitorItemDiskSMART, symbol: "heart.text.square", key: "monitorDiskSMART" }, 544 { title: S.monitorItemDiskProtection, symbol: "shield", key: "monitorDiskProtection" }, 545 { title: S.monitorItemDiskTools, symbol: "wrench.and.screwdriver", key: "monitorDiskTools" }, 546 ], 547 power: [ 548 { title: S.powerSystem, symbol: "bolt", key: "monitorPwrSystem", graph: graphKeyOf.power }, 549 { title: S.powerAdapter, symbol: "powerplug", key: "monitorPwrAdapter" }, 550 { title: S.batteryCharge, symbol: "battery.75percent", key: "monitorSysBattery", graph: graphKeyOf.battery }, 551 { title: S.powerBattery, symbol: "battery.100percent.bolt", key: "monitorPwrBattery" }, 552 { title: L.batteryTime.title, symbol: "clock", key: "monitorPwrTimeRemaining" }, 553 { title: S.monitorShowBatteryTemperature, symbol: "thermometer.medium", key: "monitorPwrTemperature" }, 554 { title: S.powerHealth, symbol: "heart", key: "monitorPwrHealth" }, 555 ], 556 }; 557} 558 559function panelCard(ctx) { 560 const { h, ui, L, store } = ctx; 561 const S = L.S; 562 const blocks = ["system", "network", "disk", "power", "mixer"].map(id => PANEL_SECTIONS.find(section => section.id === id)); 563 const content = live(ctx, ["installed"], local => { 564 const available = blocks.filter(section => section.gate.some(feature => store.isInstalled(feature))); 565 if (!available.length) return null; 566 const current = () => available.find(section => section.id === state.block) || available[0]; 567 const tiles = available.map(section => token(local, { 568 symbol: section.symbol, title: section.title(L), large: true, 569 get: () => store.get(section.key) !== false, set: on => store.set(section.key, on), 570 keys: [section.key], 571 selected: () => current() === section, 572 onSelect: () => { state.block = section.id;
572 tiles.forEach(tile => tile.sync()); items.refresh(); }, 573 })); 574 const items = live(local, [], inner => { 575 const section = current(); 576 if (section.id === "mixer") return null; 577 const master = () => store.get(section.key) !== false; 578 return [ 579 h("p.pe-subhead", section.title(L)), 580 h("div.pe-token-group", h("div.pe-token-grid.is-large", panelItems(L, store)[section.id].filter(Boolean).map(item => token(inner, { 581 symbol: item.symbol, title: item.title, large: true, 582 get: () => store.get(item.key) !== false, set: on => store.set(item.key, on), 583 keys: [item.key, section.key, ...(item.graph ? [item.graph] : [])], 584 available: master, 585 options: item.graph ? () => tokenOption(inner, { symbol: "chart.xyaxis.line", title: S.monitorGraphsSection, key: item.graph }) : null, 586 summary: () => (store.get(item.graph) !== false ? S.monitorGraphsSection : L.mouseClickDebounce.moreOptions), 587 })))), 588 ]; 589 }, "div.pe-stack.pe-panel-items"); 590 return [h("div.pe-token-grid.is-blocks", tiles), items]; 591 }, "div.pe-stack.pe-panel-config"); 592 return ui.card( 593 content, 594 ui.caption(S.monitorPanelConfigHint), 595 ui.toggleRow({ symbol: "arrow.up.to.line", title: L.GraphScaleStrings.title, key: "monitorGraphScale" })); 596} 597 598function fanControlCard(ctx) { 599 const { ui, L } = ctx; 600 const fan = L.fanControl; 601 const row = ui.row({ symbol: "fanblades.fill", title: fan.title, badge: L.S.betaBadge, caption: fan.settingsCaption, id: null, 602 accessory: ui.toggle({ key: "panelShowFanControl", label: fan.showInPanel }) }); 603 return ui.card({ id: "fanControl" }, row, ui.indented(ui.caption(L.S.betaFeatureWarning))); 604} 605 606// ----- Leituras e alertas ---------------------------------------------------------------------- 607 608function readingsCard(ctx) { 609 const { h, ui, L, store } = ctx; 610 const S = L.S; 611 const chips = [[1, S.monitorInterval1], [2, S.monitorInterval2], [5, S.monitorInterval5]].map(([seconds, title]) => 612 [seconds, h("button.pe-chip.is-compact", { type: "button", on: { click: () => store.set("monitorInterval", seconds) } }, title)]); 613 const syncChips = () => chips.forEach(([seconds, chip]) => { 614 const on = (Number(store.get("monitorInterval")) || 2) === seconds; 615 chip.classList.toggle("is-selected", on); 616 chip.setAttribute("aria-pressed", String(on)); 617 }); 618 ctx.scope.on("monitorInterval", syncChips); 619 syncChips(); 620 return ui.card( 621 ui.row({ symbol: "timer", title: S.monitorIntervalLabel, accessory: h("div.pe-chips", chips.map(([, chip]) => chip)) }), 622 ui.row({ symbol: "thermometer.medium", title: S.temperatures, accessory: ui.segmented({ 623 key: "temperatureUnit", label: S.temperatures, options: [{ value: "celsius", label: "°C" }, { value: "fahrenheit", label: "°F" }], 624 }) }), 625 ui.when(["installed"], () => store.isInstalled("monitorMemory"), ui.row({ symbol: "memorychip", title: S.monitorMemoryMetricLabel, accessory: ui.segmented({ 626 key: "monitorMemoryMetric", label: S.monitorMemoryMetricLabel, options: [{ value: "used", label: S.memoryMetricUsed }, { value: "app", label: S.memoryMetricApp }], 627 }) }))); 628} 629 630function alertsCard(ctx) { 631 const { h, ui, L, store } = ctx; 632 const A = L.monitorAlerts; 633 const alerts = [ 634 { feature: "monitorCPU", key: "monitorAlertCPU", symbol: "cpu", title: A.cpu, limit: { label: A.cpuThreshold, key: "monitorAlertCPUThreshold", min: 50, max: 100, fallback: 90 }, 635 banner: v => [A.cpuTitle, ctx.fmt(A.cpuBodyFormat, v)] }, 636 { feature: "monitorCPU", key: "monitorAlertCPUTemperature", symbol: "thermometer.medium", title: A.cpuTemperature, temperature: true, 637 limit: { label: A.cpuTemperatureThreshold, key: "monitorAlertCPUTemperatureThreshold", min: 70, max: 105, fallback: 90 }, 638 banner: v => [A.cpuTemperatureTitle, ctx.fmt(A.cpuTemperatureBodyFormat, tempText(store, v))] }, 639 { feature: "monitorMemory", key: "monitorAlertMemory", symbol: "memorychip", title: A.memory, banner: () => [A.memoryTitle, A.memoryBody] }, 640 { feature: "monitorDisk", key: "monitorAlertDisk", symbol: "internaldrive", title: A.disk, limit: { label: A.diskThreshold, key: "monitorAlertDiskFreePercent", min: 5, max: 30, fallback: 10 }, 641 banner: v => [A.diskTitle, ctx.fmt(A.diskBodyFormat, "Macintosh HD", v)] }, 642 { feature: "monitorPower", key: "monitorAlertBatteryTemperature", symbol: "thermometer.high", title: A.batteryTemperature, temperature: true, 643 limit: { label: A.batteryTemperatureThreshold, key: "monitorAlertBatteryTemperatureThreshold", min: 30, max: 50, fallback: 40 }, 644 banner: v => [A.batteryTemperatureTitle, ctx.fmt(A.batteryTemperatureBodyFormat, tempText(store, v))] }, 645 { feature: "monitorPower", key: "monitorAlertBattery", symbol: "battery.25percent", title: A.battery, limit: { label: A.batteryThreshold, key: "monitorAlertBatteryPercent", min: 5, max: 50, fallback: 15 }, 646 banner: v => [A.batteryTitle, ctx.fmt(A.batteryBodyFormat, v)] }, 647 ]; 648 const valueOf = alert => Number(store.get(alert.limit.key) ?? alert.limit.fallback); 649 const formatted = alert => (alert.temperature ? tempText(store, valueOf(alert)) : `${valueOf(alert)}%`); 650 const anyOn = () => alerts.some(alert => store.isInstalled(alert.feature) && store.get(alert.key)); 651 652 // Ligar um alerta mostra, uma vez, como ele aparece quando o limite é atingido. 653 const preview = alert => setTimeout(() => { 654 if (!store.get(alert.key)) return; 655 const [title, body] = alert.banner(alert.limit ? valueOf(alert) : 0); 656 notify({ title, body, app: "Vorssaint", icon: "app", timeout: 6000 }); 657 }, 900); 658 659 const grid = live(ctx, ["installed"], local =>
659 h("div.pe-token-grid", alerts.filter(alert => store.isInstalled(alert.feature)).map(alert => token(local, { 660 symbol: alert.symbol, title: alert.title, 661 get: () => !!store.get(alert.key), 662 set: on => { store.set(alert.key, on); if (on) preview(alert); }, 663 keys: [alert.key, ...(alert.limit ? [alert.limit.key] : []), "temperatureUnit"], 664 options: alert.limit ? () => h("div.pe-alert-limit", 665 local.ui.text([alert.limit.key, "temperatureUnit"], () => `${alert.limit.label} ${formatted(alert)}`, "span.tnum"), 666 h("span.spacer"), 667 local.ui.stepper({ key: alert.limit.key, min: alert.limit.min, max: alert.limit.max, step: 5, showValue: false })) : null, 668 summary: () => (alert.limit ? formatted(alert) : ""), 669 }))), "div.pe-token-group"); 670 671 const cooldown = ui.popup({ 672 key: "monitorAlertCooldownMinutes", label: A.cooldown, disabled: () => !anyOn(), watch: [...alerts.map(alert => alert.key), "installed"], 673 options: [[2, A.cooldown2], [5, A.cooldown5], [15, A.cooldown15], [30, A.cooldown30], [60, A.cooldown60]].map(([value, label]) => ({ value, label })), 674 }); 675 const cooldownRow = ui.row({ symbol: "bell.badge", title: A.cooldown, accessory: cooldown }); 676 const syncCooldown = () => cooldownRow.classList.toggle("is-disabled", !anyOn()); 677 ctx.scope.on([...alerts.map(alert => alert.key), "installed"], syncCooldown); 678 syncCooldown(); 679 680 return ui.card({ title: A.section }, grid, cooldownRow, ui.caption(A.caption)); 681}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.