#include "shell/bar/widgets/taskbar_widget.h"

#include "compositors/compositor_detect.h"
#include "compositors/hyprland/hyprland_window_id.h"
#include "compositors/workspace_backend.h"
#include "config/config_service.h"
#include "core/deferred_call.h"
#include "i18n/i18n.h"
#include "render/core/color.h"
#include "render/core/renderer.h"
#include "render/scene/input_area.h"
#include "shell/dock/pinned_apps.h"
#include "shell/panel/panel_manager.h"
#include "shell/tooltip/tooltip_manager.h"
#include "system/app_identity.h"
#include "system/desktop_entry.h"
#include "system/desktop_entry_launch.h"
#include "system/internal_app_metadata.h"
#include "ui/app_icon_colorization.h"
#include "ui/builders.h"
#include "ui/controls/context_menu.h"
#include "ui/controls/context_menu_popup.h"
#include "ui/palette.h"
#include "ui/style.h"
#include "util/string_utils.h"
#include "wayland/wayland_seat.h"
#include "wayland/wayland_toplevels.h"
#include "xdg-shell-client-protocol.h"

#include <algorithm>
#include <cassert>
#include <cctype>
#include <chrono>
#include <cmath>
#include <functional>
#include <linux/input-event-codes.h>
#include <memory>
#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <wayland-client-protocol.h>

namespace {

  constexpr auto kDragHoldDelay = std::chrono::milliseconds(300);
  // Lifts the dragged tile above the rest of the strip while it follows the pointer.
  constexpr std::int32_t kDragTileZIndex = 200;

  // Integer centering; optional odd spare pixel on the end side (right/bottom).
  [[nodiscard]] float centeredOffset(float extent, float content, float inset = 0.0F, bool oddSpareOnEnd = true) {
    const float inner = std::max(0.0F, extent - inset * 2.0F);
    const int innerPx = static_cast<int>(std::lround(inner));
    const int contentPx = static_cast<int>(std::lround(content));
    const int spare = std::max(0, innerPx - contentPx);
    const int start = oddSpareOnEnd ? (spare / 2) : (spare / 2 + (spare % 2));
    return inset + static_cast<float>(start);
  }

  struct ExternalBadgePosition {
    float left = 0.0F;
    float top = 0.0F;
  };

  [[nodiscard]] float externalBadgeMainStartInset(WorkspaceLabelPlacement placement, float badgeMain) {
    if (placement == WorkspaceLabelPlacement::Corner) {
      return badgeMain * 0.2F;
    }
    if (placement == WorkspaceLabelPlacement::Centered) {
      return badgeMain * 0.4F;
    }
    return 0.0F;
  }

  [[nodiscard]] ExternalBadgePosition externalBadgePosition(
      WorkspaceLabelPlacement placement, bool vertical, float groupWidth, float groupHeight, float badgeWidth,
      float badgeHeight, float outlineInset
  ) {
    if (placement == WorkspaceLabelPlacement::Corner) {
      if (vertical) {
        return {centeredOffset(groupWidth, badgeWidth, outlineInset, false), std::round(outlineInset)};
      }
      return {std::round(badgeWidth * -0.2F), 0.0F};
    }
    if (vertical) {
      return {centeredOffset(groupWidth, badgeWidth, outlineInset, false), std::round(outlineInset)};
    }
    return {std::round(-badgeWidth * 0.4F), centeredOffset(groupHeight, badgeHeight, outlineInset, false)};
  }

  [[nodiscard]] float fitBadgeFontSize(
      Renderer& renderer, std::string_view label, float maxWidth, float maxHeight, float scale, FontWeight fontWeight
  ) {
    float fontSize = std::round(Style::fontSizeMini * scale);
    const float minFontSize = std::round(8.0F * scale);
    const float maxTextWidth = maxWidth * 0.82F;
    const float maxTextHeight = maxHeight * 0.82F;
    while (fontSize >= minFontSize) {
      const auto metrics = renderer.measureText(label, fontSize, fontWeight);
      const float textWidth = std::max(0.0F, metrics.right - metrics.left);
      const float textHeight = std::max(0.0F, metrics.bottom - metrics.top);
      if (textWidth <= maxTextWidth && textHeight <= maxTextHeight) {
        return fontSize;
      }
      fontSize -= 1.0F;
    }
    return minFontSize;
  }

  struct WorkspaceDiscSize {
    float width = 0.0F;
    float height = 0.0F;
  };

  [[nodiscard]] WorkspaceDiscSize measureWorkspaceDiscSize(
      Renderer& renderer, std::string_view label, float fontSize, float minHeight, float scale, FontWeight fontWeight
  ) {
    const auto metrics = renderer.measureText(label, fontSize, fontWeight);
    const float textW = std::max(0.0F, metrics.right - metrics.left);
    const float pad = Style::spaceXs * scale;
    WorkspaceDiscSize size{};
    size.height = minHeight;
    size.width = std::round(std::max(minHeight, textW + pad * 2.0F));
    return size;
  }

  [[nodiscard]] WorkspaceDiscSize clampWorkspaceDiscToCrossLimit(WorkspaceDiscSize disc, float maxCross) {
    if (maxCross <= 0.0F) {
      return disc;
    }
    const float limit = std::floor(maxCross);
    disc.width = std::min(disc.width, limit);
    disc.height = std::min(disc.height, limit);
    return disc;
  }

  [[nodiscard]] std::uintptr_t taskHandleKey(const ToplevelInfo& window) {
    if (window.handle != nullptr) {
      return reinterpret_cast<std::uintptr_t>(window.handle);
    }
    if (window.extHandle != nullptr) {
      return reinterpret_cast<std::uintptr_t>(window.extHandle);
    }
    if (!window.identifier.empty()) {
      const std::uintptr_t key = static_cast<std::uintptr_t>(std::hash<std::string>{}(window.identifier));
      return key == 0 ? 1 : key;
    }
    if (!window.appId.empty() || !window.title.empty()) {
      const std::uintptr_t key =
          static_cast<std::uintptr_t>(std::hash<std::string>{}(window.appId + "\n" + window.title));
      return key == 0 ? 1 : key;
    }
    return 0;
  }

  [[nodiscard]] bool isOrphanAppIdentity(
      const std::string& appId, const std::string& appIdLower, const std::string& idLower,
      const std::string& startupWmClassLower, const std::string& nameLower
  ) {
    return appId.empty() && appIdLower.empty() && idLower.empty() && startupWmClassLower.empty() && nameLower.empty();
  }

  [[nodiscard]] std::uintptr_t
  syntheticAssignmentHandleKey(const WorkspaceWindowAssignment& assignment, std::size_t index) {
    const std::string seed = assignment.windowId.empty()
        ? assignment.workspaceKey + "\n" + assignment.appId + "\n" + assignment.title + "\n" + std::to_string(index)
        : assignment.windowId;
    std::uintptr_t value = static_cast<std::uintptr_t>(std::hash<std::string>{}(seed));
    if (value == 0) {
      value = static_cast<std::uintptr_t>(index + 1);
    }
    return value;
  }

  [[nodiscard]] float barCapsuleThicknessFor(const ConfigService& config, std::string_view barName) {
    for (const auto& bar : config.config().bars) {
      if (bar.name == barName) {
        return bar.capsuleThickness;
      }
    }
    return 0.76F;
  }

  [[nodiscard]] float taskbarShellCross(float barCross, bool barCapsuleEnabled, float capsuleThickness) {
    if (!barCapsuleEnabled || barCross <= 0.0F) {
      return barCross;
    }
    return std::max(1.0F, std::round(barCross * capsuleThickness));
  }

  // Inner cross budget for workspace group capsules nested inside a bar widget capsule.
  [[nodiscard]] float taskbarGroupedCrossBudget(
      float shellCross, bool barCapsuleEnabled, bool workspaceGroupCapsule, bool barCapsuleBorder, float scale
  ) {
    if (!barCapsuleEnabled || !workspaceGroupCapsule || shellCross <= 0.0F) {
      return shellCross;
    }
    const float outerBorder = barCapsuleBorder ? Style::borderWidth * scale : 0.0F;
    const float innerBorder = Style::borderWidth * scale;
    const float nestGap = std::round(std::max(1.0F, Style::spaceXs * 0.25F * scale));
    return std::max(0.0F, shellCross - 2.0F * (outerBorder + innerBorder + nestGap));
  }

} // namespace

TaskbarWidget::TaskbarWidget(
    CompositorPlatform& platform, ConfigService& config, wl_output* output, TaskbarWidgetOptions options,
    TaskbarWidgetContext context
)
    : m_platform(platform), m_configService(config), m_output(output), m_configOptions(std::move(options)),
      m_showAllOutputs(m_configOptions.showAllOutputs), m_focusedOutputOnly(m_configOptions.focusedOutputOnly),
      m_minimal(m_configOptions.minimal), m_showActiveIndicator(m_configOptions.showActiveIndicator),
      m_activeIndicatorColor(m_configOptions.activeIndicatorColor), m_activeOpacity(m_configOptions.activeOpacity),
      m_inactiveOpacity(m_configOptions.inactiveOpacity), m_pinnedOpacity(m_configOptions.pinnedOpacity),
      m_focusedColor(m_configOptions.focusedColor), m_occupiedColor(m_configOptions.occupiedColor),
      m_emptyColor(m_configOptions.emptyColor), m_urgentColor(m_configOptions.urgentColor),
      m_windowTitleMaxWidth(static_cast<float>(m_configOptions.windowTitleMaxWidth)),
      m_taskbarMaxWidth(static_cast<float>(m_configOptions.taskbarMaxWidth)),
      m_barPosition(std::move(context.barPosition)), m_barName(std::move(context.barName)),
      m_widgetName(std::move(context.widgetName)) {
  syncWorkspaceGroupingCapability();
  buildDesktopIconIndex();
}

void TaskbarWidget::syncWorkspaceGroupingCapability() {
  const bool supported = m_platform.supportsTaskbarWorkspaceGrouping();
  const bool groupByWorkspace = supported && m_configOptions.groupByWorkspace;
  const bool onlyActiveWorkspace = supported && m_configOptions.onlyActiveWorkspace;
  const bool showWorkspaceLabel = !supported || m_configOptions.showWorkspaceLabel;
  const bool hideEmptyWorkspaces = supported && m_configOptions.hideEmptyWorkspaces;
  const bool workspaceGroupCapsule = !supported || m_configOptions.workspaceGroupCapsule;
  const bool groupSingleIconPerApp = supported && m_configOptions.groupSingleIconPerApp;
  const auto workspaceGroupContent = supported ? m_configOptions.workspaceGroupContent : WorkspaceGroupContent::Icons;
  const bool showWindowTitle =
      m_configOptions.showWindowTitle && m_barPosition != "left" && m_barPosition != "right" && !groupByWorkspace;

  const bool changed = groupByWorkspace != m_groupByWorkspace
      || onlyActiveWorkspace != m_onlyActiveWorkspace
      || showWorkspaceLabel != m_showWorkspaceLabel
      || m_workspaceLabelPlacement != m_configOptions.workspaceLabelPlacement
      || hideEmptyWorkspaces != m_hideEmptyWorkspaces
      || workspaceGroupCapsule != m_workspaceGroupCapsule
      || groupSingleIconPerApp != m_groupSingleIconPerApp
      || workspaceGroupContent != m_workspaceGroupContent
      || showWindowTitle != m_showWindowTitle;

  m_groupByWorkspace = groupByWorkspace;
  m_onlyActiveWorkspace = onlyActiveWorkspace;
  m_showWorkspaceLabel = showWorkspaceLabel;
  m_workspaceLabelPlacement = supported ? m_configOptions.workspaceLabelPlacement : WorkspaceLabelPlacement::Corner;
  m_hideEmptyWorkspaces = hideEmptyWorkspaces;
  m_workspaceGroupCapsule = workspaceGroupCapsule;
  m_groupSingleIconPerApp = groupSingleIconPerApp;
  m_workspaceGroupContent = workspaceGroupContent;
  m_showWindowTitle = showWindowTitle;

  if (changed) {
    m_rebuildPending = true;
    if (root() != nullptr) {
      root()->markLayoutDirty();
    }
  }
}

TaskbarWidget::~TaskbarWidget() { m_aliveGuard.reset(); }

bool TaskbarWidget::taskInWorkspaceGroup(const TaskModel& task, const WorkspaceModel& ws) {
  if (task.workspaceKey.empty()) {
    return false;
  }
  if (task.workspaceKey == ws.key) {
    return true;
  }
  if (!ws.workspace.id.empty() && task.workspaceKey == ws.workspace.id) {
    return true;
  }
  return !ws.workspace.name.empty() && task.workspaceKey == ws.workspace.name;
}

const TaskbarWidget::TaskModel*
TaskbarWidget::resolveTask(const std::vector<TaskModel>& tasks, TaskRef ref, std::uint64_t currentGeneration) {
  if (ref.generation != currentGeneration || ref.index >= tasks.size()) {
    return nullptr;
  }
  return &tasks[ref.index];
}

std::string_view TaskbarWidget::workspaceBindingWindowId(const TaskModel& task) {
  return !task.exactWindowId.empty() ? std::string_view(task.exactWindowId) : std::string_view(task.workspaceWindowId);
}

void TaskbarWidget::activateTaskModel(const TaskModel& task) {
  if (task.firstHandle != nullptr
      || !task.workspaceWindowId.empty()
      || (compositors::isKde() && (!task.title.empty() || !task.appId.empty()))) {
    ToplevelInfo window{};
    window.title = task.title;
    window.appId = task.appId;
    window.identifier = !task.exactWindowId.empty() ? task.exactWindowId : task.workspaceWindowId;
    window.handle = !task.exactWindowId.empty() ? nullptr : task.firstHandle;
    window.exactIdentity = !task.exactWindowId.empty();
    m_platform.activateToplevelInfo(window);
    return;
  }
  if (!task.workspaceKey.empty()) {
    for (const auto& workspace : m_workspaces) {
      if (taskInWorkspaceGroup(task, workspace)) {
        m_platform.activateWorkspace(workspaceHostOutput(workspace), workspace.workspace);
        return;
      }
    }
  }
}

void TaskbarWidget::closeTaskModel(const TaskModel& task) {
  if (task.firstHandle != nullptr || !task.exactWindowId.empty()) {
    m_platform.closeToplevelInfo(
        ToplevelInfo{
            .identifier = !task.exactWindowId.empty() ? task.exactWindowId : task.workspaceWindowId,
            .handle = !task.exactWindowId.empty() ? nullptr : task.firstHandle,
            .exactIdentity = !task.exactWindowId.empty(),
        }
    );
    return;
  }
  if (compositors::isKde() && (!task.title.empty() || !task.appId.empty() || !task.workspaceWindowId.empty())) {
    ToplevelInfo info{};
    info.title = task.title;
    info.appId = task.appId;
    info.identifier = task.workspaceWindowId;
    m_platform.closeToplevelInfo(info);
  }
}

const std::vector<std::string>& TaskbarWidget::pinnedConfigIds() const noexcept { return m_configOptions.pinned; }

bool TaskbarWidget::reorderEnabled() const {
  if (m_groupByWorkspace || m_widgetName.empty()) {
    return false;
  }
  return pinnedConfigIds().size() >= 2;
}

float TaskbarWidget::pointerMainOnStrip(const InputArea& area, float localX, float localY) const {
  return m_vertical ? area.y() + localY : area.x() + localX;
}

std::size_t TaskbarWidget::computeDragTargetIndex() const {
  // Snapshotted at drag start so the target keeps referring to the list the drag began against,
  // even if the pin list changes underneath.
  const std::size_t pinnedCount = m_drag.pinnedCount;
  if (m_tilePitchMain <= 0.0F || pinnedCount == 0) {
    return m_drag.sourceIndex;
  }
  const float slots = (m_drag.currentMain - m_drag.startMain) / m_tilePitchMain;
  const auto shifted =
      static_cast<std::ptrdiff_t>(m_drag.sourceIndex) + static_cast<std::ptrdiff_t>(std::lround(slots));
  return static_cast<std::size_t>(std::clamp<std::ptrdiff_t>(shifted, 0, static_cast<std::ptrdiff_t>(pinnedCount) - 1));
}

bool TaskbarWidget::commitDragReorder() {
  if (m_widgetName.empty() || m_drag.sourceIndex == m_drag.targetIndex) {
    return false;
  }
  std::vector<std::string> pinned = pinnedConfigIds();
  if (m_drag.sourceIndex >= pinned.size() || m_drag.targetIndex >= pinned.size()) {
    return false;
  }

  std::string moved = std::move(pinned[m_drag.sourceIndex]);
  pinned.erase(pinned.begin() + static_cast<std::ptrdiff_t>(m_drag.sourceIndex));
  pinned.insert(pinned.begin() + static_cast<std::ptrdiff_t>(m_drag.targetIndex), std::move(moved));

  // Writing config rebuilds the taskbar, which can destroy the InputArea whose handler we are
  // inside — defer so the write happens after this event finishes dispatching.
  ConfigService* config = &m_configService;
  DeferredCall::callLater([config, widgetName = m_widgetName, pinned = std::move(pinned)]() mutable {
    (void)config->setOverride({"widget", widgetName, "pinned"}, std::move(pinned));
  });
  return true;
}

void TaskbarWidget::requestDragLayout() {
  if (Node* container = root(); container != nullptr) {
    container->markLayoutDirty();
  }
  // Only requestUpdate() marks the surface as needing a layout pass; requestRedraw() would paint
  // the scene again without ever running doLayout(), leaving the drag visuals stale.
  requestUpdate();
}

void TaskbarWidget::beginDrag() {
  if (m_drag.area == nullptr) {
    return;
  }
  m_drag.active = true;
  // Capture the resting position before leaving the flow — afterwards Flex closes the gap and the
  // neighbours move, but this node keeps whatever position we give it.
  m_drag.restMain = m_vertical ? m_drag.area->y() : m_drag.area->x();
  m_drag.restCross = m_vertical ? m_drag.area->x() : m_drag.area->y();
  m_drag.pinnedCount = pinnedConfigIds().size();
  // The press that started this gesture has already shown the tile's tooltip; it would hang over
  // the strip for the whole drag.
  TooltipManager::instance().onHoverChange(nullptr, static_cast<zwlr_layer_surface_v1*>(nullptr), nullptr);
  requestDragLayout();
}

void TaskbarWidget::updateDragTarget() {
  const std::size_t targetIndex = computeDragTargetIndex();
  if (targetIndex != m_drag.targetIndex) {
    m_drag.targetIndex = targetIndex;
    requestDragLayout();
  }
}

void TaskbarWidget::moveDragTile() {
  if (m_drag.area == nullptr || !m_drag.active) {
    return;
  }
  // Slot i sits at restMain + (i - sourceIndex) * pitch, so clamping travel to the first and last
  // slots keeps the tile from wandering somewhere it could never be dropped.
  const auto source = static_cast<std::ptrdiff_t>(m_drag.sourceIndex);
  const auto last = static_cast<std::ptrdiff_t>(m_drag.pinnedCount) - 1;
  const float minMain = m_drag.restMain - static_cast<float>(source) * m_tilePitchMain;
  const float maxMain = m_drag.restMain + static_cast<float>(last - source) * m_tilePitchMain;
  const float travelled = m_drag.restMain + (m_drag.currentMain - m_drag.startMain);
  const float main = (last > 0) ? std::clamp(travelled, minMain, maxMain) : travelled;
  if (m_vertical) {
    m_drag.area->setPosition(m_drag.restCross, main);
  } else {
    m_drag.area->setPosition(main, m_drag.restCross);
  }
  requestRedraw();
}

void TaskbarWidget::endDrag(bool commit) {
  m_drag.holdTimer.stop();
  const bool wasActive = m_drag.active;
  if (wasActive || m_drag.armed) {
    // A consumed gesture must not also activate or launch the app on release.
    m_suppressTileClick = true;
  }
  const bool reordered = wasActive && commit && commitDragReorder();
  if (reordered && m_drag.area != nullptr && m_dragSpacer != nullptr) {
    // Park the tile in the gap so the strip looks settled while the deferred write lands.
    m_drag.area->setPosition(m_dragSpacer->x(), m_dragSpacer->y());
  }
  // m_dragFloatTile and m_dragSpacer deliberately survive this reset: applyDragLayout() restores
  // the tile and drops the spacer on the next layout pass, whichever path ended the gesture.
  m_drag = {};
  if (!wasActive) {
    return;
  }
  if (!reordered) {
    requestDragLayout();
    return;
  }
  // Restoring now would put the tile back in the pre-write order for a frame, so it visibly jumps
  // to an edge before the reordered strip appears. Deferred calls run FIFO, so this lands after
  // the write — which normally destroys this widget outright, because a `widget.*` config change
  // reloads the bar. It therefore only runs when the write failed or changed nothing.
  m_dragParked = true;
  DeferredCall::callLater([this, alive = std::weak_ptr<void>(m_aliveGuard)]() {
    if (alive.expired()) {
      return;
    }
    m_dragParked = false;
    requestDragLayout();
  });
}

void TaskbarWidget::applyDragLayout() {
  if (m_taskStrip == nullptr) {
    return;
  }
  if (m_dragParked) {
    // A committed drop is holding its tile in the target gap until the write lands.
    return;
  }
  InputArea* const floatTile = (m_drag.active && m_drag.area != nullptr) ? m_drag.area : nullptr;
  if (m_dragFloatTile != nullptr && m_dragFloatTile != floatTile) {
    m_dragFloatTile->setZIndex(0);
    m_dragFloatTile->setParticipatesInLayout(true);
    m_dragFloatTile = nullptr;
  }
  if (floatTile == nullptr) {
    if (m_dragSpacer != nullptr) {
      m_taskStrip->removeChild(m_dragSpacer);
      m_dragSpacer = nullptr;
    }
    return;
  }

  m_dragFloatTile = floatTile;
  floatTile->setParticipatesInLayout(false);
  floatTile->setZIndex(kDragTileZIndex);
  // The dragged tile is still a child at sourceIndex, just outside the flow, so a target past the
  // source needs the gap one slot further along.
  const std::size_t insertAt = m_drag.targetIndex > m_drag.sourceIndex ? m_drag.targetIndex + 1 : m_drag.targetIndex;
  if (m_dragSpacer == nullptr) {
    // The fill is not redundant: a Box copies RectNode's default style, whose fill is opaque black,
    // and only resolves a ColorSpec once one is set. Setting it also resolves the zero-width border.
    m_taskStrip->insertChildAt(
        insertAt,
        ui::box({
            .out = &m_dragSpacer,
            .fill = clearColorSpec(),
            .width = floatTile->width(),
            .height = floatTile->height(),
            .configure = [](Box& box) { box.setHitTestVisible(false); },
        })
    );
    return;
  }
  // Re-seating the spacer dirties the strip, so only move it when the slot actually changed.
  const auto& children = m_taskStrip->children();
  const auto it = std::ranges::find_if(children, [this](const auto& child) { return child.get() == m_dragSpacer; });
  if (it != children.end() && static_cast<std::size_t>(std::distance(children.begin(), it)) != insertAt) {
    m_taskStrip->insertChildAt(insertAt, m_taskStrip->removeChild(m_dragSpacer));
  }
}

bool TaskbarWidget::taskMatchesDesktopEntry(const TaskModel& task, const DesktopEntry& entry) {
  const std::string entryIdLower = StringUtils::toLower(entry.id);
  if (!entryIdLower.empty()) {
    if (task.idLower == entryIdLower || task.appIdLower == entryIdLower || task.desktopEntryId == entry.id) {
      return true;
    }
  }
  if (!entry.startupWmClassLower.empty()
      && (task.startupWmClassLower == entry.startupWmClassLower || task.appIdLower == entry.startupWmClassLower)) {
    return true;
  }
  if (!entry.nameLower.empty() && task.nameLower == entry.nameLower) {
    return true;
  }
  if (!task.appId.empty() && shell::dock::pinned_apps::matchesEntry(entry, task.appId)) {
    return true;
  }
  if (!task.idLower.empty() && shell::dock::pinned_apps::matchesEntry(entry, task.idLower)) {
    return true;
  }
  return !task.appIdLower.empty() && app_identity::desktopEntryMatchesLower(entry, task.appIdLower);
}

std::optional<DesktopEntry> TaskbarWidget::desktopEntryForTask(const TaskModel& task) const {
  if (!task.desktopEntryId.empty()) {
    for (const auto& entry : desktopEntries()) {
      if (entry.id == task.desktopEntryId || entry.idLower == StringUtils::toLower(task.desktopEntryId)) {
        return entry;
      }
    }
    for (const auto& entry : shell::dock::pinned_apps::resolveEntries(pinnedConfigIds())) {
      if (entry.id == task.desktopEntryId || entry.idLower == StringUtils::toLower(task.desktopEntryId)) {
        return entry;
      }
    }
  }
  for (const auto& entry : desktopEntries()) {
    if (taskMatchesDesktopEntry(task, entry)) {
      return entry;
    }
  }
  return std::nullopt;
}

void TaskbarWidget::setEntryPinned(const DesktopEntry& entry, bool pinned) {
  if (m_widgetName.empty() || entry.id.empty()) {
    return;
  }
  std::vector<std::string> pinnedList = pinnedConfigIds();
  if (pinned) {
    if (shell::dock::pinned_apps::containsEntry(pinnedList, entry)) {
      return;
    }
    pinnedList.push_back(entry.id);
  } else {
    shell::dock::pinned_apps::removeEntry(pinnedList, entry);
  }
  if (m_configService.setOverride({"widget", m_widgetName, "pinned"}, pinnedList)) {
    m_configOptions.pinned = std::move(pinnedList);
  }
}

void TaskbarWidget::launchDesktopEntry(const TaskModel& task) {
  auto entry = desktopEntryForTask(task);
  if (!entry.has_value() || entry->exec.empty()) {
    return;
  }
  auto& platform = m_platform;
  auto& configService = m_configService;
  wl_output* const launchOutput = m_output;
  DeferredCall::callLater([&platform, &configService, entry = std::move(*entry), launchOutput]() mutable {
    std::string token;
    if (platform.hasXdgActivation()) {
      token = platform.requestActivationToken(nullptr);
    }
    platform.prepareAppLaunchOnOutput(launchOutput);
    (void)desktop_entry_launch::launchEntry(
        entry,
        desktop_entry_launch::LaunchOptions{
            .activationToken = std::move(token),
            .runAsSystemdService = configService.config().shell.launchAppsAsSystemdServices,
            .customCommand = configService.config().shell.launchAppsCustomCommand,
            .dbusActivatable = entry.dbusActivatable,
            .dbusAppId = entry.id,
        }
    );
  });
}

void TaskbarWidget::activateOrLaunchPinned(const TaskModel& task) {
  auto windows = m_platform.enrichedWindowsForApp(task.idLower, task.startupWmClassLower, toplevelOutputFilter());
  if (windows.empty() && !task.appIdLower.empty() && task.appIdLower != task.idLower) {
    windows = m_platform.enrichedWindowsForApp(task.appIdLower, task.startupWmClassLower, toplevelOutputFilter());
  }
  if (windows.empty()) {
    launchDesktopEntry(task);
    return;
  }
  if (windows.size() == 1) {
    m_platform.activateToplevelInfo(windows[0]);
    return;
  }

  const std::string cycleKey = !task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower;
  std::size_t& cursor = m_groupedAppCycleCursor[cycleKey];
  if (cursor >= windows.size()) {
    cursor = 0;
  }
  m_platform.activateToplevelInfo(windows[cursor]);
  cursor = (cursor + 1) % windows.size();
}

void TaskbarWidget::applyPinnedMerge(std::vector<TaskModel>& tasks) {
  const auto& pinnedIds = pinnedConfigIds();
  if (pinnedIds.empty()) {
    return;
  }

  const auto pinnedEntries = shell::dock::pinned_apps::resolveEntries(pinnedIds);
  if (pinnedEntries.empty()) {
    return;
  }

  std::vector<bool> consumed(tasks.size(), false);
  std::vector<TaskModel> merged;
  merged.reserve(pinnedEntries.size() + tasks.size());

  for (const auto& entry : pinnedEntries) {
    std::vector<std::size_t> matching;
    matching.reserve(tasks.size());
    for (std::size_t i = 0; i < tasks.size(); ++i) {
      if (!consumed[i] && taskMatchesDesktopEntry(tasks[i], entry)) {
        matching.push_back(i);
      }
    }

    TaskModel tile{};
    tile.pinned = true;
    tile.desktopEntryId = entry.id;
    tile.idLower = !entry.idLower.empty() ? entry.idLower : StringUtils::toLower(entry.id);
    tile.startupWmClassLower = entry.startupWmClassLower;
    tile.nameLower = entry.nameLower;
    tile.appId = entry.id;
    tile.appIdLower = tile.idLower;
    tile.iconPath = resolveIconPath(entry.id, entry.icon);
    tile.title = entry.name;

    if (matching.empty()) {
      tile.running = false;
      tile.active = false;
      tile.firstHandle = nullptr;
      tile.handleKey = 0;
      tile.instanceCount = 0;
      merged.push_back(std::move(tile));
      continue;
    }

    std::size_t rep = matching.front();
    for (const std::size_t index : matching) {
      if (tasks[index].active) {
        rep = index;
        break;
      }
    }

    tile = tasks[rep];
    tile.pinned = true;
    tile.running = true;
    tile.desktopEntryId = entry.id;
    tile.instanceCount = matching.size();
    if (tile.idLower.empty()) {
      tile.idLower = entry.idLower;
    }
    if (tile.startupWmClassLower.empty()) {
      tile.startupWmClassLower = entry.startupWmClassLower;
    }
    if (tile.iconPath.empty()) {
      tile.iconPath = resolveIconPath(entry.id, entry.icon);
    }
    merged.push_back(std::move(tile));
    for (const std::size_t index : matching) {
      consumed[index] = true;
    }
  }

  for (std::size_t i = 0; i < tasks.size(); ++i) {
    if (!consumed[i]) {
      merged.push_back(std::move(tasks[i]));
    }
  }
  tasks = std::move(merged);
}

void TaskbarWidget::create() {
  auto container = ui::inputArea({});

  auto root = ui::row({
      .out = &m_root,
      .align = FlexAlign::Center,
      .gap = Style::spaceSm,
  });

  root->addChild(
      ui::row({
          .out = &m_taskStrip,
          .align = FlexAlign::Center,
          .gap = Style::spaceSm,
      })
  );

  container->addChild(std::move(root));
  setRoot(std::move(container));

  m_appIconColorizeConn = shellAppIconColorizationChanged().connect([this]() {
    m_rebuildPending = true;
    requestUpdate();
  });
}

void TaskbarWidget::doLayout(Renderer& renderer, float containerWidth, float containerHeight) {
  if (m_root == nullptr || m_taskStrip == nullptr) {
    return;
  }

  const bool wasVertical = m_vertical;
  m_vertical = containerHeight > containerWidth;
  if (m_vertical != wasVertical) {
    m_rebuildPending = true;
  }
  m_containerWidth = containerWidth;
  m_containerHeight = containerHeight;
  const std::uint64_t textMetricsGeneration = renderer.textMetricsGeneration();
  if (m_textMetricsGeneration != textMetricsGeneration) {
    m_textMetricsGeneration = textMetricsGeneration;
    m_rebuildPending = true;
  }

  m_root->setDirection(m_vertical ? FlexDirection::Vertical : FlexDirection::Horizontal);
  m_root->setAlign(FlexAlign::Center);
  m_root->setGap(Style::spaceSm * m_contentScale);

  m_taskStrip->setDirection(m_vertical ? FlexDirection::Vertical : FlexDirection::Horizontal);
  m_taskStrip->setAlign(FlexAlign::Center);
  if (!m_groupByWorkspace) {
    m_taskStrip->setGap(static_cast<float>(m_configOptions.itemSpacing) * m_contentScale);
  }

  if (m_rebuildPending) {
    rebuild(renderer);
    m_rebuildPending = false;
  }

  applyDragLayout();
  m_root->layout(renderer);
  if (Node* container = root(); container != nullptr && container != m_root) {
    container->setFrameSize(m_root->width(), m_root->height());
  }
}

void TaskbarWidget::doUpdate(Renderer& /*renderer*/) {
  updateModels();
  if (m_focusedOutputOnly) {
    const bool isFocused = isFocusedOutput();
    if (isFocused != m_wasFocusedOutput) {
      m_wasFocusedOutput = isFocused;
      m_rebuildPending = true;
      if (root() != nullptr) {
        root()->markLayoutDirty();
      }
    }
  }
}

void TaskbarWidget::rebuild(Renderer& renderer) {
  if (m_taskStrip == nullptr) {
    return;
  }
  m_activeUsesFocusedColor = !m_focusedOutputOnly || isFocusedOutput();
  m_taskTiles.clear();
  m_taskTiles.reserve(m_tasks.size());
  // The strip's children are about to be destroyed; drop the gesture and its visuals so nothing
  // outlives the nodes they point at.
  m_drag = {};
  m_dragFloatTile = nullptr;
  m_dragSpacer = nullptr;
  m_dragParked = false;
  clearChildren(m_taskStrip);
  buildTaskButtons(renderer);
}

void TaskbarWidget::clearChildren(Flex* flex) const {
  while (flex != nullptr && !flex->children().empty()) {
    flex->removeChild(flex->children().back().get());
  }
}

void TaskbarWidget::buildTaskButtons(Renderer& renderer) {
  if (m_taskStrip == nullptr) {
    return;
  }
  const float effectiveIconScale =
      m_groupByWorkspace && m_workspaceGroupContent != WorkspaceGroupContent::Icons ? 1.0F : m_configOptions.iconScale;
  float iconSize = std::round(Style::baseGlyphSize * effectiveIconScale * m_contentScale);
  float tilePadding = Style::spaceXs * 0.35F * m_contentScale;
  float tileSize = std::round(iconSize + tilePadding * 2.0F);
  const float barCross = m_vertical ? m_containerWidth : m_containerHeight;
  const float capsuleThickness = barCapsuleThicknessFor(m_configService, m_barName);
  const bool barCapsule = barCapsuleSpec().enabled;
  const float shellCross = taskbarShellCross(barCross, barCapsule, capsuleThickness);
  const float crossExtent = taskbarGroupedCrossBudget(
      shellCross, barCapsule, m_groupByWorkspace && m_workspaceGroupCapsule, barCapsuleSpec().border.has_value(),
      m_contentScale
  );
  const float groupBorderInset = Style::borderWidth * m_contentScale;
  const float groupOutlineInset = m_workspaceGroupCapsule ? groupBorderInset : 0.0F;
  const float groupedCrossInner = crossExtent > 0.0F && m_groupByWorkspace && m_workspaceGroupCapsule
      ? std::max(0.0F, crossExtent - groupBorderInset * 2.0F)
      : crossExtent;
  const float groupedMinPad = std::round(std::max(1.0F, Style::spaceXs * 0.35F * m_contentScale));
  const float tileCrossLimit = m_groupByWorkspace && m_workspaceGroupCapsule
      ? std::max(0.0F, groupedCrossInner - groupedMinPad * 2.0F)
      : crossExtent;
  if (crossExtent > 0.0F && tileSize > tileCrossLimit + 0.5F) {
    const float maxTile = std::max(0.0F, tileCrossLimit);
    const float padCap = std::floor(std::max(0.0F, (maxTile - iconSize) * 0.5F));
    tilePadding = std::min(tilePadding, padCap);
    tileSize = std::round(iconSize + tilePadding * 2.0F);
    if (tileSize > maxTile + 0.5F) {
      iconSize = std::floor(std::max(0.0F, maxTile - tilePadding * 2.0F));
      tileSize = std::round(iconSize + tilePadding * 2.0F);
    }
  }
  const float tileGap = static_cast<float>(m_configOptions.itemSpacing) * m_contentScale;

  const FontWeight fontWeight = labelFontWeight();
  const std::string fontFamily = labelFontFamily();

  const float maxTileWidth = m_tasks.empty()
      ? m_taskbarMaxWidth * m_contentScale
      : std::floor(
            (m_taskbarMaxWidth * m_contentScale - tileGap * static_cast<float>(m_tasks.size() - 1))
            / static_cast<float>(m_tasks.size())
        );
  // If the title text is too narrow, all it shows is "..." which isn't useful, so we hide it instead.
  const auto metric = renderer.measureText("...", Style::fontSizeCaption * fontScale(), fontWeight);
  const float minWindowTitleWidth = (metric.right - metric.left) * 2;
  const float windowTitleGap = Style::spaceXs * m_contentScale;
  const float windowTitleWidth =
      std::min(m_windowTitleMaxWidth * m_contentScale, std::max(0.0F, maxTileWidth - tileSize - windowTitleGap));
  const bool showWindowTitle = m_showWindowTitle && windowTitleWidth > minWindowTitleWidth;
  const float tileWidthWithTitle = tileSize + (showWindowTitle ? windowTitleWidth + windowTitleGap : 0.0F);

  auto attachHover = [this](InputArea& area, float width, float height) {
    auto hoverBox = ui::box({
        .radius = resolvedBarCapsuleRadius(width, height),
        .width = width,
        .height = height,
        .configure = [](Box& box) { box.setZIndex(-1); },
    });
    hoverBox->setHitTestVisible(false);
    hoverBox->setVisible(false);
    auto* hoverBoxPtr = static_cast<Box*>(area.addChild(std::move(hoverBox)));

    auto progress = std::make_shared<float>(0.0F);
    area.setOnEnter([this, hoverBoxPtr, progress](const InputArea::PointerData&) {
      if (m_animations == nullptr)
        return;
      m_animations->cancelForOwner(hoverBoxPtr);
      const ColorSpec fill = widgetForegroundOr(colorSpecFromRole(ColorRole::OnSurface));
      m_animations->animate(
          *progress, 1.0F, Style::animFast, Easing::EaseOutCubic,
          [this, hoverBoxPtr, fill, progress](float p) {
            *progress = p;
            hoverBoxPtr->setVisible(p > 0.001F);
            ColorSpec c = fill;
            c.alpha = 0.1F * p;
            hoverBoxPtr->setFill(c);
            requestRedraw();
          },
          {}, hoverBoxPtr
      );
      requestFrameTick();
    });
    area.setOnLeave([this, hoverBoxPtr, progress]() {
      if (m_animations == nullptr)
        return;
      m_animations->cancelForOwner(hoverBoxPtr);
      const ColorSpec fill = widgetForegroundOr(colorSpecFromRole(ColorRole::OnSurface));
      m_animations->animate(
          *progress, 0.0F, Style::animFast, Easing::EaseOutCubic,
          [this, hoverBoxPtr, fill, progress](float p) {
            *progress = p;
            hoverBoxPtr->setVisible(p > 0.001F);
            ColorSpec c = fill;
            c.alpha = 0.1F * p;
            hoverBoxPtr->setFill(c);
            requestRedraw();
          },
          {}, hoverBoxPtr
      );
      requestFrameTick();
    });
  };

  // Only elements of m_tasks have a meaningful position; any other TaskModel would yield a
  // garbage index that the generation check cannot catch.
  const auto taskRefFor = [this](const TaskModel& task) {
    assert(&task >= m_tasks.data() && &task < m_tasks.data() + m_tasks.size());
    return TaskRef{
        .index = static_cast<std::size_t>(&task - m_tasks.data()),
        .generation = m_taskGeneration,
    };
  };

  const std::size_t draggableTileCount = reorderEnabled() ? pinnedConfigIds().size() : 0;
  auto createTaskTile = [&](TaskRef taskRef, std::vector<TaskRef> cycleCandidates = {}, std::string cycleKey = {},
                            std::size_t badgeCount = 1) {
    // Unreachable at build time: every ref is built from a live m_tasks element at the current
    // generation. Kept as a release-safe guard so a stale ref cannot deref null.
    const TaskModel* taskModel = resolveTask(m_tasks, taskRef, m_taskGeneration);
    assert(taskModel != nullptr);
    if (taskModel == nullptr) {
      return ui::inputArea({});
    }
    const TaskModel& task = *taskModel;
    auto area = ui::inputArea({});
    area->setFrameSize(tileWidthWithTitle, tileSize);
    float tileOpacity = task.active ? m_activeOpacity : m_inactiveOpacity;
    // Pinned-but-not-running tiles are launchers, dim them so they don't read as open apps.
    if (task.pinned && !task.running) {
      tileOpacity *= m_pinnedOpacity;
    }
    area->setOpacity(tileOpacity);
    area->setAcceptedButtons(InputArea::buttonMask({BTN_LEFT, BTN_RIGHT, BTN_MIDDLE}));
    auto* dragArea = area.get();
    const bool tileDraggable = taskRef.index < draggableTileCount;
    area->setOnPress([this, dragArea, taskRef, tileDraggable](const InputArea::PointerData& data) {
      // m_drag.area is set on press and cleared when the gesture ends, so pointer identity alone
      // says whether this tile owns the live gesture.
      const bool ownsGesture = m_drag.area == dragArea;
      if (!data.pressed) {
        if (data.button != BTN_LEFT || !ownsGesture) {
          return;
        }
        // Only commit against the pin list the drag was measured on.
        endDrag(m_drag.generation == m_taskGeneration);
        return;
      }
      if (data.button != BTN_LEFT) {
        // A second button during a live gesture must not clear its click suppression, otherwise the
        // release opens a context menu on top of the drag.
        if (!m_drag.active && !m_drag.armed) {
          m_suppressTileClick = false;
        }
        return;
      }
      // A left press always starts fresh: abandon a tile still held from a lost release or grab,
      // then drop suppression left over from a gesture that ended outside its tile.
      endDrag(false);
      m_suppressTileClick = false;
      if (!tileDraggable) {
        return;
      }
      m_drag.generation = m_taskGeneration;
      m_drag.sourceIndex = taskRef.index;
      m_drag.targetIndex = taskRef.index;
      m_drag.startMain = pointerMainOnStrip(*dragArea, data.localX, data.localY);
      m_drag.currentMain = m_drag.startMain;
      m_drag.area = dragArea;
      m_drag.holdTimer.start(kDragHoldDelay, [this, dragArea]() {
        if (m_drag.area == dragArea && !m_drag.active) {
          m_drag.armed = true;
        }
      });
    });
    if (tileDraggable) {
      area->setOnMotion([this, dragArea](const InputArea::PointerData& data) {
        // Without the ownership check a plain hover would satisfy the distance check below and
        // start a drag on a tile nobody is holding.
        if (m_drag.area != dragArea) {
          return;
        }
        if (m_drag.generation != m_taskGeneration) {
          endDrag(false);
          return;
        }
        const float main = pointerMainOnStrip(*dragArea, data.localX, data.localY);
        if (!m_drag.active) {
          const bool travelled = std::abs(main - m_drag.startMain) >= Style::dragStartThreshold * m_contentScale;
          if (!m_drag.armed && !travelled) {
            return;
          }
          m_drag.holdTimer.stop();
          m_drag.armed = true;
          beginDrag();
        }
        m_drag.currentMain = main;
        updateDragTarget();
        moveDragTile();
      });
      area->setOnCancel([this, dragArea]() {
        if (m_drag.area != dragArea) {
          return;
        }
        // Pointer capture is gone (leave or compositor grab), so no release will arrive: tear the
        // whole gesture down, hold timer included.
        endDrag(false);
      });
    }

    const WorkspaceModel* taskWorkspace = nullptr;
    if (m_groupByWorkspace && !task.workspaceKey.empty()) {
      for (const auto& workspace : m_workspaces) {
        if (taskInWorkspaceGroup(task, workspace)) {
          taskWorkspace = &workspace;
          break;
        }
      }
    }
    if (task.firstHandle != nullptr
        || !task.workspaceWindowId.empty()
        || (compositors::isKde() && (!task.title.empty() || !task.appId.empty()))
        || taskWorkspace != nullptr
        || !cycleCandidates.empty()
        || task.pinned) {
      auto* areaPtr = area.get();
      area->setOnClick([this, taskRef, areaPtr, cycleCandidates = std::move(cycleCandidates),
                        cycleKey = std::move(cycleKey)](const InputArea::PointerData& data) {
        if (m_drag.active || m_drag.armed) {
          // A click arriving mid-gesture is a second button pressed during a drag: it must neither
          // activate the app nor open a context menu over the moving tile.
          return;
        }
        if (m_suppressTileClick) {
          m_suppressTileClick = false;
          return;
        }
        const TaskModel* current = resolveTask(m_tasks, taskRef, m_taskGeneration);
        if (current == nullptr) {
          return;
        }

        if (current->pinned) {
          if (data.button == BTN_MIDDLE) {
            if (current->running) {
              closeTaskModel(*current);
            }
            return;
          }
          if (data.button == BTN_LEFT) {
            activateOrLaunchPinned(*current);
            return;
          }
          if (data.button == BTN_RIGHT && areaPtr != nullptr) {
            openTaskContextMenu(*current, *areaPtr);
          }
          return;
        }
        if (data.button == BTN_MIDDLE) {
          if (!cycleCandidates.empty()) {
            for (const TaskRef candidate : cycleCandidates) {
              const TaskModel* currentCandidate = resolveTask(m_tasks, candidate, m_taskGeneration);
              if (currentCandidate != nullptr && currentCandidate->active) {
                closeTaskModel(*currentCandidate);
                return;
              }
            }
          }
          closeTaskModel(*current);
          return;
        }
        if (data.button == BTN_LEFT) {
          if (!cycleCandidates.empty()) {
            std::size_t& cursor = m_groupedAppCycleCursor[cycleKey];
            if (cursor >= cycleCandidates.size()) {
              cursor = 0;
            }
            const TaskModel* target = resolveTask(m_tasks, cycleCandidates[cursor], m_taskGeneration);
            cursor = (cursor + 1) % cycleCandidates.size();
            if (target != nullptr) {
              activateTaskModel(*target);
            }
            return;
          }
          activateTaskModel(*current);
          return;
        }
        if (data.button == BTN_RIGHT
            && areaPtr != nullptr
            && (current->firstHandle != nullptr || !current->exactWindowId.empty() || compositors::isKde())) {
          openTaskContextMenu(*current, *areaPtr);
        }
      });
    } else {
      area->setEnabled(false);
    }

    auto content = ui::flex(
        m_vertical ? FlexDirection::Vertical : FlexDirection::Horizontal,
        {
            .align = FlexAlign::Center,
            .justify = showWindowTitle ? FlexJustify::Start : FlexJustify::Center,
            .gap = showWindowTitle ? windowTitleGap : tilePadding,
            .width = tileWidthWithTitle,
            .height = tileSize,
        }
    );

    if (!task.iconPath.empty()) {
      auto image = ui::image({
          .fit = ImageFit::Contain,
          .width = iconSize,
          .height = iconSize,
      });
      image->setAppIconColorization(effectiveShellAppIconColorizationTint(m_configService.config().shell));
      image->setSourceFile(renderer, task.iconPath, static_cast<int>(std::round(iconSize)), true);
      if (image->hasImage()) {
        content->addChild(std::move(image));
      } else {
        auto glyph = ui::glyph({
            .glyph = "app-window",
            .glyphSize = iconSize,
        });
        glyph->measure(renderer);
        content->addChild(std::move(glyph));
      }
    } else {
      auto glyph = ui::glyph({
          .glyph = "apps",
          .glyphSize = iconSize,
      });
      glyph->measure(renderer);
      content->addChild(std::move(glyph));
    }

    Label* titleLabelPtr = nullptr;
    if (showWindowTitle) {
      auto label = ui::label({
          .text = task.title,
          .fontSize = Style::fontSizeCaption * fontScale(),
          .fontWeight = fontWeight,
          .fontFamily = fontFamily,
          .maxWidth = windowTitleWidth,
          .maxLines = 1,
      });
      titleLabelPtr = label.get();
      label->measure(renderer);
      content->addChild(std::move(label));
    }

    area->addChild(std::move(content));

    if (badgeCount > 1) {
      const std::size_t dotCount = badgeCount >= 4 ? 3U : (badgeCount == 3 ? 2U : 1U);
      const float dotSize = std::round(std::max(2.0F, Style::baseGlyphSize * 0.16F * m_contentScale));
      const float dotGap = std::round(std::max(1.0F, dotSize * 0.55F));
      const float runHeight =
          dotSize * static_cast<float>(dotCount) + dotGap * static_cast<float>(dotCount > 0 ? dotCount - 1 : 0);
      const float iconRightInset = std::round(std::max(1.0F, iconSize * 0.08F));
      const float dotX = std::round(centeredOffset(tileSize, iconSize) + iconSize - dotSize - iconRightInset);
      const float startY = std::round(centeredOffset(tileSize, iconSize) + (iconSize - runHeight) * 0.5F);
      const ColorSpec dotColor = colorSpecFromRole(ColorRole::Primary, 0.9F);

      for (std::size_t i = 0; i < dotCount; ++i) {
        auto dot = ui::box({
            .fill = dotColor,
            .radius = resolvedBarCapsuleRadius(dotSize, dotSize),
            .width = dotSize,
            .height = dotSize,
        });
        dot->setPosition(dotX, std::round(startY + static_cast<float>(i) * (dotSize + dotGap)));
        area->addChild(std::move(dot));
      }
    }

    Box* indicatorPtr = nullptr;
    if (m_showActiveIndicator) {
      const float d = std::max(4.0F, std::round(Style::baseGlyphSize * 0.32F * m_contentScale));
      const float groupedCapsuleInset =
          (m_groupByWorkspace && m_workspaceGroupCapsule ? Style::borderWidth : 0.0F) * m_contentScale;
      const float bottomInset = 0.25F * m_contentScale + groupedCapsuleInset;
      if (showWindowTitle) {
        const float lineThickness = d * 0.5F;
        auto indicator = ui::box({
            .fill = m_activeIndicatorColor,
            .radius = lineThickness * 0.5F,
            .width = tileWidthWithTitle - tilePadding * 2,
            .height = lineThickness,
        });
        indicatorPtr = indicator.get();
        indicator->setVisible(task.active);
        indicator->setPosition(tilePadding, std::round(tileSize));
        area->addChild(std::move(indicator));
      } else {
        auto indicator = ui::box({
            .fill = m_activeIndicatorColor,
            .radius = resolvedBarCapsuleRadius(d, d),
            .width = d,
            .height = d,
        });
        indicatorPtr = indicator.get();
        indicator->setVisible(task.active);
        indicator->setPosition(std::round((tileSize - d) * 0.5F), std::round(tileSize - d - bottomInset));
        area->addChild(std::move(indicator));
      }
    }
    // Installed even for a currently empty title: a title arriving later must surface without a
    // rebuild. Empty content measures to nothing, so the popup never shows.
    area->setTooltipProvider([this, taskRef]() -> TooltipContent {
      const TaskModel* current = resolveTask(m_tasks, taskRef, m_taskGeneration);
      return current != nullptr && !current->title.empty() ? TooltipContent{current->title}
                                                           : TooltipContent{std::monostate{}};
    });
    m_taskTiles.push_back({
        .taskIndex = taskRef.index,
        .area = area.get(),
        .titleLabel = titleLabelPtr,
        .activeIndicator = indicatorPtr,
    });
    attachHover(*area, tileWidthWithTitle, tileSize);
    return area;
  };

  if (m_groupByWorkspace && !m_workspaces.empty()) {
    const float groupGap = Style::spaceXs * m_contentScale;
    const float groupPad = Style::spaceXs * m_contentScale;

    const bool inlineBadge = m_showWorkspaceLabel && m_workspaceLabelPlacement == WorkspaceLabelPlacement::Inside;
    const bool externalBadge = m_showWorkspaceLabel && !inlineBadge;
    const float badgeBase = std::round(std::max(11.0F, Style::baseGlyphSize * 0.72F) * m_contentScale);
    const float externalBadgeFontSize = std::round(Style::fontSizeCaption * 0.72F * fontScale());

    const float externalBadgeCrossLimit = m_vertical && crossExtent > 0.0F
        ? std::max(0.0F, (m_workspaceGroupCapsule ? groupedCrossInner : crossExtent) - 2.0F * groupOutlineInset)
        : 0.0F;

    float stripGap = groupGap;
    float stripPaddingMainStart = 0.0F;
    float stripPaddingCrossStart = 0.0F;
    if (externalBadge) {
      const float badgeGap = std::round(std::max(1.0F, Style::spaceXs * 0.5F * m_contentScale));
      float maxMainStart = 0.0F;
      for (const auto& wsm : m_workspaces) {
        auto measuredDisc =
            measureWorkspaceDiscSize(renderer, wsm.label, externalBadgeFontSize, badgeBase, m_contentScale, fontWeight);
        if (m_vertical) {
          measuredDisc = clampWorkspaceDiscToCrossLimit(measuredDisc, externalBadgeCrossLimit);
        }
        const float badgeMain = m_vertical ? measuredDisc.height : measuredDisc.width;
        maxMainStart = std::max(maxMainStart, externalBadgeMainStartInset(m_workspaceLabelPlacement, badgeMain));
      }
      stripPaddingMainStart = std::round(maxMainStart);
      if (m_vertical) {
        stripPaddingMainStart = 0.0F;
        stripPaddingCrossStart = 0.0F;
        stripGap = groupGap;
      } else {
        stripGap = std::max(stripGap, stripPaddingMainStart + badgeGap);
      }
    }
    m_taskStrip->setGap(stripGap);
    if (m_vertical) {
      m_taskStrip->setPadding(stripPaddingMainStart, 0.0F, 0.0F, stripPaddingCrossStart);
    } else {
      m_taskStrip->setPadding(stripPaddingCrossStart, 0.0F, 0.0F, stripPaddingMainStart);
    }

    auto createWorkspaceBadge = [&](const WorkspaceModel& ws, const WorkspaceDiscSize& disc, bool hover) {
      Button::ButtonPalette badgePalette{};
      const ColorSpec fill = m_minimal ? clearColorSpec() : workspaceFillColor(ws.workspace);
      const ColorSpec text = workspaceTextColor(ws.workspace);
      badgePalette.normal = Button::ButtonStateColors{fill, clearColorSpec(), text};
      badgePalette.hover = badgePalette.normal;
      badgePalette.pressed = badgePalette.normal;
      badgePalette.disabled = badgePalette.normal;
      badgePalette.borderWidth = 0.0F;

      const float badgeFontSize =
          fitBadgeFontSize(renderer, ws.label, disc.width, disc.height, fontScale(), fontWeight);

      auto badge = ui::button({
          .text = ws.label,
          .fontSize = badgeFontSize,
          .customPalette = badgePalette,
          .minWidth = disc.width,
          .minHeight = disc.height,
          .maxWidth = disc.width,
          .maxHeight = disc.height,
          .padding = 0.0F,
          .radius = resolvedBarCapsuleRadius(disc.width, disc.height),
          .width = disc.width,
          .height = disc.height,
          .configure = [this, fontWeight, fontFamily](Button& b) {
            if (b.label() != nullptr) {
              b.label()->setFontWeight(fontWeight);
              b.label()->setFontFamily(fontFamily);
              b.label()->setTextAlign(TextAlign::Center);
            }
          },
      });

      auto wsCopy = ws.workspace;
      wl_output* const wsHost = workspaceHostOutput(ws);
      badge->setOnClick([this, wsCopy, wsHost]() { m_platform.activateWorkspace(wsHost, wsCopy); });

      if (hover) {
        attachHover(*badge->inputArea(), disc.width, disc.height);
      }
      return badge;
    };

    auto createWorkspaceWindowSummary = [&](const WorkspaceModel& ws, const std::vector<const TaskModel*>& tasks) {
      const bool anyActive =
          std::ranges::any_of(tasks, [](const TaskModel* task) { return task != nullptr && task->active; });
      const float summaryOpacity = anyActive ? m_activeOpacity : m_inactiveOpacity;

      auto area = ui::inputArea({});
      area->setOpacity(summaryOpacity);
      area->setAcceptedButtons(InputArea::buttonMask(BTN_LEFT));
      auto wsCopy = ws.workspace;
      wl_output* const wsHost = workspaceHostOutput(ws);
      area->setOnClick([this, wsCopy, wsHost](const InputArea::PointerData& data) {
        if (data.button == BTN_LEFT) {
          m_platform.activateWorkspace(wsHost, wsCopy);
        }
      });

      if (m_workspaceGroupContent == WorkspaceGroupContent::Count) {
        const std::string countText = std::to_string(tasks.size());
        const float countFontSize = std::round(Style::fontSizeCaption * fontScale());
        auto content = ui::flex(
            FlexDirection::Horizontal,
            {
                .align = FlexAlign::Center,
                .justify = FlexJustify::Center,
                .width = tileSize,
                .height = tileSize,
            }
        );
        auto label = ui::label({
            .text = countText,
            .fontSize = countFontSize,
            .fontWeight = fontWeight,
            .fontFamily = fontFamily,
            .color = colorSpecFromRole(ColorRole::OnSurface),
        });
        label->measure(renderer);
        content->addChild(std::move(label));
        area->setFrameSize(tileSize, tileSize);
        area->addChild(std::move(content));
        attachHover(*area, tileSize, tileSize);
        return area;
      }

      // Dots: one per window along the bar main axis. Cap visible dots and show +N for the rest.
      constexpr std::size_t kMaxVisibleDots = 5;
      std::vector<const TaskModel*> visibleDots;
      visibleDots.reserve(std::min(tasks.size(), kMaxVisibleDots));
      const std::size_t overflow = tasks.size() > kMaxVisibleDots ? tasks.size() - kMaxVisibleDots : 0;
      if (overflow == 0) {
        visibleDots = tasks;
      } else {
        const TaskModel* activeTask = nullptr;
        for (const TaskModel* task : tasks) {
          if (task != nullptr && task->active) {
            activeTask = task;
            break;
          }
        }
        if (activeTask != nullptr) {
          visibleDots.push_back(activeTask);
        }
        for (const TaskModel* task : tasks) {
          if (visibleDots.size() >= kMaxVisibleDots) {
            break;
          }
          if (task == activeTask) {
            continue;
          }
          visibleDots.push_back(task);
        }
      }

      const float dotSize = std::round(std::max(4.0F, Style::baseGlyphSize * 0.28F * m_contentScale));
      const float dotGap = std::round(std::max(2.0F, Style::spaceXs * 0.5F * m_contentScale));
      const float overflowFontSize = std::round(Style::fontSizeCaption * 0.85F * fontScale());
      float overflowLabelWidth = 0.0F;
      float overflowLabelHeight = 0.0F;
      std::string overflowText;
      if (overflow > 0) {
        overflowText = "+" + std::to_string(overflow);
        const TextMetrics tm = renderer.measureText(overflowText, overflowFontSize, fontWeight);
        overflowLabelWidth = std::max(tm.right - tm.left, tm.inkRight - tm.inkLeft);
        overflowLabelHeight = tm.bottom - tm.top;
      }

      const std::size_t visibleCount = visibleDots.size();
      const float dotsRun = visibleCount == 0
          ? 0.0F
          : (dotSize * static_cast<float>(visibleCount)
             + dotGap * static_cast<float>(visibleCount > 1 ? visibleCount - 1 : 0));
      const float overflowRun = overflow > 0 ? (dotGap + overflowLabelWidth) : 0.0F;
      const float run = dotsRun + overflowRun;
      const float mainExtent = std::max(tileSize, run + Style::spaceXs * m_contentScale);
      area->setFrameSize(m_vertical ? tileSize : mainExtent, m_vertical ? mainExtent : tileSize);

      auto content = ui::flex(
          m_vertical ? FlexDirection::Vertical : FlexDirection::Horizontal,
          {
              .align = FlexAlign::Center,
              .justify = FlexJustify::Center,
              .gap = dotGap,
              .width = m_vertical ? tileSize : mainExtent,
              .height = m_vertical ? mainExtent : tileSize,
          }
      );
      ColorSpec activeDotFill = m_activeIndicatorColor;
      activeDotFill.alpha = 0.95F;
      for (const TaskModel* task : visibleDots) {
        const bool highlight = task != nullptr && task->active && m_showActiveIndicator;
        const ColorSpec fill =
            highlight ? activeDotFill : colorSpecFromRole(ColorRole::OnSurface, anyActive ? 0.55F : 0.35F);
        content->addChild(
            ui::box({
                .fill = fill,
                .radius = resolvedBarCapsuleRadius(dotSize, dotSize),
                .width = dotSize,
                .height = dotSize,
            })
        );
      }
      if (overflow > 0) {
        auto overflowLabel = ui::label({
            .text = overflowText,
            .fontSize = overflowFontSize,
            .fontWeight = fontWeight,
            .fontFamily = fontFamily,
            .color = colorSpecFromRole(ColorRole::OnSurface, anyActive ? 0.7F : 0.5F),
            .width = overflowLabelWidth,
            .height = std::max(dotSize, overflowLabelHeight),
        });
        overflowLabel->measure(renderer);
        content->addChild(std::move(overflowLabel));
      }
      area->addChild(std::move(content));
      attachHover(*area, m_vertical ? tileSize : mainExtent, m_vertical ? mainExtent : tileSize);
      return area;
    };

    std::unordered_set<std::string> cycleKeysThisFrame;
    for (const auto& ws : m_workspaces) {
      std::vector<const TaskModel*> tasks;
      for (const auto& task : m_tasks) {
        if (taskInWorkspaceGroup(task, ws)) {
          tasks.push_back(&task);
        }
      }
      std::ranges::stable_sort(tasks, [](const TaskModel* lhs, const TaskModel* rhs) {
        if (lhs->workspaceOrder != rhs->workspaceOrder) {
          return lhs->workspaceOrder < rhs->workspaceOrder;
        }
        if (lhs->order != rhs->order) {
          return lhs->order < rhs->order;
        }
        return lhs->handleKey < rhs->handleKey;
      });

      std::vector<const TaskModel*> renderedTasks = tasks;
      std::unordered_map<std::uintptr_t, std::vector<TaskRef>> cycleCandidatesByHandle;
      std::unordered_map<std::uintptr_t, std::string> cycleKeyByHandle;
      std::unordered_map<std::uintptr_t, std::size_t> badgeCountByHandle;
      if (m_workspaceGroupContent == WorkspaceGroupContent::Icons && m_groupSingleIconPerApp && !tasks.empty()) {
        struct GroupedTaskItem {
          const TaskModel* representative = nullptr;
          std::string cycleKey;
          std::vector<TaskRef> candidates;
        };
        std::vector<GroupedTaskItem> groupedItems;
        std::unordered_map<std::string, std::size_t> groupedIndexByKey;
        groupedItems.reserve(tasks.size());
        groupedIndexByKey.reserve(tasks.size());

        const std::string cyclePrefix = ws.key + '\n';
        for (const TaskModel* task : tasks) {
          std::string appKey =
              !task->appIdLower.empty() ? task->appIdLower : (!task->idLower.empty() ? task->idLower : task->nameLower);
          if (appKey.empty()) {
            appKey = std::to_string(task->handleKey);
          }
          const std::string groupedKey = cyclePrefix + appKey;
          const auto [it, inserted] = groupedIndexByKey.emplace(groupedKey, groupedItems.size());
          if (inserted) {
            groupedItems.push_back(
                GroupedTaskItem{
                    .representative = task,
                    .cycleKey = groupedKey,
                    .candidates = {taskRefFor(*task)},
                }
            );
          } else {
            auto& grouped = groupedItems[it->second];
            grouped.candidates.push_back(taskRefFor(*task));
            if (!grouped.representative->active && task->active) {
              grouped.representative = task;
            }
          }
        }

        renderedTasks.clear();
        renderedTasks.reserve(groupedItems.size());
        for (auto& grouped : groupedItems) {
          if (grouped.representative == nullptr) {
            continue;
          }
          renderedTasks.push_back(grouped.representative);
          badgeCountByHandle[grouped.representative->handleKey] = grouped.candidates.size();
          if (grouped.candidates.size() > 1) {
            cycleCandidatesByHandle[grouped.representative->handleKey] = std::move(grouped.candidates);
            cycleKeyByHandle[grouped.representative->handleKey] = grouped.cycleKey;
            cycleKeysThisFrame.insert(grouped.cycleKey);
          }
        }
      }

      const bool emptyWorkspace = tasks.empty();
      const auto surfaceFill = colorSpecFromRole(ColorRole::SurfaceVariant, ws.workspace.active ? 0.52F : 0.18F);
      const auto borderColor = colorSpecFromRole(ColorRole::Primary, ws.workspace.active ? 0.65F : 0.16F);

      const float crossSize = std::round(tileSize + groupPad * 2.0F);

      float groupPadTop = groupPad;
      float groupPadRight = groupPad;
      float groupPadBottom = groupPad;
      float groupPadLeft = groupPad;
      std::optional<WorkspaceDiscSize> externalBadgeDisc;
      if (externalBadge) {
        externalBadgeDisc =
            measureWorkspaceDiscSize(renderer, ws.label, externalBadgeFontSize, badgeBase, m_contentScale, fontWeight);
        if (m_vertical) {
          *externalBadgeDisc = clampWorkspaceDiscToCrossLimit(*externalBadgeDisc, externalBadgeCrossLimit);
        }
        if (!tasks.empty() || m_vertical) {
          const float half =
              std::round(m_vertical ? externalBadgeDisc->height * 0.6F : externalBadgeDisc->width * 0.6F);
          if (m_vertical) {
            groupPadTop += half;
          } else {
            groupPadLeft += half;
          }
        }
      }
      if (groupedCrossInner > 0.0F) {
        if (m_vertical) {
          const float maxCrossPad = std::max(0.0F, (groupedCrossInner - tileWidthWithTitle) * 0.5F);
          groupPadLeft = std::min(groupPadLeft, maxCrossPad);
          groupPadRight = std::min(groupPadRight, maxCrossPad);
        } else {
          const float maxCrossPad = std::max(0.0F, (groupedCrossInner - tileSize) * 0.5F);
          groupPadTop = std::min(groupPadTop, maxCrossPad);
          groupPadBottom = std::min(groupPadBottom, maxCrossPad);
        }
      }

      auto group = ui::flex(
          m_vertical ? FlexDirection::Vertical : FlexDirection::Horizontal,
          {
              .align = FlexAlign::Center,
              .justify = FlexJustify::Center,
              .gap = groupGap,
              .fill = m_workspaceGroupCapsule ? surfaceFill : clearColorSpec(),
              .radius = m_workspaceGroupCapsule ? resolvedBarCapsuleRadius(crossSize, crossSize) : 0.0F,
              .border = m_workspaceGroupCapsule ? borderColor : clearColorSpec(),
              .borderWidth = m_workspaceGroupCapsule ? Style::borderWidth * m_contentScale : 0.0F,
          }
      );
      group->setPadding(groupPadTop, groupPadRight, groupPadBottom, groupPadLeft);

      if (inlineBadge && m_showWorkspaceLabel) {
        const float inlineBadgeFontSize = std::round(Style::fontSizeCaption * 0.85F * fontScale());
        const float inlineBadgeHeight = std::round(std::max(10.0F, iconSize - (Style::spaceXs * m_contentScale)));
        WorkspaceDiscSize disc = measureWorkspaceDiscSize(
            renderer, ws.label, inlineBadgeFontSize, inlineBadgeHeight, m_contentScale, fontWeight
        );
        disc.height = inlineBadgeHeight;
        disc.width = std::round(std::max(inlineBadgeHeight, disc.width));
        if (m_vertical) {
          disc = clampWorkspaceDiscToCrossLimit(disc, externalBadgeCrossLimit);
        }
        group->addChild(createWorkspaceBadge(ws, disc, true));
      }

      if (emptyWorkspace) {
        if (!inlineBadge || !m_showWorkspaceLabel) {
          auto switcher = ui::inputArea({});
          switcher->setFrameSize(tileSize, tileSize);
          switcher->setAcceptedButtons(InputArea::buttonMask(BTN_LEFT));
          auto wsCopy = ws.workspace;
          wl_output* const wsHost = workspaceHostOutput(ws);
          switcher->setOnClick([this, wsCopy, wsHost](const InputArea::PointerData& data) {
            if (data.button == BTN_LEFT) {
              m_platform.activateWorkspace(wsHost, wsCopy);
            }
          });
          group->addChild(std::move(switcher));
        } else {
          // Inline badge is clickable; reserve tile cross extent so empty groups match occupied ones.
          if (m_vertical) {
            group->setMinWidth(groupPadLeft + tileSize + groupPadRight);
          } else {
            group->setMinHeight(groupPadTop + tileSize + groupPadBottom);
          }
        }
      } else if (m_workspaceGroupContent == WorkspaceGroupContent::Icons) {
        for (const auto* task : renderedTasks) {
          const auto cycleIt = cycleCandidatesByHandle.find(task->handleKey);
          const auto cycleKeyIt = cycleKeyByHandle.find(task->handleKey);
          const std::size_t badgeCount =
              badgeCountByHandle.contains(task->handleKey) ? badgeCountByHandle[task->handleKey] : 1;
          group->addChild(createTaskTile(
              taskRefFor(*task), cycleIt != cycleCandidatesByHandle.end() ? cycleIt->second : std::vector<TaskRef>{},
              cycleKeyIt != cycleKeyByHandle.end() ? cycleKeyIt->second : std::string{}, badgeCount
          ));
        }
      } else {
        group->addChild(createWorkspaceWindowSummary(ws, tasks));
      }

      if (externalBadge) {
        // The group already has its content laid out; measure its real box (which
        // includes groupPad padding and the capsule border) so the badge is
        // centered against the actual group extent, not the tileSize approximation.
        const auto groupSize = group->measure(renderer, {});
        const WorkspaceDiscSize disc = *externalBadgeDisc;
        const auto badgePos = externalBadgePosition(
            m_workspaceLabelPlacement, m_vertical, groupSize.width, groupSize.height, disc.width, disc.height,
            groupOutlineInset
        );
        auto badge = createWorkspaceBadge(ws, disc, !emptyWorkspace);
        badge->setParticipatesInLayout(false);
        badge->setPosition(badgePos.left, badgePos.top);
        badge->layout(renderer);
        group->addChild(std::move(badge));
      }

      m_taskStrip->addChild(std::move(group));
    }
    std::erase_if(m_groupedAppCycleCursor, [&](const auto& item) { return !cycleKeysThisFrame.contains(item.first); });
    return;
  }
  m_taskStrip->setPadding(0.0F, 0.0F, 0.0F, 0.0F);
  m_taskStrip->setGap(tileGap);
  m_tilePitchMain = (m_vertical ? tileSize : tileWidthWithTitle) + tileGap;
  std::unordered_set<std::string> pinnedCycleKeysThisFrame;
  for (std::size_t i = 0; i < m_tasks.size(); ++i) {
    const auto& task = m_tasks[i];
    if (task.pinned && task.running && task.instanceCount > 1) {
      pinnedCycleKeysThisFrame.insert(!task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower);
    }
    const std::size_t badgeCount = task.pinned ? std::max<std::size_t>(1, task.instanceCount) : 1;
    m_taskStrip->addChild(createTaskTile(TaskRef{.index = i, .generation = m_taskGeneration}, {}, {}, badgeCount));
  }
  std::erase_if(m_groupedAppCycleCursor, [&](const auto& item) {
    return !pinnedCycleKeysThisFrame.contains(item.first);
  });
}

void TaskbarWidget::updateModels() {

  syncWorkspaceGroupingCapability();

  const auto desktopVersion = desktopEntriesVersion();
  if (desktopVersion != m_desktopEntriesVersion) {
    buildDesktopIconIndex();
  }

  const auto active = m_platform.activeToplevel();
  const auto* activeHandle = active.has_value() ? active->handle : nullptr;
  const auto focusedCompositorWindowId = m_platform.focusedCompositorWindowId();

  wl_output* const topFilter = toplevelOutputFilter();
  const auto assignmentMode = m_platform.taskbarAssignmentMode();
  std::vector<WorkspaceModel> nextWorkspaces;
  std::unordered_map<std::string, std::vector<std::string>> runningByWorkspace;
  std::vector<WorkspaceWindowAssignment> workspaceAssignments;

  {
    // Always load compositor workspace layout so flat-strip tile order can track window moves.
    nextWorkspaces.reserve(32);
    std::unordered_map<wl_output*, int> monitorOrdinal;
    int nextOrdinal = 1;
    for (const auto& wo : m_platform.outputs()) {
      if (wo.output == nullptr) {
        continue;
      }
      if (!m_showAllOutputs && wo.output != m_output) {
        continue;
      }
      monitorOrdinal.emplace(wo.output, nextOrdinal++);
    }

    for (const auto& wo : m_platform.outputs()) {
      if (wo.output == nullptr) {
        continue;
      }
      if (!m_showAllOutputs && wo.output != m_output) {
        continue;
      }
      const auto workspaces = m_platform.workspaces(wo.output);
      const auto displayKeys = m_platform.workspaceDisplayKeys(wo.output);
      const std::string keyPrefix = workspaceKeyPrefixForOutput(wo.output);
      nextWorkspaces.reserve(nextWorkspaces.size() + workspaces.size());
      for (std::size_t i = 0; i < workspaces.size(); ++i) {
        WorkspaceModel item{};
        item.workspace = workspaces[i];
        item.hostOutput = wo.output;
        const std::string fallbackLabel = workspaceLabel(item.workspace, i);
        const std::string label = compositors::isKde() && item.workspace.index > 0
            ? std::to_string(item.workspace.index)
            : (i < displayKeys.size() && !displayKeys[i].empty() ? displayKeys[i] : fallbackLabel);
        const std::string baseKey = compositors::isKde() && !item.workspace.id.empty()
            ? item.workspace.id
            : (i < displayKeys.size() && !displayKeys[i].empty() ? displayKeys[i] : fallbackLabel);
        item.key = keyPrefix + baseKey;
        if (useMultiOutputWorkspaceKeys()) {
          const auto ordIt = monitorOrdinal.find(wo.output);
          if (ordIt != monitorOrdinal.end()) {
            item.label = label + "\u00B7" + std::to_string(ordIt->second);
          } else {
            item.label = label;
          }
        } else {
          item.label = label;
        }
        nextWorkspaces.push_back(std::move(item));
      }
    }

    if (m_showAllOutputs) {
      for (const auto& wo : m_platform.outputs()) {
        if (wo.output == nullptr) {
          continue;
        }
        const std::string prefix = workspaceKeyPrefixForOutput(wo.output);
        const auto perApps = m_platform.appIdsByWorkspace(wo.output);
        for (const auto& [wsKey, apps] : perApps) {
          auto& bucket = runningByWorkspace[prefix + wsKey];
          bucket.insert(bucket.end(), apps.begin(), apps.end());
        }
        auto perAssign = m_platform.workspaceWindowAssignments(wo.output);
        for (auto& row : perAssign) {
          row.workspaceKey = prefix + row.workspaceKey;
          workspaceAssignments.push_back(std::move(row));
        }
      }
    } else {
      runningByWorkspace = m_platform.appIdsByWorkspace(m_output);
      workspaceAssignments = m_platform.workspaceWindowAssignments(m_output);
    }

    std::unordered_map<std::string, std::size_t> workspaceKeyToOrder;
    for (std::size_t i = 0; i < nextWorkspaces.size(); ++i) {
      workspaceKeyToOrder[nextWorkspaces[i].key] = i;
    }

    std::ranges::stable_sort(workspaceAssignments, [&](const auto& a, const auto& b) {
      if (a.workspaceKey != b.workspaceKey) {
        const auto itA = workspaceKeyToOrder.find(a.workspaceKey);
        const auto itB = workspaceKeyToOrder.find(b.workspaceKey);
        if (itA != workspaceKeyToOrder.end() && itB != workspaceKeyToOrder.end()) {
          return itA->second < itB->second;
        }
        return a.workspaceKey < b.workspaceKey;
      }
      if (a.x != b.x) {
        return a.x < b.x;
      }
      if (a.y != b.y) {
        return a.y < b.y;
      }
      return a.windowId < b.windowId;
    });
  }

  std::vector<std::string> running = m_platform.runningAppIds(topFilter);
  if (compositors::isHyprland() || compositors::isKde()) {
    std::unordered_set<std::string> seenApps(running.begin(), running.end());
    for (const auto& row : workspaceAssignments) {
      if (!row.appId.empty() && seenApps.insert(row.appId).second) {
        running.push_back(row.appId);
      }
    }
  }
  const auto resolvedRunning = app_identity::resolveRunningApps(running, desktopEntries());

  std::vector<TaskModel> nextTasks;
  std::unordered_set<std::uintptr_t> processedHandles;
  for (const auto& run : resolvedRunning) {
    const std::string idLower = run.runningLower;
    const std::string startupLower =
        !run.entry.startupWmClass.empty() ? toLower(run.entry.startupWmClass) : run.runningLower;
    const std::string nameLower = !run.entry.nameLower.empty() ? run.entry.nameLower : run.runningLower;

    const auto windows = m_platform.enrichedWindowsForApp(idLower, startupLower, topFilter);
    for (const auto& window : windows) {
      const auto handleKey = taskHandleKey(window);
      if (handleKey == 0 || !processedHandles.insert(handleKey).second) {
        continue;
      }

      TaskModel task{};
      task.handleKey = handleKey;
      task.order = window.order;
      task.appId = !window.appId.empty() ? window.appId : run.runningAppId;
      task.idLower = idLower;
      task.startupWmClassLower = startupLower;
      task.nameLower = nameLower;
      task.appIdLower = toLower(task.appId);
      task.title = window.title;
      task.active = activeHandle != nullptr && activeHandle == window.handle;
      task.firstHandle = window.handle;
      if (window.exactIdentity && !window.identifier.empty()) {
        task.workspaceWindowId = window.identifier;
        task.exactWindowId = window.identifier;
      } else if (!window.exactIdentity && !window.identifier.empty()) {
        task.workspaceWindowId = window.identifier;
      }
      task.iconPath = resolveIconPath(run.runningAppId, run.entry.icon);
      task.workspaceKey = {};
      nextTasks.push_back(std::move(task));
    }
  }

  // Windows with no app id still get a task keyed by toplevel handle / window id.
  for (const auto& window : m_platform.enrichedWindowsWithoutAppId(topFilter)) {
    // Skip anonymous XWayland menu popups (no app id and no title to show).
    if (window.title.empty()) {
      continue;
    }
    const auto handleKey = taskHandleKey(window);
    if (handleKey == 0 || !processedHandles.insert(handleKey).second) {
      continue;
    }

    TaskModel task{};
    task.handleKey = handleKey;
    task.order = window.order;
    task.title = window.title;
    task.active = activeHandle != nullptr && activeHandle == window.handle;
    task.firstHandle = window.handle;
    if (window.exactIdentity && !window.identifier.empty()) {
      task.workspaceWindowId = window.identifier;
      task.exactWindowId = window.identifier;
    }
    task.iconPath = resolveIconPath({}, {});
    nextTasks.push_back(std::move(task));
  }

  if (compositors::isKde() && nextTasks.empty()) {
    for (const auto& assignment : workspaceAssignments) {
      if (assignment.appId.empty() && assignment.windowId.empty() && assignment.title.empty()) {
        continue;
      }
      const std::string idLower = toLower(assignment.appId);
      std::uintptr_t handleKey = 0;
      if (!assignment.windowId.empty()) {
        handleKey = static_cast<std::uintptr_t>(std::hash<std::string>{}(assignment.windowId));
        if (handleKey == 0) {
          handleKey = 1;
        }
      }
      if (handleKey == 0) {
        handleKey = static_cast<std::uintptr_t>(
            std::hash<std::string>{}(assignment.appId + "\n" + assignment.title + "\n" + assignment.workspaceKey)
        );
        if (handleKey == 0) {
          handleKey = 1;
        }
      }
      if (!processedHandles.insert(handleKey).second) {
        continue;
      }

      TaskModel task{};
      task.handleKey = handleKey;
      task.appId = assignment.appId;
      task.idLower = idLower;
      task.startupWmClassLower = idLower;
      task.nameLower = idLower;
      task.appIdLower = idLower;
      task.title = assignment.title;
      task.workspaceWindowId = assignment.windowId;
      task.workspaceKey = assignment.workspaceKey;
      task.iconPath = resolveIconPath(assignment.appId, {});
      nextTasks.push_back(std::move(task));
    }
  }

  std::ranges::stable_sort(nextTasks, [](const TaskModel& a, const TaskModel& b) {
    if (a.order != b.order) {
      return a.order < b.order;
    }
    return a.handleKey < b.handleKey;
  });

  if (compositors::isHyprland()) {
    for (auto& task : nextTasks) {
      ToplevelInfo toplevelInfo{};
      toplevelInfo.handle = task.firstHandle;
      if (task.firstHandle == nullptr) {
        toplevelInfo.extHandle = reinterpret_cast<ext_foreign_toplevel_handle_v1*>(task.handleKey);
      }
      if (const auto mappedId = m_platform.compositorWindowIdForToplevelInfo(toplevelInfo); mappedId.has_value()) {
        task.workspaceWindowId = *mappedId;
      } else if (toplevelInfo.extHandle != nullptr) {
        task.workspaceWindowId.clear();
      }
      if (!task.active
          && focusedCompositorWindowId.has_value()
          && !task.workspaceWindowId.empty()
          && task.workspaceWindowId == *focusedCompositorWindowId) {
        task.active = true;
      }
    }
  }

  if (compositors::isKde()) {
    const auto activeToplevel = m_platform.activeToplevel();
    for (auto& task : nextTasks) {
      if (!task.workspaceWindowId.empty()) {
        for (const auto& row : workspaceAssignments) {
          if (row.windowId == task.workspaceWindowId) {
            task.workspaceKey = row.workspaceKey;
            break;
          }
        }
      }
      if (task.workspaceWindowId.empty()) {
        for (const auto& row : workspaceAssignments) {
          if (row.title == task.title && toLower(row.appId) == toLower(task.appId)) {
            task.workspaceWindowId = row.windowId;
            task.workspaceKey = row.workspaceKey;
            break;
          }
        }
      }
      if (!task.active && activeToplevel.has_value()) {
        const bool titleMatch = !activeToplevel->title.empty() && task.title == activeToplevel->title;
        const bool appMatch = !activeToplevel->appId.empty() && toLower(task.appId) == toLower(activeToplevel->appId);
        const bool idMatch = !activeToplevel->identifier.empty()
            && !task.workspaceWindowId.empty()
            && task.workspaceWindowId == activeToplevel->identifier;
        if (idMatch || (appMatch && titleMatch)) {
          task.active = true;
        }
      }
    }
  }

  if (!workspaceAssignments.empty()) {
    if (assignmentMode == TaskbarAssignmentMode::WorkspaceOccurrenceTitle) {
      std::vector<TaskbarWindowCandidate> candidates;
      candidates.reserve(nextTasks.size());
      for (const auto& task : nextTasks) {
        TaskbarWindowCandidate candidate{};
        candidate.handleKey = task.handleKey;
        candidate.title = task.title;
        auto append = [&](const std::string& value) {
          if (value.empty()) {
            return;
          }
          if (!std::ranges::contains(candidate.appIds, value)) {
            candidate.appIds.push_back(value);
          }
        };
        append(task.appIdLower);
        append(task.idLower);
        append(task.startupWmClassLower);
        append(task.nameLower);
        candidates.push_back(std::move(candidate));
      }

      std::unordered_map<std::uintptr_t, WorkspaceWindow> assignedByHandle;
      if (m_showAllOutputs) {
        for (const auto& wo : m_platform.outputs()) {
          if (wo.output == nullptr) {
            continue;
          }
          const auto part = m_platform.assignTaskbarWindows(candidates, wo.output);
          const std::string prefix = workspaceKeyPrefixForOutput(wo.output);
          for (const auto& [handleKey, assigned] : part) {
            WorkspaceWindow copy = assigned;
            if (!prefix.empty()) {
              copy.workspaceKey = prefix + copy.workspaceKey;
            }
            assignedByHandle[handleKey] = std::move(copy);
          }
        }
      } else {
        assignedByHandle = m_platform.assignTaskbarWindows(candidates, m_output);
      }
      std::vector<bool> representedAssignments(workspaceAssignments.size(), false);

      for (auto& task : nextTasks) {
        const auto assignedIt = assignedByHandle.find(task.handleKey);
        if (assignedIt == assignedByHandle.end()) {
          continue;
        }

        const auto& assigned = assignedIt->second;
        task.workspaceKey = assigned.workspaceKey;
        task.workspaceWindowId = assigned.windowId;

        for (std::size_t assignmentIndex = 0; assignmentIndex < workspaceAssignments.size(); ++assignmentIndex) {
          if (representedAssignments[assignmentIndex]) {
            continue;
          }
          const auto& assignment = workspaceAssignments[assignmentIndex];
          if (assignment.workspaceKey != assigned.workspaceKey) {
            continue;
          }
          if (!assigned.windowId.empty() && assignment.windowId != assigned.windowId) {
            continue;
          }
          if (toLower(assignment.appId) != task.appIdLower
              && toLower(assignment.appId) != task.idLower
              && toLower(assignment.appId) != task.startupWmClassLower
              && toLower(assignment.appId) != task.nameLower) {
            if (!assignment.appId.empty()
                || !isOrphanAppIdentity(
                    task.appId, task.appIdLower, task.idLower, task.startupWmClassLower, task.nameLower
                )) {
              continue;
            }
          }
          if (!assigned.title.empty() && !assignment.title.empty() && assignment.title != assigned.title) {
            continue;
          }
          if (assignment.appId.empty()
              && !assignment.title.empty()
              && !task.title.empty()
              && assignment.title != task.title) {
            continue;
          }
          task.workspaceOrder = assignmentIndex;
          representedAssignments[assignmentIndex] = true;
          break;
        }
      }

      auto syntheticTaskKey = [](const WorkspaceWindowAssignment& assignment, std::size_t index) {
        return syntheticAssignmentHandleKey(assignment, index);
      };

      if (m_groupByWorkspace) {
        for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
          if (representedAssignments[i]) {
            continue;
          }

          const auto& assignment = workspaceAssignments[i];
          if (assignment.workspaceKey.empty()) {
            continue;
          }
          if (assignment.appId.empty() && assignment.windowId.empty()) {
            continue;
          }

          TaskModel task{};
          task.handleKey = syntheticTaskKey(assignment, i);
          task.order = static_cast<std::uint64_t>(std::numeric_limits<std::int32_t>::max()) + i;
          task.appId = assignment.appId;
          task.idLower = toLower(task.appId);
          task.startupWmClassLower = task.idLower;
          task.nameLower = task.idLower;
          task.appIdLower = task.idLower;
          task.title = assignment.title;
          task.iconPath = resolveIconPath(task.appId, {});
          task.workspaceKey = assignment.workspaceKey;
          task.workspaceWindowId = assignment.windowId;
          task.workspaceOrder = i;
          nextTasks.push_back(std::move(task));
        }
      }
    } else {
      std::unordered_map<std::uintptr_t, std::string> previousWorkspaceByHandle;
      std::unordered_map<std::uintptr_t, std::string> previousWorkspaceWindowByHandle;
      previousWorkspaceByHandle.reserve(m_tasks.size());
      previousWorkspaceWindowByHandle.reserve(m_tasks.size());
      for (const auto& task : m_tasks) {
        if (!task.workspaceKey.empty()) {
          previousWorkspaceByHandle[task.handleKey] = task.workspaceKey;
        }
        if (!task.workspaceWindowId.empty()) {
          previousWorkspaceWindowByHandle[task.handleKey] = task.workspaceWindowId;
        }
      }
      for (auto& task : nextTasks) {
        if (task.workspaceWindowId.empty()) {
          const auto previousWindow = previousWorkspaceWindowByHandle.find(task.handleKey);
          if (previousWindow != previousWorkspaceWindowByHandle.end()) {
            task.workspaceWindowId = previousWindow->second;
          }
        }
      }
      std::unordered_map<std::string, const WorkspaceModel*> workspaceByAnyKey;
      workspaceByAnyKey.reserve(std::max<std::size_t>(m_workspaces.size(), nextWorkspaces.size()) * 3);
      for (const auto& ws : nextWorkspaces) {
        workspaceByAnyKey.emplace(ws.key, &ws);
        if (!ws.workspace.id.empty()) {
          workspaceByAnyKey.emplace(ws.workspace.id, &ws);
        }
        if (!ws.workspace.name.empty()) {
          workspaceByAnyKey.emplace(ws.workspace.name, &ws);
        }
      }
      auto isTransientWorkspace = [&](const std::string& workspaceKey) {
        const auto it = workspaceByAnyKey.find(workspaceKey);
        if (it == workspaceByAnyKey.end() || it->second == nullptr) {
          return false;
        }
        const auto& workspace = it->second->workspace;
        return !workspace.active && !workspace.occupied;
      };

      std::vector<bool> used(workspaceAssignments.size(), false);
      auto windowIdsMatch = [](std::string_view lhs, std::string_view rhs) {
        if (lhs.empty() || rhs.empty()) {
          return false;
        }
        if (lhs == rhs) {
          return true;
        }
        return compositors::isHyprland() && compositors::hyprland::windowIdsEqual(lhs, rhs);
      };
      auto assignmentIndexForWindowId = [&](std::string_view windowId) -> std::optional<std::size_t> {
        if (windowId.empty()) {
          return std::nullopt;
        }
        for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
          if (windowIdsMatch(workspaceAssignments[i].windowId, windowId)) {
            return i;
          }
        }
        return std::nullopt;
      };
      auto matchesApp = [&](const TaskModel& task, const WorkspaceWindowAssignment& assignment) {
        if (assignment.appId.empty()) {
          return isOrphanAppIdentity(
              task.appId, task.appIdLower, task.idLower, task.startupWmClassLower, task.nameLower
          );
        }
        if (isOrphanAppIdentity(task.appId, task.appIdLower, task.idLower, task.startupWmClassLower, task.nameLower)) {
          return false;
        }
        const std::string assignmentAppLower = toLower(assignment.appId);
        return assignmentAppLower == task.appIdLower
            || assignmentAppLower == task.idLower
            || assignmentAppLower == task.startupWmClassLower
            || assignmentAppLower == task.nameLower;
      };

      // Prefer compositor window ids that actually appear in the assignment list
      // (Hyprland address mapping). Unrelated identifiers (e.g. ext-toplevel tokens)
      // must not block later title matching or icons disappear from workspace groups.
      for (auto& task : nextTasks) {
        const std::string_view bindingWindowId = workspaceBindingWindowId(task);
        if (bindingWindowId.empty() || !task.workspaceKey.empty()) {
          continue;
        }
        const auto index = assignmentIndexForWindowId(bindingWindowId);
        if (!index.has_value() || used[*index]) {
          continue;
        }
        const auto& assignment = workspaceAssignments[*index];
        task.workspaceKey = assignment.workspaceKey;
        task.workspaceWindowId = assignment.windowId;
        task.workspaceOrder = *index;
        used[*index] = true;
      }

      // Bind focused compositor window id to the active toplevel before title match.
      if (focusedCompositorWindowId.has_value()) {
        if (const auto index = assignmentIndexForWindowId(*focusedCompositorWindowId);
            index.has_value() && !used[*index]) {
          TaskModel* activeTask = nullptr;
          for (auto& task : nextTasks) {
            if (task.active) {
              activeTask = &task;
              break;
            }
          }
          if (activeTask != nullptr && matchesApp(*activeTask, workspaceAssignments[*index])) {
            const auto& assignment = workspaceAssignments[*index];
            activeTask->workspaceKey = assignment.workspaceKey;
            activeTask->workspaceWindowId = assignment.windowId;
            activeTask->workspaceOrder = *index;
            used[*index] = true;
          }
        }
      }

      auto assignMatch = [&](TaskModel& task, bool requireTitle,
                             const std::function<bool(const WorkspaceWindowAssignment&)>& extraPredicate) -> bool {
        if (!task.exactWindowId.empty()) {
          return false;
        }
        if (const auto existing = assignmentIndexForWindowId(task.workspaceWindowId); existing.has_value()) {
          if (!used[*existing] && extraPredicate(workspaceAssignments[*existing])) {
            const auto& assignment = workspaceAssignments[*existing];
            task.workspaceKey = assignment.workspaceKey;
            task.workspaceWindowId = assignment.windowId;
            task.workspaceOrder = *existing;
            used[*existing] = true;
            return true;
          }
          // This id is a real compositor window — do not rebind via title to another one.
          return false;
        }
        for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
          if (used[i]) {
            continue;
          }
          const auto& assignment = workspaceAssignments[i];
          if (!matchesApp(task, assignment)) {
            continue;
          }
          if (requireTitle && assignment.title.empty()) {
            continue;
          }
          if (requireTitle && assignment.title != task.title) {
            continue;
          }
          const auto previous = previousWorkspaceByHandle.find(task.handleKey);
          if (previous != previousWorkspaceByHandle.end()
              && assignment.workspaceKey != previous->second
              && isTransientWorkspace(assignment.workspaceKey)) {
            continue;
          }
          if (!extraPredicate(assignment)) {
            continue;
          }
          task.workspaceKey = assignment.workspaceKey;
          task.workspaceWindowId = assignment.windowId;
          task.workspaceOrder = i;
          used[i] = true;
          return true;
        }
        return false;
      };

      for (auto& task : nextTasks) {
        if (!task.workspaceKey.empty() || !task.exactWindowId.empty()) {
          continue;
        }
        const auto previous = previousWorkspaceWindowByHandle.find(task.handleKey);
        if (previous == previousWorkspaceWindowByHandle.end()) {
          continue;
        }
        for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
          if (used[i]) {
            continue;
          }
          const auto& assignment = workspaceAssignments[i];
          if (!windowIdsMatch(assignment.windowId, previous->second) || !matchesApp(task, assignment)) {
            continue;
          }
          task.workspaceKey = assignment.workspaceKey;
          task.workspaceWindowId = assignment.windowId;
          task.workspaceOrder = i;
          used[i] = true;
          break;
        }
      }

      for (auto& task : nextTasks) {
        if (!task.workspaceKey.empty() || !task.exactWindowId.empty()) {
          continue;
        }
        const auto previous = previousWorkspaceByHandle.find(task.handleKey);
        if (previous == previousWorkspaceByHandle.end()) {
          continue;
        }
        (void)assignMatch(task, true, [&](const WorkspaceWindowAssignment& assignment) {
          return assignment.workspaceKey == previous->second;
        });
      }

      for (auto& task : nextTasks) {
        if (!task.workspaceKey.empty() || !task.exactWindowId.empty()) {
          continue;
        }
        const auto previous = previousWorkspaceByHandle.find(task.handleKey);
        if (previous == previousWorkspaceByHandle.end()) {
          continue;
        }
        (void)assignMatch(task, false, [&](const WorkspaceWindowAssignment& assignment) {
          return assignment.workspaceKey == previous->second;
        });
      }

      for (auto& task : nextTasks) {
        if (!task.workspaceKey.empty()) {
          continue;
        }
        (void)assignMatch(task, true, [](const WorkspaceWindowAssignment&) { return true; });
      }

      for (auto& task : nextTasks) {
        if (!task.workspaceKey.empty() || !task.exactWindowId.empty()) {
          continue;
        }
        // Real compositor window id already present in assignments — leave it alone.
        if (assignmentIndexForWindowId(task.workspaceWindowId).has_value()) {
          continue;
        }

        std::optional<std::size_t> matchIndex;
        for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
          if (used[i]) {
            continue;
          }
          const auto& assignment = workspaceAssignments[i];
          if (!matchesApp(task, assignment)) {
            continue;
          }
          const auto previous = previousWorkspaceByHandle.find(task.handleKey);
          if (previous != previousWorkspaceByHandle.end()
              && assignment.workspaceKey != previous->second
              && isTransientWorkspace(assignment.workspaceKey)) {
            continue;
          }
          if (matchIndex.has_value()) {
            matchIndex = std::nullopt;
            break;
          }
          matchIndex = i;
        }

        if (matchIndex.has_value()) {
          task.workspaceKey = workspaceAssignments[*matchIndex].workspaceKey;
          task.workspaceWindowId = workspaceAssignments[*matchIndex].windowId;
          task.workspaceOrder = *matchIndex;
          used[*matchIndex] = true;
        }
      }

      for (auto& task : nextTasks) {
        if (!task.exactWindowId.empty()
            || task.workspaceKey.empty()
            || task.workspaceOrder != std::numeric_limits<std::uint64_t>::max()) {
          continue;
        }

        for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
          if (used[i]) {
            continue;
          }
          const auto& assignment = workspaceAssignments[i];
          const std::string assignmentAppLower = toLower(assignment.appId);
          if (assignment.appId.empty()) {
            if (!isOrphanAppIdentity(
                    task.appId, task.appIdLower, task.idLower, task.startupWmClassLower, task.nameLower
                )) {
              continue;
            }
          } else if (
              assignmentAppLower != task.appIdLower
              && assignmentAppLower != task.idLower
              && assignmentAppLower != task.startupWmClassLower
              && assignmentAppLower != task.nameLower
          ) {
            continue;
          }
          if (assignment.workspaceKey != task.workspaceKey) {
            continue;
          }

          task.workspaceOrder = i;
          task.workspaceWindowId = assignment.windowId;
          used[i] = true;
          break;
        }
      }

      // Active indicator follows the focused compositor window id when bound.
      if (focusedCompositorWindowId.has_value()) {
        TaskModel* focusedTask = nullptr;
        for (auto& task : nextTasks) {
          const std::string_view activationWindowId = !task.exactWindowId.empty()
              ? std::string_view(task.exactWindowId)
              : std::string_view(task.workspaceWindowId);
          if (!activationWindowId.empty() && windowIdsMatch(activationWindowId, *focusedCompositorWindowId)) {
            focusedTask = &task;
            break;
          }
        }
        if (focusedTask != nullptr) {
          for (auto& task : nextTasks) {
            task.active = (&task == focusedTask);
          }
        }
      }

      // Rebuild workspaceOrder from assignment stream order every frame so
      // left/right reorders are reflected even when toplevel `order` is static.
      std::unordered_set<std::string> currentAssignmentWindowIds;
      currentAssignmentWindowIds.reserve(workspaceAssignments.size());
      for (const auto& assignment : workspaceAssignments) {
        if (!assignment.windowId.empty()) {
          currentAssignmentWindowIds.insert(assignment.windowId);
        }
      }
      for (auto& task : nextTasks) {
        task.workspaceOrder = std::numeric_limits<std::uint64_t>::max();
      }
      std::vector<bool> orderClaimed(nextTasks.size(), false);
      std::unordered_set<std::string> claimedWindowIds;
      for (std::size_t taskIndex = 0; taskIndex < nextTasks.size(); ++taskIndex) {
        auto& task = nextTasks[taskIndex];
        const std::string_view bindingWindowId = workspaceBindingWindowId(task);
        if (bindingWindowId.empty()) {
          continue;
        }
        for (std::size_t assignmentIndex = 0; assignmentIndex < workspaceAssignments.size(); ++assignmentIndex) {
          const auto& assignment = workspaceAssignments[assignmentIndex];
          if (assignment.windowId != bindingWindowId || (task.exactWindowId.empty() && !matchesApp(task, assignment))) {
            continue;
          }
          task.workspaceKey = assignment.workspaceKey;
          task.workspaceOrder = assignmentIndex;
          orderClaimed[taskIndex] = true;
          claimedWindowIds.insert(assignment.windowId);
          break;
        }
      }
      for (std::size_t assignmentIndex = 0; assignmentIndex < workspaceAssignments.size(); ++assignmentIndex) {
        const auto& assignment = workspaceAssignments[assignmentIndex];
        if (!assignment.windowId.empty() && claimedWindowIds.contains(assignment.windowId)) {
          continue;
        }
        const std::string assignmentAppLower = toLower(assignment.appId);

        auto appMatches = [&](const TaskModel& task) {
          if (assignment.appId.empty()) {
            return isOrphanAppIdentity(
                task.appId, task.appIdLower, task.idLower, task.startupWmClassLower, task.nameLower
            );
          }
          if (isOrphanAppIdentity(
                  task.appId, task.appIdLower, task.idLower, task.startupWmClassLower, task.nameLower
              )) {
            return false;
          }
          return assignmentAppLower == task.appIdLower
              || assignmentAppLower == task.idLower
              || assignmentAppLower == task.startupWmClassLower
              || assignmentAppLower == task.nameLower;
        };

        auto tryClaim = [&](bool requireWorkspace, bool requireTitle) -> bool {
          for (std::size_t i = 0; i < nextTasks.size(); ++i) {
            auto& task = nextTasks[i];
            if (orderClaimed[i] || !task.exactWindowId.empty() || !appMatches(task)) {
              continue;
            }
            if (!task.workspaceWindowId.empty() && !currentAssignmentWindowIds.contains(task.workspaceWindowId)) {
              continue;
            }
            if (requireWorkspace && task.workspaceKey != assignment.workspaceKey) {
              continue;
            }
            if (requireTitle && !assignment.title.empty() && assignment.title != task.title) {
              continue;
            }
            if (task.workspaceKey.empty()) {
              task.workspaceKey = assignment.workspaceKey;
            }
            task.workspaceWindowId = assignment.windowId;
            task.workspaceOrder = assignmentIndex;
            orderClaimed[i] = true;
            if (!assignment.windowId.empty()) {
              claimedWindowIds.insert(assignment.windowId);
            }
            return true;
          }
          return false;
        };

        if (tryClaim(true, true)) {
          continue;
        }
        if (tryClaim(true, false)) {
          continue;
        }
        if (tryClaim(false, true)) {
          continue;
        }
        (void)tryClaim(false, false);
      }
    }

    if (compositors::isHyprland()) {
      std::unordered_set<std::string> representedWindowIds;
      representedWindowIds.reserve(nextTasks.size());
      for (const auto& task : nextTasks) {
        if (!task.workspaceWindowId.empty()) {
          representedWindowIds.insert(task.workspaceWindowId);
        }
      }

      for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
        const auto& assignment = workspaceAssignments[i];
        if (assignment.appId.empty()) {
          continue;
        }
        if (!assignment.windowId.empty()) {
          if (representedWindowIds.contains(assignment.windowId)) {
            continue;
          }
          if (m_platform.isCompositorWindowIdKnown(assignment.windowId)) {
            continue;
          }
        }

        TaskModel task{};
        task.handleKey = syntheticAssignmentHandleKey(assignment, i);
        task.order = static_cast<std::uint64_t>(std::numeric_limits<std::int32_t>::max()) + i;
        task.appId = assignment.appId;
        task.idLower = toLower(task.appId);
        task.startupWmClassLower = task.idLower;
        task.nameLower = task.idLower;
        task.appIdLower = task.idLower;
        task.title = assignment.title;
        task.iconPath = resolveIconPath(task.appId, {});
        task.workspaceKey = assignment.workspaceKey;
        task.workspaceWindowId = assignment.windowId;
        task.workspaceOrder = i;
        nextTasks.push_back(std::move(task));
        if (!assignment.windowId.empty()) {
          representedWindowIds.insert(assignment.windowId);
        }
      }
    }

    // Compositor-agnostic: workspace windows with no app id become their own tasks.
    {
      std::unordered_set<std::string> representedWindowIds;
      representedWindowIds.reserve(nextTasks.size());
      std::unordered_set<std::uintptr_t> representedHandles;
      representedHandles.reserve(nextTasks.size());
      for (const auto& task : nextTasks) {
        if (!task.workspaceWindowId.empty()) {
          representedWindowIds.insert(task.workspaceWindowId);
        }
        representedHandles.insert(task.handleKey);
      }

      for (std::size_t i = 0; i < workspaceAssignments.size(); ++i) {
        const auto& assignment = workspaceAssignments[i];
        if (!assignment.appId.empty() || assignment.windowId.empty()) {
          continue;
        }
        // Same empty-title skip as the foreign-toplevel orphan path above.
        if (assignment.title.empty()) {
          continue;
        }
        if (representedWindowIds.contains(assignment.windowId)) {
          continue;
        }

        const std::uintptr_t handleKey = syntheticAssignmentHandleKey(assignment, i);
        if (!representedHandles.insert(handleKey).second) {
          continue;
        }

        TaskModel task{};
        task.handleKey = handleKey;
        task.order = static_cast<std::uint64_t>(std::numeric_limits<std::int32_t>::max()) + i;
        task.title = assignment.title;
        task.iconPath = resolveIconPath({}, {});
        task.workspaceKey = assignment.workspaceKey;
        task.workspaceWindowId = assignment.windowId;
        if (m_platform.hasExactWindowIdentity()) {
          task.exactWindowId = assignment.windowId;
          task.active = focusedCompositorWindowId.has_value() && assignment.windowId == *focusedCompositorWindowId;
        }
        task.workspaceOrder = i;
        nextTasks.push_back(std::move(task));
        representedWindowIds.insert(assignment.windowId);
      }
    }
  }

  if (workspaceAssignments.empty() && !runningByWorkspace.empty()) {
    std::unordered_map<std::uintptr_t, std::string> workspaceByHandle;
    std::unordered_map<std::string, std::size_t> appOccurrence;
    for (const auto& ws : nextWorkspaces) {
      const auto byKey = runningByWorkspace.find(ws.key);
      const auto byName = runningByWorkspace.find(ws.workspace.name);
      const auto byId = runningByWorkspace.find(ws.workspace.id);
      const auto* list = byKey != runningByWorkspace.end()
          ? &byKey->second
          : (byName != runningByWorkspace.end() ? &byName->second
                                                : (byId != runningByWorkspace.end() ? &byId->second : nullptr));
      if (list == nullptr) {
        continue;
      }
      for (const auto& appId : *list) {
        const std::string appLower = toLower(appId);
        const std::string startupWmClassLower = toLower(appId);
        const auto windows = m_platform.enrichedWindowsForApp(appLower, startupWmClassLower, topFilter);
        if (windows.empty()) {
          continue;
        }
        const std::size_t index = appOccurrence[appLower]++;
        if (index < windows.size()) {
          workspaceByHandle[reinterpret_cast<std::uintptr_t>(windows[index].handle)] = ws.key;
        }
      }
    }
    for (auto& task : nextTasks) {
      if (const auto it = workspaceByHandle.find(task.handleKey);
          task.workspaceKey.empty() && it != workspaceByHandle.end()) {
        task.workspaceKey = it->second;
      }
    }
  }

  std::unordered_map<std::uintptr_t, std::string> previousWorkspaceByHandle;
  previousWorkspaceByHandle.reserve(m_tasks.size());
  for (const auto& task : m_tasks) {
    if (!task.workspaceKey.empty()) {
      previousWorkspaceByHandle[task.handleKey] = task.workspaceKey;
    }
  }
  const bool hasStableWorkspaceWindowAssignments =
      std::ranges::any_of(workspaceAssignments, [](const WorkspaceWindowAssignment& assignment) {
        return !assignment.windowId.empty();
      });
  if (assignmentMode != TaskbarAssignmentMode::WorkspaceOccurrenceTitle && !hasStableWorkspaceWindowAssignments) {
    std::unordered_set<std::uintptr_t> seenHandles;
    seenHandles.reserve(nextTasks.size());
    for (auto& task : nextTasks) {
      seenHandles.insert(task.handleKey);
      const auto previous = previousWorkspaceByHandle.find(task.handleKey);
      if (previous == previousWorkspaceByHandle.end() || previous->second.empty() || task.workspaceKey.empty()) {
        m_pendingWorkspaceTransitions.erase(task.handleKey);
        continue;
      }
      if (task.workspaceKey == previous->second) {
        m_pendingWorkspaceTransitions.erase(task.handleKey);
        continue;
      }

      auto& pending = m_pendingWorkspaceTransitions[task.handleKey];
      if (pending.targetWorkspaceKey != task.workspaceKey) {
        pending.targetWorkspaceKey = task.workspaceKey;
        pending.votes = 1;
      } else if (pending.votes < 255) {
        ++pending.votes;
      }

      if (pending.votes < 2) {
        task.workspaceKey = previous->second;
      } else {
        m_pendingWorkspaceTransitions.erase(task.handleKey);
      }
    }

    for (auto it = m_pendingWorkspaceTransitions.begin(); it != m_pendingWorkspaceTransitions.end();) {
      if (!seenHandles.contains(it->first)) {
        it = m_pendingWorkspaceTransitions.erase(it);
      } else {
        ++it;
      }
    }
  } else {
    m_pendingWorkspaceTransitions.clear();
    for (auto& task : nextTasks) {
      if (!task.workspaceKey.empty()) {
        continue;
      }
      const auto previous = previousWorkspaceByHandle.find(task.handleKey);
      if (previous != previousWorkspaceByHandle.end() && !previous->second.empty()) {
        task.workspaceKey = previous->second;
      }
    }
  }

  if (m_onlyActiveWorkspace && !nextWorkspaces.empty()) {
    std::unordered_set<std::string> activeKeys;
    activeKeys.reserve(nextWorkspaces.size() * 3);
    for (const auto& wsm : nextWorkspaces) {
      if (wsm.workspace.active) {
        activeKeys.insert(wsm.key);
        if (!wsm.workspace.id.empty()) {
          activeKeys.insert(wsm.workspace.id);
        }
        if (!wsm.workspace.name.empty()) {
          activeKeys.insert(wsm.workspace.name);
        }
      }
    }
    if (!activeKeys.empty()) {
      std::erase_if(nextTasks, [&activeKeys](const TaskModel& t) {
        return !t.workspaceKey.empty() && !activeKeys.contains(t.workspaceKey);
      });
      if (m_groupByWorkspace) {
        std::erase_if(nextWorkspaces, [](const WorkspaceModel& wsm) { return !wsm.workspace.active; });
      }
    }
  }

  if (m_groupByWorkspace && m_hideEmptyWorkspaces && !nextWorkspaces.empty()) {
    m_allWorkspaces = nextWorkspaces;
    const auto workspaceHasTask = [](const WorkspaceModel& wsm, const std::vector<TaskModel>& tasks) {
      for (const auto& t : tasks) {
        if (taskInWorkspaceGroup(t, wsm)) {
          return true;
        }
      }
      return false;
    };
    std::erase_if(nextWorkspaces, [&](const WorkspaceModel& wsm) {
      return !wsm.workspace.active && !workspaceHasTask(wsm, nextTasks);
    });
  } else {
    m_allWorkspaces.clear();
  }

  if (!m_groupByWorkspace) {
    nextWorkspaces.clear();
    m_allWorkspaces.clear();
    std::ranges::stable_sort(nextTasks, [](const TaskModel& a, const TaskModel& b) {
      if (a.workspaceOrder != b.workspaceOrder) {
        return a.workspaceOrder < b.workspaceOrder;
      }
      if (a.order != b.order) {
        return a.order < b.order;
      }
      return a.handleKey < b.handleKey;
    });
    applyPinnedMerge(nextTasks);
  }

  const ModelComparison comparison =
      compareModels(m_groupByWorkspace, m_tasks, m_workspaces, nextTasks, nextWorkspaces);
  if (comparison.layoutEqual) {
    m_tasks = std::move(nextTasks);
    m_workspaces = std::move(nextWorkspaces);
    if (comparison.titlesChanged || comparison.activesChanged) {
      bool textChanged = false;
      for (const TaskTile& tile : m_taskTiles) {
        if (tile.taskIndex >= m_tasks.size()) {
          continue;
        }
        const TaskModel& task = m_tasks[tile.taskIndex];
        if (comparison.titlesChanged) {
          if (tile.titleLabel != nullptr && tile.titleLabel->setText(task.title)) {
            textChanged = true;
          }
          if (tile.area != nullptr) {
            tile.area->requestTooltipRefresh();
          }
        }
        if (comparison.activesChanged) {
          if (tile.area != nullptr) {
            float opacity = task.active ? m_activeOpacity : m_inactiveOpacity;
            if (task.pinned && !task.running) {
              opacity *= m_pinnedOpacity;
            }
            tile.area->setOpacity(opacity);
          }
          if (tile.activeIndicator != nullptr) {
            tile.activeIndicator->setVisible(task.active);
          }
        }
      }
      if (textChanged && root() != nullptr) {
        root()->markLayoutDirty();
      }
      if (comparison.activesChanged) {
        requestRedraw();
      }
    }
    return;
  }
  ++m_taskGeneration;
  m_tasks = std::move(nextTasks);
  m_workspaces = std::move(nextWorkspaces);
  m_rebuildPending = true;
  if (root() != nullptr) {
    root()->markLayoutDirty();
  }
}

bool TaskbarWidget::onPointerEvent(const PointerEvent& event) {
  if (m_contextMenuPopup == nullptr || !m_contextMenuPopup->isOpen()) {
    return false;
  }
  const bool consumed = m_contextMenuPopup->onPointerEvent(event);
  if (!consumed && event.type == PointerEvent::Type::Button && event.pressed) {
    m_contextMenuPopup->close();
    return true;
  }
  return consumed;
}

void TaskbarWidget::openTaskContextMenu(const TaskModel& task, InputArea& area) {
  auto* renderContext = PanelManager::instance().renderContext();
  if (renderContext == nullptr) {
    return;
  }

  wl_surface* pointerSurface = m_platform.lastPointerSurface();
  auto* layerSurface = m_platform.layerSurfaceFor(pointerSurface);
  if (layerSurface == nullptr) {
    return;
  }

  const auto windows = m_platform.enrichedWindowsForApp(task.idLower, task.startupWmClassLower, toplevelOutputFilter());
  m_contextMenuHandles.clear();
  m_contextMenuInfoWindows.clear();
  m_contextMenuPrimaryHandle = task.firstHandle;
  m_contextMenuInfoPrimary = {};

  const bool kde = compositors::isKde();
  const bool infoClose = kde || !task.exactWindowId.empty();
  if (infoClose) {
    m_contextMenuInfoWindows = windows;
    m_contextMenuInfoPrimary = ToplevelInfo{
        .title = task.title,
        .appId = task.appId,
        .identifier = !task.exactWindowId.empty() ? task.exactWindowId : task.workspaceWindowId,
        .handle = !task.exactWindowId.empty() ? nullptr : task.firstHandle,
        .exactIdentity = !task.exactWindowId.empty(),
    };
    for (const auto& window : m_contextMenuInfoWindows) {
      if (!m_contextMenuInfoPrimary.identifier.empty() && window.identifier == m_contextMenuInfoPrimary.identifier) {
        m_contextMenuInfoPrimary = window;
        break;
      }
      if (task.firstHandle != nullptr && window.handle == task.firstHandle) {
        m_contextMenuInfoPrimary = window;
        break;
      }
    }
  } else {
    m_contextMenuHandles.reserve(windows.size());
    for (const auto& window : windows) {
      if (window.handle != nullptr) {
        m_contextMenuHandles.push_back(window.handle);
      }
    }
  }

  std::vector<DesktopAction> entryActions;
  std::string entryAppName = task.idLower.empty() ? task.appId : task.idLower;
  std::string entryWorkingDir;
  bool entryTerminal = false;
  std::optional<DesktopEntry> menuEntry = desktopEntryForTask(task);
  if (menuEntry.has_value()) {
    entryActions = menuEntry->actions;
    entryAppName = menuEntry->id.empty() ? menuEntry->name : menuEntry->id;
    entryWorkingDir = menuEntry->workingDir;
    entryTerminal = menuEntry->terminal;
  } else {
    const auto& entriesIndex = desktopEntries();
    for (const auto& entry : entriesIndex) {
      if (entry.idLower == task.idLower
          || entry.idLower == task.appIdLower
          || entry.startupWmClassLower == task.idLower
          || entry.startupWmClassLower == task.startupWmClassLower
          || entry.nameLower == task.nameLower) {
        entryActions = entry.actions;
        entryAppName = entry.id.empty() ? entry.name : entry.id;
        entryWorkingDir = entry.workingDir;
        entryTerminal = entry.terminal;
        menuEntry = entry;
        break;
      }
    }
  }

  const auto infoCanClose = [](const ToplevelInfo& window) {
    return !window.identifier.empty() || !window.title.empty() || !window.appId.empty();
  };
  const bool showClose = task.running
      && (infoClose ? (infoCanClose(m_contextMenuInfoPrimary) || !m_contextMenuInfoWindows.empty())
                    : !m_contextMenuHandles.empty());
  const bool closePrimaryEnabled =
      infoClose ? infoCanClose(m_contextMenuInfoPrimary) : m_contextMenuPrimaryHandle != nullptr;
  const std::size_t closeAllCount = infoClose
      ? (m_contextMenuInfoWindows.empty() ? (closePrimaryEnabled ? 1U : 0U) : m_contextMenuInfoWindows.size())
      : m_contextMenuHandles.size();

  const bool pinAvailable = !m_groupByWorkspace && menuEntry.has_value() && !menuEntry->id.empty();
  const bool isPinned = pinAvailable && shell::dock::pinned_apps::containsEntry(pinnedConfigIds(), *menuEntry);

  // IDs 0..N-1 => desktop actions, -1 => close single, -2 => close all, -4 => pin toggle.
  std::vector<ContextMenuControlEntry> entries;
  entries.reserve(entryActions.size() + 5);
  if (pinAvailable) {
    entries.push_back(
        ContextMenuControlEntry{
            .id = -4,
            .label = i18n::tr(isPinned ? "tray.menu.unpin" : "tray.menu.pin"),
            .enabled = true,
            .separator = false,
            .hasSubmenu = false,
        }
    );
  }
  for (std::int32_t i = 0; i < static_cast<std::int32_t>(entryActions.size()); ++i) {
    entries.push_back(
        ContextMenuControlEntry{
            .id = i,
            .label = entryActions[static_cast<std::size_t>(i)].name,
            .enabled = true,
            .separator = false,
            .hasSubmenu = false,
        }
    );
  }
  if (showClose) {
    if (!entries.empty()) {
      entries.push_back(
          ContextMenuControlEntry{.id = -3, .label = {}, .enabled = false, .separator = true, .hasSubmenu = false}
      );
    }
    entries.push_back(
        ContextMenuControlEntry{
            .id = -1,
            .label = i18n::tr("dock.actions.close"),
            .enabled = closePrimaryEnabled,
            .separator = false,
            .hasSubmenu = false,
        }
    );
    if (closeAllCount > 1) {
      entries.push_back(
          ContextMenuControlEntry{
              .id = -2,
              .label = i18n::tr("dock.actions.close-all"),
              .enabled = true,
              .separator = false,
              .hasSubmenu = false,
          }
      );
    }
  }

  if (entries.empty()) {
    return;
  }

  if (m_contextMenuPopup == nullptr) {
    m_contextMenuPopup = std::make_unique<ContextMenuPopup>(m_platform.wayland(), *renderContext);
  }
  m_contextMenuPopup->setShadowConfig(m_configService.config().shell.shadow);
  m_contextMenuPopup->setOnActivate([this, entryActions, entryAppName, entryWorkingDir, entryTerminal, menuEntry,
                                     isPinned, infoClose](const ContextMenuControlEntry& entry) {
    if (entry.id == -4) {
      if (menuEntry.has_value()) {
        setEntryPinned(*menuEntry, !isPinned);
      }
      return;
    }
    if (entry.id >= 0) {
      const auto idx = static_cast<std::size_t>(entry.id);
      if (idx < entryActions.size()) {
        const auto& action = entryActions[idx];
        auto& platform = m_platform;
        auto& configService = m_configService;
        const bool dbusActivatable = menuEntry.has_value() && menuEntry->dbusActivatable;
        DeferredCall::callLater([action, appName = entryAppName, workingDir = entryWorkingDir, terminal = entryTerminal,
                                 dbusActivatable, &platform, &configService]() {
          std::string token;
          if (platform.hasXdgActivation()) {
            token = platform.requestActivationToken(nullptr);
          }
          (void)desktop_entry_launch::launchAction(
              action, appName, workingDir, terminal,
              desktop_entry_launch::LaunchOptions{
                  .activationToken = std::move(token),
                  .runAsSystemdService = configService.config().shell.launchAppsAsSystemdServices,
                  .customCommand = configService.config().shell.launchAppsCustomCommand,
                  .dbusActivatable = dbusActivatable,
                  .dbusAppId = appName,
              }
          );
        });
      }
      return;
    }
    if (entry.id == -1) {
      if (infoClose) {
        m_platform.closeToplevelInfo(m_contextMenuInfoPrimary);
      } else if (m_contextMenuPrimaryHandle != nullptr) {
        m_platform.closeToplevel(m_contextMenuPrimaryHandle);
      }
      return;
    }
    if (entry.id == -2) {
      if (infoClose) {
        if (!m_contextMenuInfoWindows.empty()) {
          for (const auto& window : m_contextMenuInfoWindows) {
            m_platform.closeToplevelInfo(window);
          }
        } else {
          m_platform.closeToplevelInfo(m_contextMenuInfoPrimary);
        }
      } else {
        for (auto* handle : m_contextMenuHandles) {
          if (handle != nullptr) {
            m_platform.closeToplevel(handle);
          }
        }
      }
    }
  });

  float absX = 0.0F;
  float absY = 0.0F;
  Node::absolutePosition(&area, absX, absY);
  const float anchorInset = std::round(std::max(6.0F, Style::spaceSm * m_contentScale));
  float anchorX = absX + anchorInset;
  float anchorY = absY + anchorInset;
  float anchorW = std::max(1.0F, area.width() - (anchorInset * 2.0F));
  float anchorH = std::max(1.0F, area.height() - (anchorInset * 2.0F));

  constexpr float kTaskMenuWidth = 240.0F;
  const std::int32_t gap = std::max(2, static_cast<std::int32_t>(std::lround(Style::spaceMd * m_contentScale)));

  std::optional<ContextMenuPopupPlacement> placement;
  if (m_barPosition == "top") {
    anchorY = absY + area.height() + static_cast<float>(gap);
    anchorH = 1.0F;
  } else if (m_barPosition == "bottom") {
    // Mirror top: gap from the task tile edge, not the pointer (tray still uses pointer-centered icons).
    anchorX = absX;
    anchorY = absY;
    anchorW = area.width();
    anchorH = 1.0F;
    placement = ContextMenuPopupPlacement{
        .anchor = XDG_POSITIONER_ANCHOR_TOP,
        .gravity = XDG_POSITIONER_GRAVITY_TOP,
        .offsetX = 0,
        .offsetY = -gap,
        .chromeAttachment = popup_chrome::Attachment{
            .horizontal = popup_chrome::HorizontalAttachment::Center,
            .vertical = popup_chrome::VerticalAttachment::Bottom
        },
    };
  } else if (m_barPosition == "left") {
    // Gravity-based placement instead of a width-derived anchor offset, so the menu can auto-size.
    placement = ContextMenuPopupPlacement{
        .anchor = XDG_POSITIONER_ANCHOR_RIGHT,
        .gravity = XDG_POSITIONER_GRAVITY_RIGHT,
        .offsetX = gap,
        .offsetY = 0,
        .chromeAttachment = popup_chrome::Attachment{
            .horizontal = popup_chrome::HorizontalAttachment::Left,
            .vertical = popup_chrome::VerticalAttachment::Center,
        },
    };
  } else if (m_barPosition == "right") {
    placement = ContextMenuPopupPlacement{
        .anchor = XDG_POSITIONER_ANCHOR_LEFT,
        .gravity = XDG_POSITIONER_GRAVITY_LEFT,
        .offsetX = -gap,
        .offsetY = 0,
        .chromeAttachment = popup_chrome::Attachment{
            .horizontal = popup_chrome::HorizontalAttachment::Right,
            .vertical = popup_chrome::VerticalAttachment::Center,
        },
    };
  }

  m_contextMenuPopup->open(
      ContextMenuPopupRequest{
          .entries = std::move(entries),
          .minMenuWidth = kTaskMenuWidth * m_contentScale,
          .maxMenuWidth = Style::menuAutoMaxWidth * m_contentScale,
          .maxVisible = 12,
          .anchor =
              PopupAnchorRect{
                  .x = static_cast<std::int32_t>(std::round(anchorX)),
                  .y = static_cast<std::int32_t>(std::round(anchorY)),
                  .width = static_cast<std::int32_t>(std::round(anchorW)),
                  .height = static_cast<std::int32_t>(std::round(anchorH)),
              },
          .parent =
              PopupSurfaceParent{
                  .layerSurface = layerSurface,
                  .output = m_output,
                  .wlSurface = pointerSurface,
              },
          .placement = placement,
      }
  );
}

std::string TaskbarWidget::toLower(std::string value) { return StringUtils::toLower(std::move(value)); }

std::string TaskbarWidget::workspaceLabel(const Workspace& workspace, std::size_t index) {
  const auto parseLeadingNumber = [](const std::string& value) -> std::optional<std::size_t> {
    if (value.empty() || !std::isdigit(static_cast<unsigned char>(value.front()))) {
      return std::nullopt;
    }
    std::size_t parsed = 0;
    std::size_t i = 0;
    while (i < value.size() && std::isdigit(static_cast<unsigned char>(value[i]))) {
      parsed = parsed * 10 + static_cast<std::size_t>(value[i] - '0');
      ++i;
    }
    return parsed > 0 ? std::optional<std::size_t>(parsed) : std::nullopt;
  };

  if (const auto id = parseLeadingNumber(workspace.id); id.has_value()) {
    return std::to_string(*id);
  }
  if (const auto name = parseLeadingNumber(workspace.name); name.has_value()) {
    return std::to_string(*name);
  }
  if (!workspace.name.empty()) {
    return workspace.name;
  }
  if (!workspace.id.empty()) {
    return workspace.id;
  }
  if (!workspace.coordinates.empty()) {
    return std::to_string(static_cast<std::size_t>(workspace.coordinates.front()) + 1U);
  }
  return std::to_string(index + 1);
}

TaskbarWidget::ModelComparison TaskbarWidget::compareModels(
    bool groupByWorkspace, const std::vector<TaskModel>& previousTasks,
    const std::vector<WorkspaceModel>& previousWorkspaces, const std::vector<TaskModel>& nextTasks,
    const std::vector<WorkspaceModel>& nextWorkspaces
) {
  if (nextTasks.size() != previousTasks.size() || nextWorkspaces.size() != previousWorkspaces.size()) {
    return {};
  }
  bool titlesChanged = false;
  bool activesChanged = false;
  for (std::size_t i = 0; i < nextTasks.size(); ++i) {
    const bool titleChanged = nextTasks[i].title != previousTasks[i].title;
    const bool activeChanged = nextTasks[i].active != previousTasks[i].active;
    titlesChanged = titlesChanged || titleChanged;
    activesChanged = activesChanged || activeChanged;
    if (nextTasks[i].appId != previousTasks[i].appId
        || nextTasks[i].iconPath != previousTasks[i].iconPath
        || nextTasks[i].firstHandle != previousTasks[i].firstHandle
        || (groupByWorkspace
            && (activeChanged
                || nextTasks[i].workspaceKey != previousTasks[i].workspaceKey
                || nextTasks[i].workspaceWindowId != previousTasks[i].workspaceWindowId
                || nextTasks[i].exactWindowId != previousTasks[i].exactWindowId))
        || nextTasks[i].order != previousTasks[i].order
        || nextTasks[i].workspaceOrder != previousTasks[i].workspaceOrder
        || nextTasks[i].handleKey != previousTasks[i].handleKey
        || nextTasks[i].pinned != previousTasks[i].pinned
        || nextTasks[i].running != previousTasks[i].running
        || nextTasks[i].instanceCount != previousTasks[i].instanceCount
        || nextTasks[i].desktopEntryId != previousTasks[i].desktopEntryId) {
      return {};
    }
  }
  for (std::size_t i = 0; i < nextWorkspaces.size(); ++i) {
    const auto& a = nextWorkspaces[i].workspace;
    const auto& b = previousWorkspaces[i].workspace;
    if (a.id != b.id
        || a.name != b.name
        || a.active != b.active
        || a.urgent != b.urgent
        || a.occupied != b.occupied
        || nextWorkspaces[i].key != previousWorkspaces[i].key
        || nextWorkspaces[i].label != previousWorkspaces[i].label
        || nextWorkspaces[i].hostOutput != previousWorkspaces[i].hostOutput) {
      return {};
    }
  }
  return {.layoutEqual = true, .titlesChanged = titlesChanged, .activesChanged = activesChanged};
}

void TaskbarWidget::buildDesktopIconIndex() {
  m_appIconsByLower.clear();
  const auto& entries = desktopEntries();
  for (const auto& entry : entries) {
    if (entry.icon.empty()) {
      continue;
    }
    if (!entry.id.empty()) {
      m_appIconsByLower[toLower(entry.id)] = entry.icon;
    }
    if (!entry.startupWmClass.empty()) {
      m_appIconsByLower[toLower(entry.startupWmClass)] = entry.icon;
    }
    if (!entry.nameLower.empty()) {
      m_appIconsByLower[entry.nameLower] = entry.icon;
    }
  }
  m_desktopEntriesVersion = desktopEntriesVersion();
}

std::string TaskbarWidget::resolveIconPath(const std::string& appId, const std::string& iconNameOrPath) {
  const int iconTargetSize =
      std::max(1, static_cast<int>(std::round(Style::baseGlyphSize * m_configOptions.iconScale * m_contentScale)));

  auto resolveIconName = [this, iconTargetSize](const std::string& name) -> std::string {
    if (name.empty()) {
      return {};
    }
    return m_iconResolver.resolve(name, iconTargetSize);
  };

  if (!iconNameOrPath.empty()) {
    if (const std::string primary = resolveIconName(iconNameOrPath); !primary.empty()) {
      return primary;
    }
  }

  if (appId.starts_with("steam_app_")) {
    if (const auto entry = app_identity::findDesktopEntry(appId, desktopEntries());
        entry.has_value() && !entry->icon.empty()) {
      if (const std::string steamIcon = resolveIconName(entry->icon); !steamIcon.empty()) {
        return steamIcon;
      }
    }
  }

  if (const auto internal = internal_apps::metadataForAppId(appId); internal.has_value()) {
    return internal->iconPath;
  }

  const std::string appIdLower = toLower(appId);
  const auto it = m_appIconsByLower.find(appIdLower);
  if (it != m_appIconsByLower.end()) {
    if (const std::string desktopIcon = resolveIconName(it->second); !desktopIcon.empty()) {
      return desktopIcon;
    }
  }
  if (!appId.empty()) {
    if (const std::string appIcon = resolveIconName(appId); !appIcon.empty()) {
      return appIcon;
    }
  }
  return m_iconResolver.resolve("application-x-executable", iconTargetSize);
}

bool TaskbarWidget::activeWorkspaceIndex(std::size_t& index) const {
  const auto& workspaces = navigationWorkspaces();
  if (workspaces.empty()) {
    return false;
  }

  // Try to find the workspace of the globally active task first
  for (const auto& task : m_tasks) {
    if (task.active) {
      for (std::size_t i = 0; i < workspaces.size(); ++i) {
        if (taskInWorkspaceGroup(task, workspaces[i])) {
          index = i;
          return true;
        }
      }
      break; // Found active task, but it doesn't belong to any workspace we have
    }
  }

  // Fallback to the active workspace on the current output
  for (std::size_t i = 0; i < workspaces.size(); ++i) {
    if (workspaces[i].workspace.active && workspaces[i].hostOutput == m_output) {
      index = i;
      return true;
    }
  }

  // Fallback to any active workspace
  for (std::size_t i = 0; i < workspaces.size(); ++i) {
    if (workspaces[i].workspace.active) {
      index = i;
      return true;
    }
  }
  return false;
}

const std::vector<TaskbarWidget::WorkspaceModel>& TaskbarWidget::navigationWorkspaces() const noexcept {
  return (m_hideEmptyWorkspaces && !m_allWorkspaces.empty()) ? m_allWorkspaces : m_workspaces;
}

void TaskbarWidget::activateAdjacentWorkspace(int direction) {
  const auto& workspaces = navigationWorkspaces();
  if (!m_groupByWorkspace || workspaces.empty() || direction == 0) {
    return;
  }

  std::size_t targetIndex = 0;
  std::size_t current = 0;
  if (!activeWorkspaceIndex(current)) {
    targetIndex = direction > 0 ? 0 : (workspaces.size() - 1);
  } else if (direction > 0) {
    if (current + 1 >= workspaces.size()) {
      return;
    }
    targetIndex = current + 1;
  } else {
    if (current == 0) {
      return;
    }
    targetIndex = current - 1;
  }

  const auto& targetWs = workspaces[targetIndex];
  m_platform.activateWorkspace(workspaceHostOutput(targetWs), targetWs.workspace);
}

void TaskbarWidget::cycleAdjacent(int direction) {
  if (m_groupByWorkspace) {
    activateAdjacentWorkspace(direction);
  } else {
    activateAdjacentTask(direction);
  }
}

void TaskbarWidget::activateAdjacentTask(int direction) {
  if (m_tasks.size() < 2 || direction == 0) {
    return;
  }

  const size_t activeTaskIndex =
      std::ranges::find_if(m_tasks, [](const TaskModel& t) { return t.active; }) - m_tasks.begin();
  if (activeTaskIndex >= m_tasks.size()) {
    return;
  }

  size_t newIndex = activeTaskIndex;
  for (size_t tries = 0; tries < m_tasks.size(); ++tries) {
    if (direction > 0) {
      if (newIndex + 1 >= m_tasks.size()) {
        return;
      }
      ++newIndex;
    } else {
      if (newIndex == 0) {
        return;
      }
      --newIndex;
    }
    const auto& candidate = m_tasks[newIndex];
    if (!candidate.running && candidate.pinned) {
      continue;
    }
    if (candidate.pinned) {
      activateOrLaunchPinned(candidate);
    } else {
      activateTaskModel(candidate);
    }
    return;
  }
}

wl_output* TaskbarWidget::toplevelOutputFilter() const noexcept { return m_showAllOutputs ? nullptr : m_output; }

bool TaskbarWidget::useMultiOutputWorkspaceKeys() const noexcept {
  if (!m_showAllOutputs) {
    return false;
  }
  std::size_t n = 0;
  for (const auto& wo : m_platform.outputs()) {
    if (wo.output != nullptr) {
      ++n;
    }
  }
  return n > 1;
}

std::string TaskbarWidget::workspaceKeyPrefixForOutput(wl_output* out) const {
  if (!useMultiOutputWorkspaceKeys()) {
    return {};
  }
  std::string connector;
  if (const auto* info = m_platform.findOutputByWl(out); info != nullptr) {
    connector = info->connectorName;
  }
  if (!connector.empty()) {
    return connector + '\x1e';
  }
  return "display\x1e";
}

wl_output* TaskbarWidget::workspaceHostOutput(const WorkspaceModel& model) const noexcept {
  return model.hostOutput != nullptr ? model.hostOutput : m_output;
}

ColorSpec TaskbarWidget::workspaceFillColor(const Workspace& workspace) const {
  if (workspace.active) {
    if (m_activeUsesFocusedColor) {
      return m_focusedColor;
    }
    return m_occupiedColor;
  }
  if (workspace.urgent) {
    return m_urgentColor;
  }
  if (workspace.occupied) {
    return m_occupiedColor;
  }
  ColorSpec color = m_emptyColor;
  color.alpha *= 0.55F;
  return color;
}

bool TaskbarWidget::isFocusedOutput() const { return m_platform.preferredInteractiveOutput() == m_output; }

ColorSpec TaskbarWidget::workspaceTextColor(const Workspace& workspace) const {
  if (workspace.urgent) {
    return m_minimal ? m_urgentColor : readableColorForFill(m_urgentColor);
  }
  if (!m_minimal) {
    return readableColorForFill(workspaceFillColor(workspace));
  }
  if (workspace.active) {
    if (m_activeUsesFocusedColor) {
      return m_focusedColor;
    }
    return m_occupiedColor;
  }
  if (workspace.occupied) {
    return m_occupiedColor;
  }
  ColorSpec color = widgetForegroundOr(colorSpecFromRole(ColorRole::OnSurfaceVariant));
  color.alpha *= 0.55F;
  return color;
}

ColorRole TaskbarWidget::onRoleForFill(ColorRole fill) {
  switch (fill) {
  case ColorRole::Primary:
    return ColorRole::OnPrimary;
  case ColorRole::Secondary:
    return ColorRole::OnSecondary;
  case ColorRole::Tertiary:
    return ColorRole::OnTertiary;
  case ColorRole::Error:
    return ColorRole::OnError;
  case ColorRole::Surface:
  case ColorRole::SurfaceVariant:
  case ColorRole::Outline:
  case ColorRole::Shadow:
  case ColorRole::Hover:
  case ColorRole::OnPrimary:
  case ColorRole::OnSecondary:
  case ColorRole::OnTertiary:
  case ColorRole::OnError:
  case ColorRole::OnSurface:
  case ColorRole::OnSurfaceVariant:
  case ColorRole::OnHover:
    return ColorRole::OnSurface;
  }
  return ColorRole::OnSurface;
}

ColorSpec TaskbarWidget::readableColorForFill(const ColorSpec& fill) {
  if (fill.role.has_value()) {
    return colorSpecFromRole(onRoleForFill(*fill.role));
  }
  return fixedColorSpec(readableTextColorForBackground(resolveColorSpec(fill)));
}
