Download
112 lines 4.6 KiBJavaScriptModified 2026-08-02 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
// Battery widget: SVG battery whose fill width tracks the exact charge
// percent; when charging starts the outline goes accent-colored and a
// shimmer sweeps the fill a few times. Hides itself entirely on machines
// with no battery (stats.battery is null).

// Interior fill geometry: the body rect is x=1.5..34.5, the fill inset to
// x=3.7..32.3, so full charge is 28.6 units wide.
const FILL_X = 3.7;
const FILL_MAX_WIDTH = 28.6;

export default {
  name: "battery",
  css: `
    .widget-battery { display: none; }
    .widget-battery .value { width: 4ch; text-align: left; } /* "100%" */
    .widget-battery.compact .value { display: none; }
    /* Wide battery so the fill level reads accurately at a glance */
    .widget-battery .icon svg {
      width: calc(var(--icon-size) * 5 / 3);
      height: var(--icon-size);
    }
    .widget-battery.present { display: flex; }
    .widget-battery.charging .icon { color: var(--accent); }
    /* Highlight sweeping across the fill when charging starts. A finite
       iteration count, not infinite: the bar renders without GPU
       compositing, so a perpetual animation would repaint the whole time
       the machine is plugged in. After the last sweep the shimmer rect
       rests outside the clip, i.e. invisible. */
    .widget-battery .shimmer { display: none; }
    .widget-battery.charging .shimmer {
      display: block;
      animation: divebar-bat-shimmer 2.2s linear 3;
    }
    @keyframes divebar-bat-shimmer {
      from { transform: translateX(0); }
      to   { transform: translateX(52px); }
    }
    .widget-battery.low .icon,
    .widget-battery.low .value { color: var(--critical); }
  `,
  settings: [
    { key: "showPercent", label: "Show percent", type: "boolean", default: false },
    {
      key: "lowThreshold",
      label: "Low battery threshold (%)",
      type: "number",
      min: 5,
      max: 50,
      default: 15,
    },
  ],
  create({ options, onStats }) {
    const el = document.createElement("div");
    el.className = "chip widget-battery";
    el.classList.toggle("compact", !options.showPercent);
    el.innerHTML = `
      <span class="icon">
        <svg viewBox="0 0 40 24" fill="none" stroke="currentColor" stroke-width="1.6">
          <defs>
            <clipPath id="divebar-bat-clip">
              <rect class="clip" x="${FILL_X}" y="7.2" width="0" height="9.6" rx="1.4"/>
            </clipPath>
            <linearGradient id="divebar-bat-shine" x1="0" y1="0" x2="1" y2="0">
              <stop offset="0" stop-color="#fff" stop-opacity="0"/>
              <stop offset="0.5" stop-color="#fff" stop-opacity="0.45"/>
              <stop offset="1" stop-color="#fff" stop-opacity="0"/>
            </linearGradient>
          </defs>
          <rect x="1.5" y="5" width="33" height="14" rx="3"/>
          <rect x="35.5" y="9" width="3" height="6" rx="1.2" fill="currentColor" stroke="none"/>
          <rect class="fill" x="${FILL_X}" y="7.2" width="0" height="9.6" rx="1.4"
                fill="currentColor" stroke="none"/>
          <g clip-path="url(#divebar-bat-clip)">
            <rect class="shimmer" x="-10" y="7.2" width="9" height="9.6"
                  fill="url(#divebar-bat-shine)" stroke="none"/>
          </g>
        </svg>
      </span>
      <span class="value">--%</span>
    `;
    const fillEl = el.querySelector(".fill");
    const clipEl = el.querySelector(".clip");
    const valueEl = el.querySelector(".value");

    const unsubscribe = onStats((stats) => {
      const battery = stats.battery;
      el.classList.toggle("present", battery != null);
      if (battery == null) return;
      valueEl.textContent = `${battery.percent}%`;
      let title = `Battery: ${battery.percent}%${battery.charging ? " (charging)" : ""}`;
      const mins = battery.timeRemainingMinutes;
      if (mins != null) {
        const t = `${Math.floor(mins / 60)}h ${String(mins % 60).padStart(2, "0")}m`;
        title += battery.charging ? ` · ${t} until full` : ` · ${t} remaining`;
      }
      el.title = title;
      const fillWidth = ((battery.percent / 100) * FILL_MAX_WIDTH).toFixed(2);
      fillEl.setAttribute("width", fillWidth);
      clipEl.setAttribute("width", fillWidth);
      // Continuous green (100%) → yellow (50%) → red (0%) via HSL hue,
      // kept low-saturation so the chip doesn't pull the eye.
      fillEl.setAttribute("fill", `hsl(${1.2 * battery.percent}, 30%, 55%)`);
      el.classList.toggle("charging", battery.charging);
      el.classList.toggle(
        "low",
        !battery.charging && battery.percent <= options.lowThreshold,
      );
    });

    return { el, destroy: unsubscribe };
  },
};