PageSourceSearch

https://vorssaint.com/js/settings/pages/monitor.js?v=3247965fce

js vorssaint.com collected 2026-10-08 05:08:08 UTC 39,446 bytes, 681 lines download raw bytes

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.