Download
181 lines 6.3 KiBJavaScriptModified 2026-08-02 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
// Focused window title. Expands to fill available space (flex-grow) and
// fades out at the edge when truncated. Shows the app's real icon
// (_NET_WM_ICON via the backend) and optionally the app name, strips the
// redundant " — AppName" suffix from titles, animates changes, and falls
// back to the current workspace name when nothing is focused.

// Strips trailing " — Mozilla Firefox" style suffixes when they contain
// the window class.
function stripAppSuffix(title, cls) {
  if (!cls) return title;
  const esc = cls.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const re = new RegExp(`\\s+[-—–|·]\\s+[^-—–|·]*${esc}[^-—–|·]*$`, "i");
  return title.replace(re, "");
}

const ENTER_ANIMATION = [
  { opacity: 0, transform: "translateY(3px)" },
  { opacity: 1, transform: "none" },
];

export default {
  name: "window-title",
  css: `
    .widget-window-title {
      flex: 1;
      min-width: 0;
      display: flex;
      align-items: center;
      gap: 0.5em;
      padding: 0 6px;
    }
    .widget-window-title .app-badge {
      display: none;
      align-items: center;
      gap: 0.5em;
      height: var(--chip-height);
      font-size: calc(var(--bar-font) * 0.72);
      font-weight: 600;
      letter-spacing: 0.02em;
      padding: 0 calc(var(--chip-height) * 0.3) 0 calc(var(--chip-height) * 0.18);
      border-radius: var(--chip-radius);
      white-space: nowrap;
      color: var(--bar-fg-dim);
    }
    .widget-window-title .app-badge .app-icon {
      display: none;
      width: calc(var(--chip-height) * 0.62);
      height: calc(var(--chip-height) * 0.62);
      border-radius: 4px;
    }
    /* No icon → even side padding */
    .widget-window-title .app-badge.no-icon {
      padding-left: calc(var(--chip-height) * 0.3);
    }
    /* Icon only → the text padding just adds a gap before the title */
    .widget-window-title .app-badge.no-name {
      padding: 0;
    }
    .widget-window-title .title-text {
      flex: 1;
      min-width: 0;
      overflow: hidden;
      white-space: nowrap;
      color: var(--bar-fg);
      font-weight: 500;
    }
    .widget-window-title .title-text.dim {
      color: var(--bar-fg-dim);
      font-weight: 400;
    }
    /* Truncation fades out instead of showing an ellipsis; side depends
       on text alignment. */
    .widget-window-title .title-text.fade-right {
      -webkit-mask-image: linear-gradient(to right, black calc(100% - 28px), transparent);
      mask-image: linear-gradient(to right, black calc(100% - 28px), transparent);
    }
    .widget-window-title .title-text.fade-left {
      -webkit-mask-image: linear-gradient(to left, black calc(100% - 28px), transparent);
      mask-image: linear-gradient(to left, black calc(100% - 28px), transparent);
    }
    .widget-window-title .title-text.fade-both {
      -webkit-mask-image: linear-gradient(to right, transparent, black 28px, black calc(100% - 28px), transparent);
      mask-image: linear-gradient(to right, transparent, black 28px, black calc(100% - 28px), transparent);
    }
  `,
  settings: [
    { key: "showAppIcon", label: "Show app icon", type: "boolean", default: true },
    { key: "showAppName", label: "Show app name", type: "boolean", default: false },
    {
      key: "align",
      label: "Text alignment",
      type: "select",
      choices: ["left", "center", "right"],
      default: "left",
    },
    {
      key: "font",
      label: "Font",
      type: "font",
      default: "", // empty = the bar's default font
    },
  ],
  create({ options, onEvent }) {
    const el = document.createElement("div");
    el.className = "widget-window-title";
    if (options.font) el.style.fontFamily = `"${options.font}"`;

    const badgeEl = document.createElement("span");
    badgeEl.className = "app-badge";
    const iconEl = document.createElement("img");
    iconEl.className = "app-icon";
    const nameEl = document.createElement("span");
    badgeEl.append(iconEl, nameEl);
    const textEl = document.createElement("span");
    textEl.className = "title-text";
    textEl.style.textAlign = options.align;
    textEl.classList.add(
      { left: "fade-right", right: "fade-left", center: "fade-both" }[
        options.align
      ] ?? "fade-right",
    );
    el.append(badgeEl, textEl);

    let lastClass = null;
    let lastWindowId = null;
    let seenFirst = false;

    const unsubscribe = onEvent(
      "divebar://hlwm",
      ({ windowId, windowTitle, windowClass, windowIcon, tags }) => {
        const cls = windowClass ?? "";
        // Animate only when focus moves to a different window — the same
        // window renaming itself (page loads, terminal titles) should
        // update the text quietly, or rapid renames flicker.
        const windowChanged = windowId !== lastWindowId;
        lastWindowId = windowId;
        let title = stripAppSuffix(windowTitle ?? "", cls).trim();
        const empty = !title && !cls;
        if (empty) {
          const tag = tags?.find?.((t) => t.state === "focused")?.name;
          title = tag ? `workspace ${tag}` : "";
        }

        textEl.classList.toggle("dim", empty);
        el.title = windowTitle ?? "";

        if (textEl.textContent !== title) {
          textEl.textContent = title;
          if (seenFirst && windowChanged) {
            for (const a of textEl.getAnimations()) a.cancel();
            textEl.animate(ENTER_ANIMATION, { duration: 140, easing: "ease-out" });
          }
        }

        const showIcon = options.showAppIcon && Boolean(windowIcon) && !empty;
        const showName = options.showAppName && Boolean(cls) && !empty;
        if (showIcon || showName) {
          nameEl.textContent = cls.toLowerCase();
          badgeEl.style.display = "flex";
          if (cls !== lastClass && seenFirst && windowChanged) {
            for (const a of badgeEl.getAnimations()) a.cancel();
            badgeEl.animate(ENTER_ANIMATION, { duration: 140, easing: "ease-out" });
          }
        } else {
          badgeEl.style.display = "none";
        }
        lastClass = cls;

        if (showIcon && iconEl.src !== windowIcon) iconEl.src = windowIcon;
        iconEl.style.display = showIcon ? "block" : "none";
        nameEl.style.display = showName ? "" : "none";
        badgeEl.classList.toggle("no-icon", !showIcon);
        badgeEl.classList.toggle("no-name", !showName);

        seenFirst = true;
      },
    );

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