Download
222 lines 7.4 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
// divebar bootstrap: fetches the bar config from the backend, dynamically
// imports each widget module, injects its CSS, and mounts it into its zone.
// Re-renders live when the settings window saves a new config.
//
// Widget contract — each module in ./widgets/ default-exports:
//   {
//     name: string,            // unique id, matches the filename
//     css: string,             // widget-scoped CSS, injected once
//     settings: [ ... ],       // optional settings schema (see README)
//     create(ctx): { el, destroy? }
//   }
// where ctx = { options, onStats(cb), onEvent(name, cb), invoke, listen }
// (onStats/onEvent return an unsubscribe fn and replay the latest payload;
// options come pre-merged with the schema's defaults).

import { initCoordinator } from "./lib/coordinator.js";
import { initTooltips } from "./lib/tooltip.js";
import { applyBarStyle } from "./lib/bar-config.js";
import { openWidgetSettings } from "./lib/widget-settings-popup.js";
import { createLayoutEditor } from "./lib/layout-edit.js";

const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;

initCoordinator(invoke);
initTooltips();

// Adapt styling to the environment: without a compositor, shaped windows
// can't have soft shadows or rounded transparent corners.
invoke("get_env")
  .then((env) => {
    if (!env.composited) {
      document.documentElement.classList.add("no-compositor");
    }
  })
  .catch((err) => console.error("divebar: get_env failed", err));

// --- Event stores ----------------------------------------------------------
// One subscription per backend event channel, fanned out to widgets. Caches
// the latest payload so widgets mounted after the first event still get data
// immediately.

const stores = new Map(); // event name -> { latest, subscribers }

function onEvent(name, cb) {
  let store = stores.get(name);
  if (!store) {
    store = { latest: null, subscribers: new Set() };
    stores.set(name, store);
    listen(name, (event) => dispatch(name, event.payload));
  }
  store.subscribers.add(cb);
  if (store.latest !== null) cb(store.latest);
  return () => store.subscribers.delete(cb);
}

function dispatch(name, payload, onlyIfEmpty = false) {
  const store = stores.get(name);
  if (!store || payload == null) return;
  if (onlyIfEmpty && store.latest !== null) return;
  store.latest = payload;
  for (const sub of store.subscribers) sub(store.latest);
}

const onStats = (cb) => onEvent("divebar://stats", cb);

// --- Widget registry -------------------------------------------------------

const injectedCss = new Set();

function injectCss(name, css) {
  if (!css || injectedCss.has(name)) return;
  injectedCss.add(name);
  const style = document.createElement("style");
  style.dataset.widget = name;
  style.textContent = css;
  document.head.appendChild(style);
}

function defaultOptions(widget) {
  const defaults = {};
  for (const setting of widget.settings ?? []) {
    defaults[setting.key] = setting.default;
  }
  return defaults;
}

let mounted = []; // [{ key, instance, widget, configIndex }] in bar order
let currentConfig = null;

// In-place layout editing (drag to reorder, ✕ to remove, toolbar under
// the bar). Entered from the right-click popup or the settings window.
const layoutEditor = createLayoutEditor({
  barEl: document.getElementById("bar"),
  invoke,
  getConfig: () => currentConfig,
  entryFor: (el) => {
    const m = mounted.find((m) => m.instance.el === el);
    return m ? currentConfig?.widgets?.[m.configIndex] : null;
  },
});

async function createInstance({ widget: name, options = {} }) {
  try {
    const module = await import(`./widgets/${name}.js`);
    const widget = module.default;
    injectCss(widget.name, widget.css);
    const merged = { ...defaultOptions(widget), ...options };
    const instance = widget.create({
      options: merged,
      onStats,
      onEvent,
      invoke,
      listen,
    });
    return instance?.el ? { instance, widget } : null;
  } catch (err) {
    console.error(`divebar: failed to mount widget "${name}"`, err);
    return null;
  }
}

// Diffs against the mounted widgets so unchanged ones keep their DOM,
// timers, and state — config saves don't blink the whole bar.
async function renderBar(config) {
  currentConfig = config;
  applyBarStyle(config);
  const barEl = document.getElementById("bar");
  const old = mounted;
  const usedOld = new Set();
  const next = [];

  for (const [i, entry] of (config?.widgets ?? []).entries()) {
    const key = JSON.stringify(entry);
    const idx = old.findIndex((m, j) => !usedOld.has(j) && m.key === key);
    if (idx >= 0) {
      usedOld.add(idx);
      old[idx].configIndex = i;
      next.push(old[idx]);
    } else {
      const created = await createInstance(entry);
      if (created) {
        next.push({ key, ...created, configIndex: i });
      }
    }
  }

  for (let i = 0; i < old.length; i++) {
    if (usedOld.has(i)) continue;
    try {
      old[i].instance.destroy?.();
    } catch (err) {
      console.error("divebar: widget destroy failed", err);
    }
    old[i].instance.el.remove();
  }

  // appendChild moves already-mounted nodes, so this both orders and
  // inserts in one pass.
  for (const m of next) barEl.appendChild(m.instance.el);
  mounted = next;
}

invoke("get_config")
  .then(async (config) => {
    await renderBar(config);
    // Initial backend emits happen before this page loads and are lost;
    // pull the cached states so widgets aren't blank until the next real
    // event. Per channel, skipped if a live event already arrived.
    const states = await invoke("get_latest_states");
    for (const [channel, payload] of Object.entries(states)) {
      dispatch(`divebar://${channel}`, payload, true);
    }
  })
  .catch((err) => console.error("divebar: startup failed", err));

// Live-apply config saved from the settings window or the layout editor.
// Re-decorate afterwards so widgets added mid-edit get their edit chrome.
listen("divebar://config", async (event) => {
  await renderBar(event.payload);
  layoutEditor.refresh();
});

// The settings window's "Edit bar layout…" button hands off to the bar.
listen("divebar://edit-layout", () => layoutEditor.start());

// Right-click a widget: its settings inline in a pop-down (plus links
// to layout editing and the full settings window). Right-click
// elsewhere on the bar (or inside an open popup): the settings window
// directly. While layout editing, the editor swallows the event.
const openGlobalSettings = () =>
  invoke("open_settings").catch((err) =>
    console.error("divebar: open_settings failed", err),
  );

document.addEventListener("contextmenu", (e) => {
  e.preventDefault();
  if (e.target.closest(".dpanel")) return;
  const m = mounted.find((m) => m.instance.el.contains(e.target));
  if (!m || !currentConfig) {
    openGlobalSettings();
    return;
  }
  openWidgetSettings({
    anchor: m.instance.el,
    invoke,
    x: e.clientX,
    title: m.widget.name,
    schemas: m.widget.settings ?? [],
    options: currentConfig.widgets[m.configIndex]?.options ?? {},
    onChange(key, value) {
      const entry = currentConfig.widgets[m.configIndex];
      if (!entry) return;
      (entry.options ??= {})[key] = value;
      invoke("save_config", { config: currentConfig }).catch((err) =>
        console.error("divebar: save_config failed", err),
      );
    },
    onEditLayout: () => layoutEditor.start(),
    onOpenGlobal: openGlobalSettings,
  });
});