Skip to content

Commit 08827a8

Browse files
committed
feat(control-center): group audio device menu by shared label prefix and size popup to content
1 parent 3b1eff5 commit 08827a8

5 files changed

Lines changed: 180 additions & 21 deletions

File tree

src/shell/control_center/tabs/audio_tab.cpp

Lines changed: 126 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
#include <format>
2727
#include <memory>
2828
#include <ranges>
29+
#include <span>
2930
#include <string>
31+
#include <string_view>
32+
#include <tuple>
3033
#include <unordered_map>
3134
#include <utility>
3235
#include <vector>
@@ -739,6 +742,115 @@ namespace {
739742
return out;
740743
}
741744

745+
std::vector<std::string_view> deviceLabelTokens(std::string_view label) {
746+
std::vector<std::string_view> tokens;
747+
std::size_t pos = 0;
748+
while (pos < label.size()) {
749+
while (pos < label.size() && label[pos] == ' ') {
750+
++pos;
751+
}
752+
const std::size_t start = pos;
753+
while (pos < label.size() && label[pos] != ' ') {
754+
++pos;
755+
}
756+
if (pos > start) {
757+
tokens.push_back(label.substr(start, pos - start));
758+
}
759+
}
760+
return tokens;
761+
}
762+
763+
std::size_t commonTokenCount(std::span<const std::string_view> a, std::span<const std::string_view> b) {
764+
const std::size_t limit = std::min(a.size(), b.size());
765+
std::size_t count = 0;
766+
while (count < limit && a[count] == b[count]) {
767+
++count;
768+
}
769+
return count;
770+
}
771+
772+
std::string joinTokens(std::span<const std::string_view> tokens) {
773+
std::string out;
774+
for (const std::string_view token : tokens) {
775+
if (!out.empty()) {
776+
out.push_back(' ');
777+
}
778+
out.append(token);
779+
}
780+
return out;
781+
}
782+
783+
struct DeviceMenuItem {
784+
std::uint32_t id = 0;
785+
std::string label;
786+
bool selected = false;
787+
};
788+
789+
// PipeWire sink/source descriptions are "card + profile/port" concatenations, so a multi-port card
790+
// yields several near-identical labels. Fold labels sharing a substantial token prefix under one
791+
// non-interactive header so each entry carries only its distinguishing tail.
792+
std::vector<ContextMenuControlEntry> buildDeviceMenuEntries(std::vector<DeviceMenuItem> items) {
793+
constexpr std::size_t kMinSharedTokens = 2;
794+
795+
std::ranges::sort(items, [](const DeviceMenuItem& a, const DeviceMenuItem& b) {
796+
return std::tie(a.label, a.id) < std::tie(b.label, b.id);
797+
});
798+
799+
std::vector<std::vector<std::string_view>> tokens;
800+
tokens.reserve(items.size());
801+
for (const DeviceMenuItem& item : items) {
802+
tokens.push_back(deviceLabelTokens(item.label));
803+
}
804+
805+
auto deviceEntry = [](const DeviceMenuItem& item, std::string label) {
806+
return ContextMenuControlEntry{
807+
.id = static_cast<std::int32_t>(item.id),
808+
.label = std::move(label),
809+
.radio = true,
810+
.toggleState = item.selected ? 1 : 0,
811+
.ellipsize = TextEllipsize::Middle,
812+
};
813+
};
814+
815+
std::vector<ContextMenuControlEntry> entries;
816+
entries.reserve(items.size());
817+
std::size_t i = 0;
818+
while (i < items.size()) {
819+
// Grow the run while the shared token prefix stays substantial.
820+
std::size_t shared = tokens[i].size();
821+
std::size_t j = i + 1;
822+
while (j < items.size()) {
823+
const std::size_t common = commonTokenCount(std::span(tokens[i]).first(shared), tokens[j]);
824+
if (common < kMinSharedTokens) {
825+
break;
826+
}
827+
shared = common;
828+
++j;
829+
}
830+
// A member whose whole label is the shared prefix would fold to an empty entry; keep such
831+
// runs unfolded (the first item is emitted alone and grouping restarts at the next one).
832+
bool foldable = j - i >= 2;
833+
for (std::size_t k = i; foldable && k < j; ++k) {
834+
foldable = tokens[k].size() > shared;
835+
}
836+
if (foldable) {
837+
entries.push_back({
838+
.label = joinTokens(std::span(tokens[i]).first(shared)),
839+
.header = true,
840+
.ellipsize = TextEllipsize::Middle,
841+
});
842+
for (std::size_t k = i; k < j; ++k) {
843+
entries.push_back(deviceEntry(items[k], joinTokens(std::span(tokens[k]).subspan(shared))));
844+
}
845+
i = j;
846+
} else {
847+
entries.push_back(deviceEntry(items[i], items[i].label));
848+
++i;
849+
}
850+
}
851+
return entries;
852+
}
853+
742854
class AudioDeviceRow : public Flex {
743855
public:
744856
explicit AudioDeviceRow(float scale, std::function<void()> onSelect) : m_onSelect(std::move(onSelect)) {
@@ -1323,19 +1435,16 @@ void AudioTab::openDeviceMenu(DeviceVolumeCardState& card, const DeviceMenuModel
13231435
const AudioState& state = m_audio->state();
13241436

13251437
const std::uint32_t defaultDeviceId = menu.defaultDeviceId(state);
1326-
auto entries = availableDevices(menu.devices(state), defaultDeviceId)
1438+
auto items = availableDevices(menu.devices(state), defaultDeviceId)
13271439
| std::views::transform([&](const AudioNode& node) {
1328-
const std::string_view selectedPrefix = node.id == defaultDeviceId ? "" : "";
1329-
return ContextMenuControlEntry{
1330-
.id = static_cast<std::int32_t>(node.id),
1331-
.label = std::format("{}{}", selectedPrefix, audioDeviceLabel(node)),
1332-
.enabled = true,
1333-
.separator = false,
1334-
.hasSubmenu = false,
1335-
.ellipsize = TextEllipsize::Middle,
1336-
};
1337-
})
1440+
return DeviceMenuItem{
1441+
.id = node.id,
1442+
.label = audioDeviceLabel(node),
1443+
.selected = node.id == defaultDeviceId,
1444+
};
1445+
})
13381446
| std::ranges::to<std::vector>();
1447+
auto entries = buildDeviceMenuEntries(std::move(items));
13391448

13401449
if (card.menuAnchor == nullptr) {
13411450
return;
@@ -1350,8 +1459,11 @@ void AudioTab::openDeviceMenu(DeviceVolumeCardState& card, const DeviceMenuModel
13501459
float anchorAbsY = 0.0f;
13511460
Node::absolutePosition(card.menuAnchor, anchorAbsX, anchorAbsY);
13521461

1462+
// Size the popup to its entries: at least the old card-bound width, but free to grow past the
1463+
// half-width card so long device names stay readable.
13531464
const float scale = contentScale();
1354-
const float menuWidth = std::min(280.0f * scale, card.menuAnchor->width());
1465+
const float minMenuWidth = std::min(280.0f * scale, card.menuAnchor->width());
1466+
const float maxMenuWidth = 420.0f * scale;
13551467

13561468
if (m_config != nullptr) {
13571469
m_deviceMenuPopup->setShadowConfig(m_config->config().shell.shadow);
@@ -1376,7 +1488,8 @@ void AudioTab::openDeviceMenu(DeviceVolumeCardState& card, const DeviceMenuModel
13761488
m_deviceMenuPopup->open(
13771489
ContextMenuPopupRequest{
13781490
.entries = std::move(entries),
1379-
.menuWidth = menuWidth,
1491+
.minMenuWidth = minMenuWidth,
1492+
.maxMenuWidth = maxMenuWidth,
13801493
.maxVisible = 10,
13811494
.anchor =
13821495
PopupAnchorRect{

src/ui/controls/context_menu.cpp

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "ui/style.h"
88

99
#include <algorithm>
10+
#include <cmath>
1011
#include <linux/input-event-codes.h>
1112

1213
namespace {
@@ -26,6 +27,10 @@ namespace {
2627

2728
bool hasToggle(const ContextMenuControlEntry& entry) { return entry.checkmark || entry.radio; }
2829

30+
bool isInteractive(const ContextMenuControlEntry& entry) {
31+
return entry.enabled && !entry.separator && !entry.header;
32+
}
33+
2934
std::string toggleGlyphName(const ContextMenuControlEntry& entry) {
3035
if (entry.toggleState == 2) {
3136
return "minus";
@@ -91,7 +96,7 @@ void ContextMenuControl::setRedrawCallback(std::function<void()> redrawCallback)
9196

9297
std::size_t ContextMenuControl::firstInteractiveIndex() const noexcept {
9398
for (std::size_t i = 0; i < m_entries.size(); ++i) {
94-
if (m_entries[i].enabled && !m_entries[i].separator) {
99+
if (isInteractive(m_entries[i])) {
95100
return i;
96101
}
97102
}
@@ -154,7 +159,7 @@ bool ContextMenuControl::activateHighlighted() {
154159
return false;
155160
}
156161
const ContextMenuControlEntry& entry = m_entries[m_highlightedIndex];
157-
if (!entry.enabled || entry.separator) {
162+
if (!isInteractive(entry)) {
158163
return false;
159164
}
160165
if (entry.hasSubmenu) {
@@ -183,6 +188,25 @@ float ContextMenuControl::rowBottom(std::size_t index) const noexcept {
183188

184189
float ContextMenuControl::preferredHeight() const { return preferredHeight(m_entries, m_maxVisible, m_contentScale); }
185190

191+
float ContextMenuControl::preferredWidth(
192+
Renderer& renderer, const std::vector<ContextMenuControlEntry>& entries, float scale
193+
) {
194+
scale = safeScale(scale);
195+
float maxRowWidth = 0.0f;
196+
for (const ContextMenuControlEntry& entry : entries) {
197+
if (entry.separator || entry.label.empty()) {
198+
continue;
199+
}
200+
const float toggleSlot = hasToggle(entry) ? 22.0f * scale : 0.0f;
201+
const FontWeight weight = entry.header ? FontWeight::Bold : FontWeight::Normal;
202+
const float textWidth = std::ceil(renderer.measureText(entry.label, kMenuFontSize * scale, weight).width);
203+
// Mirrors rebuildRows: 8px label inset each side, 30px right when a chevron is drawn.
204+
const float sidePadding = (entry.hasSubmenu ? 30.0f : 16.0f) * scale;
205+
maxRowWidth = std::max(maxRowWidth, textWidth + toggleSlot + sidePadding);
206+
}
207+
return maxRowWidth + kMenuPadding * scale * 2.0f;
208+
}
209+
186210
float ContextMenuControl::preferredHeight(
187211
const std::vector<ContextMenuControlEntry>& entries, std::size_t maxVisible, float scale
188212
) {
@@ -245,7 +269,7 @@ void ContextMenuControl::rebuildRows(Renderer& renderer) {
245269

246270
for (std::size_t i = 0; i < visibleItems; ++i) {
247271
const ContextMenuControlEntry& entry = m_entries[i];
248-
const bool interactive = entry.enabled && !entry.separator;
272+
const bool interactive = isInteractive(entry);
249273
const bool separator = entry.separator;
250274
const float rowHeight = separator ? separatorHeight : itemHeight;
251275

@@ -261,7 +285,7 @@ void ContextMenuControl::rebuildRows(Renderer& renderer) {
261285

262286
const float rowCenterY = currentY + rowHeight * 0.5f;
263287
row->setOnClick([this, entry, rowCenterY](const InputArea::PointerData& data) {
264-
if (!entry.enabled || entry.separator || data.button != BTN_LEFT) {
288+
if (!isInteractive(entry) || data.button != BTN_LEFT) {
265289
return;
266290
}
267291
if (entry.hasSubmenu) {
@@ -305,7 +329,10 @@ void ContextMenuControl::rebuildRows(Renderer& renderer) {
305329
.out = &labelPtr,
306330
.text = entry.label,
307331
.fontSize = kMenuFontSize * scale,
308-
.color = entry.enabled ? enabledItemColor() : disabledItemColor(),
332+
.fontWeight = entry.header ? FontWeight::Bold : FontWeight::Normal,
333+
.color = entry.header ? colorSpecFromRole(ColorRole::OnSurfaceVariant)
334+
: entry.enabled ? enabledItemColor()
335+
: disabledItemColor(),
309336
.maxWidth =
310337
entry.hasSubmenu ? (rowWidth - 30.0f * scale - toggleSlot) : (rowWidth - 16.0f * scale - toggleSlot),
311338
.maxLines = 1,
@@ -367,9 +394,10 @@ void ContextMenuControl::rebuildRows(Renderer& renderer) {
367394
.interactive = interactive,
368395
};
369396
if (rowBgPtr != nullptr && labelPtr != nullptr) {
370-
visual.apply = [rowBgPtr, labelPtr, togglePtr, chevronPtr, interactive, separator](bool highlighted) {
397+
visual.apply = [rowBgPtr, labelPtr, togglePtr, chevronPtr, interactive, separator,
398+
header = entry.header](bool highlighted) {
371399
rowBgPtr->setFill(highlighted ? colorSpecFromRole(ColorRole::Hover) : clearColorSpec());
372-
if (separator) {
400+
if (separator || header) {
373401
labelPtr->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
374402
} else {
375403
labelPtr->setColor(

src/ui/controls/context_menu.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ struct ContextMenuControlEntry {
2020
std::string label;
2121
bool enabled = true;
2222
bool separator = false;
23+
// Non-interactive group heading; entries below it read as members of the group.
24+
bool header = false;
2325
bool hasSubmenu = false;
2426
bool checkmark = false;
2527
bool radio = false;
@@ -51,6 +53,9 @@ class ContextMenuControl : public Node {
5153
[[nodiscard]] float preferredHeight() const;
5254
[[nodiscard]] static float
5355
preferredHeight(const std::vector<ContextMenuControlEntry>& entries, std::size_t maxVisible, float scale = 1.0f);
56+
// Width that fits the widest entry without elision (label + toggle/submenu slots + padding).
57+
[[nodiscard]] static float
58+
preferredWidth(Renderer& renderer, const std::vector<ContextMenuControlEntry>& entries, float scale = 1.0f);
5459

5560
private:
5661
struct RowVisual {

src/ui/controls/context_menu_popup.cpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,18 @@ void ContextMenuPopup::open(ContextMenuPopupRequest request) {
4242
const std::size_t maxVisible =
4343
request.maxVisible > 0 ? request.maxVisible : std::max<std::size_t>(1, request.entries.size());
4444
const float menuHeight = ContextMenuControl::preferredHeight(request.entries, maxVisible);
45+
float menuWidth = request.menuWidth;
46+
if (menuWidth <= 0.0f) {
47+
menuWidth = ContextMenuControl::preferredWidth(m_renderContext, request.entries);
48+
if (request.maxMenuWidth > 0.0f) {
49+
menuWidth = std::min(menuWidth, request.maxMenuWidth);
50+
}
51+
if (request.minMenuWidth > 0.0f) {
52+
menuWidth = std::max(menuWidth, request.minMenuWidth);
53+
}
54+
}
4555
const auto chrome =
46-
popup_chrome::computeGeometry(request.menuWidth, menuHeight, m_shadowConfig, Style::popupShadowsEnabled());
56+
popup_chrome::computeGeometry(menuWidth, menuHeight, m_shadowConfig, Style::popupShadowsEnabled());
4757
m_scrollState = {};
4858
m_scrollView = nullptr;
4959
m_menu = nullptr;

src/ui/controls/context_menu_popup.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ struct ContextMenuPopupPlacement {
3434

3535
struct ContextMenuPopupRequest {
3636
std::vector<ContextMenuControlEntry> entries;
37+
// <= 0 sizes the menu to its widest entry, clamped to [minMenuWidth, maxMenuWidth] (0 = unbounded).
3738
float menuWidth = 0.0f;
39+
float minMenuWidth = 0.0f;
40+
float maxMenuWidth = 0.0f;
3841
std::size_t maxVisible = 0;
3942
PopupAnchorRect anchor;
4043
PopupSurfaceParent parent;

0 commit comments

Comments
 (0)